The easy way, and where it breaks
Say you’re building an allowlist on Sui: a gated mint, an airdrop, a per-epoch settlement of payout claims. A Move module needs to answer one question, over and over, for the life of the contract: is this item in my set?
For a handful of entries, the answer is boring and correct. Put them in a vector<u64>. When the collection grows, reach for sui::table, which gives you keyed storage and table::contains for the membership check [1]. The data lives onchain, it’s authoritative, and the transaction reads it directly. If that’s your situation, stop reading and go write that code.
Now change one assumption. The set isn’t yours. Another system owns the real collection: a sequencer’s state tree, an oracle’s price history, an issuer’s revocation list, a settlement engine that already knows every approved claim for epoch 42. Your contract’s only job is to check individual statements against it without re-hosting the whole thing onchain. Copying millions of entries into Sui storage to answer one yes/no question is exactly what you’re trying to avoid.
Hashing the set and publishing the digest feels like it should work:
It doesn’t. A commits to some bytes, but a Move function holding only A has no way to tell whether 7 is among them. Checking that would require the original collection, the thing you didn’t want to store.
This is the gap a cryptographic accumulator closes. The prover, whoever holds the real set, hands your verifier three things: a compact commitment, an element, and a witness proving that element belongs (or doesn’t) relative to that specific commitment.
An accumulator doesn’t make your data disappear, and it isn’t automatically cheaper than native storage. What it buys you is a way to authenticate a claim about data you don’t hold, inside a Move function you do control.
One caveat up front, because it’s the one that bites. The commitment has to be authoritative. If a caller gets to supply both the root and the witness, nothing stops them from committing to whatever set makes their claim true. An accumulator proves a fact relative to a particular commitment; it says nothing about who was allowed to publish that commitment, or whether the dataset behind it was assembled correctly. Keep that separation in mind. It reappears in every construction below.
What an accumulator proves
The classical membership interface has four operations [2]:
Here pp is the public parameters, \lambda the security parameter, and A the commitment. Strip away the cryptography and, as the Move developer, you only ever touch the last line: you receive a witness \pi_7 from a prover you don’t control, and you run VerifyMember against a root A that you do. Everything else (Setup, Commit, ProveMember) happens offchain, on someone else’s machine.
Two promises make that check worth anything:
Completeness is the honest case: a real member always has a witness that verifies. Soundness is the adversarial case: nobody should be able to forge a passing witness for a nonmember (say, convince your contract that 13 is in the set) without breaking the scheme’s hardness assumption.
What about proving that 13 is genuinely absent? A failed membership check doesn’t establish absence; it might just be a bad proof. A universal accumulator adds a separate non-membership proof (NMP):
Hold onto the distinction between membership proofs (MPs) and non-membership proofs (NMPs); it runs through the entire implementation story. Some constructions give you both for free; others need extra structure, and a few can’t produce a useful short NMP at all.
Updates matter just as much as proofs. A static accumulator commits to a fixed collection. An append-only one lets you add entries but not arbitrarily remove them. A dynamic one allows defined insertions and removals. The moment the set changes, so does the commitment. Remove 7, add 23, and you’re committing to a different root:
The old proof for 7 was about A; it’s worthless against A'. A dynamic scheme needs more than a cheap root update, which is easy to underestimate. Every client holding a witness has to refresh it, and your verifier has to know which root version it’s supposed to trust.
So when this guide compares constructions, it tracks five costs: commitment size, witness size, verifier work, offchain construction and maintenance, and (for dynamic sets) witness refresh.
Proof size is the number everyone quotes. It’s rarely the one that decides the design. A one-group-element witness can be expensive to generate or verify; a Merkle proof carries more bytes but asks the verifier for nothing but hashing.
Hash-based accumulators
Let’s build the simplest one that works: a binary Merkle tree. Put the four elements in a fixed order, [3, 7, 11, 19], hash each leaf, and repeatedly hash pairs until a single digest remains. The only thing the verifier and the tree-builder must agree on, exactly, is the bytes that go into each hash and the position of every leaf.
Here’s a reproducible SHA-256 instance you can check by hand. Set D = ASCII("SUI-MERKLE-EXAMPLE:1"), and let LE64 encode a u64 as eight little-endian bytes:
Here H is SHA-256 and x_i is the element at index i. The three distinct leading tags (0x00, 0x01, 0x03) keep leaves, internal nodes, and the final commitment from ever colliding, and the last hash binds the number of real leaves into the root. Those details aren’t decoration. They’re the difference between a Merkle root and a hash an attacker can reinterpret.¹ Sui gives you std::hash::sha2_256 for the onchain half of this calculation [3].
¹ In a larger tree you also need a deterministic padding rule for a non-power-of-two leaf count, and a production encoding would pin its application domain and record schema explicitly. There is no universally interchangeable “Merkle root” format; two systems that hash “the same” tree slightly differently will silently disagree.
The tree itself is small enough to draw:
T
/ \
N01 N23
/ \ / \
L0 L1 L2 L3
3 7 11 19
To prove that 7 is present, the prover sends the leaf index 1, the count 4, and two sibling hashes: L0 and N23. Your Move function hashes 7 at index 1, combines it with L0 on the left, combines that result with N23 on the right, applies the final count-binding hash, and compares the result with the accepted A. The two siblings contribute 64 bytes; the index, count, and transaction encoding add a little more. Plug the numbers in and you get A = 0xc8c506ad71885d3b0f54a25b50ca52c39b8ccdcce8eea57ffada4bd76a72a355 for this four-element set. Run the SHA-256 yourself and you’ll land on the same digest. The one thing the verifier can’t skip: it has to derive left/right placement from the index, and reject an unexpected path length or an index outside the committed range.
The proof is just the two received hashes plus the leaf, folded up to the root:
Here’s that verifier in Move. It checks the exact four-leaf example above: it accepts only a depth-two path, derives branch order from the index, and binds the leaf count into the returned root. Note what it does not do: it takes root as an argument and trusts it. In real code that root has to come from authorized application state, never from the caller submitting the proof.
module accumulator_examples::merkle_four;
use std::bcs;
use std::hash;
public fun verify(
root: &vector<u8>,
value: u64,
index: u64,
siblings: &vector<vector<u8>>,
): bool {
if (index >= 4 || root.length() != 32 || siblings.length() != 2) {
return false;
};
let domain = b"SUI-MERKLE-EXAMPLE:1";
let mut input = vector[0u8];
input.append(copy domain);
input.append(bcs::to_bytes(&index));
input.append(bcs::to_bytes(&value));
let mut current = hash::sha2_256(input);
let mut position = index;
let mut level = 0u64;
while (level < 2) {
let sibling = &siblings[level];
if (sibling.length() != 32) return false;
let mut preimage = vector[1u8];
preimage.append(copy domain);
if (position % 2 == 0) {
preimage.append(current);
preimage.append(*sibling);
} else {
preimage.append(*sibling);
preimage.append(current);
};
current = hash::sha2_256(preimage);
position = position / 2;
level = level + 1;
};
let mut final_input = vector[3u8];
final_input.append(domain);
let leaf_count = 4u64;
final_input.append(bcs::to_bytes(&leaf_count));
final_input.append(current);
hash::sha2_256(final_input) == *root
}
Hardcoding four leaves is what keeps this readable; the general case needs a non-power-of-two padding rule, a bounded tree height, and strict checks on the number of siblings. And a real leaf is rarely a bare u64. It’s usually a BCS-encoded struct (a claim, a balance, a registry entry), which only sharpens the rule that the encoding and domain tags must be byte-identical in Move and in the offchain prover.
The cost picture for a complete binary tree with n leaves is the familiar one: building it is Theta(n) hashes, a single proof is Theta(log n) sibling hashes and Theta(log n) hashes to check, and replacing one leaf touches only its ancestor path. The catch is shape. Inserting a fifth leaf into this four-leaf tree changes the tree’s structure, not just a path, unless you’ve already reserved spare capacity or defined an append-specific update rule. That gap is exactly where the next three variants earn their keep.
Why a Merkle path cannot prove absence
Now ask the tree for the opposite: prove that 13 is not in the set. You might hand the verifier a Merkle path for 11 and another for 19, but all that establishes is that those two numbers sit at those two positions. It says nothing about whether some other position holds 13. Even showing 11 and 19 as neighbors doesn’t help, unless the verifier already knows the whole committed collection is sorted. And that’s the crux: an ordinary Merkle MP authenticates leaf bytes and positions. It knows nothing about the rules used to arrange the dataset, so it can’t certify the absence of something it was never shown.
A Sparse Merkle Tree changes what a position means. Instead of allocating consecutive slots when elements arrive, the key determines its path. For our u64 example, one can use its 64 bits as a path in a binary tree of fixed depth 64. An occupied leaf authenticates its key and value; an unoccupied leaf has a separately defined empty commitment. Default hashes for empty subtrees can be computed in advance. To prove that 13 is absent, start from the empty leaf at the path for 13, combine it with the authenticated siblings, and recover the accepted root. A different value at the same key would change the root, assuming the leaf encoding and hash are sound [4].
That proof has a different cost model: a straightforward full-depth witness has d sibling positions and O(d) verification work, where d is the key width, not log n. Here d = 64; for a 256-bit hashed key it may be 256. Compression can omit siblings known to be empty, provided the proof records which levels were omitted and the verifier reconstructs the correct default hashes. Inserting 23 replaces the previously empty leaf at its deterministic position and recomputes the affected d levels. A Move function that accepts the new root must still verify the old root and this transition if the data updater is not trusted.
An Indexed Merkle Tree takes another route. Rather than reserve a path for every possible key, it authenticates an ordered relation between the keys that are actually present. In one common layout, a leaf for 11 also records 19 as its next-greater key. A Merkle MP for that leaf, together with 11 < 13 < 19, establishes the gap containing 13 if the structure’s linked-order invariant is maintained. Physical leaf positions need not be sorted; ordering is authenticated through the successor metadata [5].
This makes updates more involved than changing one ordinary Merkle leaf. Inserting 13 requires changing the successor of 11 from 19 to 13 and adding a leaf whose successor is 19. A verifier of untrusted updates needs valid proofs against the appropriate intermediate roots, an unused insertion slot, uniqueness checks, and an invariant-preserving initialization procedure. Under that model, absence and a constant number of leaf updates require O(log n)-sized paths and hash work for a balanced tree of capacity proportional to n. Without the ordering invariant, the apparent non-membership proof is just a convincing-looking Merkle path.
Sui ships one more structure from this family, easy to misfile: the Merkle Mountain Range behind authenticated events.² A Merkle tree, a sparse map, an indexed map, and an MMR all run on the same hashing machinery yet certify different facts: inclusion, keyed presence, ordered absence, append-only history. Picking the wrong one gives you a proof that verifies and still doesn’t mean what you need it to.
² event::emit_authenticated<T>() feeds an authenticated, package-scoped event stream whose per-consensus-commit roots are appended to a Merkle Mountain Range. An MMR authenticates append-only history, not a mutable set with arbitrary deletion or non-membership. Sui documents a light client for verifying that stream, but its RPC does not currently expose arbitrary event-level MMR inclusion proofs a Move contract could just accept [6].
Compact algebraic witnesses
Our Merkle proof for 7 carried two sibling hashes. At a million leaves it would carry about twenty, and the number keeps growing with the set. Algebraic accumulators attack the same membership question from a different angle: the witness is a single group element whose size doesn’t grow with the number of entries. You trade bytes for arithmetic: hash folding gives way to operations in a large cryptographic group.
RSA: a product of primes in the exponent
Here’s an RSA modulus N = pq with secret factorization and a base g in the group of quadratic residues mod N. The point of the secret factorization is that the group’s order stays hidden. Now represent each set element by a distinct prime and multiply the representatives together [2], [7]. Conveniently, every number in our example is already prime:
The verifier needs A, the representative 7, and the single group element w_7. It does not need the other three primes. The relation is valid because the exponent of A contains a factor of 7; forging such a root for a prime not in the set is the security problem addressed by RSA accumulator constructions under the Strong RSA assumption.
Don’t mistake these small integers for a secure encoding; they’re here so you can check the arithmetic by hand. A real implementation has to deterministically map arbitrary identifiers to suitably sized primes with no two identifiers ever sharing a representative, and it needs a modulus whose factorization no adversary holds (whoever knows the factorization can break the assumptions the whole scheme rests on). What is real, and is the entire appeal: the accumulator is one modulus-sized value and the witness is another, no matter how many elements you accumulated.
A universal RSA accumulator can also prove that 13 is absent [7]. Since 13 does not divide U, the extended Euclidean algorithm finds integers a, b satisfying aU + 13b = 1. For this set, one solution is a = 5, b = -1688. Let d = g^b mod N, using a modular inverse for the negative exponent. Then the verifier checks:
The non-membership witness consists of d and a Bézout coefficient a chosen modulo the candidate prime. If 13 had been accumulated, gcd(13,U) would not equal one and the required Bézout identity would not exist. The verification equation authenticates the witness only under the specified RSA construction and its prime-representation rules; it is not a general-purpose absence test for arbitrary integers supplied by a caller.
Insertions are simple at the commitment level. Adding prime 23 gives A' = A^{23} mod N, and an existing membership witness can be updated by exponentiating it by 23. For a deletion, the valid witness for the removed element can serve as the new commitment, but other users still need an appropriate witness-update procedure and the verifier must recognize the new root as authorized. A small update message to the accumulator is not the same thing as updating every holder’s proof.
And then RSA hits a wall on Sui, immediately. Even the innocent-looking check w_7^7 mod N is modular exponentiation over a modulus far wider than Move’s u256. Sui exposes no native primitive for it. This isn’t a tuning problem you can gas-optimize around; it’s a missing primitive.
You could build multi-limb big-integer arithmetic in Move, or prove the RSA relation inside another proof system. Either is a real project with its own security analysis, not a call to a ready-made crypto API. It’s the sharpest dividing line in the whole comparison.
Pairings: commit to a polynomial instead
Pairings get you the same constant-size witness as RSA, but without the unknown-order arithmetic that just put RSA out of reach, and with primitives Sui actually ships. We’ll use a Nguyen-style polynomial-product accumulator plus the non-membership extension of Damgård and Triandopoulos [8, 9]. Let G1 and G2 be prime-order groups with generators G_1 and G_2, scalar field Fr, and pairing e: G1 × G2 → GT. The set’s elements are distinct scalars in Fr.
Choose a secret setup scalar τ and define:
For the member 7, the prover removes its factor from the polynomial:
Bilinearity gives the final equality, which is the verifier equation. On BLS12-381, a compressed G1 element occupies 48 bytes; the witness does not acquire additional group elements as the set grows. Unlike the Merkle verifier, however, the computation now involves scalar multiplication, group operations, and pairings.
A properly constructed prover does not learn τ. Instead, setup publishes powers of that secret, such as [G_1], [\tau]G_1, ..., [\tau^d]G_1, along with G_2 and [\tau]G_2. The prover expands the polynomial and combines these public powers to obtain the commitment and witness. Here d bounds the number of accumulated elements. The published parameters take Theta(d) group elements, and constructing a witness involves polynomial work and a multi-scalar multiplication over its coefficients. Revealing τ or accepting maliciously generated parameters destroys the intended security. The construction relies on a bounded-degree strong Diffie–Hellman-type assumption, not merely on the existence of a pairing function.
Non-membership was the hard case for hashing, too; it’s what forced us from plain Merkle trees to sparse and indexed variants. Pairings handle it with no empty-leaf trick at all. For y = 13, the polynomial has a nonzero evaluation F_S(-13) = -720. Define u = -F_S(-13) = 720 and:
The quotient is a polynomial because its numerator evaluates to zero at T = -13. The verifier must also reject u = 0 in Fr. Otherwise, a valid membership witness could satisfy the same equation for a member, defeating the claimed non-membership guarantee. The NMP is one G1 element plus one field scalar, not just the original membership witness with a different label [9].
A public powers-of-τ setup lets anyone compute commitments and witnesses without ever touching the trapdoor, but “computable” isn’t “free.” Updating the polynomial rewrites its coefficients and generally means more offchain group work, and every old witness is tied to the previous commitment. How expensive depends on what preprocessing the prover keeps around: coefficient trees, intermediate commitments, and so on. For Sui the important part is the opposite of the RSA story: the arithmetic the verifier needs has native counterparts in the framework. sui::bls12381 exposes pairing, g1_add, g2_mul, and the rest [10], [11]. These are exactly the operations the verification equation calls for. Whether they’re cheap enough is a question only a gas profile answers, not this article.
What Sui Move can actually verify
Theory settled. What will Move let you ship today, and what will it quietly block?
The native operations do most of the narrowing for you. Hash-based schemes need a precise tree format and a hash function; pairing accumulators add typed group elements and setup parameters; RSA needs large-modulus arithmetic the current Sui API doesn’t expose. And through all three, one rule never relaxes: the verifier reads an application-approved commitment, never a root the caller chose.
Hash-based: ready today. Everything you need is already in the framework. Move exposes std::hash::sha2_256 and std::hash::sha3_256, plus sui::hash::keccak256 and sui::hash::blake2b256 [3]. A Merkle MP verifier hashes a leaf and folds the sibling path with these; it still owes you checks on index, depth, branch order, domain tags, padding, and the accepted root. Sparse and indexed variants use the same primitives and add their own empty-key or successor-link rules. The hashing is never the hard part; a correctly computed hash can’t rescue an incorrectly specified tree.
The thing that actually trips people up isn’t SHA-256 versus Keccak; it’s byte compatibility. Your Rust prover and your Move verifier must hash identical byte sequences, down to the last byte. BCS serialization of a u64 is not its decimal string. Concatenating variable-length fields with no length prefix and no domain separation lets two different records collide into the same preimage. Our four-leaf example dodges all of this with eight-byte little-endian integers and distinct tags for leaves, nodes, and the root. Change the hash function or the encoding and you’ve changed the commitment and every proof under it. And no, sharing Keccak with Ethereum does not make Sui proofs interoperable with Ethereum-style ones for free.
There’s a second hash to know about: sui::poseidon::poseidon_bn254 takes a vector<u256> of one to sixteen canonical BN254 field elements and returns a field element [12]. Values at or above the field modulus, empty input, and more than sixteen inputs are all rejected by the documented interface. Reach for it when your accumulator’s hash also has to be computed inside a BN254 zero-knowledge circuit, but don’t treat it as a drop-in for a byte-oriented SHA-256 tree. The input packing, field representation, and exact Poseidon parameters all have to agree in three places at once: offchain, in Move, and in the circuit.
The direct route for our polynomial-product accumulator is sui::bls12381. Its public API includes g1_from_bytes, g2_from_bytes, scalar_from_u64, scalar_from_bytes, group addition and multiplication, and pairing [10]. For the witness of 7, the verifier would decode the G1 witness and commitment, compute from the approved setup points, and compare with . Here denotes scalar multiplication in the group, not ordinary integer multiplication. The group_ops::equal helper is public, while many generic operations inside sui::group_ops are public(package) and should be accessed through the exported bls12381 wrappers [11].
For example, the two relations from the previous section map to the following Move checks. These functions take already-decoded elements; the surrounding package handles byte decoding and the selection of approved parameters.
module accumulator_examples::pairing;
use sui::bls12381::{Self, G1, G2, Scalar};
use sui::group_ops::{Self, Element};
public fun verify_member(
a: &Element<G1>,
witness: &Element<G1>,
x: &Element<Scalar>,
tau_g2: &Element<G2>,
): bool {
if (group_ops::equal(a, &bls12381::g1_identity()) ||
group_ops::equal(witness, &bls12381::g1_identity()) ||
group_ops::equal(tau_g2, &bls12381::g2_identity())) return false;
let g2 = bls12381::g2_generator();
let x_g2 = bls12381::g2_mul(x, &g2);
let factor = bls12381::g2_add(tau_g2, &x_g2);
if (group_ops::equal(&factor, &bls12381::g2_identity())) return false;
let lhs = bls12381::pairing(witness, &factor);
let rhs = bls12381::pairing(a, &g2);
group_ops::equal(&lhs, &rhs)
}
public fun verify_nonmember(
a: &Element<G1>,
witness: &Element<G1>,
y: &Element<Scalar>,
u: &Element<Scalar>,
tau_g2: &Element<G2>,
): bool {
if (group_ops::equal(u, &bls12381::scalar_zero()) ||
group_ops::equal(a, &bls12381::g1_identity()) ||
group_ops::equal(tau_g2, &bls12381::g2_identity())) return false;
let g1 = bls12381::g1_generator();
let g2 = bls12381::g2_generator();
let factor = bls12381::g2_add(tau_g2, &bls12381::g2_mul(y, &g2));
if (group_ops::equal(&factor, &bls12381::g2_identity())) return false;
let adjusted = bls12381::g1_add(a, &bls12381::g1_mul(u, &g1));
let lhs = bls12381::pairing(witness, &factor);
let rhs = bls12381::pairing(&adjusted, &g2);
group_ops::equal(&lhs, &rhs)
}
The load-bearing line is the u != 0 check in the NMP verifier. Drop it, and a perfectly valid membership witness can be replayed as a bogus absence proof; the whole non-membership guarantee quietly evaporates. And notice what neither function does: neither authorizes the accumulator or the powers-of-τ parameters. The caller has to obtain both from an authenticated configuration, not from the proof itself.
The full powers-of- sequence is really the offchain prover’s baggage. Your verifier needs far less: the commitment, the witness, and a few fixed verification parameters (notably G_2 and [\tau]G_2), and it never has to touch the whole Theta(d) SRS per claim. Those few parameters still have to be pinned to the accepted setup and root. Round it out with exact scalar encoding, malformed-point handling, the identity and degenerate-case checks the scheme calls for, and that u != 0 condition, and you have a verifier. The one rule that makes all of it safe: never let the proof submitter hand you the setup parameters or the root. Bind both to the application ahead of time, or your verifier is diligently checking the wrong statement.
One caveat hides in plain sight in the API list. Yes, bls12381 exports g1_multi_scalar_multiplication and g2_multi_scalar_multiplication, but the generic MSM underneath them is documented as Devnet-only [11], so don’t assume it on Mainnet. For the verifier we’ve described that’s fine: it only needs ordinary point multiplication and pairings, and any large MSM belongs to the offchain prover anyway. The broader lesson is that “the group arithmetic exists” is not the same claim as “a KZG or IPA verifier, with every operation it needs, is ready to deploy.” Check the whole operation list against what’s actually on Mainnet before you promise one.
RSA draws the opposite boundary. Its witness equation needs modular exponentiation over a secure modulus far wider than u256, and Sui’s published crypto API has no such operation. Multi-limb arithmetic in Move or an RSA relation proved inside another system are both possible, but each is an implementation, a security analysis, and a round of gas measurements. That’s not in the same maturity bracket as calling sha2_256 or bls12381::pairing.
There’s also an escape hatch. The sui::groth16 module verifies circuit-defined proofs over BN254 or BLS12-381, capped today at eight public input field elements [13]. A circuit can prove Merkle membership, or arithmetic that’s flatly impractical to run directly in Move, while your contract just verifies the Groth16 proof. The cost is everything around it: the circuit, how its proving and verifying keys were generated, which verifying key you trust, and the exact binding of root and member. Groth16 verification is a powerful tool, but it is not, by itself, an MP or NMP API.
Finally, two Sui features with suggestive names that do not belong in this comparison. Don’t reach for sui::accumulator or sui::funds_accumulator: they maintain protocol-managed additive state like balances, not cryptographic membership witnesses for arbitrary sets [14]. And authenticated events, as covered earlier, ride a protocol-maintained MMR whose history an offchain client can verify, but whose RPC doesn’t hand a Move contract arbitrary event-level inclusion proofs today [6]. Both are solving a different problem; neither is a general-purpose set-accumulator verifier.
| Construction | Usable Sui primitives | Additional verifier work |
|---|---|---|
| Binary Merkle | SHA-256, SHA3-256, Keccak256, Blake2b-256 | Leaf encoding, path shape, root binding, padding and commitment authorization |
| Sparse / Indexed Merkle | Same hashes, optionally BN254 Poseidon | Empty-key or authenticated successor semantics; valid insertion and deletion transitions |
| Pairing-based MP/NMP | Public BLS12-381 scalar, group and pairing operations | Point decoding, approved SRS parameters, group checks, witness equation and u != 0 for NMP |
| Universal RSA | No native general RSA-modulus exponentiation verifier | Large-integer arithmetic, or a separate proof system verifying the RSA relation |
| Groth16-wrapped statement | Native BN254/BLS12-381 Groth16 verification | Circuit, proving keys, approved verification key, root/element binding and public-input encoding |
| Authenticated event MMR | Protocol-managed commitment and offchain event verification | Application-specific individual receipt format and verifier where required [6] |
Choosing a construction
Every construction here authenticates a related statement, but they put the computation and the trust in different hands. That’s the real axis of the decision. A short witness is cold comfort if producing it needs an expensive setup, or if Move can’t verify it economically.
Let n be the number of elements, k the sparse-tree key width, and d the maximum polynomial degree supported by a pairing setup (d >= n). Fixed-size hashes and group elements assume a fixed security level. These are baseline bounds; batching and preprocessing change specific costs.
| Construction | Commitment and one witness | MP / NMP | Verification work | Changes and prover obligations | Sui Move implementation |
|---|---|---|---|---|---|
| Binary Merkle tree | One hash; Theta(log n) sibling hashes | MP; no general NMP for an unordered collection | Theta(log n) hashes | Build Theta(n) hashes; replace a fixed-position leaf with Theta(log n) new hashes. A new tree shape needs explicit rules. | Native hashes; custom path verifier |
| Sparse Merkle tree | One hash; at most k sibling positions, often compressed | MP and empty-key NMP | O(k) hashes | Key insertion/deletion affects O(k) levels; witnesses depend on the accepted root version. | Native hashes; custom keyed-tree verifier |
| Indexed Merkle tree | One hash; O(log n) paths at fixed proportional capacity | MP and gap-based NMP | O(log n) hashes for a constant number of paths | Insertion changes the new leaf and predecessor metadata. Sound updates must preserve ordered links and handle capacity changes. | Native hashes; custom invariant and transition checks |
| Universal RSA accumulator | One modulus-sized element; MP: one element; NMP: element plus bounded Bézout coefficient | MP and NMP in the specified universal scheme | Constant number of modulus-width exponentiations in n, not constant bit cost | Compute prime representatives and exponent products; witness generation/refresh can involve the full set. RSA modulus setup must be trusted or verifiably generated. | No native general RSA modular-exponentiation verifier identified |
| Polynomial pairing accumulator | One G1 point; MP: one G1 point; NMP: point plus scalar | MP and NMP for the construction shown | Constant number of pairings and group operations in n | Construct degree-n polynomial and witnesses off-chain; update affected coefficients/witnesses. Requires Theta(d) powers-of-tau setup parameters. | Public BLS12-381 operations; full verifier untested |
The RSA and pairing witnesses are constant-size in n, not constant-cost cryptographic operations. RSA and pairings also differ sharply in available Move primitives.
For a fixed collection, binary Merkle is the simplest starting point: its proof format is explicit and the verifier needs only native hashes. A mutable keyed collection changes the question: use a sparse tree when deterministic key positions and empty-key proofs match the application, or an indexed tree when authenticated ordering is preferable and the update procedure can enforce it. Neither is a drop-in extension of the four-leaf Merkle example.
Pairing-based accumulators are attractive when fixed-size witnesses justify a degree-bounded setup and offchain polynomial work. Sui has the needed verifier-side group primitives, although their transaction cost must be measured for the actual workload. RSA faces a more basic obstacle: the verifier requires arbitrary-width modular exponentiation, for which Sui exposes no comparable native API.
That is the practical dividing line. Merkle commitments are the sensible baseline on Sui because their verifier operations are native hashes. Pairing accumulators buy you smaller witnesses, paid for in setup and offchain polynomial work. RSA needs a verification strategy Sui doesn’t hand you at all.
To make this concrete rather than theoretical, the two reference implementations accompanying this guide stake out the ends of that line: A1, a SHA-256 Merkle membership verifier with a Rust prover, a fixed 147-byte BCS claim schema, and golden vectors independently reproduced in Python and Go; and P1, Move source for the two BLS12-381 pairing equations above, backed by a Go model of the underlying polynomials. Both are honest about their limits: the offchain vectors verify, but neither has been through sui move build, an in-VM pairing, or a gas benchmark, and both use toy, insecure parameters. They’re a reference design to read and extend, not a settlement contract to deploy.³
³ If you take one habit from those packages, take their discipline about the root: both treat the accepted commitment as something an authorized settlement object must supply, and both refuse to prove uniqueness, prevent replay, or vouch for the dataset’s construction. Those are exactly the guarantees that live outside the accumulator, the gap this guide opened with.
So before you pick a construction, answer three questions in order: what statement do you actually need to authenticate, how often does it change, and who is on the hook for refreshing witnesses when it does. Get those right and the accumulator becomes a quiet footnote in your design doc. Get them wrong, and no amount of elegant algebra will save you: you’ll have a proof that verifies beautifully and certifies the wrong thing.
References
- Sui Framework,
sui::table.Tablestorage model andtable::contains. - J. Camenisch and A. Lysyanskaya, Dynamic Accumulators and Application to Efficient Revocation of Anonymous Credentials, CRYPTO 2002. Classical accumulator interface and dynamic-update motivation.
- Sui Documentation, Hashing. Native hash functions available in Move.
- Iden3 Documentation, Sparse Merkle Tree. Authenticated keyed positions and non-membership proofs.
- Aztec Documentation, Indexed Merkle Tree (Nullifier Tree). Indexed leaves, authenticated successor metadata, non-membership and insertion rules.
- Sui Documentation, Authenticated Events. Per-commit Merkle roots, MMR updates, verification client, and the current RPC proof limitation.
- J. Li, N. Li, and R. Xue, Universal Accumulators with Efficient Nonmembership Proofs, ACNS 2007. Universal RSA accumulator and Bézout-based absence proofs.
- L. Nguyen, Accumulators from Bilinear Pairings and Applications, CT-RSA 2005 / extended version. Polynomial-product accumulator and membership witnesses.
- I. Damgård and N. Triandopoulos, Supporting Non-membership Proofs with Bilinear-map Accumulators, 2008. Nonzero-scalar non-membership witness and verification equation.
- Sui Framework,
sui::bls12381. BLS12-381 scalar, G1/G2, GT, and pairing operations. - Sui Framework,
sui::group_ops. Native group operation surface and documented Devnet-only MSM feature gate. - Sui Framework,
sui::poseidon. BN254 input canonicality, supported arity, and field-element interface. - Sui Framework,
sui::groth16. BN254/BLS12-381 Groth16 verifier and public-input bound. - Sui Framework,
sui::accumulatorandsui::funds_accumulator. Protocol additive/funds accumulation, not set MP/NMP.