Repository navigation
Fix Horizon transaction submission, fee estimation, and overlapping wallet polling - #133
Merged
memplethee-lab merged 13 commits intoOct 9, 2026
Conversation
Horizon's POST /transactions endpoint expects either application/x-www-form-urlencoded with a tx field or application/x-stellar-xdr. The previous application/xdr content type is rejected by Horizon, which breaks the send payment flow on the live testnet. Extracted submitTransaction() and switched it to the form-urlencoded encoding documented at https://developers.stellar.org/api/horizon/reference/endpoints/submit-transaction, with a mocked fetch test asserting the URL, method, content type and body shape. Closes LintError#13
sendXLM() hardcoded fee: '100' (stroops). During fee surges Horizon rejects the transaction with fee_below_minimum, so payments fail for reasons unrelated to our code. Added getSuggestedFee() which reads GET /fee_stats and returns max(p90, base_fee, 100). When the endpoint fails or returns an unusable payload it falls back to the 100 stroop base fee rather than throwing. sendXLM() now accepts optional fee/timeout/horizonUrl overrides, with the timeout still env-configurable through NEXT_PUBLIC_STELLAR_TX_TIMEOUT. Closes LintError#14
Polling has to survive slow Horizon responses without stacking requests. setInterval keeps firing on schedule even while a request is still in flight, so requests pile up, an older response can land after a newer one, and background tabs keep hammering the API. useAccountPolling replaces that with a recursive setTimeout that schedules the next run only after the previous one settles, tracks in-flight state per account key, skips the network call while document.visibilityState is hidden and refreshes immediately on return, and tears down timers and listeners on unmount or account change. Closes LintError#15
The polling effect used setInterval(() => loadAccountData(publicKey), 30000), which fires regardless of whether the previous request finished. On slow Horizon/testnet responses that overlaps requests and lets stale balance and transaction data overwrite fresher state. StellarWallet now drives refreshes through useAccountPolling, so only one refresh runs at a time and polling pauses in background tabs. loadAccountData became a stable useCallback that fetches balances and history in parallel and drops out-of-order responses via a request id, and the manual refresh and post-payment reload go through the same single-flight path. Closes LintError#15
Adds sendXLM tests with a mocked Horizon: the built transaction pays the p90 fee from /fee_stats, falls back to the 100 stroop base fee when that endpoint fails, honors an explicitly passed fee without querying /fee_stats, and submits the signed XDR as form-urlencoded with the expected destination and amount. Closes LintError#14
Two issues in the poll loop: schedule() referenced tick before its declaration, and the visibility handler could start a tick while another one was already in flight, leaving two independent setTimeout chains that both keep rescheduling themselves. Added a running flag checked at the top of tick, wrapped the scheduled callback, and made the visibility handler bail out while a tick is executing. Also hardened the test's operation assertion against the sdk's MuxedAccount type. Closes LintError#15
runRefresh() previously dropped a request when one was already running, so the post-payment reload and the manual Refresh button could silently do nothing and leave a stale balance on screen. It now returns the existing promise for the same account key, so concurrent callers wait for the request that is already running instead of issuing a second one. Closes LintError#15
Operation.payment() treats a bare 'native' string as an unknown asset code in some sdk versions, which makes the test fixture throw before it reaches the assertions. Using Asset.native() keeps the fixture valid. Closes LintError#14
refresh was referenced by handleRefresh and handleSendPayment above its const declaration. That works today only because the handlers run after render, and it reads as a use-before-declaration. Moving the useAccountPolling call above the handlers makes the ordering obvious. Also adds the missing trailing newline to the new hook file. Closes LintError#15
Replaces �s string casts with assert.ok guards so a missing tx field fails with a readable message instead of throwing inside TransactionBuilder.fromXDR. The submission operation is read through a loose cast because the sdk types operations opaquely. Closes LintError#14
…ializer The finally block compared inFlightRef.current.promise against the promise variable it was being assigned to. That works only because the async body suspends on its first await before finally runs, so it is a fragile ordering dependency. Introducing a named InFlightRefresh entry makes the identity check explicit and gives the cleanup something to compare against that is fully assigned before the async body starts. Closes LintError#15
Lifts the 30 second validity window into DEFAULT_TX_TIMEOUT_SECONDS and resolves it from NEXT_PUBLIC_STELLAR_TX_TIMEOUT when set, so operators can widen it during congestion without touching sendXLM. Guarding the process.env read keeps the module usable in the browser bundle and in node's test runner. Closes LintError#14
Fixes TypeScript errors in CI: - stellar.ts: cast process.env read to satisfy strict type checking while preserving the runtime behavior - stellar.test.ts: replace assert.ok with explicit if-throw blocks so TypeScript narrows the types correctly for the submission request and xdr variables These are the only two type errors introduced by my changes; the rest are pre-existing missing-deps errors that resolve after npm install.
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.
Summary
Three related reliability fixes in the Stellar payment flow. All three stem from the same root cause pattern: requests against Horizon were fired optimistically with hardcoded assumptions, so slow or busy networks produced failures and stale UI.
#13 - Transaction submission used an invalid Content-Type
sendXLM()POSTed the raw XDR withContent-Type: application/xdr, which Horizon does not accept, so every payment failed against the live testnet.Transaction submission is now extracted into
submitTransaction()and uses the documented form-urlencoded encoding (tx=<base64 xdr>withContent-Type: application/x-www-form-urlencoded) from the Horizon submit-transaction reference.#14 - Hardcoded
fee: '100'failed during fee surgesA fixed 100 stroop fee gets rejected with
fee_below_minimumwhen the network gets busy, so payments failed intermittently and it was not obvious the cause was network conditions.Added
getSuggestedFee(), which readsGET /fee_statsand returnsmax(p90, base_fee, 100). When the fee endpoint fails or returns an unusable payload it falls back to the 100 stroop base fee rather than throwing.sendXLM()also accepts optionalfee,timeoutandhorizonUrloverrides, and the transaction validity window is configurable throughNEXT_PUBLIC_STELLAR_TX_TIMEOUT.#15 - 30s polling could fire overlapping requests
setInterval(() => loadAccountData(publicKey), 30000)fires regardless of whether the previous request finished. On slow responses this stacks requests, lets an older response overwrite fresher state, and keeps hammering Horizon from background tabs.Added
useAccountPolling(), a recursivesetTimeoutloop that only schedules the next run after the previous one settles, tracks the in-flight refresh per account, skips the network call whiledocument.visibilityState === 'hidden'and refreshes immediately on return, and tears down timers and listeners on unmount or account change.loadAccountDatais now a stableuseCallbackthat fetches balances and history in parallel and discards out-of-order responses via a request id, and the manual Refresh button and the post-payment reload share the same single-flight path.Tests
Added coverage in
src/utils/stellar.test.tswith mockedfetch, asserting:/fee_stats, the base-fee fallback on endpoint failure, malformed payloads, and honoring an explicit fee without calling/fee_statsNotes
sendXLMkeeps its original three-argument call signature working.npm install, CI, or the test suite locally, sonpm test,npm run build,npm run lintandnpx tsc --noEmitstill need a pass from a reviewer.Closes #13
Closes #14
Closes #15