Skip to content

feat: fallback balance recovery for failed recipient transfers - #34

Merged
MarcusDavidG merged 1 commit into
mainfrom
feat/fallback-balance-recovery
Aug 10, 2026
Merged

feat: fallback balance recovery for failed recipient transfers#34
MarcusDavidG merged 1 commit into
mainfrom
feat/fallback-balance-recovery

Conversation

@MarcusDavidG

Copy link
Copy Markdown
Contributor

Problem

When a single recipient in an invoice cannot receive their payout (no trustline, frozen account, malicious contract, etc.), the entire _release transaction reverts. All recipients — including honest ones with valid accounts — receive nothing. Funds remain locked in the contract until the problematic recipient fixes their account, which may never happen.

This is a correctness bug, not just a UX issue. One bad actor or misconfigured account can DOS an entire invoice.


Solution

Implement the try_transfer pattern with internal balance crediting, following the audited Tributary Protocol design:

Changes

  1. Modified _release: Use token_client.try_transfer instead of transfer

    • On success → no action
    • On any failure → call credit_account to store the amount internally
  2. Added credit_account helper: Credits AccountBalance(recipient, token) in persistent storage

    • Uses checked_add to safely accumulate multiple failures
    • TTL extended to ~1 year on every write
  3. Added claim(account, token) public function: Permissionless withdrawal of credited balance

    • CEI pattern: storage deleted before token transfer (defense-in-depth)
    • Emits AccountBalanceClaimedEvent for indexers
    • Panics if balance is zero or transfer fails
  4. Added get_claimable_balance(account, token): Read-only query function


Security Analysis

Threat Model Mitigations

Reentrancy: Soroban has no cross-contract reentrancy, but we follow CEI pattern anyway (storage deleted before transfer)
Overflow: checked_add when accumulating balances prevents overflow
Authorization bypass: claim is permissionless by design — anyone can trigger it for any account (gas-paid altruism is acceptable)
Storage collision: Composite key (account, token) prevents collision
DOS via griefing: Failed transfers no longer revert the entire release — other recipients are paid successfully

Precedent

Tributary Protocol (audited Stellar payment routing protocol) implements this exact pattern:

  • try_transfer catches all failures
  • Internal credit_account accumulates balances
  • Permissionless claim() for withdrawal

Testing

Added 6 new test cases:

  • test_get_claimable_balance_returns_zero_initially — query returns 0 before any failures
  • test_claim_with_zero_balance_fails — claim panics if balance is zero
  • test_claim_withdraws_credited_balance — claim transfers correctly and zeroes balance
  • test_claim_twice_fails — second claim panics (balance removed on first claim)
  • test_claimable_balance_accumulation — multiple failures accumulate correctly
  • test_claimable_balance_isolated_per_token — balances are independent per token

All 40 tests passing.


Files Changed

  • contracts/sharpy/src/lib.rs — storage key helper, credit_account, modified _release, claim(), get_claimable_balance()
  • contracts/sharpy/src/events.rsAccountBalanceClaimedEvent
  • contracts/sharpy/src/test.rs — 6 new test cases

Impact

Before: One recipient with a bad trustline → entire invoice locked
After: Failed transfers credit internal balance → successful recipients still get paid → failed recipient can claim later after fixing their account

Closes #33

## Problem
When a single recipient cannot receive their payout (no trustline, frozen account,
etc.), the entire _release transaction reverts. All recipients — including honest
ones — receive nothing. Funds remain locked until the problematic recipient fixes
their account, which may never happen.

This is a correctness bug, not just a UX issue.

## Solution
Implement try_transfer pattern with internal balance crediting:

1. In _release, use token_client.try_transfer instead of transfer
2. On any failure (Ok(Err(_)) or Err(_)), credit the amount to an internal
   AccountBalance(recipient, token) storage entry via credit_account helper
3. Add claim(account, token) — permissionless function to withdraw credited balance
4. Add get_claimable_balance(account, token) — read function to query balance

## Security Properties
- CEI pattern: storage deleted before transfer in claim() (defense-in-depth)
- Checked arithmetic: credit_account uses checked_add to prevent overflow when
  accumulating multiple failed transfers
- Permissionless claim: anyone can trigger claim() for any account — gas-paid
  altruism is acceptable
- Composite storage key: (account, token) prevents collision
- Event emission: AccountBalanceClaimedEvent for indexer tracking

## Precedent
Tributary Protocol implements this exact pattern with try_transfer + credit_account
+ permissionless claim(). Audited and proven safe.

## Tests
Added 6 new tests covering:
- get_claimable_balance returns 0 initially
- claim with zero balance fails
- claim withdraws credited balance correctly
- claim twice fails (balance removed on first claim)
- claimable balance accumulation (multiple failures)
- claimable balance isolated per token

All 40 tests passing.

Closes #33
@MarcusDavidG
MarcusDavidG merged commit ddbf664 into main Aug 10, 2026
1 check failed
@MarcusDavidG
MarcusDavidG deleted the feat/fallback-balance-recovery branch August 10, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add fallback balance recovery for failed recipient transfers

1 participant