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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

326 changes: 326 additions & 0 deletions app/onchain/contracts/aid_escrow/tests/merkle_allowlist_tool.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
#![cfg(test)]

//! Compatibility tests between `tools/merkle-allowlist` output and the
//! contract's `claim_with_proof` verifier (issue #425).
//!
//! The JS tool and these tests implement the *same* canonical encoding the
//! contract verifies: leaf = sha256(<Address string>), pair = sha256(left ||
//! right) with left <= right (byte-wise), hex without a 0x prefix. The root
//! and proof fixtures below were generated by running `node index.js` inside
//! `tools/merkle-allowlist`; if either side drifts, these tests fail.

use aid_escrow::{AidEscrow, AidEscrowClient, Error};
use soroban_sdk::{
token::{StellarAssetClient, TokenClient},
Address, Bytes, Env, Map, String, Symbol, Vec,
};

const UNIT: i128 = 10_000_000;

/// Canonical allowlist — must match `tools/merkle-allowlist/sample_allowlist.json`.
///
/// These are deterministic test-env contract addresses from the SDK's
/// `Address::generate` sequence, deliberately chosen from generator slots that
/// the harness below never consumes: `env.register(AidEscrow, ())` takes slot 0
/// (the escrow contract itself) and `register_stellar_asset_contract_v2` takes
/// slot 1 (the SAC issuer), so slots 2-4 are the first addresses the harness
/// itself will never use. Contract (C...) recipients can hold the test SAC
/// asset without a trustline, unlike G... accounts, so the harness must not
/// mint or transfer to G-addresses (SAC `TrustlineMissingError`).
const ALLOWLIST: [&str; 3] = [
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M",
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4",
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM",
];

/// Admin for the test harness — generator slot 5, deliberately outside the
/// allowlist and never consumed by the harness setup.
const ADMIN: &str = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4";

/// A stranger used in negative tests — generator slot 6, also not in the
/// allowlist.
const STRANGER: &str = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM";

/// Root emitted by `node tools/merkle-allowlist/index.js` for ALLOWLIST.
const EXPECTED_TOOL_ROOT: &str = "6e8a1e99583601eac4e0aa76aa9a3733c86de13844d59f6a3480995695a0bf6f";

/// Proof for ALLOWLIST[0] emitted by the same tool run.
const EXPECTED_TOOL_PROOF_0: [&str; 2] = [
"0ecb188156fe839e84b079ba797da21e155fef714cee6b0da7bfad7be630ea72",
"d8f338ba99f1755387461749eabca43fd379b61dab5909112f41b3bde74d0ca6",
];

// --- Canonical encoding (mirrors the contract's verifier) ---

fn leaf(env: &Env, address: &Address) -> [u8; 32] {
let addr = address.to_string();
let mut data = Bytes::new(env);
for b in addr.to_bytes().iter() {
data.push_back(b);
}
env.crypto().sha256(&data).to_array()
}

fn hash_pair(env: &Env, left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
let (a, b) = if left <= right {
(left, right)
} else {
(right, left)
};
let mut data = Bytes::new(env);
for byte in a.iter().chain(b.iter()) {
data.push_back(*byte);
}
env.crypto().sha256(&data).to_array()
}

fn build_root(env: &Env, leaves: &[[u8; 32]]) -> [u8; 32] {
let mut level: std::vec::Vec<[u8; 32]> = leaves.to_vec();
while level.len() > 1 {
let mut next: std::vec::Vec<[u8; 32]> = std::vec::Vec::new();
let mut i = 0;
while i < level.len() {
if i + 1 < level.len() {
next.push(hash_pair(env, &level[i], &level[i + 1]));
} else {
next.push(level[i]); // odd leaf promoted unchanged
}
i += 2;
}
level = next;
}
level[0]
}

