diff --git a/contracts/sharpy/src/events.rs b/contracts/sharpy/src/events.rs index 161413a..adf4554 100644 --- a/contracts/sharpy/src/events.rs +++ b/contracts/sharpy/src/events.rs @@ -83,3 +83,15 @@ pub fn dispute_raised(env: &Env, invoice_id: u64, creator: &Address) { pub fn dispute_resolved(env: &Env, invoice_id: u64, resolver: &Address, release: bool) { env.events().publish((symbol_short!("dsprslv"),), DisputeResolvedEvent { invoice_id, resolver: resolver.clone(), release }); } + +#[contracttype] +#[derive(Clone)] +pub struct AccountBalanceClaimedEvent { + pub account: Address, + pub token: Address, + pub amount: i128, +} + +pub fn account_balance_claimed(env: &Env, account: &Address, token: &Address, amount: i128) { + env.events().publish((symbol_short!("claimed"),), AccountBalanceClaimedEvent { account: account.clone(), token: token.clone(), amount }); +} diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index d4e39c6..121fc99 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -24,6 +24,9 @@ fn escrow_state_key(id: u64) -> (Symbol, u64) { (symbol_short!("escrow"), id) } fn recurring_params_key(id: u64) -> (Symbol, u64) { (symbol_short!("rec"), id) } fn next_invoice_key(id: u64) -> (Symbol, u64) { (symbol_short!("next_inv"), id) } fn creator_index_key(creator: &Address) -> (Symbol, Address) { (symbol_short!("by_ctr"), creator.clone()) } +fn account_balance_key(account: &Address, token: &Address) -> (Symbol, Address, Address) { + (symbol_short!("acc_bal"), account.clone(), token.clone()) +} fn is_paused(env: &Env) -> bool { env.storage().persistent().get(&paused_key()).unwrap_or(false) @@ -71,6 +74,18 @@ fn index_invoice_for_creator(env: &Env, creator: &Address, invoice_id: u64) { env.storage().persistent().extend_ttl(&key, 100_000, 6_307_200); } +/// Credits an internal balance for an account+token pair when a direct transfer fails. +/// Used in _release when a recipient cannot receive funds (no trustline, frozen account, etc.) +/// The credited amount can be withdrawn later via claim(). +fn credit_account(env: &Env, account: &Address, token: &Address, amount: i128) { + let key = account_balance_key(account, token); + let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); + // Use checked_add to prevent overflow if multiple failed transfers accumulate + let new_balance = current.checked_add(amount).expect("account balance overflow"); + env.storage().persistent().set(&key, &new_balance); + env.storage().persistent().extend_ttl(&key, 100_000, 6_307_200); +} + fn build_invoice( env: &Env, creator: Address, @@ -390,7 +405,8 @@ impl SharpyContract { for i in 0..n { let recipient = invoice.recipients.get(i).unwrap(); let amount = invoice.amounts.get(i).unwrap(); - let token_client = token::Client::new(env, &invoice.tokens.get(i).expect("no token")); + let token = invoice.tokens.get(i).expect("no token"); + let token_client = token::Client::new(env, &token); let proportional = if !invoice.split_rules.is_empty() { match invoice.split_rules.get(i as u32).unwrap() { @@ -433,7 +449,17 @@ impl SharpyContract { distributed += proportional; if proportional > 0 { - token_client.transfer(&env.current_contract_address(), &recipient, &proportional); + // Use try_transfer to catch failures (no trustline, frozen account, etc.) + // On any failure, credit an internal balance that can be claimed later + match token_client.try_transfer(&env.current_contract_address(), &recipient, &proportional) { + Ok(Ok(())) => { + // Transfer succeeded — no action needed + } + _ => { + // Transfer failed — credit internal balance for later claim + credit_account(env, &recipient, &token, proportional); + } + } } } @@ -649,6 +675,44 @@ impl SharpyContract { .unwrap_or_else(|| Vec::new(&env)) } + /// Returns the claimable balance for a given account and token. + /// Balances accumulate when recipient transfers fail during invoice release. + /// Returns 0 if no balance exists. + pub fn get_claimable_balance(env: Env, account: Address, token: Address) -> i128 { + env.storage() + .persistent() + .get(&account_balance_key(&account, &token)) + .unwrap_or(0) + } + + /// Withdraws a credited balance for an account and token. + /// Permissionless — anyone can trigger the claim for any account. + /// The transfer goes from the contract vault to the account. + /// + /// # Panics + /// - If the claimable balance is zero + /// - If the token transfer fails (e.g., account still has no trustline) + /// + /// # Security + /// - CEI pattern: storage is deleted before the transfer (defense-in-depth) + /// - Checked arithmetic prevents overflow (balance accumulation uses checked_add) + /// - Composite key (account, token) prevents collision + pub fn claim(env: Env, account: Address, token: Address) -> i128 { + require_not_paused(&env); + let key = account_balance_key(&account, &token); + let balance: i128 = env.storage().persistent().get(&key).unwrap_or(0); + assert!(balance > 0, "no claimable balance"); + + // CEI pattern: delete storage BEFORE transfer (even though Soroban has no reentrancy) + env.storage().persistent().remove(&key); + + let token_client = token::Client::new(&env, &token); + token_client.transfer(&env.current_contract_address(), &account, &balance); + + events::account_balance_claimed(&env, &account, &token, balance); + balance + } + /// Returns a SHA-256 fingerprint of the invoice's immutable fields. /// Protocol 25 CAP-75 / Protocol 26 crypto module: deterministic, tamper-evident /// content hash. The fingerprint commits to invoice_id, deadline, funded amount, diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 2a92f14..c5c5a7b 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -835,4 +835,150 @@ mod tests { assert_eq!(ids.len(), 1); assert_eq!(ids.get(0).unwrap(), id); } + + // ----------------------------------------------------------------------- + // Fallback balance recovery tests + // ----------------------------------------------------------------------- + + #[test] + fn test_get_claimable_balance_returns_zero_initially() { + let (env, client) = setup(); + let account = Address::generate(&env); + let token = Address::generate(&env); + + let balance = client.get_claimable_balance(&account, &token); + assert_eq!(balance, 0i128); + } + + #[test] + #[should_panic(expected = "no claimable balance")] + fn test_claim_with_zero_balance_fails() { + let (env, client) = setup(); + let account = Address::generate(&env); + let token = Address::generate(&env); + + // Attempting to claim with no balance should panic + client.claim(&account, &token); + } + + #[test] + fn test_claim_withdraws_credited_balance() { + let (env, client) = setup(); + let account = Address::generate(&env); + let admin = Address::generate(&env); + let token = env.register_stellar_asset_contract(admin.clone()); + let sac = token::StellarAssetClient::new(&env, &token); + + // Mint tokens to the contract (simulating failed transfer funds held in vault) + let contract_addr = client.address.clone(); + sac.mint(&contract_addr, &1000i128); + + // Manually credit a balance (simulating what would happen on failed transfer) + // We do this by directly manipulating storage since credit_account is private + use soroban_sdk::symbol_short; + let key = (symbol_short!("acc_bal"), account.clone(), token.clone()); + env.as_contract(&contract_addr, || { + env.storage().persistent().set(&key, &500i128); + env.storage().persistent().extend_ttl(&key, 100_000, 6_307_200); + }); + + // Verify balance is queryable + let balance_before = client.get_claimable_balance(&account, &token); + assert_eq!(balance_before, 500i128); + + // Claim should transfer the balance + let claimed = client.claim(&account, &token); + assert_eq!(claimed, 500i128); + + // Balance should now be zero + let balance_after = client.get_claimable_balance(&account, &token); + assert_eq!(balance_after, 0i128); + + // Account should have received the tokens + assert_eq!(sac.balance(&account), 500i128); + } + + #[test] + #[should_panic(expected = "no claimable balance")] + fn test_claim_twice_fails() { + let (env, client) = setup(); + let account = Address::generate(&env); + let admin = Address::generate(&env); + let token = env.register_stellar_asset_contract(admin.clone()); + let sac = token::StellarAssetClient::new(&env, &token); + + let contract_addr = client.address.clone(); + sac.mint(&contract_addr, &1000i128); + + // Credit balance + use soroban_sdk::symbol_short; + let key = (symbol_short!("acc_bal"), account.clone(), token.clone()); + env.as_contract(&contract_addr, || { + env.storage().persistent().set(&key, &500i128); + env.storage().persistent().extend_ttl(&key, 100_000, 6_307_200); + }); + + // First claim succeeds + client.claim(&account, &token); + + // Second claim should panic + client.claim(&account, &token); + } + + #[test] + fn test_claimable_balance_accumulation() { + let (env, client) = setup(); + let account = Address::generate(&env); + let admin = Address::generate(&env); + let token = env.register_stellar_asset_contract(admin.clone()); + let sac = token::StellarAssetClient::new(&env, &token); + + let contract_addr = client.address.clone(); + sac.mint(&contract_addr, &2000i128); + + // Simulate multiple failed transfers crediting the same account + use soroban_sdk::symbol_short; + let key = (symbol_short!("acc_bal"), account.clone(), token.clone()); + env.as_contract(&contract_addr, || { + // First failure + let current1: i128 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(current1 + 300i128)); + // Second failure + let current2: i128 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(current2 + 700i128)); + }); + + // Balance should be sum of all failures + let balance = client.get_claimable_balance(&account, &token); + assert_eq!(balance, 1000i128); + + // Claim should withdraw the full accumulated amount + let claimed = client.claim(&account, &token); + assert_eq!(claimed, 1000i128); + assert_eq!(sac.balance(&account), 1000i128); + } + + #[test] + fn test_claimable_balance_isolated_per_token() { + let (env, client) = setup(); + let account = Address::generate(&env); + let admin = Address::generate(&env); + let token_a = env.register_stellar_asset_contract(admin.clone()); + let token_b = env.register_stellar_asset_contract(admin.clone()); + + let contract_addr = client.address.clone(); + + // Credit balances for two different tokens + use soroban_sdk::symbol_short; + let key_a = (symbol_short!("acc_bal"), account.clone(), token_a.clone()); + let key_b = (symbol_short!("acc_bal"), account.clone(), token_b.clone()); + env.as_contract(&contract_addr, || { + env.storage().persistent().set(&key_a, &500i128); + env.storage().persistent().set(&key_b, &300i128); + }); + + // Balances should be independent + assert_eq!(client.get_claimable_balance(&account, &token_a), 500i128); + assert_eq!(client.get_claimable_balance(&account, &token_b), 300i128); + } }