Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 63 additions & 3 deletions app/onchain/contracts/aid_escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const KEY_TOTAL_COMMITTED: Symbol = symbol_short!("cmt"); // Map<Address, i128>
const KEY_TOTAL_EXPIRED_CANCELLED: Symbol = symbol_short!("expcan"); // Map<Address, i128>
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1017,8 +1026,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, now)
}
Expand Down Expand Up @@ -1598,21 +1615,39 @@ 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<Symbol, String>) -> 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,
proof: &Vec<String>,
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).
if expires_at > 0 && expires_at <= now {
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) {
Expand Down Expand Up @@ -1654,6 +1689,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() {
Expand Down
47 changes: 33 additions & 14 deletions tools/merkle-allowlist/index.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 }) {
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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 }));
Expand Down
Loading