fn build_proof(env: &Env, leaves: &[[u8; 32]], index: usize) -> std::vec::Vec<[u8; 32]> {
let mut level: std::vec::Vec<[u8; 32]> = leaves.to_vec();
let mut idx = index;
let mut proof: std::vec::Vec<[u8; 32]> = std::vec::Vec::new();
while level.len() > 1 {
let mut next: std::vec::Vec<[u8; 32]> = std::vec::Vec::new();
let mut i = 0;
while i < level.len() {
if i + 1 < level.len() {
next.push(hash_pair(env, &level[i], &level[i + 1]));
if i == idx {
proof.push(level[i + 1]);
} else if i + 1 == idx {
proof.push(level[i]);
}
} else {
next.push(level[i]);
}
i += 2;
}
idx /= 2;
level = next;
}
proof
}

fn to_hex(bytes: &[u8]) -> std::string::String {
let mut out = std::string::String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{:02x}", b));
}
out
}

fn strkey(a: &Address) -> std::string::String {
let sb = a.to_string().to_bytes();
let mut out = std::string::String::new();
for b in sb.iter() {
out.push(b as char);
}
out
}

fn allowlist_addresses(env: &Env) -> std::vec::Vec<Address> {
ALLOWLIST
.iter()
.map(|s| Address::from_str(env, s))
.collect()
}

fn allowlist_leaves(env: &Env) -> std::vec::Vec<[u8; 32]> {
allowlist_addresses(env)
.iter()
.map(|a| leaf(env, a))
.collect()
}

fn proof_to_soroban_vec(env: &Env, proof: &[[u8; 32]]) -> Vec<String> {
let mut out: Vec<String> = Vec::new(env);
for h in proof {
out.push_back(String::from_str(env, &to_hex(h)));
}
out
}

// --- Harness ---

struct Harness {
env: Env,
client: AidEscrowClient<'static>,
admin: Address,
token: Address,
token_client: TokenClient<'static>,
}

impl Harness {
fn new() -> Self {
let env = Env::default();
env.mock_all_auths();
let admin = Address::from_str(&env, ADMIN);
let client = AidEscrowClient::new(&env, &env.register(AidEscrow, ()));
client.init(&admin);

let token_contract = env.register_stellar_asset_contract_v2(admin.clone());
let token = token_contract.address();
let sac = StellarAssetClient::new(&env, &token);
sac.mint(&admin, &(100 * UNIT));
client.fund(&token, &admin, &(100 * UNIT));
let token_client = TokenClient::new(&env, &token);

Harness {
env,
client,
admin,
token,
token_client,
}
}

fn create_merkle_package(&self, id: u64, claimant: &Address, root_hex: &str) -> u64 {
let mut metadata = Map::new(&self.env);
metadata.set(
Symbol::new(&self.env, "merkle_root"),
String::from_str(&self.env, root_hex),
);
self.client.create_package(
&self.admin,
&id,
claimant,
&UNIT,
&self.token,
&86_400,
&metadata,
)
}
}

// --- Tests ---

/// The Rust re-computation of the canonical root/proofs matches the JS tool's
/// committed output — this is the tool <-> contract sync pin.
#[test]
fn rust_encoding_matches_tool_output() {
let env = Env::default();
let leaves = allowlist_leaves(&env);

assert_eq!(to_hex(&build_root(&env, &leaves)), EXPECTED_TOOL_ROOT);
let proof0 = build_proof(&env, &leaves, 0);
let proof0_hex: std::vec::Vec<std::string::String> = proof0.iter().map(|h| to_hex(h)).collect();
assert_eq!(proof0_hex, EXPECTED_TOOL_PROOF_0);
}

