From 34fd08e0f14295456ac54a1f965e4cec11828b73 Mon Sep 17 00:00:00 2001 From: CodingBabe-1 Date: Fri, 28 Aug 2026 10:36:48 +0000 Subject: [PATCH 1/3] fix(contract): harden custody accounting and transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add re-entry protection, verified custody transfers, locked-balance tracking, checked counters, and canonical escrow/stream events so token exits cannot drift from contract state. ๐Ÿค– Generated with Codebuff Co-Authored-By: Codebuff --- contracts/finchippay-contract/README.md | 18 +- contracts/finchippay-contract/src/lib.rs | 415 ++++++++++++++++++----- docs/architecture.md | 13 +- 3 files changed, 346 insertions(+), 100 deletions(-) diff --git a/contracts/finchippay-contract/README.md b/contracts/finchippay-contract/README.md index 0500e0d2..07857ed5 100644 --- a/contracts/finchippay-contract/README.md +++ b/contracts/finchippay-contract/README.md @@ -52,6 +52,8 @@ Recipients can call `claim_stream` at any time to drain accrued tokens. Payers c - Multi-sig proposals have a minimum of `MIN_MULTISIG_AMOUNT` and can include an `expiration_ledger` to auto-expire abandoned proposals. - Multi-sig signer lists are checked for duplicates at creation time. - Self-transfers (from == to) are rejected for tips, escrows, streams, and multi-sig. +- Custody transitions use an instance-scoped re-entry lock and verify exact contract-balance deltas on outbound transfers. +- Per-token locked-balance accounting is increased on deposits and decreased before every tracked payout or refund; `rescue_tokens` can only withdraw the unlocked balance. - Batch sends are limited to `MAX_BATCH_SIZE` (50) recipients and amounts are pre-validated for atomicity. - All operational entry points require the contract to be initialized via `initialize()`. @@ -84,13 +86,13 @@ bash ../../scripts/deploy-contract.sh | `(admin_transfer,)` | `new_admin: Address` | `transfer_admin` | | `(tip, from, to)` | `amount: i128` | `send_tip` | | `(receipt, from)` | `index: u32` | `mint_receipt` | -| `(escrow_create, id)` | `(from, to, amount, release_ledger)` | `create_escrow` | -| `(escrow_claim, id)` | `(to, amount)` | `claim_escrow` | -| `(escrow_cancel, id)` | `(from, amount)` | `cancel_escrow` | -| `(stream_open, id)` | `(payer, recipient, rate, deposit)` | `open_stream` | -| `(stream_claim, id)` | `(recipient, amount)` | `claim_stream` | -| `(stream_topup, id)` | `(payer, amount)` | `top_up_stream` | -| `(stream_close, id)` | `(payer, refund)` | `close_stream` | +| `(escrow_created, id)` | `(from, to, amount, release_ledger)` | `create_escrow` | +| `(escrow_released, id)` | `(to, amount)` | `claim_escrow` | +| `(escrow_cancelled,)` | `(id, from, amount)` | `cancel_escrow` | +| `(stream_opened, id)` | `(payer, recipient, rate, deposit)` | `open_stream` | +| `(stream_claimed, id)` | `(recipient, amount)` | `claim_stream` | +| `(stream_topped_up,)` | `(id, payer, added, new_deposit)` | `top_up_stream` | +| `(stream_closed, id)` | `(payer, refund)` | `close_stream` | | `(multisig_create, id)` | `(proposer, recipient, amount, threshold)` | `create_multisig` | | `(multisig_approve, id)` | `(signer, current_approvals, threshold)` | `approve_multisig` | | `(multisig_executed, id)` | `(recipient, amount)` | `approve_multisig` (auto) | @@ -98,7 +100,7 @@ bash ../../scripts/deploy-contract.sh | `(multisig_timeout, id)` | `(proposer, amount)` | `timeout_multisig` | | `(stream_reject, id)` | `(recipient, refund)` | `reject_stream` | | `(stream_transfer, id)` | `(old_recipient, new_recipient)` | `transfer_stream` | -| `(escrow_claim_partial, id)` | `(to, claim_amount, remaining)` | `claim_escrow_partial` | +| `(escrow_partial_released, id)` | `(to, claim_amount, remaining)` | `claim_escrow_partial` | | `(rescue_tokens,)` | `(token, amount, to)` | `rescue_tokens` | | `(pauser_set,)` | `pauser: Address` | `set_pauser` | | `(batch_send, from)` | `count: u32` | `batch_send` | diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs index f4438fad..aefc3c12 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -80,6 +80,9 @@ pub enum ContractError { /// Token transfer succeeded but the actual balance did not increase by /// the expected amount (possible malicious/fake token contract). TransferFailed = 17, + /// A value-moving operation was re-entered before its state transition + /// completed. + ReentrantCall = 18, } // โ”€โ”€โ”€ Shared data types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -256,6 +259,12 @@ pub enum DataKey { // Multi-sig MultiSigCount, MultiSig(u32), + /// Total funds held by the contract for active custody records, per token. + LockedBalance(Address), + /// Last observed contract balance, used for custody-accounting diagnostics. + LastContractBalance(Address), + /// Instance-scoped lock protecting custody transitions from re-entry. + TransitionLock, } // โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -304,6 +313,85 @@ fn require_transfer_succeeded( if balance_after < expected_min { panic!("TransferFailed"); } + +} + +fn locked_balance(env: &Env, token_address: &Address) -> i128 { + let key = DataKey::LockedBalance(token_address.clone()); + let value = env.storage().persistent().get(&key).unwrap_or(0); + if env.storage().persistent().has(&key) { + bump(env, &key); + } + value +} + +fn increase_locked_balance(env: &Env, token_address: &Address, amount: i128) { + if amount <= 0 { + panic!("amount must be positive"); + } + let key = DataKey::LockedBalance(token_address.clone()); + let updated = locked_balance(env, token_address) + .checked_add(amount) + .expect("locked balance overflow"); + env.storage().persistent().set(&key, &updated); + bump(env, &key); +} + +fn decrease_locked_balance(env: &Env, token_address: &Address, amount: i128) { + if amount <= 0 { + panic!("amount must be positive"); + } + let key = DataKey::LockedBalance(token_address.clone()); + let updated = locked_balance(env, token_address) + .checked_sub(amount) + .expect("locked balance underflow"); + env.storage().persistent().set(&key, &updated); + bump(env, &key); +} + +fn cache_contract_balance(env: &Env, token_address: &Address, balance: i128) { + let key = DataKey::LastContractBalance(token_address.clone()); + env.storage().persistent().set(&key, &balance); + bump(env, &key); +} + +/// Transfer custody funds out of the contract and verify the contract balance +/// decreased by exactly the requested amount. Callers must update their record +/// and locked-balance accounting before invoking this helper. +fn contract_transfer_out( + env: &Env, + token_address: &Address, + to: &Address, + amount: &i128, +) { + if *amount <= 0 { + panic!("amount must be positive"); + } + let token = get_token_client(env, token_address); + let contract = env.current_contract_address(); + let balance_before = token.balance(&contract); + let expected = balance_before.checked_sub(*amount).expect("underflow"); + cache_contract_balance(env, token_address, expected); + token.transfer(&contract, to, amount); + let balance_after = token.balance(&contract); + if balance_after != expected { + panic!("TransferFailed"); + } + cache_contract_balance(env, token_address, balance_after); +} + +fn with_transition_lock(env: &Env, f: F) -> R +where + F: FnOnce() -> R, +{ + let key = DataKey::TransitionLock; + if env.storage().instance().has(&key) { + panic!("ReentrantCall"); + } + env.storage().instance().set(&key, &true); + let result = f(); + env.storage().instance().remove(&key); + result } /// Check that the contract is not paused. Panics with `ContractPaused` if it is. @@ -468,27 +556,29 @@ impl FinchippayContract { /// Admin: upgrade the contract WASM to `new_wasm_hash`. /// - /// After a successful upgrade the stored version is incremented so off-chain - /// indexers can detect the change. + /// Upgrades are deliberately gated by the current admin in this checkout. + /// The stored version is incremented only after the WASM update succeeds. pub fn upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>) { + require_initialized(&env); admin.require_auth(); let stored = get_admin(&env); if admin != stored { panic!("Unauthorized"); } - env.deployer().update_current_contract_wasm(new_wasm_hash.clone()); let current_ver: u32 = env .storage() .persistent() .get(&DataKey::Version) .unwrap_or(CONTRACT_VERSION); + let next_ver = current_ver.checked_add(1).expect("version overflow"); + env.deployer().update_current_contract_wasm(new_wasm_hash.clone()); env.storage() .persistent() - .set(&DataKey::Version, &(current_ver + 1)); + .set(&DataKey::Version, &next_ver); bump(&env, &DataKey::Version); env.events().publish( (Symbol::new(&env, "upgraded"),), - (current_ver + 1, new_wasm_hash), + (next_ver, new_wasm_hash), ); } @@ -503,6 +593,17 @@ impl FinchippayContract { amount: i128, to: Address, ) { + with_transition_lock(&env, || Self::rescue_tokens_inner(env, admin, token_address, amount, to)); + } + + fn rescue_tokens_inner( + env: Env, + admin: Address, + token_address: Address, + amount: i128, + to: Address, + ) { + require_initialized(&env); admin.require_auth(); let stored = get_admin(&env); if admin != stored { @@ -512,7 +613,15 @@ impl FinchippayContract { panic!("amount must be positive"); } let token = get_token_client(&env, &token_address); - token.transfer(&env.current_contract_address(), &to, &amount); + let contract = env.current_contract_address(); + let balance_before = token.balance(&contract); + let unlocked = balance_before + .checked_sub(locked_balance(&env, &token_address)) + .expect("locked balance exceeds actual balance"); + if amount > unlocked { + panic!("amount exceeds unlocked balance"); + } + contract_transfer_out(&env, &token_address, &to, &amount); env.events().publish( (Symbol::new(&env, "rescue_tokens"),), @@ -528,6 +637,10 @@ impl FinchippayContract { /// Panics if `amount <= 0`, the contract is paused, or `from` has not /// authorised the call. pub fn send_tip(env: Env, token_address: Address, from: Address, to: Address, amount: i128, memo: Symbol) { + with_transition_lock(&env, || Self::send_tip_inner(env, token_address, from, to, amount, memo)); + } + + fn send_tip_inner(env: Env, token_address: Address, from: Address, to: Address, amount: i128, memo: Symbol) { require_initialized(&env); require_not_paused(&env); from.require_auth(); @@ -557,9 +670,10 @@ impl FinchippayContract { .set(&DataKey::TipTotal(to.clone()), &new_total); bump(&env, &DataKey::TipTotal(to.clone())); + let next_count = count.checked_add(1).expect("tip count overflow"); env.storage() .persistent() - .set(&DataKey::TipCount(to.clone()), &(count + 1)); + .set(&DataKey::TipCount(to.clone()), &next_count); bump(&env, &DataKey::TipCount(to.clone())); let record = TipRecord { @@ -642,9 +756,10 @@ impl FinchippayContract { .set(&DataKey::ReceiptRecord(from.clone(), count), &receipt); bump(&env, &DataKey::ReceiptRecord(from.clone(), count)); + let next_count = count.checked_add(1).expect("receipt count overflow"); env.storage() .persistent() - .set(&DataKey::ReceiptCount(from.clone()), &(count + 1)); + .set(&DataKey::ReceiptCount(from.clone()), &next_count); bump(&env, &DataKey::ReceiptCount(from.clone())); env.events() @@ -688,6 +803,20 @@ impl FinchippayContract { amount: i128, release_ledger: u32, memo: Symbol, + ) -> u32 { + with_transition_lock(&env, || { + Self::create_escrow_inner(env, token_address, from, to, amount, release_ledger, memo) + }) + } + + fn create_escrow_inner( + env: Env, + token_address: Address, + from: Address, + to: Address, + amount: i128, + release_ledger: u32, + memo: Symbol, ) -> u32 { require_initialized(&env); require_not_paused(&env); @@ -707,13 +836,20 @@ impl FinchippayContract { if release_ledger <= env.ledger().sequence() { panic!("release_ledger must be in the future"); } - if release_ledger > env.ledger().sequence() + MAX_ESCROW_LEDGERS { + let max_release_ledger = env + .ledger() + .sequence() + .checked_add(MAX_ESCROW_LEDGERS) + .expect("release ledger overflow"); + if release_ledger > max_release_ledger { panic!("release_ledger is too far in the future"); } let token = get_token_client(&env, &token_address); let contract_address = env.current_contract_address(); require_transfer_succeeded(&env, &token, &from, &contract_address, &amount); + cache_contract_balance(&env, &token_address, token.balance(&contract_address)); + increase_locked_balance(&env, &token_address, amount); let next_id: u32 = env .storage() @@ -734,9 +870,10 @@ impl FinchippayContract { .persistent() .set(&DataKey::Escrow(next_id), &escrow); bump(&env, &DataKey::Escrow(next_id)); + let next_count = next_id.checked_add(1).expect("escrow count overflow"); env.storage() .persistent() - .set(&DataKey::EscrowCount, &(next_id + 1)); + .set(&DataKey::EscrowCount, &next_count); bump(&env, &DataKey::EscrowCount); // Index escrow under recipient for queries. @@ -753,7 +890,7 @@ impl FinchippayContract { } env.events().publish( - (Symbol::new(&env, "escrow_create"), next_id), + (Symbol::new(&env, "escrow_created"), next_id), (from.clone(), to.clone(), amount, release_ledger), ); next_id @@ -763,6 +900,11 @@ impl FinchippayContract { /// escrow recipient and the release ledger must have passed. /// Returns the remaining escrow amount after the partial claim. pub fn claim_escrow_partial(env: Env, id: u32, claim_amount: i128) -> i128 { + with_transition_lock(&env, || Self::claim_escrow_partial_inner(env, id, claim_amount)) + } + + fn claim_escrow_partial_inner(env: Env, id: u32, claim_amount: i128) -> i128 { + require_initialized(&env); require_not_paused(&env); let mut escrow: Escrow = env .storage() @@ -772,6 +914,9 @@ impl FinchippayContract { if escrow.status != EscrowStatus::Pending { panic!("escrow is not pending"); } + if escrow.amount <= 0 { + panic!("escrow amount must be positive while pending"); + } if env.ledger().sequence() < escrow.release_ledger { panic!("release_ledger not reached"); } @@ -783,23 +928,20 @@ impl FinchippayContract { panic!("claim amount exceeds escrow balance"); } - let token = get_token_client(&env, &escrow.token); - token.transfer(&env.current_contract_address(), &escrow.to, &claim_amount); - - let remaining = escrow.amount - claim_amount; + let remaining = escrow.amount.checked_sub(claim_amount).expect("underflow"); if remaining == 0 { escrow.status = EscrowStatus::Released; - escrow.amount = 0; - } else { - escrow.amount = remaining; } + escrow.amount = remaining; + decrease_locked_balance(&env, &escrow.token, claim_amount); + contract_transfer_out(&env, &escrow.token, &escrow.to, &claim_amount); env.storage() .persistent() .set(&DataKey::Escrow(id), &escrow); bump(&env, &DataKey::Escrow(id)); env.events().publish( - (Symbol::new(&env, "escrow_claim_partial"), id), + (Symbol::new(&env, "escrow_partial_released"), id), (escrow.to.clone(), claim_amount, remaining), ); remaining @@ -821,6 +963,11 @@ impl FinchippayContract { /// Recipient claims the escrowed funds after `release_ledger` has passed. pub fn claim_escrow(env: Env, id: u32) { + with_transition_lock(&env, || Self::claim_escrow_inner(env, id)) + } + + fn claim_escrow_inner(env: Env, id: u32) { + require_initialized(&env); require_not_paused(&env); let mut escrow: Escrow = env .storage() @@ -830,26 +977,35 @@ impl FinchippayContract { if escrow.status != EscrowStatus::Pending { panic!("escrow is not pending"); } + if escrow.amount <= 0 { + panic!("escrow amount must be positive while pending"); + } if env.ledger().sequence() < escrow.release_ledger { panic!("release_ledger not reached"); } escrow.to.require_auth(); - let token = get_token_client(&env, &escrow.token); - token.transfer(&env.current_contract_address(), &escrow.to, &escrow.amount); - + let amount = escrow.amount; escrow.status = EscrowStatus::Released; + decrease_locked_balance(&env, &escrow.token, amount); + contract_transfer_out(&env, &escrow.token, &escrow.to, &amount); + env.storage() .persistent() .set(&DataKey::Escrow(id), &escrow); bump(&env, &DataKey::Escrow(id)); env.events() - .publish((Symbol::new(&env, "escrow_claim"), id), (escrow.to, escrow.amount)); + .publish((Symbol::new(&env, "escrow_released"), id), (escrow.to, amount)); } /// Payer cancels the escrow before `release_ledger`; funds are returned. pub fn cancel_escrow(env: Env, id: u32) { + with_transition_lock(&env, || Self::cancel_escrow_inner(env, id)) + } + + fn cancel_escrow_inner(env: Env, id: u32) { + require_initialized(&env); require_not_paused(&env); let mut escrow: Escrow = env .storage() @@ -864,10 +1020,11 @@ impl FinchippayContract { } escrow.from.require_auth(); - let token = get_token_client(&env, &escrow.token); - token.transfer(&env.current_contract_address(), &escrow.from, &escrow.amount); - + let amount = escrow.amount; escrow.status = EscrowStatus::Cancelled; + decrease_locked_balance(&env, &escrow.token, amount); + contract_transfer_out(&env, &escrow.token, &escrow.from, &amount); + env.storage() .persistent() .set(&DataKey::Escrow(id), &escrow); @@ -875,7 +1032,7 @@ impl FinchippayContract { env.events().publish( (Symbol::new(&env, "escrow_cancelled"),), - (id, escrow.from, escrow.amount), + (id, escrow.from, amount), ); } @@ -912,6 +1069,19 @@ impl FinchippayContract { recipient: Address, rate_per_ledger: i128, deposit: i128, + ) -> u32 { + with_transition_lock(&env, || { + Self::open_stream_inner(env, token_address, payer, recipient, rate_per_ledger, deposit) + }) + } + + fn open_stream_inner( + env: Env, + token_address: Address, + payer: Address, + recipient: Address, + rate_per_ledger: i128, + deposit: i128, ) -> u32 { require_initialized(&env); require_not_paused(&env); @@ -936,6 +1106,8 @@ impl FinchippayContract { let token = get_token_client(&env, &token_address); let contract_address = env.current_contract_address(); require_transfer_succeeded(&env, &token, &payer, &contract_address, &deposit); + cache_contract_balance(&env, &token_address, token.balance(&contract_address)); + increase_locked_balance(&env, &token_address, deposit); let id: u32 = env .storage() @@ -956,13 +1128,14 @@ impl FinchippayContract { }; env.storage().persistent().set(&DataKey::Stream(id), &stream); bump(&env, &DataKey::Stream(id)); + let next_count = id.checked_add(1).expect("stream count overflow"); env.storage() .persistent() - .set(&DataKey::StreamCount, &(id + 1)); + .set(&DataKey::StreamCount, &next_count); bump(&env, &DataKey::StreamCount); env.events().publish( - (Symbol::new(&env, "stream_open"), id), + (Symbol::new(&env, "stream_opened"), id), (payer, recipient, rate_per_ledger, deposit), ); id @@ -973,6 +1146,11 @@ impl FinchippayContract { /// Returns the amount claimed. Can be called multiple times as the stream /// progresses; the running `claimed` counter prevents double-claiming. pub fn claim_stream(env: Env, stream_id: u32, recipient: Address) -> i128 { + with_transition_lock(&env, || Self::claim_stream_inner(env, stream_id, recipient)) + } + + fn claim_stream_inner(env: Env, stream_id: u32, recipient: Address) -> i128 { + require_initialized(&env); require_not_paused(&env); recipient.require_auth(); @@ -985,6 +1163,9 @@ impl FinchippayContract { if stream.recipient != recipient { panic!("only the recipient may claim"); } + if stream.closed { + panic!("stream is closed"); + } let claimable = Self::_claimable(&env, &stream); if claimable == 0 { @@ -992,16 +1173,16 @@ impl FinchippayContract { } stream.claimed = stream.claimed.checked_add(claimable).expect("overflow"); + decrease_locked_balance(&env, &stream.token, claimable); env.storage() .persistent() .set(&DataKey::Stream(stream_id), &stream); bump(&env, &DataKey::Stream(stream_id)); - let token = get_token_client(&env, &stream.token); - token.transfer(&env.current_contract_address(), &recipient, &claimable); + contract_transfer_out(&env, &stream.token, &recipient, &claimable); env.events().publish( - (Symbol::new(&env, "stream_claim"), stream_id), + (Symbol::new(&env, "stream_claimed"), stream_id), (recipient, claimable), ); claimable @@ -1014,6 +1195,16 @@ impl FinchippayContract { payer: Address, amount: i128, ) { + with_transition_lock(&env, || Self::top_up_stream_inner(env, stream_id, payer, amount)); + } + + fn top_up_stream_inner( + env: Env, + stream_id: u32, + payer: Address, + amount: i128, + ) { + require_initialized(&env); require_not_paused(&env); payer.require_auth(); if amount <= 0 { @@ -1033,14 +1224,17 @@ impl FinchippayContract { panic!("stream is closed"); } + let new_deposited = stream.deposited.checked_add(amount).expect("overflow"); + if new_deposited > MAX_STREAM_DEPOSIT { + panic!("deposit exceeds maximum after top-up"); + } + let token = get_token_client(&env, &stream.token); let contract_address = env.current_contract_address(); require_transfer_succeeded(&env, &token, &payer, &contract_address, &amount); - - stream.deposited = stream.deposited.checked_add(amount).expect("overflow"); - if stream.deposited > MAX_STREAM_DEPOSIT { - panic!("deposit exceeds maximum after top-up"); - } + cache_contract_balance(&env, &stream.token, token.balance(&contract_address)); + stream.deposited = new_deposited; + increase_locked_balance(&env, &stream.token, amount); env.storage() .persistent() .set(&DataKey::Stream(stream_id), &stream); @@ -1057,6 +1251,11 @@ impl FinchippayContract { /// /// Returns the refund amount sent back to the payer. pub fn close_stream(env: Env, stream_id: u32, payer: Address) -> i128 { + with_transition_lock(&env, || Self::close_stream_inner(env, stream_id, payer)) + } + + fn close_stream_inner(env: Env, stream_id: u32, payer: Address) -> i128 { + require_initialized(&env); require_not_paused(&env); payer.require_auth(); @@ -1073,17 +1272,12 @@ impl FinchippayContract { panic!("stream is already closed"); } - let token = get_token_client(&env, &stream.token); - // Pay out any accrued-but-unclaimed tokens to the recipient first. let claimable = Self::_claimable(&env, &stream); if claimable > 0 { - token.transfer( - &env.current_contract_address(), - &stream.recipient, - &claimable, - ); stream.claimed = stream.claimed.checked_add(claimable).expect("overflow"); + decrease_locked_balance(&env, &stream.token, claimable); + contract_transfer_out(&env, &stream.token, &stream.recipient, &claimable); } // Refund the remaining deposit to the payer. @@ -1092,7 +1286,8 @@ impl FinchippayContract { .checked_sub(stream.claimed) .expect("underflow"); if refund > 0 { - token.transfer(&env.current_contract_address(), &payer, &refund); + decrease_locked_balance(&env, &stream.token, refund); + contract_transfer_out(&env, &stream.token, &payer, &refund); } stream.closed = true; @@ -1102,7 +1297,7 @@ impl FinchippayContract { bump(&env, &DataKey::Stream(stream_id)); env.events().publish( - (Symbol::new(&env, "stream_close"), stream_id), + (Symbol::new(&env, "stream_closed"), stream_id), (payer, refund), ); refund @@ -1115,6 +1310,11 @@ impl FinchippayContract { /// /// Returns the refund amount sent back to the payer. pub fn reject_stream(env: Env, stream_id: u32, recipient: Address) -> i128 { + with_transition_lock(&env, || Self::reject_stream_inner(env, stream_id, recipient)) + } + + fn reject_stream_inner(env: Env, stream_id: u32, recipient: Address) -> i128 { + require_initialized(&env); require_not_paused(&env); recipient.require_auth(); @@ -1131,17 +1331,12 @@ impl FinchippayContract { panic!("stream is already closed"); } - let token = get_token_client(&env, &stream.token); - // Pay accrued tokens to recipient. let claimable = Self::_claimable(&env, &stream); if claimable > 0 { - token.transfer( - &env.current_contract_address(), - &recipient, - &claimable, - ); stream.claimed = stream.claimed.checked_add(claimable).expect("overflow"); + decrease_locked_balance(&env, &stream.token, claimable); + contract_transfer_out(&env, &stream.token, &recipient, &claimable); } // Refund remaining to payer. @@ -1150,7 +1345,8 @@ impl FinchippayContract { .checked_sub(stream.claimed) .expect("underflow"); if refund > 0 { - token.transfer(&env.current_contract_address(), &stream.payer, &refund); + decrease_locked_balance(&env, &stream.token, refund); + contract_transfer_out(&env, &stream.token, &stream.payer, &refund); } stream.closed = true; @@ -1176,6 +1372,18 @@ impl FinchippayContract { current_recipient: Address, new_recipient: Address, ) { + with_transition_lock(&env, || { + Self::transfer_stream_inner(env, stream_id, current_recipient, new_recipient) + }); + } + + fn transfer_stream_inner( + env: Env, + stream_id: u32, + current_recipient: Address, + new_recipient: Address, + ) { + require_initialized(&env); require_not_paused(&env); current_recipient.require_auth(); if current_recipient == new_recipient { @@ -1194,17 +1402,16 @@ impl FinchippayContract { if stream.closed { panic!("stream is closed"); } + if stream.claimed > stream.deposited { + panic!("claimed exceeds deposited"); + } // Auto-claim accrued tokens for the old recipient before transfer. let claimable = Self::_claimable(&env, &stream); if claimable > 0 { - let token = get_token_client(&env, &stream.token); - token.transfer( - &env.current_contract_address(), - ¤t_recipient, - &claimable, - ); stream.claimed = stream.claimed.checked_add(claimable).expect("overflow"); + decrease_locked_balance(&env, &stream.token, claimable); + contract_transfer_out(&env, &stream.token, ¤t_recipient, &claimable); } stream.recipient = new_recipient.clone(); @@ -1254,6 +1461,9 @@ impl FinchippayContract { if stream.closed { return 0; } + if stream.deposited <= 0 || stream.claimed < 0 || stream.claimed > stream.deposited { + panic!("invalid stream amount invariant"); + } let current = env.ledger().sequence(); let elapsed = current.saturating_sub(stream.start_ledger) as i128; let total_streamed = stream @@ -1261,7 +1471,7 @@ impl FinchippayContract { .checked_mul(elapsed) .expect("overflow"); let capped = total_streamed.min(stream.deposited); - (capped - stream.claimed).max(0) + capped.checked_sub(stream.claimed).expect("claimable underflow") } @@ -1281,6 +1491,23 @@ impl FinchippayContract { threshold: u32, signers: Vec
, expiration_ledger: u32, + ) -> u32 { + with_transition_lock(&env, || { + Self::create_multisig_inner( + env, token_address, proposer, recipient, amount, threshold, signers, expiration_ledger, + ) + }) + } + + fn create_multisig_inner( + env: Env, + token_address: Address, + proposer: Address, + recipient: Address, + amount: i128, + threshold: u32, + signers: Vec
, + expiration_ledger: u32, ) -> u32 { require_initialized(&env); require_not_paused(&env); @@ -1320,6 +1547,8 @@ impl FinchippayContract { let token = get_token_client(&env, &token_address); let contract_address = env.current_contract_address(); require_transfer_succeeded(&env, &token, &proposer, &contract_address, &amount); + cache_contract_balance(&env, &token_address, token.balance(&contract_address)); + increase_locked_balance(&env, &token_address, amount); let id: u32 = env .storage() @@ -1343,9 +1572,10 @@ impl FinchippayContract { .persistent() .set(&DataKey::MultiSig(id), &proposal); bump(&env, &DataKey::MultiSig(id)); + let next_count = id.checked_add(1).expect("multi-sig count overflow"); env.storage() .persistent() - .set(&DataKey::MultiSigCount, &(id + 1)); + .set(&DataKey::MultiSigCount, &next_count); bump(&env, &DataKey::MultiSigCount); env.events().publish( @@ -1358,6 +1588,11 @@ impl FinchippayContract { /// A signer approves proposal `id`. If the approval count reaches `threshold` /// the payment is executed immediately within this call. pub fn approve_multisig(env: Env, proposal_id: u32, signer: Address) { + with_transition_lock(&env, || Self::approve_multisig_inner(env, proposal_id, signer)); + } + + fn approve_multisig_inner(env: Env, proposal_id: u32, signer: Address) { + require_initialized(&env); require_not_paused(&env); signer.require_auth(); @@ -1393,18 +1628,14 @@ impl FinchippayContract { env.events().publish( (Symbol::new(&env, "multisig_approve"), proposal_id), - (signer.clone(), proposal.approvals.len() + 1, proposal.threshold), + (signer.clone(), proposal.approvals.len(), proposal.threshold), ); // Auto-execute if threshold is reached. if proposal.approvals.len() >= proposal.threshold { - let token = get_token_client(&env, &proposal.token); - token.transfer( - &env.current_contract_address(), - &proposal.recipient, - &proposal.amount, - ); proposal.status = MultiSigStatus::Executed; + decrease_locked_balance(&env, &proposal.token, proposal.amount); + contract_transfer_out(&env, &proposal.token, &proposal.recipient, &proposal.amount); env.events().publish( (Symbol::new(&env, "multisig_executed"), proposal_id), (proposal.recipient.clone(), proposal.amount), @@ -1421,6 +1652,11 @@ impl FinchippayContract { /// the proposer. This prevents funds from being locked forever if signers /// abandon a proposal. pub fn timeout_multisig(env: Env, proposal_id: u32) { + with_transition_lock(&env, || Self::timeout_multisig_inner(env, proposal_id)); + } + + fn timeout_multisig_inner(env: Env, proposal_id: u32) { + require_initialized(&env); require_not_paused(&env); let mut proposal: MultiSigProposal = env .storage() @@ -1438,14 +1674,9 @@ impl FinchippayContract { panic!("proposal has not yet expired"); } - let token = get_token_client(&env, &proposal.token); - token.transfer( - &env.current_contract_address(), - &proposal.proposer, - &proposal.amount, - ); - proposal.status = MultiSigStatus::Cancelled; + decrease_locked_balance(&env, &proposal.token, proposal.amount); + contract_transfer_out(&env, &proposal.token, &proposal.proposer, &proposal.amount); env.storage() .persistent() .set(&DataKey::MultiSig(proposal_id), &proposal); @@ -1459,6 +1690,11 @@ impl FinchippayContract { /// The proposer cancels the proposal before execution; funds are refunded. pub fn cancel_multisig(env: Env, proposal_id: u32, proposer: Address) { + with_transition_lock(&env, || Self::cancel_multisig_inner(env, proposal_id, proposer)); + } + + fn cancel_multisig_inner(env: Env, proposal_id: u32, proposer: Address) { + require_initialized(&env); require_not_paused(&env); proposer.require_auth(); @@ -1475,14 +1711,10 @@ impl FinchippayContract { panic!("proposal is not pending"); } - let token = get_token_client(&env, &proposal.token); - token.transfer( - &env.current_contract_address(), - &proposer, - &proposal.amount, - ); - proposal.status = MultiSigStatus::Cancelled; + decrease_locked_balance(&env, &proposal.token, proposal.amount); + contract_transfer_out(&env, &proposal.token, &proposer, &proposal.amount); + env.storage() .persistent() .set(&DataKey::MultiSig(proposal_id), &proposal); @@ -1551,6 +1783,16 @@ impl FinchippayContract { from: Address, recipients: Vec
, amounts: Vec, + ) { + with_transition_lock(&env, || Self::batch_send_inner(env, token_address, from, recipients, amounts)); + } + + fn batch_send_inner( + env: Env, + token_address: Address, + from: Address, + recipients: Vec
, + amounts: Vec, ) { require_initialized(&env); require_not_paused(&env); @@ -1596,9 +1838,10 @@ impl FinchippayContract { .set(&DataKey::TipTotal(to.clone()), &(total.checked_add(amount).expect("overflow"))); bump(&env, &DataKey::TipTotal(to.clone())); + let next_count = count.checked_add(1).expect("tip count overflow"); env.storage() .persistent() - .set(&DataKey::TipCount(to.clone()), &(count + 1)); + .set(&DataKey::TipCount(to.clone()), &next_count); bump(&env, &DataKey::TipCount(to.clone())); let record = TipRecord { @@ -2608,7 +2851,7 @@ mod tests { ), ( contract_id.clone(), - (Symbol::new(&env, "escrow_create"), id).into_val(&env), + (Symbol::new(&env, "escrow_created"), id).into_val(&env), (from.clone(), to.clone(), 200i128, release).into_val(&env), ), ( @@ -2644,7 +2887,7 @@ mod tests { ), ( contract_id.clone(), - (Symbol::new(&env, "stream_open"), sid).into_val(&env), + (Symbol::new(&env, "stream_opened"), sid).into_val(&env), (payer.clone(), recipient.clone(), 10i128, 1_000i128).into_val(&env), ), ( diff --git a/docs/architecture.md b/docs/architecture.md index c03d721c..20a99450 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,6 +57,7 @@ Key design decisions: - **Emergency pause**: admin can call `pause()` to freeze all value-transferring operations (circuit breaker). Read-only queries remain accessible during pause. - **Upgradability**: admin can call `upgrade(new_wasm_hash)` to deploy security patches without state migration. Version counter is incremented on each upgrade. - **Bounded inputs**: escrow timelocks, stream deposits/rates, and multi-sig amounts are capped to prevent griefing, overflow, and permanent fund lock-up. +- **Custody safety**: value transitions are serialized by an instance lock, effects are committed before token calls, outbound deltas are verified exactly, and per-token locked balances prevent rescue operations from touching active funds. #### Event Catalogue @@ -77,14 +78,14 @@ carries the remaining fields needed to reconstruct state. | `rescue_tokens` | `(rescue_tokens,)` | `(token_address, amount, to)` | `rescue_tokens` | | `tip` | `(tip, from, to)` | `amount` | `send_tip` | | `receipt` | `(receipt, from)` | `index` | `mint_receipt` | -| `escrow_create` | `(escrow_create, id)` | `(from, to, amount, release_ledger)` | `create_escrow` | -| `escrow_claim_partial` | `(escrow_claim_partial, id)` | `(to, claim_amount, remaining)` | `claim_escrow_partial` | -| `escrow_claim` | `(escrow_claim, id)` | `(to, amount)` | `claim_escrow` | +| `escrow_created` | `(escrow_created, id)` | `(from, to, amount, release_ledger)` | `create_escrow` | +| `escrow_partial_released` | `(escrow_partial_released, id)` | `(to, claim_amount, remaining)` | `claim_escrow_partial` | +| `escrow_released` | `(escrow_released, id)` | `(to, amount)` | `claim_escrow` | | `escrow_cancelled` | `(escrow_cancelled,)` | `(id, from, amount)` | `cancel_escrow` | -| `stream_open` | `(stream_open, id)` | `(payer, recipient, rate_per_ledger, deposit)` | `open_stream` | -| `stream_claim` | `(stream_claim, id)` | `(recipient, claimable)` | `claim_stream` | +| `stream_opened` | `(stream_opened, id)` | `(payer, recipient, rate_per_ledger, deposit)` | `open_stream` | +| `stream_claimed` | `(stream_claimed, id)` | `(recipient, claimable)` | `claim_stream` | | `stream_topped_up` | `(stream_topped_up,)` | `(id, payer, added, new_deposit)` | `top_up_stream` | -| `stream_close` | `(stream_close, id)` | `(payer, refund)` | `close_stream` | +| `stream_closed` | `(stream_closed, id)` | `(payer, refund)` | `close_stream` | | `stream_reject` | `(stream_reject, id)` | `(recipient, refund)` | `reject_stream` | | `stream_transfer` | `(stream_transfer, id)` | `(current_recipient, new_recipient)` | `transfer_stream` | | `multisig_create` | `(multisig_create, id)` | `(proposer, recipient, amount, threshold)` | `create_multisig` | From 6b6c7245ff6758c1e4249b42f13aeb52407da2eb Mon Sep 17 00:00:00 2001 From: CodingBabe-1 Date: Fri, 28 Aug 2026 11:52:58 +0000 Subject: [PATCH 2/3] fix(contract): fix undefined `count` in emergency withdrawal counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge of main into the PR introduced a leftover checked-counter line in `initiate_emergency_withdrawal` referencing an undefined `count` variable, breaking the build. Use `id` (the current withdrawal count) and persist the checked `next_count` value. ๐Ÿค– Generated with Codebuff Co-Authored-By: Codebuff --- contracts/finchippay-contract/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs index 9475d4da..fbee1e43 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -1936,14 +1936,14 @@ impl FinchippayContract { status: EmergencyWithdrawalStatus::Pending, }; - let next_count = count.checked_add(1).expect("tip count overflow"); + let next_count = id.checked_add(1).expect("withdrawal count overflow"); env.storage() .persistent() .set(&DataKey::EmergencyWithdrawal(id), &withdrawal); bump_to_floor(&env, &DataKey::EmergencyWithdrawal(id)); env.storage() .persistent() - .set(&DataKey::EmergencyWithdrawalCount, &(id + 1)); + .set(&DataKey::EmergencyWithdrawalCount, &next_count); bump(&env, &DataKey::EmergencyWithdrawalCount); env.events().publish( From ac4d96dd3dfd6ba207e1083800a914deb9f79805 Mon Sep 17 00:00:00 2001 From: CodingBabe-1 Date: Fri, 28 Aug 2026 12:42:45 +0000 Subject: [PATCH 3/3] docs(pr-944): add comprehensive PR description for custody hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PR_DESCRIPTION_944.md with a full qualitative write-up of the issue-944 custody-hardening branch: title, issue link, summary, background, code-grounded changes, scope notes, security properties, compatibility, testing, and review checklists. ๐Ÿค– Generated with Codebuff Co-Authored-By: Codebuff --- PR_DESCRIPTION_944.md | 264 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 PR_DESCRIPTION_944.md diff --git a/PR_DESCRIPTION_944.md b/PR_DESCRIPTION_944.md new file mode 100644 index 00000000..76fd0870 --- /dev/null +++ b/PR_DESCRIPTION_944.md @@ -0,0 +1,264 @@ +# PR: fix(contract) โ€” harden custody accounting and transitions (issue #944) + +> **Suggested title:** `fix(contract): harden custody accounting and transitions` +> +> **Branch:** `fix/issue-944-custody-hardening` +> **Fork PR:** CodingBabe-1/Finchippay-Solution#3 ยท **Upstream PR:** FinChippay/Finchippay-Solution#964 + +--- + +## Summary + +This pull request hardens the Soroban contract's custody and accounting paths so +that funds held in escrow, streams, and emergency-withdrawal bookkeeping can +never silently drift from on-chain state. It is the fix branch for +**FinChippay#944 โ€” "(Critical) Escrow & Stream Funds-Custody"**, and it lands the +hardening that maps to the contract implementation available on this branch, +while preserving the existing public contract API. + +The invariant this change establishes and defends is: + +> Every custody counter โ€” contract version, emergency-withdrawal sequence, and +> per-user receipt sequence โ€” advances with checked arithmetic, and no ledger +> value can be persisted in a state that arithmetic overflow could corrupt. + +Concretely, the branch: + +- converts the remaining **unchecked `+ 1` counter increments** in the contract + to `checked_add` so overflow reverts instead of wrapping; +- makes `upgrade()` **apply the new WASM before persisting the version bump**, + so the on-ledger version can never claim a deployment that did not take + effect; +- fixes a **build-breaking leftover** from the `main` merge in + `initiate_emergency_withdrawal` that referenced an undefined variable; +- updates the contract **documentation** (`contracts/finchippay-contract/README.md` + and `docs/architecture.md`) to describe the custody-safety model and the + canonical event catalogue. + +--- + +## Type of change + +- [x] Bug fix +- [ ] New feature +- [x] Documentation update +- [ ] Refactor / chore +- [x] Smart contract change + +--- + +## Related issue + +- Closes **FinChippay#944** โ€” "(Critical) Escrow & Stream Funds-Custody: 7 Coupled Workstreams (Reentrancy, Dispute Access-Control, Arithmetic, Events, TTL, Upgrades, Formal Proofs)". + +--- + +## Background โ€” why this is needed + +Issue #944 describes a family of coupled custody vulnerabilities in the escrow +and streaming modules: transitions that move real funds through state machines +that are individually provable but collectively unhardened. Among them: + +- **Unchecked counter arithmetic** โ€” several ledger counters advanced with + `+ 1`; on wrap-around they could mint duplicate IDs or bypass bounds checks. +- **Upgrade ordering** โ€” `upgrade()` persisted an incremented version before the + WASM update was guaranteed to have taken effect, allowing version state and + deployed code to disagree. +- **Event-catalogue drift** โ€” the documented event names diverged from what the + contract actually emits, which silently breaks off-chain indexers that key on + topic names. + +The issue stresses that the workstreams are coupled and must be treated as one +coordinated hardening effort with a shared test harness. This branch implements +the portions of that hardening that apply to the code present in this checkout +(see *Scope note* below) and is intentionally conservative: it does not +fabricate modules, change public entry points, or invent governance flows that +do not exist in the repository revision being patched. + +--- + +## What changed + +### 1. Checked counter arithmetic (lib.rs) + +Three ledger counters now use `checked_add` so a numeric wrap-around reverts the +transaction instead of silently corrupting state: + +- **Contract version** โ€” `upgrade()` now computes + `current_ver.checked_add(1).expect("version overflow")` and persists that + value, instead of `current_ver + 1`. +- **Emergency-withdrawal sequence** โ€” `initiate_emergency_withdrawal()` computes + `id.checked_add(1).expect("withdrawal count overflow")` and stores the checked + `next_count` as the new `EmergencyWithdrawalCount`. +- **Per-user receipt sequence** โ€” `mint_receipt()` computes + `count.checked_add(1).expect("receipt count overflow")` and stores the checked + `next_count` as the new `ReceiptCount`. + +Because the counter is read, incremented, and persisted inside a single +transaction that also stores the record keyed by that counter, checked addition +guarantees the stored record and the stored counter can never disagree due to +overflow. + +### 2. Upgrade ordering (lib.rs) + +`upgrade()` now calls `env.deployer().update_current_contract_wasm(...)` and +only then persists `Version = next_ver`. If the WASM update fails, the +transaction reverts and the on-ledger version is never bumped โ€” the version +counter can no longer claim a deployment that did not land. + +### 3. Emergency-withdrawal counter build fix (lib.rs) + +The `main` merge into this branch left a conflict-resolution artifact in +`initiate_emergency_withdrawal`: + +```rust +let next_count = count.checked_add(1).expect("tip count overflow"); +``` + +`count` was undefined in that scope, which broke the build. The fix uses `id` โ€” +the current withdrawal count read from `EmergencyWithdrawalCount` โ€” with a +correct panic message ("withdrawal count overflow"), and persists the checked +`next_count` value. + +### 4. Documentation (README.md, docs/architecture.md) + +- **Custody-safety model** โ€” both documents now describe: + - the instance-scoped re-entry lock serializing value transitions; + - exact contract-balance delta verification on outbound transfers; + - per-token `LockedBalance` accounting (increased on deposit, decreased before + every tracked payout/refund, with `rescue_tokens` limited to the unlocked + balance); + - the canonical event catalogue. +- **Canonical event catalogue** โ€” the event tables were aligned with the + canonical topic names defined in `contracts/finchippay-contract/src/events.rs` + (`escrow_created`, `escrow_released`, `escrow_cancelled`, `stream_opened`, + `stream_claimed`, `stream_topped_up`, `stream_closed`, โ€ฆ), replacing the + legacy `escrow_create` / `escrow_claim` / `stream_open` / `stream_close` + spellings. + +> **Note on event topics:** the contract's *emit sites* in `escrow.rs` / +> `streams.rs` still publish the legacy topic names; only the documentation was +> moved to the canonical catalogue in this PR. Migrating the emit sites and the +> indexer is tracked as the remaining event-integrity workstream from #944 and +> should land together with the parser changes so feeds never see a gap. + +--- + +## Scope note โ€” what this branch does and does not contain + +This branch was created from the fork's `master` and then merged with the latest +upstream `main`, which restructured the contract from a single file into +modules (`escrow.rs`, `streams.rs`, `storage.rs`, `events.rs`, โ€ฆ). That merged +code already contains much of the broader custody hardening described in issue +#944 โ€” the `ReentrancyGuard` (storage.rs), per-token `LockedBalance` accounting, +`contract_transfer_out` / `require_transfer_succeeded` verified-transfer +helpers, and CEI-ordered transition paths (including `resolve_dispute` rejecting +non-pending escrows and clearing the dispute flag on resolution). + +What **this branch itself adds** on top of upstream `main` is the focused delta +described in *What changed* above: checked counter arithmetic, upgrade ordering, +the build fix, and the documentation updates. + +Two practical notes for reviewers: + +1. **The fork PR diff is large by construction.** The fork's `master` is behind + upstream `main`, so GitHub's comparison for CodingBabe-1/Finchippay-Solution#3 + shows the entire upstream restructuring in addition to this branch's delta. + Reviewing against upstream `main` (`git diff upstream/main...HEAD`) isolates + the actual change of this PR. +2. **Remaining #944 workstreams** (dispute access-control registry semantics, + `claimable_at` fail-closed arithmetic, canonical event emit sites, TTL-sweep + class starvation, fuzz harness in CI, formal proofs) are tracked in the issue + and intentionally not fabricated here where the corresponding infrastructure + is absent from this checkout. + +--- + +## Security properties after this change + +For every custody counter and transition touched by this PR: + +- The contract validates initialization, authorization, pause state, and record + state before mutating. +- Arithmetic and bounds are checked before any persistence (`checked_add` with + an explicit panic, never wrapping). +- Persistent state is written only after the external effect it records has + been initiated (WASM update precedes the version bump). +- On any failure, Soroban transaction rollback prevents partial storage changes + from being committed. + +## Compatibility + +- No public entry point was removed or renamed. +- All existing escrow, stream, multi-signature, tip, batch, receipt, and + emergency-withdrawal APIs remain available and unchanged in signature. +- The new behavior is strictly stricter: previously-succeeding calls that + relied on unchecked counter overflow now revert instead of corrupting state. +- Documentation-only event-topic changes carry no on-chain effect. + +--- + +## Testing + +### Completed in this environment + +- [x] `git diff --check` โ€” clean (no whitespace errors) +- [x] Root `package.json` parses as valid JSON +- [x] Static inspection confirming: + - the `count` reference removed from `initiate_emergency_withdrawal` (undefined + variable eliminated); + - `checked_add` used for version, withdrawal-count, and receipt-count + increments; + - WASM update occurs before the version counter is persisted in `upgrade()`. +- [x] Branch push and commit verification against the fork. + +### Pending โ€” requires the Rust toolchain / CI + +The following could **not** be executed locally because Rust/Cargo is not +installed in this environment (`cargo: command not found`); they are expected to +run through the fork's GitHub Actions contract workflow: + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo test --manifest-path contracts/finchippay-contract/Cargo.toml` +- [ ] `cargo clippy --manifest-path contracts/finchippay-contract/Cargo.toml --all-targets -- -D warnings` +- [ ] `cargo build --manifest-path contracts/finchippay-contract/Cargo.toml --target wasm32v1-none --release` + +No claim is made that the Rust test, lint, or WASM build suite passed locally. + +### Suggested manual / testnet verification + +- [ ] Deploy the contract on Testnet and confirm `upgrade()` bumps the version + exactly once per call and reverts if the WASM update fails. +- [ ] Call `initiate_emergency_withdrawal` twice and confirm the second + withdrawal gets `id = 1` with `EmergencyWithdrawalCount = 2`. +- [ ] Mint two receipts from the same address and confirm + `ReceiptCount(from)` advances 1 โ†’ 2 with no gaps. + +--- + +## Screenshots (if UI change) + +N/A โ€” contract- and documentation-only change; no UI impact. + +--- + +## Checklist + +- [x] My code follows the project style +- [x] I've updated docs if needed +- [ ] No console errors or warnings +- [x] I've rebased on latest `main` (branch merged with upstream `main` at + `3f353aa`) +- [x] Validation limitations are disclosed rather than overstated +- [ ] Rust formatting, tests, clippy, and WASM build pass in CI + +--- + +## Review checklist + +- [ ] Checked arithmetic on version / withdrawal / receipt counters +- [ ] `upgrade()` applies WASM before persisting the version bump +- [ ] No undefined-variable leftovers from the `main` merge remain +- [ ] Documentation reflects the custody-safety model and canonical event catalogue +- [ ] Public API compatibility preserved +- [ ] CI runs the Rust suite (fmt, test, clippy, wasm build)