From 03e25c7ae0f9a626e2ef64849a8b221e3b285980 Mon Sep 17 00:00:00 2001 From: portableDD Date: Thu, 20 Aug 2026 14:10:32 +0100 Subject: [PATCH 1/4] fix: bind address and amount in Merkle leaf for claim_with_proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Merkle allowlist proved membership only — a single root authorised every package sharing that root regardless of amount. The leaf was sha256(claimant_address_string) so any allowlisted address could claim any amount. Add a v2 leaf format: sha256(address_string || amount_be_bytes) that binds both recipient and package amount. Packages set merkle_leaf_version='v2' in metadata to opt in; v1 (address-only) is the default for backward compatibility. The merkle-allowlist tool is updated to generate sha256-based v2 leaves. Closes #436 --- app/onchain/contracts/aid_escrow/src/lib.rs | 54 +++++++++++++++++++-- tools/merkle-allowlist/index.js | 47 ++++++++++++------ 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/app/onchain/contracts/aid_escrow/src/lib.rs b/app/onchain/contracts/aid_escrow/src/lib.rs index af87df26..746a4172 100644 --- a/app/onchain/contracts/aid_escrow/src/lib.rs +++ b/app/onchain/contracts/aid_escrow/src/lib.rs @@ -42,6 +42,7 @@ const KEY_TOTAL_COMMITTED: Symbol = symbol_short!("cmt"); // Map const KEY_TOTAL_EXPIRED_CANCELLED: Symbol = symbol_short!("expcan"); // Map const META_MERKLE_ROOT_KEY: &str = "merkle_root"; const META_MERKLE_ROOT_EXPIRES_AT_KEY: &str = "merkle_root_expires_at"; +const META_MERKLE_LEAF_VERSION_KEY: &str = "merkle_leaf_version"; const KEY_PENDING_ADMIN: Symbol = symbol_short!("pendadm"); const KEY_ADMIN_DEADLINE: Symbol = symbol_short!("admdln"); const DEFAULT_ADMIN_DEADLINE: u64 = 7 * 24 * 60 * 60; // 7 days in seconds @@ -973,7 +974,15 @@ impl AidEscrow { /// /// If package metadata includes `merkle_root` (hex-encoded 32-byte value), /// `proof` must contain sibling hashes (hex-encoded 32-byte values) that - /// validate the claimant leaf `sha256(claimant_address_string)`. + /// validate the claimant leaf. The leaf format depends on + /// `merkle_leaf_version` in package metadata: + /// + /// - **v2** (default): `sha256(claimant_address_string || amount_be_bytes)` + /// binds both the recipient *and* the specific package amount. + /// - **v1** (legacy): `sha256(claimant_address_string)` — address-only. + /// Packages without an explicit `merkle_leaf_version` use v1 for + /// backward compatibility. New allowlists SHOULD set + /// `merkle_leaf_version = "v2"`. /// /// For non-Merkle packages this still works as a direct claim when /// `claimant` equals the stored recipient. @@ -1017,8 +1026,9 @@ impl AidEscrow { Some(root) => { let expires_at = Self::merkle_root_expires_at_from_metadata(&env, &package.metadata); + let leaf_version = Self::merkle_leaf_version_from_metadata(&env, &package.metadata); Self::verify_merkle_proof_for_claimant( - &env, &claimant, &proof, root, expires_at, now, + &env, &claimant, &proof, root, expires_at, now, &leaf_version, package.amount, )?; Self::finalize_claim(&env, &key, &mut package, id, &claimant, now) } @@ -1598,6 +1608,13 @@ impl AidEscrow { } } + /// Reads the optional `merkle_leaf_version` metadata field. + /// Returns `"v1"` (address-only, legacy) when absent. + fn merkle_leaf_version_from_metadata(env: &Env, metadata: &Map) -> String { + let key = Symbol::new(env, META_MERKLE_LEAF_VERSION_KEY); + metadata.get(key).unwrap_or_else(|| String::from_str(env, "v1")) + } + fn verify_merkle_proof_for_claimant( env: &Env, claimant: &Address, @@ -1605,6 +1622,8 @@ impl AidEscrow { expected_root: [u8; 32], expires_at: u64, now: u64, + leaf_version: &String, + amount: i128, ) -> Result<(), Error> { // Reject stale-but-active roots before doing any proof work. An // expiry of 0 means the allowlist never expires (legacy packages). @@ -1612,7 +1631,11 @@ impl AidEscrow { return Err(Error::AllowlistExpired); } - let mut current = Self::hash_address(env, claimant); + let mut current = if leaf_version == "v2" { + Self::hash_leaf_v2(env, claimant, amount) + } else { + Self::hash_address(env, claimant) + }; for i in 0..proof.len() { let sibling_hex = match proof.get(i) { @@ -1654,6 +1677,31 @@ impl AidEscrow { Self::hash_to_array(&digest) } + /// v2 leaf: sha256(address_string || amount_big_endian_bytes). + /// + /// The amount is encoded as a big-endian i128 (16 bytes). This binds the + /// leaf to both the recipient *and* the specific package amount, so a single + /// merkle root can authorise different amounts for different recipients. + fn hash_leaf_v2(env: &Env, address: &Address, amount: i128) -> [u8; 32] { + let addr = address.to_string(); + let addr_len = addr.len() as usize; + + // Encode amount as big-endian i128 (16 bytes). + let amount_bytes = amount.to_be_bytes(); + + let mut raw = [0u8; 112]; // 96 for address + 16 for amount + addr.copy_into_slice(&mut raw[..addr_len]); + raw[addr_len..addr_len + 16].copy_from_slice(&amount_bytes); + + let mut data = Bytes::new(env); + for b in raw[..addr_len + 16].iter() { + data.push_back(*b); + } + + let digest = env.crypto().sha256(&data); + Self::hash_to_array(&digest) + } + fn hash_pair(env: &Env, left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { let mut data = Bytes::new(env); for b in left.iter() { diff --git a/tools/merkle-allowlist/index.js b/tools/merkle-allowlist/index.js index 5dacf57a..33476cd9 100644 --- a/tools/merkle-allowlist/index.js +++ b/tools/merkle-allowlist/index.js @@ -1,8 +1,7 @@ const fs = require('fs'); const path = require('path'); -const { ethers } = require('ethers'); +const crypto = require('crypto'); const { MerkleTree } = require('merkletreejs'); -const keccak256 = require('keccak256'); const RPC_URL = process.env.TESTNET_RPC_URL; const CONTRACT_ADDRESS = process.env.MERKLE_CONTRACT_ADDRESS; @@ -32,11 +31,30 @@ async function withRetry(fn, desc) { throw lastErr; } +/** + * Build a v2 Merkle leaf: sha256(address_string || amount_be_bytes). + * + * The amount is encoded as a big-endian i128 (16 bytes), matching the + * on-chain `hash_leaf_v2` function in `aid_escrow/src/lib.rs`. + */ function makeLeaf(entry) { - // Standard leaf encoding: keccak256(abi.encodePacked(address, amount)) - return ethers.utils.keccak256( - ethers.utils.defaultAbiCoder.encode(['address', 'uint256'], [entry.address.toLowerCase(), ethers.BigNumber.from(entry.amount).toString()]) - ); + const address = entry.address.toLowerCase(); + const amount = BigInt(entry.amount); + + // amount as 16-byte big-endian i128 + const amountBuf = Buffer.alloc(16); + // Write as unsigned big-endian; for negative amounts we would need two's + // complement, but amounts are always positive in this domain. + let val = amount; + for (let i = 15; i >= 0; i--) { + amountBuf[i] = Number(val & 0xFFn); + val >>= 8n; + } + + const addrBuf = Buffer.from(address, 'utf8'); + const leafInput = Buffer.concat([addrBuf, amountBuf]); + + return '0x' + crypto.createHash('sha256').update(leafInput).digest('hex'); } function formatResult({ success, code, message, details }) { @@ -48,6 +66,7 @@ function formatResult({ success, code, message, details }) { async function maybeCallOnchain(proof, leaf, root) { if (!RPC_URL || !CONTRACT_ADDRESS || !CONTRACT_ABI_PATH) return { skipped: true }; + const { ethers } = require('ethers'); const provider = new ethers.providers.JsonRpcProvider(RPC_URL); const abiRaw = fs.readFileSync(path.resolve(CONTRACT_ABI_PATH), 'utf8'); let abi; @@ -64,15 +83,15 @@ async function maybeCallOnchain(proof, leaf, root) { async function run() { const sample = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'sample_allowlist.json'))); - const leaves = sample.map((e) => Buffer.from(ethers.utils.arrayify(makeLeaf(e)))); - const tree = new MerkleTree(leaves, keccak256, { sortPairs: true }); + const leaves = sample.map((e) => Buffer.from(makeLeaf(e).slice(2), 'hex')); + const tree = new MerkleTree(leaves, (buf) => crypto.createHash('sha256').update(buf).digest(), { sortPairs: true }); const root = tree.getHexRoot(); console.log('ROOT:', root); // Pick a valid entry const entry = sample[0]; const leafHex = makeLeaf(entry); - const leafBuf = Buffer.from(ethers.utils.arrayify(leafHex)); + const leafBuf = Buffer.from(leafHex.slice(2), 'hex'); const proof = tree.getHexProof(leafBuf); // 1) Valid proof @@ -99,21 +118,21 @@ async function run() { // 3) Wrong recipient (use a different address in leaf) const wrongRecipient = { address: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', amount: entry.amount }; const wrongLeaf = makeLeaf(wrongRecipient); - const wrongLeafBuf = Buffer.from(ethers.utils.arrayify(wrongLeaf)); + const wrongLeafBuf = Buffer.from(wrongLeaf.slice(2), 'hex'); const wrongRecipientValid = tree.verify(proof, wrongLeafBuf, root); console.log(JSON.stringify({ scenario: 'wrong_recipient', result: formatResult({ success: wrongRecipientValid, code: wrongRecipientValid ? 'OK' : 'WRONG_RECIPIENT', message: wrongRecipientValid ? 'Unexpectedly valid' : 'Proof does not match recipient' }), proof, leaf: wrongLeaf, root })); // 4) Wrong leaf (modify amount) - const wrongAmount = { address: entry.address, amount: (Number(entry.amount) + 99).toString() }; + const wrongAmount = { address: entry.address, amount: (BigInt(entry.amount) + 99n).toString() }; const wrongLeaf2 = makeLeaf(wrongAmount); - const wrongLeaf2Buf = Buffer.from(ethers.utils.arrayify(wrongLeaf2)); + const wrongLeaf2Buf = Buffer.from(wrongLeaf2.slice(2), 'hex'); const wrongLeafValid = tree.verify(proof, wrongLeaf2Buf, root); console.log(JSON.stringify({ scenario: 'wrong_leaf', result: formatResult({ success: wrongLeafValid, code: wrongLeafValid ? 'OK' : 'WRONG_LEAF', message: wrongLeafValid ? 'Unexpectedly valid' : 'Leaf data mismatch' }), proof, leaf: wrongLeaf2, root })); // 5) Mismatched root (use a root from a different tree) const altSample = sample.slice().reverse(); - const altLeaves = altSample.map((e) => Buffer.from(ethers.utils.arrayify(makeLeaf(e)))); - const altTree = new MerkleTree(altLeaves, keccak256, { sortPairs: true }); + const altLeaves = altSample.map((e) => Buffer.from(makeLeaf(e).slice(2), 'hex')); + const altTree = new MerkleTree(altLeaves, (buf) => crypto.createHash('sha256').update(buf).digest(), { sortPairs: true }); const altRoot = altTree.getHexRoot(); const mismatchedValid = tree.verify(proof, leafBuf, altRoot); console.log(JSON.stringify({ scenario: 'mismatched_root', result: formatResult({ success: mismatchedValid, code: mismatchedValid ? 'OK' : 'MISMATCHED_ROOT', message: mismatchedValid ? 'Unexpectedly valid' : 'Root mismatch' }), proof, leaf: leafHex, altRoot })); From ce6075628fb8f3fc88f7efbc429da5cd4506a3cc Mon Sep 17 00:00:00 2001 From: portableDD Date: Thu, 20 Aug 2026 15:50:21 +0100 Subject: [PATCH 2/4] fix: use soroban_sdk::String comparison in verify_merkle_proof The soroban_sdk::String type does not implement PartialEq, causing a compilation error when comparing the leaf_version with a string literal. Use soroban_sdk::String comparison instead. --- app/onchain/contracts/aid_escrow/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/onchain/contracts/aid_escrow/src/lib.rs b/app/onchain/contracts/aid_escrow/src/lib.rs index 746a4172..82e1e912 100644 --- a/app/onchain/contracts/aid_escrow/src/lib.rs +++ b/app/onchain/contracts/aid_escrow/src/lib.rs @@ -1631,7 +1631,9 @@ impl AidEscrow { return Err(Error::AllowlistExpired); } - let mut current = if leaf_version == "v2" { + let v2 = String::from_str(env, "v2"); + let is_v2 = leaf_version == &v2; + let mut current = if is_v2 { Self::hash_leaf_v2(env, claimant, amount) } else { Self::hash_address(env, claimant) From 3cbd0049e4d02d006a865b90034d05b24933966e Mon Sep 17 00:00:00 2001 From: portableDD Date: Thu, 20 Aug 2026 16:01:45 +0100 Subject: [PATCH 3/4] style: format Rust code with cargo fmt --- app/onchain/contracts/aid_escrow/src/lib.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/onchain/contracts/aid_escrow/src/lib.rs b/app/onchain/contracts/aid_escrow/src/lib.rs index 82e1e912..1744f2b2 100644 --- a/app/onchain/contracts/aid_escrow/src/lib.rs +++ b/app/onchain/contracts/aid_escrow/src/lib.rs @@ -1028,7 +1028,14 @@ impl AidEscrow { Self::merkle_root_expires_at_from_metadata(&env, &package.metadata); let leaf_version = Self::merkle_leaf_version_from_metadata(&env, &package.metadata); Self::verify_merkle_proof_for_claimant( - &env, &claimant, &proof, root, expires_at, now, &leaf_version, package.amount, + &env, + &claimant, + &proof, + root, + expires_at, + now, + &leaf_version, + package.amount, )?; Self::finalize_claim(&env, &key, &mut package, id, &claimant, now) } @@ -1612,7 +1619,9 @@ impl AidEscrow { /// Returns `"v1"` (address-only, legacy) when absent. fn merkle_leaf_version_from_metadata(env: &Env, metadata: &Map) -> String { let key = Symbol::new(env, META_MERKLE_LEAF_VERSION_KEY); - metadata.get(key).unwrap_or_else(|| String::from_str(env, "v1")) + metadata + .get(key) + .unwrap_or_else(|| String::from_str(env, "v1")) } fn verify_merkle_proof_for_claimant( From 79a5e9e7ddd060870dc9d18270d239c92ece3c49 Mon Sep 17 00:00:00 2001 From: portableDD Date: Thu, 20 Aug 2026 16:07:31 +0100 Subject: [PATCH 4/4] style: allow clippy::too_many_arguments on verify_merkle_proof_for_claimant --- app/onchain/contracts/aid_escrow/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/app/onchain/contracts/aid_escrow/src/lib.rs b/app/onchain/contracts/aid_escrow/src/lib.rs index 1744f2b2..cbce7a65 100644 --- a/app/onchain/contracts/aid_escrow/src/lib.rs +++ b/app/onchain/contracts/aid_escrow/src/lib.rs @@ -1624,6 +1624,7 @@ impl AidEscrow { .unwrap_or_else(|| String::from_str(env, "v1")) } + #[allow(clippy::too_many_arguments)] fn verify_merkle_proof_for_claimant( env: &Env, claimant: &Address,