/// Every allowlist entry can claim a merkle-protected package on-chain with
/// the tool-format proof (multi-leaf tree, real claim_with_proof calls).
#[test]
fn claim_with_proof_accepts_tool_format_proofs() {
let h = Harness::new();
let addrs = allowlist_addresses(&h.env);
let leaves = allowlist_leaves(&h.env);
let root_hex = to_hex(&build_root(&h.env, &leaves));

for (i, addr) in addrs.iter().enumerate() {
let id = h.create_merkle_package(i as u64, addr, &root_hex);
let proof = proof_to_soroban_vec(&h.env, &build_proof(&h.env, &leaves, i));

let res = h.client.try_claim_with_proof(&id, addr, &proof);
assert_eq!(res, Ok(Ok(())), "entry {} should claim successfully", i);
let bal = h.token_client.balance(addr);
assert_eq!(
bal,
UNIT,
"entry {} balance: addr={}, admin={}, contract={}, UNIT={}",
i,
strkey(addr),
strkey(&h.admin),
h.token_client.balance(&h.admin),
bal
);
}
}

/// An address that is not in the allowlist cannot claim a merkle-protected
/// package, even with a proof for an allowlisted address, and a direct
/// (proof-less) claim on a merkle package is rejected.
#[test]
fn wrong_recipient_proof_is_rejected() {
let h = Harness::new();
let addrs = allowlist_addresses(&h.env);
let leaves = allowlist_leaves(&h.env);
let root_hex = to_hex(&build_root(&h.env, &leaves));

let id = h.create_merkle_package(0, &addrs[0], &root_hex);

// A stranger (not in the allowlist) presents entry 0's proof.
let stranger = Address::from_str(&h.env, STRANGER);
let wrong_proof = proof_to_soroban_vec(&h.env, &build_proof(&h.env, &leaves, 0));
let res = h.client.try_claim_with_proof(&id, &stranger, &wrong_proof);
assert_eq!(res, Err(Ok(Error::InvalidProof)));

// Direct claim is also rejected for merkle-protected packages.
let direct = h.client.try_claim(&id, &addrs[0]);
assert_eq!(direct, Err(Ok(Error::InvalidProof)));
}

/// Tampering with any sibling in the proof fails verification.
#[test]
fn tampered_proof_is_rejected() {
let h = Harness::new();
let addrs = allowlist_addresses(&h.env);
let leaves = allowlist_leaves(&h.env);
let root_hex = to_hex(&build_root(&h.env, &leaves));

let id = h.create_merkle_package(0, &addrs[0], &root_hex);

let mut tampered = build_proof(&h.env, &leaves, 0);
let first_hex = to_hex(&tampered[0]);
let flipped = format!(
"{}{}",
&first_hex[..first_hex.len() - 1],
if first_hex.ends_with('0') { '1' } else { '0' }
);
let mut raw = [0u8; 32];
for (k, byte) in raw.iter_mut().enumerate() {
*byte = u8::from_str_radix(&flipped[k * 2..k * 2 + 2], 16).unwrap();
}
tampered[0] = raw;

let proof = proof_to_soroban_vec(&h.env, &tampered);
let res = h.client.try_claim_with_proof(&id, &addrs[0], &proof);
assert_eq!(res, Err(Ok(Error::InvalidProof)));
}

/// A root built from a different tree (same leaves, different order) is
/// rejected even with a proof valid against the real root.
#[test]
fn mismatched_root_is_rejected() {
let h = Harness::new();
let addrs = allowlist_addresses(&h.env);
let leaves = allowlist_leaves(&h.env);
let real_root = to_hex(&build_root(&h.env, &leaves));

// Different tree: same leaves in reverse order.
let reversed: std::vec::Vec<[u8; 32]> = leaves.iter().rev().copied().collect();
let alt_root = to_hex(&build_root(&h.env, &reversed));

assert_ne!(real_root, alt_root);

let id = h.create_merkle_package(0, &addrs[0], &alt_root);
let proof = proof_to_soroban_vec(&h.env, &build_proof(&h.env, &leaves, 0));
let res = h.client.try_claim_with_proof(&id, &addrs[0], &proof);
assert_eq!(res, Err(Ok(Error::InvalidProof)));
}
Loading
Loading