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
12 changes: 12 additions & 0 deletions contracts/sharpy/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
68 changes: 66 additions & 2 deletions contracts/sharpy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
}
}
}
}

Expand Down Expand Up @@ -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,
Expand Down
146 changes: 146 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading