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" }
]