Skip to content

Fix Horizon transaction submission, fee estimation, and overlapping wallet polling - #133

Merged
memplethee-lab merged 13 commits into
LintError:mainfrom
memplethee-lab:feature/stellar-payment-polling-fixes
Oct 9, 2026
Merged

memplethee-lab merged 13 commits into
LintError:mainfrom
memplethee-lab:feature/stellar-payment-polling-fixes

Conversation

@memplethee-lab

Copy link
Copy Markdown
Contributor

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 with Content-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> with Content-Type: application/x-www-form-urlencoded) from the Horizon submit-transaction reference.

#14 - Hardcoded fee: '100' failed during fee surges

A fixed 100 stroop fee gets rejected with fee_below_minimum when the network gets busy, so payments failed intermittently and it was not obvious the cause was network conditions.

Added getSuggestedFee(), which reads GET /fee_stats and returns max(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 optional fee, timeout and horizonUrl overrides, and the transaction validity window is configurable through NEXT_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 recursive setTimeout loop that only schedules the next run after the previous one settles, tracks the in-flight refresh per account, skips the network call while document.visibilityState === 'hidden' and refreshes immediately on return, and tears down timers and listeners on unmount or account change. loadAccountData is now a stable useCallback that 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.ts with mocked fetch, asserting:

  • submission URL, method, content type and body shape
  • fee selection from /fee_stats, the base-fee fallback on endpoint failure, malformed payloads, and honoring an explicit fee without calling /fee_stats
  • the submitted transaction decodes back to the expected destination, amount and fee

Notes

  • No changes to public behavior beyond the fixes above; sendXLM keeps its original three-argument call signature working.
  • I did not run npm install, CI, or the test suite locally, so npm test, npm run build, npm run lint and npx tsc --noEmit still need a pass from a reviewer.

Closes #13
Closes #14
Closes #15

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.
@memplethee-lab
memplethee-lab merged commit 5ad276d into LintError:main Oct 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant