Post
Share your knowledge.
How do I verify a Merkle proof inside a Move smart contract
I'm trying to understand this aspect of the Sui Network because I'm either building, debugging, or deploying something that touches this area. I want a detailed explanation of how this mechanism or feature works, along with relevant CLI usage, Move code structure, or architectural concepts. My goal is to gain enough clarity to apply this knowledge in a real project—whether that's a custom smart contract, an NFT system, a wallet integration, or a DeFi tool. The Sui Network has unique features compared to EVM chains, so I'm particularly interested in what sets it apart and how that affects development best practices. It would help to have sample code, command line examples, or typical errors to watch for, especially when using the Sui CLI, SDK, or deploying on localnet/testnet. Ultimately, I want to avoid common mistakes, follow the best security principles, and ensure that the functionality I’m working on behaves as expected under realistic conditions.
- Sui
- Architecture
- Move
Answers
5How Merkle Proofs Work (Quick Refresher)
A Merkle proof consists of:
A leaf hash (typically the hash of the user's address or data)
A sequence of sibling hashes up to the root
The Merkle root, which is stored on-chain
A direction flag per level (left or right sibling)
You reconstruct the root by hashing the leaf with each sibling in the correct order and comparing the result to the stored root.
✅ Move + Sui-Compatible Approach
Move doesn’t support:
Arbitrary-length vectors (gas cost constraint)
Bitwise operations or SHA-256 (only Blake2b is available via Sui's crypto)
Generic third-party libraries (no OpenZeppelin equivalent yet)
But you can verify Merkle proofs using recursive hashing with BLAKE2b, as long as the data is structured right.
🛠️ Basic Merkle Verification Strategy in Sui Move
🔹 Step 1: Store the root hash
struct MerkleRoot has key {
id: UID,
root: vector
🔹 Step 2: Verify Proof
use sui::hash; use sui::crypto; use 0x1::vector; use 0x1::error;
public fun verify_merkle_proof(
leaf: vector
let mut i = 0;
let len = vector::length(&proof);
while (i < len) {
let sibling = vector::borrow(&proof, i);
if ((index >> i) & 1) == 0 {
// Current node is left, sibling is right
hash = crypto::blake2b256(vector::concat(&hash, sibling));
} else {
// Current node is right, sibling is left
hash = crypto::blake2b256(vector::concat(sibling, &hash));
};
i = i + 1;
};
hash == expected_root
}
index determines the sibling arrangement: we use bit shifts to guide L/R hashing.
🧪 Sample Usage
entry fun claim_reward(
merkle_root: &MerkleRoot,
user: address,
proof: vector<vector
let valid = verify_merkle_proof(
leaf,
proof,
index,
&merkle_root.root
);
assert!(valid, error::invalid_argument(0));
// Continue: mint NFT, give SUI, etc.
}
CLI Deployment
You can test locally by generating proofs off-chain using a Merkle tree library in TypeScript or Python. Then:
-
Use sui move build to compile.
-
Use sui client publish to deploy.
-
Call claim_reward with:
A correct leaf (your address)
Proof array of hashes
index of your leaf
Stored Merkle root
Common Mistakes to Avoid
Problem Fix
Hash size mismatch Use blake2b256 for 32-byte output consistently Wrong sibling order Make sure your off-chain proof provides order + direction Gas too high Avoid deep trees or large vectors Data mismatch Double-check how you're encoding leaf data (address::to_bytes, etc.) Index misalignment Your index must be the correct path for that leaf in the Merkle tree
Testing the Contract
Here’s a basic test setup:
#[test_only]
public fun test_verify_merkle() {
let leaf = crypto::blake2b256(address::to_bytes(@0x123));
let proof = vector::empty<vector
let valid = verify_merkle_proof(leaf, proof, 0, root);
assert!(valid, 101);
}
🔄 Off-chain Proof Generator (TypeScript)
import { MerkleTree } from 'merkletreejs' import { blake2b } from 'blakejs'
function hash(x: string): Buffer { return Buffer.from(blake2b(Buffer.from(x), null, 32)) }
const leaves = ['0x123...', '0x456...'].map(hash) const tree = new MerkleTree(leaves, hash, { sortPairs: true })
const proof = tree.getProof(leaves[0]).map(x => x.data.toString('hex')) const index = 0 // depends on position in tree
Summary
Merkle proofs can be verified inside Move contracts using blake2b256.
You must provide: leaf, sibling proof, and leaf index in the tree.
You manage ordering logic manually since there's no bitwise type in Move.
Generate trees + proofs off-chain (JS/Python) and use CLI/SDK to test.
Store the MerkleRoot as a vector
Let me know if you'd like:
A full airdrop claim system template using Merkle proofs
An NFT-gated whitelist mint
Scripts for batch proof generation from CSV
I'm happy to walk you through the integration.
Okay, let's dive into Sui's object model, particularly the distinction between Owned and Shared Objects, which is a cornerstone of Sui's design and a major differentiator from EVM chains. Understanding this is crucial for building efficient and scalable dApps.
Sui's Object-Centric Model: A Paradigm Shift Unlike EVM chains where state is primarily managed within accounts (e.g., an ERC-20 token is a number in a mapping within the token contract's state), Sui's blockchain is a global, programmable storage of objects. Every piece of data—from an NFT to a smart contract itself—is an object with a unique ID and a specific owner (or lack thereof, in the case of shared objects). This allows for a more direct, granular approach to state management and transaction processing.
1. Owned Objects: The Power of Parallelism
- How it Works: An owned object is exclusively controlled by a single Sui address. When you create an object, it's typically owned by the address that created it. To mutate an owned object (e.g., change its properties, transfer it), the transaction must be signed by its current owner. Because only one owner can initiate a transaction on their owned object at a time, Sui's execution layer (Mysticeti/Narwhal/Bullshark) can process many such independent transactions in parallel. No global consensus is needed for two separate users to interact with their distinct owned objects simultaneously. This is Sui's secret sauce for high throughput.
- Move Code Structure: Owned objects are typically represented by structs with the
keyandstoreabilities. For instance, a simple NFTstruct MyNFT has key, store { id: UID, value: u64 }. Transferring an owned object usessui::transfer::transfer(my_nft_object, recipient_address). To create an owned object that's initially owned by the transaction sender, you'd typically return it from anentryfunction. - CLI Usage:
sui client publish <path_to_package>: Publishes your Move package, creating an ownedPackageobject.sui client call --function create_my_nft --package <package_id> --module <module_name> --gas-budget 0.1: Calls a function that might create and transfer an NFT to your address (as the sender).sui client transfer --recipient <recipient_address> --object-id <object_id> --gas-budget 0.1: Directly transfers an owned object.sui client object <object_id>: Shows details, including theOwnerfield, which will be a specific address.
- Best Practices: Design your dApps to leverage owned objects as much as possible. For example, instead of a single global vault holding all users' funds (a shared object), create an individual vault object for each user (an owned object). This maximizes parallelism and reduces contention.
2. Shared Objects: When Consensus is Needed
- How it Works: A shared object is not owned by any single address. Multiple addresses can interact with and mutate it. Because concurrent modifications by different users could lead to conflicting states, any transaction interacting with a shared object must go through the full consensus protocol. This ensures that operations on that specific shared object are processed sequentially, maintaining consistency. While this introduces a bottleneck for that specific object, it doesn't block parallel execution of transactions involving other independent owned objects.
- Move Code Structure: Shared objects are created explicitly using
sui::transfer::share_object(object_to_share). When anentryfunction in Move needs to modify a shared object, its argument type will be&mut MySharedObjector&mut sui::object::Object<MySharedObject>. Thesui::object::UIDinside the shared object identifies it globally. - CLI Usage:
- You create an object and then share it in an entry function:
sui client call --function create_and_share_pool --package <package_id> --module <module_name> --gas-budget 0.1. - Interacting with a shared object:
sui client call --function deposit_to_pool --package <package_id> --module <module_name> --args <pool_object_id> <amount> --gas-budget 0.1. sui client object <object_id>: ShowsOwner: Shared.
- You create an object and then share it in an entry function:
- Best Practices: Shared objects should be used sparingly and only when truly necessary, for example, for global registries, immutable constants, or liquidity pools where multiple users genuinely need to interact with the same instance. When designing shared objects, optimize for minimal contention (e.g., batching operations if possible, or using smaller, more numerous shared objects if the logic allows).
Sui vs. EVM & Development Best Practices:
- EVM Account Model: In EVM, all state changes go through the global state machine, implicitly requiring global consensus for every transaction. This limits parallelism. Funds are
balanceOf[address], not distinct token objects. - Sui Parallelism: The object model allows Sui to execute a significant portion of transactions in parallel. Transactions touching only owned objects don't need global consensus. This is a massive throughput advantage.
- Architectural Implications: Instead of monolithic smart contracts holding all user data, Sui encourages a design with many small, owned objects. For instance, a DeFi lending protocol might issue each user an owned
Loanobject instead of tracking all loans in a single, large shared contract. This makes your dApp inherently more scalable. - Security: Mutable references (
&mut) to shared objects are powerful but must be handled carefully. Ensure your access control logic within Move prevents unauthorized modifications. If a contract is shared, anyone can call itsentryfunctions, so robust checks are vital. - Common Mistakes & Errors:
- Over-reliance on Shared Objects: New developers often default to shared objects, replicating an EVM-like global state. This negates Sui's parallelism benefits and can lead to transaction failures due to contention.
- Incorrect Object Ownership: Attempting to mutate an owned object without being its owner will fail. Forgetting to transfer an object you created to the intended owner can also be an issue.
- Missing
&mutfor Shared Object Args: When calling a function that modifies a shared object, the object reference in the Moveentryfunction must be&mut. Forgetting this leads to compilation errors or runtime failures if the function tries to modify it. - Transaction Dry Runs/Debugging: Use
sui client dry-runto test transactions locally without spending gas. When a transaction involving shared objects fails due to contention, you'll often seeExecutionStatus::Failure { status: MoveAbort ... 0x...::shared_object_module::Error::ObjectContention }or similar. Retrying after a short delay might help, or redesigning the contract to reduce contention.
By embracing Sui's object model and prioritizing owned objects, you'll build more efficient, scalable, and resilient applications on the Sui network.
Sui does not provide a built-in Move library for Merkle proof verification. To verify a Merkle proof in a Move smart contract, you must implement the verification logic manually. Define a function that takes the leaf, the root, the proof (as a vector<vector<u8>>), and the index, then compute the hash path iteratively using a cryptographic hash function like BLAKE2b (available via sui::hash). Compare the computed root to the expected one. Ensure all input validations are in place to prevent malleability or bypass attacks. Since complex computations increase gas costs, optimize by limiting proof depth and avoiding storage-heavy operations. This approach is commonly used for light clients, bridge validations, or off-chain data attestations.
To verify a Merkle proof in a Move smart contract on the Sui Network, you need to reconstruct the Merkle root from the given leaf and proof nodes inside your smart contract, and then compare that result to the known root. In Sui, this process is slightly different from what you're used to on EVM chains because of the object-centric architecture and the limited built-in hashing functions. Instead of using keccak256 like in Solidity, you work with blake2b256, which is the supported hashing algorithm in Sui's standard library. First, you hash the leaf data using blake2b256, then loop through the vector of proof nodes (sibling hashes), combining each one with the current hash by concatenating them in sorted order, and rehashing at every step. This continues until you compute the root, which you then compare against the known root passed as a parameter or stored on-chain.
Here’s how you might structure that logic in Move:
use sui::hash::blake2b256;
use std::vector;
public fun verify_merkle_proof(
leaf: vector<u8>,
proof: vector<vector<u8>>,
root: vector<u8>
): bool {
let mut hash = blake2b256(&leaf);
let len = vector::length(&proof);
let mut i = 0;
while (i < len) {
let sibling = vector::borrow(&proof, i);
let concat = if hash < *sibling {
vector::concat(&hash, sibling)
} else {
vector::concat(sibling, &hash)
};
hash = blake2b256(&concat);
i = i + 1;
};
hash == root
}
In your Node.js or Sui CLI environment, you can simulate the same hash logic off-chain using tools like merkletreejs to generate leaves, proofs, and the root, and pass those to your contract call. When deploying or testing this on localnet or testnet, you can publish your Move module with sui move build and sui client publish, then call your function with sui client call passing the correct serialized leaf and proof values. A common mistake is misordering the concatenation of hash pairs or not hashing the initial leaf properly. You should also validate that proof items are always 32 bytes long and that the result of your final hash matches the expected root exactly.
Here’s a CLI example:
sui client call \
--package 0xYourPackage \
--module your_module \
--function verify_merkle_proof \
--args 0xYourLeaf '[0xSibling1, 0xSibling2]' 0xRootHash \
--gas-budget 10000000
The Sui-specific difference lies in how objects are managed—you might store your Merkle root in a has key object, then only allow specific admin capabilities to update that root. For security, always track usage of proofs to avoid replay attacks and log successful claims with emit_event for transparency. To go deeper, visit Sui’s Move documentation for more examples and architectural patterns tailored to object-based state management.
Do you know the answer?
Please log in and share it.
Sui is a Layer 1 protocol blockchain designed as the first internet-scale programmable blockchain platform.
- How to Maximize Profit Holding SUI: Sui Staking vs Liquid Staking616
- Why does BCS require exact field order for deserialization when Move structs have named fields?65
- Multiple Source Verification Errors" in Sui Move Module Publications - Automated Error Resolution55
- Sui Move Error - Unable to process transaction No valid gas coins found for the transaction419
- Sui Transaction Failing: Objects Reserved for Another Transaction410