fix(merkle-allowlist): emit sha256 Soroban-compatible proofs instead of keccak256 - #448
Merged
kilodesodiq-arch merged 1 commit intoAug 20, 2026
Conversation
…of keccak256 The tool built EVM-style keccak256(abi.encodePacked(address, amount)) leaves for an Ethereum verifier, so every proof it produced failed on-chain with Error::InvalidProof while printing "valid" locally. Rewrite it on Node's crypto sha256 with the contract's exact leaf (sha256 of the canonical Address string) and sorted-pair combine, and drop ethers/merkletreejs/keccak256. Pin the agreement with a Rust compat test that recomputes the root and claims on-chain with the tool's proofs.
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #425
tools/merkle-allowlistbuilt EVM-style proofs (keccak256 +abi.encodePacked(address, amount)viaethers/merkletreejs) for a verifier the Sorobanaid_escrowcontract does not run, so every root and proof it emitted failed on-chain withError::InvalidProofwhile printing "valid" locally — a false-positive smoke signal. This PR rewrites the tool on Node's built-incryptoSHA-256 with the contract's exact leaf (sha256(<canonical Address string>)) and sorted-pair combine, drops the EVM dependency stack entirely, and pins the JS↔Rust agreement with a new contract-side compatibility test suite that recomputes the tool's root and claims on-chain with its proofs.The single most important design decision: the canonical encoding is now defined in one place (the tool README, mirrored by doc comments) and enforced bidirectionally — the Rust test recomputes the tool's committed root and proofs from
sample_allowlist.json, so any drift on either side fails CI.Why
The old tool's leaf was
keccak256(abi.encodePacked(address, amount))— a keccak hash over address and amount — whileverify_merkle_proof_for_claimantrecomputessha256(claimant.to_string())binding only the address. Three independent incompatibilities (hash function, leaf payload, address casing/encoding) meant every generated proof was rejected on-chain. The tool even shipped an "on-chain verification" path wired toethers.JsonRpcProvider+MERKLE_CONTRACT_ADDRESS/MERKLE_CONTRACT_ABI_PATH, which cannot talk to a Soroban contract. The fix is not a dependency swap: it replicates the contract's exact byte-level algorithm in JS and proves equivalence with on-chain tests.The amount-binding question is deliberately left untouched (it is a separate open issue, per the issue's own scope): the tool now emits the address-only leaf the contract actually verifies and documents that binding amounts is a future, coordinated change.
What was built
tools/merkle-allowlist/:index.jsmakeLeaf=sha256(addressString)(matcheshash_addressbyte-for-byte),hashPair=sha256(left || right)withleft <= rightbyte-wise,buildRoot/buildProofwith odd-leaf promotion, and averifywalk identical toverify_merkle_proof_for_claimant. Emits the root plus thevalid,invalid_proof_path,wrong_recipient,wrong_leaf, andmismatched_rootscenarios with stableresultobjects, plus a per-entry proof dump.package.jsonethers,merkletreejs,keccak256removed; zero runtime dependencies (engines.node >= 16).sample_allowlist.jsonREADME.mdNew test file (matching test):
app/onchain/contracts/aid_escrow/tests/merkle_allowlist_tool.rs— 5 tests:rust_encoding_matches_tool_output— recomputes the root and proof forsample_allowlist.jsonand asserts they equal the tool's committed output (the sync pin).claim_with_proof_accepts_tool_format_proofs— every allowlist entry claims a real merkle-protected package on-chain with tool-format proofs and receives the payout.wrong_recipient_proof_is_rejected— a stranger presenting an allowlisted entry's proof fails withInvalidProof; a direct (proof-less) claim on a merkle package also fails.tampered_proof_is_rejected— flipping one bit of a sibling in the proof fails verification.mismatched_root_is_rejected— a root from a reordered tree is rejected even with a proof valid against the real root.Acceptance criteria coverage
node index.jsgenerates a root and proof thatverify_merkle_proof_for_claimant(sha256, sorted-pair) accepts for a known leaf; a test or smoke script demonstrates on-chain acceptance. (tests/merkle_allowlist_tool.rs—rust_encoding_matches_tool_outputpins the exact hashes;claim_with_proof_accepts_tool_format_proofsperforms real on-chain claims with those proofs.)valid,invalid_proof_path,wrong_recipient,wrong_leaf, andmismatched_rootscenarios all behave correctly against the Soroban leaf encoding. (Tool output:valid→success: true; the other four → the documented error codes; negative cases mirrored on-chain in the Rust tests.)ethers,merkletreejs, andkeccak256are removed fromtools/merkle-allowlist/package.json. (package.jsonnow has nodependenciesfield.)tools/merkle-allowlist/README.mddocuments the exact leaf byte string and hash/pairing algorithm so Rust and JS stay in sync. (New "Canonical encoding" table + "Previous EVM implementation" section explaining why the old format is gone.)Deliberately deferred
amount(orpackage_id) into the leaf is a separate open issue per the issue's scope; the tool now emits the contract's actual address-only leaf and the README names the dependency explicitly rather than silently encoding amounts.maybeCallOnchainSoroban equivalent — the old EVM on-chain verification path is removed (it targeted an Ethereum JSON-RPC endpoint and cannot talk to Soroban). A real on-chain smoke script is out of scope; the Rust test suite provides the on-chain acceptance proof instead.Test plan
cargo test --package aid_escrow --test merkle_allowlist_tool— 5/5 passing (5 new)cargo test --package aid_escrow— 193/193 passing (0 failures; 5 new tests for this feature)cargo fmt --all -- --check— cleancargo clippy --tests --target x86_64-unknown-linux-gnu— 0 warnings/errorscargo clippy --target wasm32-unknown-unknown— 0 warnings/errorsnode index.js(intools/merkle-allowlist) — all 5 scenarios behave correctlyEnv vars / Notes
No new environment variables. The previous
TESTNET_RPC_URL/MERKLE_CONTRACT_ADDRESS/MERKLE_CONTRACT_ABI_PATHvariables and thenpm ciinstall step are gone — the tool is dependency-free and offline. Any root previously published from the old tool is invalid under the corrected encoding and must be regenerated; the contract-side verifier is unchanged, so no contract migration is involved.