Automatic fee-bump for low-XLM users — when a withdrawal fails because the transaction fee is too low, StreamPay wraps the original transaction in a Stellar fee-bump transaction and resubmits it on the user's behalf.
POST /api/streams/:id/withdraw
└─ evaluateWithdrawalState(stream)
└─ maybeFeeBump(result) ← lib/feeBump.ts
├─ isFeeRelatedFailure? ← checks withdrawal.failureCode
├─ resolveFeeBumpConfig() ← validates env vars at call-time
├─ GET /transactions/:hash ← fetches original envelope from Horizon
├─ buildFeeBumpEnvelope() ← wraps inner tx (TODO: real SDK)
└─ POST /transactions ← submits fee-bump to Horizon
-
The withdraw route calls
evaluateWithdrawalState, which setsstream.withdrawal.state = "failed"andstream.withdrawal.failureCode = "tx_insufficient_fee"when the Horizon response includes one of the known fee-error codes. -
maybeFeeBumpdetects the failure, validates configuration, fetches the original transaction envelope from Horizon, builds a fee-bump envelope, and submits it. -
On success the stream record is updated in-place:
settlementTxHash→ new fee-bump tx hashwithdrawal.state→"pending"withdrawal.failureCode→ clearedwithdrawal.attempts→0withdrawal.settlementTxHash→ new fee-bump tx hash
-
The withdraw response includes a
feeBumpfield when a bump occurred:{ "data": { … }, "withdrawal": { "state": "pending", … }, "feeBump": { "bumped": true, "newTxHash": "abc…" } }
The following failureCode values (or substrings) trigger a fee-bump:
| Code | Source |
|---|---|
tx_insufficient_fee |
Horizon result code |
tx_too_late |
Horizon result code (sequence number expired) |
INSUFFICIENT_FEE |
Soroban RPC result code |
Any other failure code (REORG_DETECTED, tx_bad_auth, etc.) is left
unchanged — no fee-bump is attempted.
| Variable | Required | Default | Description |
|---|---|---|---|
FEE_BUMP_SECRET_KEY |
yes | — | Stellar secret key of the fee-bump payer (strkey, starts with S). Never logged. |
HORIZON_URL |
no | https://horizon-testnet.stellar.org |
Horizon endpoint. Must be an http(s) URL. |
FEE_BUMP_MAX_FEE |
no | 100000 |
Maximum base fee in stroops. Positive integer, max 10_000_000. |
If FEE_BUMP_SECRET_KEY is absent or invalid the fee-bump is skipped
silently (the original failure is preserved). No exception is thrown.
# Fee-bump payer key (testnet — replace for mainnet)
FEE_BUMP_SECRET_KEY=SABCDEFG…
# Optional overrides
HORIZON_URL=https://horizon-testnet.stellar.org
FEE_BUMP_MAX_FEE=100000All fee-bump events are emitted as structured JSON log entries via
app/lib/logger.ts and include the stream_id field for correlation:
| Level | Message | When |
|---|---|---|
info |
fee-bump: fee-related failure detected; evaluating eligibility |
Fee error detected |
warn |
fee-bump: configuration invalid; skipping |
Env var validation failed |
warn |
fee-bump: no settlement tx hash available; skipping |
Missing tx hash |
info |
fee-bump: attempting fee-bump |
About to call Horizon |
error |
fee-bump: horizon fetch failed |
Horizon returned non-2xx |
error |
fee-bump: missing envelope_xdr in Horizon response |
Horizon response malformed |
error |
fee-bump: unexpected error fetching original tx |
Network error (fetch) |
error |
fee-bump: submission rejected by Horizon |
Horizon returned non-2xx on submit |
error |
fee-bump: success response missing hash field |
Response shape unexpected |
error |
fee-bump: unexpected error during submission |
Network error (submit) |
info |
fee-bump: successfully submitted fee-bump transaction |
Fee-bump confirmed |
The secret key is never included in any log entry. Log fields include
stream_id, original_tx_hash, new_tx_hash, max_fee, and HTTP status
codes, but not credentials.
- Secret key isolation:
FEE_BUMP_SECRET_KEYis read insideresolveFeeBumpConfig()(not at module load time), which prevents it from being captured in module-scope closures and keeps test environments isolated. - No key logging: The secret key is explicitly excluded from all structured log fields. Tests assert this property.
- Input validation: All env vars are validated before any network
call. An invalid
HORIZON_URLprotocol or aFEE_BUMP_MAX_FEEabove the ceiling (10 M stroops) causes an early, logged return — not a crash. - Fee cap: The
FEE_BUMP_MAX_FEEceiling prevents runaway configurations from draining the fee-bump account unexpectedly. - No silent fallback to mainnet:
HORIZON_URLdefaults to testnet. Switch to mainnet by setting the variable explicitly.
buildFeeBumpEnvelope() is currently a placeholder that tags the
envelope for testing purposes. To replace it with a real implementation:
- Add
@stellar/stellar-sdktodependenciesinpackage.json. - Replace the body of
buildFeeBumpEnvelopeinlib/feeBump.tswith:import { FeeBumpTransaction, Keypair, Networks, TransactionBuilder } from "@stellar/stellar-sdk"; const inner = new Transaction(innerEnvelopeXdr, Networks.TESTNET); const keypair = Keypair.fromSecret(secretKey); const feeBumpTx = TransactionBuilder.buildFeeBumpTransaction( keypair, String(maxFee), inner, Networks.TESTNET, ); feeBumpTx.sign(keypair); return feeBumpTx.toEnvelope().toXDR("base64");
- Remove the
void secretKeyno-op line. - Update
buildFeeBumpEnvelopetests to verify valid XDR output shape.
Tracked in issue: GrantFox #sdk-integration.
import {
isFeeRelatedFailure, // (result: EvaluationResult) => boolean
maybeFeeBump, // (result, fetcher?) => Promise<{result, feeBump}>
resolveFeeBumpConfig, // () => {ok: true, config} | {ok: false, error}
buildFeeBumpEnvelope, // (xdr, secretKey, maxFee) => string [internal]
type FeeBumpResult, // {bumped, newTxHash?, error?}
} from "@/lib/feeBump";isFeeRelatedFailure and maybeFeeBump are the public surface. The
other exports exist solely to support unit testing; treat them as
internal details subject to change when the real SDK is integrated.
lib/feeBump.ts— implementationlib/feeBump.test.ts— unit tests (50 cases, ≥98% coverage)app/api/streams/[id]/withdraw/route.ts— integration pointdocs/runbooks/RUNBOOK_SETTLEMENT_FAILURES.md— on-call runbookdocs/error-codes.md— full error code reference