Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -53,6 +53,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 @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -1767,21 +1784,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 @@ -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() {
Expand Down
51 changes: 41 additions & 10 deletions tools/merkle-allowlist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
* The contract (app/onchain/contracts/aid_escrow/src/lib.rs) verifies proofs
* with:
*
* leaf = sha256(<canonical Address string>)
* pair = sha256(sorted(left, right)) // sorted = byte-wise ascending
* root = the Merkle root built with that leaf and pairing rule
* v1 leaf: sha256(<canonical Address string>)
* v2 leaf: sha256(<canonical Address string> || <amount big-endian i128>)
* 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`
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 }));
Expand Down
6 changes: 3 additions & 3 deletions tools/merkle-allowlist/sample_allowlist.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[
{ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" },
{ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" },
{ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" }
{ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", "amount": "1000" },
{ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", "amount": "2000" },
{ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", "amount": "3000" }
]
Loading