diff --git a/app/onchain/contracts/aid_escrow/src/lib.rs b/app/onchain/contracts/aid_escrow/src/lib.rs index 9607d9bf..43e03422 100644 --- a/app/onchain/contracts/aid_escrow/src/lib.rs +++ b/app/onchain/contracts/aid_escrow/src/lib.rs @@ -53,6 +53,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 @@ -1040,7 +1041,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. @@ -1084,8 +1093,16 @@ 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, &claimant, now) } @@ -1767,6 +1784,16 @@ 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")) + } + + #[allow(clippy::too_many_arguments)] fn verify_merkle_proof_for_claimant( env: &Env, claimant: &Address, @@ -1774,6 +1801,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). @@ -1781,7 +1810,13 @@ impl AidEscrow { return Err(Error::AllowlistExpired); } - let mut current = Self::hash_address(env, claimant); + 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) + }; for i in 0..proof.len() { let sibling_hex = match proof.get(i) { @@ -1823,6 +1858,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 da1e3922..a37d7323 100644 --- a/tools/merkle-allowlist/index.js +++ b/tools/merkle-allowlist/index.js @@ -6,9 +6,10 @@ * The contract (app/onchain/contracts/aid_escrow/src/lib.rs) verifies proofs * with: * - * leaf = sha256() - * pair = sha256(sorted(left, right)) // sorted = byte-wise ascending - * root = the Merkle root built with that leaf and pairing rule + * v1 leaf: sha256() + * v2 leaf: sha256( || ) + * pair: sha256(sorted(left, right)) // sorted = byte-wise ascending + * root: the Merkle root built with that leaf and pairing rule * * Proofs are hex-encoded (64 lowercase hex chars, no 0x prefix), bottom-up, * one sibling per tree level. The `merkle_root` and `merkle_root_expires_at` @@ -29,15 +30,40 @@ const path = require('path'); const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest(); /** - * Contract-compatible leaf: sha256 of the exact bytes `Address::to_string()` - * produces on-chain. The amount is intentionally NOT part of the leaf — the - * contract's verifier only binds the claimant address (see - * `hash_address` in src/lib.rs). + * v1 Contract-compatible leaf: sha256 of the exact bytes `Address::to_string()` + * produces on-chain. */ -function makeLeaf(addressString) { +function makeLeafV1(addressString) { return sha256(Buffer.from(addressString, 'utf8')); } +/** + * v2 Contract-compatible 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 makeLeafV2(addressString, amount) { + const amountBuf = Buffer.alloc(16); + let val = BigInt(amount); + for (let i = 15; i >= 0; i--) { + amountBuf[i] = Number(val & 0xFFn); + val >>= 8n; + } + const addrBuf = Buffer.from(addressString, 'utf8'); + return sha256(Buffer.concat([addrBuf, amountBuf])); +} + +/** + * Creates a leaf based on the entry format. + * If entry has an `amount` field, uses v2 format; otherwise v1. + */ +function makeLeaf(entry) { + if (typeof entry === 'string') return makeLeafV1(entry); + if (entry.amount !== undefined) return makeLeafV2(entry.address, entry.amount); + return makeLeafV1(entry.address); +} + /** Contract-compatible pair combine: sort the two 32-byte hashes byte-wise * ascending, then hash left || right with sha256. */ function hashPair(left, right) { @@ -105,16 +131,20 @@ function main() { throw new Error('sample_allowlist.json must be a non-empty array of { address } entries'); } + const hasAmount = sample.length > 0 && sample[0].amount !== undefined; + const leafVersion = hasAmount ? 'v2' : 'v1'; + const leaves = sample.map((entry) => { if (typeof entry.address !== 'string' || !/^[A-Z2-7]{56}$/.test(entry.address)) { throw new Error(`Invalid Stellar address in allowlist: ${entry.address}`); } - return makeLeaf(entry.address); + return makeLeaf(entry); }); const root = buildRoot(leaves); const rootHex = toHex(root); console.log(`ROOT: ${rootHex}`); + console.log(`LEAF_VERSION: ${leafVersion}`); // 1) Valid proof for the first entry. const validIndex = 0; @@ -158,7 +188,7 @@ function main() { // 3) Wrong recipient (a leaf for an address not in the tree). const stranger = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; - const wrongLeaf = makeLeaf(stranger); + const wrongLeaf = makeLeaf({ address: stranger, amount: hasAmount ? sample[0].amount : undefined }); const wrongRecipientValid = verify(wrongLeaf, proof, root); console.log( JSON.stringify({ @@ -216,6 +246,7 @@ function main() { // metadata and distributing per-recipient proofs). const entries = sample.map((entry, i) => ({ address: entry.address, + ...(hasAmount ? { amount: entry.amount } : {}), proof: buildProof(leaves, i).map(toHex), })); console.log(JSON.stringify({ scenario: 'proofs', entries })); diff --git a/tools/merkle-allowlist/sample_allowlist.json b/tools/merkle-allowlist/sample_allowlist.json index 3acd33c3..58a4b3c6 100644 --- a/tools/merkle-allowlist/sample_allowlist.json +++ b/tools/merkle-allowlist/sample_allowlist.json @@ -1,5 +1,5 @@ [ - { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" }, - { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" }, - { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" } + { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", "amount": "1000" }, + { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", "amount": "2000" }, + { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", "amount": "3000" } ]