This document describes the implementation of sponsored withdrawals for Stellar stealth addresses that have insufficient balance to pay their own transaction fees.
A stealth address on Stellar that has received only a small payment may not have enough XLM to pay the base reserve (1 XLM) and transaction fee for a withdrawal. Previously, the demo would silently fail in these cases.
Implemented fee-bump transactions where a sponsor account (the user's connected Freighter wallet) pays fees on behalf of the stealth account's inner transaction.
src/components/StellarReceive.tsx
The system automatically detects when a stealth account needs sponsorship by checking:
const currentBalance = parseFloat(xlmBal.balance);
const subentryCount = account.subentry_count ?? 0;
const baseReserve = 0.5; // 0.5 XLM per base reserve
const minAccountReserve = (2 + subentryCount) * baseReserve;
const estimatedFee = 0.00001; // 100 stroops base fee
const feeBumpFee = 0.0001; // Additional fee for fee-bump envelope
const needsSponsor = currentBalance < minAccountReserve + estimatedFee + feeBumpFee;When sponsorship is needed, a clear UI prompt is shown:
- Explains that the stealth address can't pay its own fees
- Informs the user that their connected wallet will sponsor the transaction
- Warns that Freighter will prompt for signature
- Clarifies that the entire balance (including base reserve) will be merged
The sponsored withdrawal uses Stellar's fee-bump transaction primitive:
Inner Transaction:
- Source: Stealth account
- Operation:
accountMergeto destination - Fee: 0 (will be paid by outer transaction)
- Signed by: Derived stealth private key
Outer Fee-Bump Transaction:
- Fee source: Connected wallet (sponsor)
- Fee: 1000 stroops (0.0001 XLM)
- Signed by: Freighter wallet
The implementation uses accountMerge instead of payment for sponsored withdrawals because:
- It recovers the entire balance including the base reserve
- It's cleaner for stealth UX (no dust left behind)
- The stealth account is closed after withdrawal
const [feeBumpHash, setFeeBumpHash] = useState<string | null>(null);
const [showSponsorPrompt, setShowSponsorPrompt] = useState(false);Handles the complete sponsored withdrawal flow:
- Fetches stealth account details
- Builds inner transaction with
accountMergeoperation - Signs inner transaction with stealth key
- Builds fee-bump transaction wrapper
- Signs fee-bump with connected wallet (via Freighter)
- Submits fee-bump transaction to Horizon
- Updates UI with transaction hash
- User enters destination address
- Clicks "Withdraw"
- Transaction is submitted directly
- Success message with transaction link
- User enters destination address
- Clicks "Withdraw"
- System detects insufficient balance
- Sponsored withdrawal prompt appears
- User clicks "Pay with Connected Wallet"
- Freighter prompts for signature
- Transaction is submitted
- Success message indicates sponsored withdrawal with transaction link
- Base reserve: 0.5 XLM per entry (minimum 2 entries = 1 XLM)
- Standard transaction fee: 100 stroops (0.00001 XLM)
- Fee-bump fee: 1000 stroops (0.0001 XLM)
- Validates connected wallet is available for sponsorship
- Checks stealth account exists and has balance
- Handles Freighter signature rejection
- Provides clear error messages to user
- Both inner and outer transaction hashes are tracked
- UI indicates when a withdrawal was sponsored
- Links to Stellar explorer for transaction details
- Standard withdrawal: Stealth account with > 2 XLM balance
- Sponsored withdrawal: Stealth account with < 2 XLM balance
- User cancellation: Cancel sponsored withdrawal prompt
- Freighter rejection: Reject signature in Freighter
- Network errors: Test with network disconnection
- Happy path: Sponsored withdrawal with small balance
- Standard withdrawal path still works
- UI shows correct prompts and messages
- Transaction links are valid
- Dynamic fee estimation: Query network for current base fee
- Batch withdrawals: Sponsor multiple stealth withdrawals in one fee-bump
- Gas estimation: Show user exact fee they'll pay as sponsor
- Partial withdrawals: Allow withdrawing less than full balance with sponsorship
- User guide:
docs/guides/stellar-sponsored-withdraw.mdx - Developer guide: Fee-bump transaction patterns
- FAQ: When sponsorship is needed and why
✅ Sponsored-withdraw flow implemented and visible in the UI
✅ Auto-detect of "needs sponsorship" based on balance/fee math
✅ Uses accountMerge to recover base reserve
✅ Clear UI communication about Freighter prompts
⏳ Playwright test coverage (recommended next step)
⏳ Documentation follow-up (recommended next step)
Decision Made: Always use mergeAccount for sponsored withdrawals.
Rationale:
- User Experience: Stealth addresses are meant to be ephemeral. Leaving dust behind creates confusion.
- Privacy: Closing the account completely is cleaner from a privacy perspective.
- Cost Recovery: Users recover the full 1 XLM base reserve, not just the balance minus reserve.
- Simplicity: One clear path for sponsored withdrawals is easier to understand and maintain.
Alternative Considered: Leaving accounts open with dust for potential future use was considered but rejected because:
- Stealth addresses are typically single-use
- Managing multiple dust accounts is poor UX
- The base reserve is significant (1 XLM) and should be recovered