Overview
src/app/api/sign-fee-bump/route.ts is a public, unauthenticated Next.js API route that takes a caller-supplied innerTxXdr, wraps it in a fee-bump transaction sponsored by STELLAR_FEE_SPONSOR_SECRET, signs the fee-bump envelope with the sponsor's keypair, and returns the signed XDR:
const { innerTxXdr } = body;
...
innerTxObj = TransactionBuilder.fromXDR(innerTxXdr, networkPassphrase) as Transaction;
...
const feeBumpTx = buildFeeBumpTransaction(innerTxObj, sponsorKeypair.publicKey(), networkPassphrase);
feeBumpTx.sign(sponsorKeypair);
return NextResponse.json({ feeBumpTxXdr: feeBumpTx.toEnvelope().toXDR('base64') });
There is no check anywhere in this handler that innerTxObj actually invokes a SmartDrop pool contract's lock_assets/unlock_assets, no check on operation count/type, no per-account or per-IP rate limit, and no session/auth requirement of any kind — it only verifies the XDR parses. The intended client-side gate (isFeeSponsored in src/lib/soroban.ts's lockAssets/unlockAssets, based on the caller's XLM balance being under 1.0) is purely advisory: it lives entirely in the browser and is never re-checked server-side. Anyone can curl this endpoint directly with an arbitrary, unrelated, already-self-signed transaction (e.g. a Payment operation between two accounts they control) and receive back a valid fee-bump envelope signed by SmartDrop's sponsor keypair. Once submitted to the network, the sponsor account pays the base fee for a transaction that has nothing to do with SmartDrop's pools. buildFeeBumpTransaction (src/lib/soroban.ts:238-257) even scales the sponsored fee by the inner transaction's operation count (innerOps = txObj.operations.length || 1), so an attacker can pad a transaction with the maximum allowed operations to amplify the sponsored fee per request. With no rate limiting, this is a fully automatable drain of the sponsor's XLM balance, unrelated to any real SmartDrop deposit/withdrawal activity.
Requirements
- Validate
innerTxObj server-side before fee-bumping: it must contain exactly the operation(s) SmartDrop actually sponsors (an invokeHostFunction call whose target contract ID is a known pool contract — from the factory — and whose function name is lock_assets or unlock_assets), rejecting anything else with 400.
- Require the inner transaction to already carry a valid signature from its own source account before fee-bumping it (reject unsigned/invalid-signature inner transactions rather than blindly trusting XDR structure).
- Add a rate limit (per source-account and/or per-IP) on this route, since it authorizes real, sponsor-funded spend.
- Cap the number of operations / bound the fee amplification
buildFeeBumpTransaction will sponsor for a single call.
- Log/alert on rejected fee-bump attempts so abuse is observable.
Acceptance Criteria
Additional Notes
More precise references
src/app/api/sign-fee-bump/route.ts:1-79 — the entire handler; confirmed no validation beyond "does this parse as XDR" (line 45-55) exists anywhere in the file.
src/lib/soroban.ts:238-257 (buildFeeBumpTransaction) — confirmed innerOps = txObj.operations.length || 1 directly scales baseFee = Math.max(100, Math.ceil(innerFee / innerOps)), i.e. the caller's own transaction shape controls how much the sponsor commits to pay.
src/lib/soroban.ts:902-916 and :1029-1043 — confirmed isFeeSponsored is computed client-side from fetchAccountBalances(userAddress) and is never passed to, or re-verified by, the API route; the route trusts whatever XDR it's handed regardless of the caller's real balance.
src/lib/soroban.ts:944-961 and :1086-1102 — the two call sites that POST to /api/sign-fee-bump, both with only { innerTxXdr: signedTransaction } in the body, confirming the route's only input surface is the raw XDR.
Additional edge cases
- An attacker doesn't need a SmartDrop account/wallet at all — any Stellar keypair with a trivial signed transaction (even a 0-XLM self-payment) is enough to receive a valid sponsor signature and drain fees on submission.
- Because
buildFeeBumpTransaction accepts innerTx: Transaction | string, a caller can also submit a transaction the sponsor account itself is unrelated to entirely (any random source account), so this isn't limited to griefing SmartDrop users — it's an open subsidized-fee oracle for the whole network as long as the endpoint is reachable.
- If
STELLAR_FEE_SPONSOR_SECRET funds are exhausted by abuse, legitimate low-balance users attempting to deposit/unlock would silently start failing the real sponsored flow too (shared blast radius).
Implementation sketch
- Decode
innerTxObj.operations and require operations.length === 1 and operations[0].type === 'invokeHostFunction'.
- Extract the invoked contract address and function name (mirroring the XDR-walking already done in
validateSimulationAuth/decodeAuthEntryContractFunction in soroban.ts:514-591) and check the contract ID against sorobanService's known pool set (from getFactoryPools()), and the function name against an allow-list (lock_assets, unlock_assets).
- Verify
innerTxObj.signatures.length > 0 and that the signature validates against the transaction's declared source account before proceeding.
- Add a simple in-memory or edge-cache rate limiter keyed by the inner transaction's source account (e.g. N requests per minute).
Test/reproduction plan
- New
route.test.ts: POST a Payment-only signed XDR → expect 400, no feeBumpTxXdr in response, sponsor keypair never invoked.
- POST a valid
invokeHostFunction XDR targeting a contract ID not in the current factory pool list → expect 400.
- POST a valid
lock_assets call against a real pool, unsigned → expect 400 (currently this would succeed, since signatures aren't checked at all).
- POST the same valid, properly-signed
lock_assets XDR N+1 times in quick succession → the (N+1)th is rate-limited.
- Regression: the existing sponsored-fee happy path (used by
lockAssets/unlockAssets when the wallet has <1 XLM) still returns a usable feeBumpTxXdr.
Cross-references
Overview
src/app/api/sign-fee-bump/route.tsis a public, unauthenticated Next.js API route that takes a caller-suppliedinnerTxXdr, wraps it in a fee-bump transaction sponsored bySTELLAR_FEE_SPONSOR_SECRET, signs the fee-bump envelope with the sponsor's keypair, and returns the signed XDR:There is no check anywhere in this handler that
innerTxObjactually invokes a SmartDrop pool contract'slock_assets/unlock_assets, no check on operation count/type, no per-account or per-IP rate limit, and no session/auth requirement of any kind — it only verifies the XDR parses. The intended client-side gate (isFeeSponsoredinsrc/lib/soroban.ts'slockAssets/unlockAssets, based on the caller's XLM balance being under 1.0) is purely advisory: it lives entirely in the browser and is never re-checked server-side. Anyone cancurlthis endpoint directly with an arbitrary, unrelated, already-self-signed transaction (e.g. aPaymentoperation between two accounts they control) and receive back a valid fee-bump envelope signed by SmartDrop's sponsor keypair. Once submitted to the network, the sponsor account pays the base fee for a transaction that has nothing to do with SmartDrop's pools.buildFeeBumpTransaction(src/lib/soroban.ts:238-257) even scales the sponsored fee by the inner transaction's operation count (innerOps = txObj.operations.length || 1), so an attacker can pad a transaction with the maximum allowed operations to amplify the sponsored fee per request. With no rate limiting, this is a fully automatable drain of the sponsor's XLM balance, unrelated to any real SmartDrop deposit/withdrawal activity.Requirements
innerTxObjserver-side before fee-bumping: it must contain exactly the operation(s) SmartDrop actually sponsors (aninvokeHostFunctioncall whose target contract ID is a known pool contract — from the factory — and whose function name islock_assetsorunlock_assets), rejecting anything else with 400.buildFeeBumpTransactionwill sponsor for a single call.Acceptance Criteria
innerTxXdrfor aPaymentoperation (or any operation that isn't aninvokeHostFunctioncall to a known pool'slock_assets/unlock_assets) is rejected with 400, not sponsored.invokeHostFunctiontargets a contract ID that is not a currently-known pool (from the factory) is rejected.src/lib/soroban.tslockAssets/unlockAssetswhenisFeeSponsored) continues to work end-to-end.Additional Notes
More precise references
src/app/api/sign-fee-bump/route.ts:1-79— the entire handler; confirmed no validation beyond "does this parse as XDR" (line 45-55) exists anywhere in the file.src/lib/soroban.ts:238-257(buildFeeBumpTransaction) — confirmedinnerOps = txObj.operations.length || 1directly scalesbaseFee = Math.max(100, Math.ceil(innerFee / innerOps)), i.e. the caller's own transaction shape controls how much the sponsor commits to pay.src/lib/soroban.ts:902-916and:1029-1043— confirmedisFeeSponsoredis computed client-side fromfetchAccountBalances(userAddress)and is never passed to, or re-verified by, the API route; the route trusts whatever XDR it's handed regardless of the caller's real balance.src/lib/soroban.ts:944-961and:1086-1102— the two call sites that POST to/api/sign-fee-bump, both with only{ innerTxXdr: signedTransaction }in the body, confirming the route's only input surface is the raw XDR.Additional edge cases
buildFeeBumpTransactionacceptsinnerTx: Transaction | string, a caller can also submit a transaction the sponsor account itself is unrelated to entirely (any random source account), so this isn't limited to griefing SmartDrop users — it's an open subsidized-fee oracle for the whole network as long as the endpoint is reachable.STELLAR_FEE_SPONSOR_SECRETfunds are exhausted by abuse, legitimate low-balance users attempting to deposit/unlock would silently start failing the real sponsored flow too (shared blast radius).Implementation sketch
innerTxObj.operationsand requireoperations.length === 1andoperations[0].type === 'invokeHostFunction'.validateSimulationAuth/decodeAuthEntryContractFunctioninsoroban.ts:514-591) and check the contract ID againstsorobanService's known pool set (fromgetFactoryPools()), and the function name against an allow-list (lock_assets,unlock_assets).innerTxObj.signatures.length > 0and that the signature validates against the transaction's declared source account before proceeding.Test/reproduction plan
route.test.ts: POST aPayment-only signed XDR → expect 400, nofeeBumpTxXdrin response, sponsor keypair never invoked.invokeHostFunctionXDR targeting a contract ID not in the current factory pool list → expect 400.lock_assetscall against a real pool, unsigned → expect 400 (currently this would succeed, since signatures aren't checked at all).lock_assetsXDR N+1 times in quick succession → the (N+1)th is rate-limited.lockAssets/unlockAssetswhen the wallet has <1 XLM) still returns a usablefeeBumpTxXdr.Cross-references