diff --git a/src/analysis/modifications/moment.jl b/src/analysis/modifications/moment.jl
new file mode 100644
index 0000000000000000000000000000000000000000..eddebc1ae8351c3ca2a4c6c2adba0340fd64992c
--- /dev/null
+++ b/src/analysis/modifications/moment.jl
@@ -0,0 +1,127 @@
+"""
+    add_moment_constraints(
+        ksas::Dict{String,Float64},
+        protein_mass_fraction::Float64
+    )
+
+A modification that adds enzyme capacity constraints to the problem using a _modified_
+version of the MOMENT algorithm. Requires specific activities, `ksas` [mmol product/g
+enzyme/h], for each reaction. Proteins are identified by their associated gene IDs. Adds a
+variable vector `y` to the problem corresponding to the protein concentration [g enzyme/gDW
+cell] of each gene product in the order of `genes(model)`. The total protein concentration
+[g protein/gDW cell] is constrained to be less than or equal to the `protein_mass_fraction`.
+Reaction flux constraints are changed to the MOMENT constraints (see below) for all
+reactions that have a gene reaction rule, otherwise the flux bounds are left unaltered.
+
+See Adadi, Roi, et al. "Prediction of microbial growth rate versus biomass yield by a
+metabolic network with kinetic parameters." PLoS computational biology (2012) for more
+details of the original algorithm.
+
+Here, a streamlined version of the algorithm is implemented to ensure that the correct units
+are used. Specifically, this implementation uses specific activities instead of `kcats`.
+Thus, for a reaction that can only proceed forward and is catalyzed by protein `a`, the flux
+`x[i]` is bounded by `x[i] <= ksas[i] * y[a]`. If isozymes `a` or `b` catalyse the
+reaction, then `x[i] <= ksas[i] * (y[a] + y[b])`. If a reaction is catalyzed by subunits `a`
+and `b` then x[i] <= ksas[i] * min(y[a], y[b]). These rules are applied recursively in the
+model like in the original algorithm. The enzyme capacity constraint is then implemented by
+`sum(y) ≤ protein_mass_fraction`. The major benefit of using `ksas` instead of `kcats` is
+that active site number and unit issues are prevented.
+
+# Example
+```
+sol = flux_balance_analysis_dict(
+    model,
+    Tulip.Optimizer;
+    modifications = [
+        add_moment_constraints(
+            ksas,
+            protein_mass_fraction
+        ),
+        change_constraint("EX_glc__D_e", lb = -1000),
+    ],
+)
+```
+"""
+add_moment_constraints(kcats::Dict{String,Float64}, protein_mass_fraction::Float64) =
+    (model, opt_model) -> begin
+
+        lbs, ubs = get_optmodel_bounds(opt_model) # to assign directions
+        # get grrs and ignore empty blocks: TODO: fix importing to avoid this ugly conditional see #462
+        grrs = Dict(
+            rid => reaction_gene_association(model, rid) for
+            rid in reactions(model) if !(
+                reaction_gene_association(model, rid) == [[""]] ||
+                isnothing(reaction_gene_association(model, rid))
+            )
+        )
+
+        # add protein variables
+        y = @variable(opt_model, y[1:n_genes(model)] >= 0)
+
+        # add variables to deal with enzyme subunits
+        num_temp = sum(length(values(grr)) for grr in values(grrs)) # number "AND" blocks in grrs
+        t = @variable(opt_model, [1:num_temp]) # anonymous variable
+        @constraint(opt_model, t .>= 0)
+
+        #=
+        Note, not all of t needs to be created, only those with OR GRR rules, however this
+        adds a lot of complexity to the code - re-implement this method if efficiency becomes
+        an issue.
+        =#
+
+        # add capacity constraint
+        @constraint(opt_model, sum(y) <= protein_mass_fraction)
+
+        k = 1 # counter
+        kstart = 1
+        kend = 1
+        x = opt_model[:x]
+        for (ridx, rid) in enumerate(reactions(model))
+
+            isnothing(get(grrs, rid, nothing)) && continue # only apply MOMENT constraints to reactions with a valid GRR
+            grrs[rid] == [[""]] && continue # TODO: remove once #462 is implemented
+
+            # delete original constraints
+            delete(opt_model, opt_model[:lbs][ridx])
+            delete(opt_model, opt_model[:ubs][ridx])
+
+            #=
+            For multi-subunit enzymes, the flux is bounded by the minimum concentration of
+            any subunit in the enzyme. If multiple isozymes with subunits exist, then the
+            sum of these minima bound the enzyme flux. E.g., suppose that you have a GRR
+            [[y, z], [w, u, v]], then x <= sum(min(y, z), min(w, u, v)).
+
+            It is possible to reformulate x <= min(y, z) to preserve convexity:
+            x <= t && t <= y && t <= z where t is a subunit variable.
+
+            The remainder of the code implements these ideas.
+            =#
+
+            # build up the subunit variables
+            kstart = k
+            for grr in grrs[rid]
+                for gid in grr
+                    gidx = first(indexin([gid], genes(model)))
+                    @constraint(opt_model, t[k] <= y[gidx])
+                end
+                k += 1
+            end
+            kend = k - 1
+
+            # total enzyme concentration is sum of minimums
+            isozymes = @expression(opt_model, kcats[rid] * sum(t[kstart:kend]))
+
+            if lbs[ridx] >= 0 && ubs[ridx] > 0 # forward only
+                @constraint(opt_model, x[ridx] <= isozymes)
+                @constraint(opt_model, 0 <= x[ridx])
+            elseif lbs[ridx] < 0 && ubs[ridx] <= 0 # reverse only
+                @constraint(opt_model, -isozymes <= x[ridx])
+                @constraint(opt_model, x[ridx] <= 0)
+            elseif lbs[ridx] == ubs[ridx] == 0 # set to zero
+                @constraint(opt_model, x[ridx] == 0)
+            else # reversible
+                @constraint(opt_model, x[ridx] <= isozymes)
+                @constraint(opt_model, -isozymes <= x[ridx])
+            end
+        end
+    end
diff --git a/test/analysis/moment.jl b/test/analysis/moment.jl
new file mode 100644
index 0000000000000000000000000000000000000000..d8a0264b2dc4ff5a8884f3a867428786d79c0a92
--- /dev/null
+++ b/test/analysis/moment.jl
@@ -0,0 +1,21 @@
+@testset "Moment algorithm" begin
+    model = load_model(StandardModel, model_paths["e_coli_core.json"])
+
+    ksas = Dict(rid => 1000.0 for rid in reactions(model))
+    protein_mass_fraction = 0.56
+
+    sol = flux_balance_analysis_dict(
+        model,
+        Tulip.Optimizer;
+        modifications = [
+            add_moment_constraints(ksas, protein_mass_fraction;),
+            change_constraint("EX_glc__D_e", lb = -1000),
+        ],
+    )
+
+    @test isapprox(
+        sol["BIOMASS_Ecoli_core_w_GAM"],
+        0.6623459899423948,
+        atol = TEST_TOLERANCE,
+    )
+end