feat: fallback balance recovery for failed recipient transfers - #34
Merged
Conversation
## 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When a single recipient in an invoice cannot receive their payout (no trustline, frozen account, malicious contract, etc.), the entire
_releasetransaction 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_transferpattern with internal balance crediting, following the audited Tributary Protocol design:Changes
Modified
_release: Usetoken_client.try_transferinstead oftransfercredit_accountto store the amount internallyAdded
credit_accounthelper: CreditsAccountBalance(recipient, token)in persistent storagechecked_addto safely accumulate multiple failuresAdded
claim(account, token)public function: Permissionless withdrawal of credited balanceAccountBalanceClaimedEventfor indexersAdded
get_claimable_balance(account, token): Read-only query functionSecurity Analysis
Threat Model Mitigations
✅ Reentrancy: Soroban has no cross-contract reentrancy, but we follow CEI pattern anyway (storage deleted before transfer)
✅ Overflow:
checked_addwhen accumulating balances prevents overflow✅ Authorization bypass:
claimis 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_transfercatches all failurescredit_accountaccumulates balancesclaim()for withdrawalTesting
Added 6 new test cases:
test_get_claimable_balance_returns_zero_initially— query returns 0 before any failurestest_claim_with_zero_balance_fails— claim panics if balance is zerotest_claim_withdraws_credited_balance— claim transfers correctly and zeroes balancetest_claim_twice_fails— second claim panics (balance removed on first claim)test_claimable_balance_accumulation— multiple failures accumulate correctlytest_claimable_balance_isolated_per_token— balances are independent per tokenAll 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.rs—AccountBalanceClaimedEventcontracts/sharpy/src/test.rs— 6 new test casesImpact
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