Skip to main content
sodax.swaps.swap() no longer runs a single linear pipeline. It creates the intent, then tries a backend submit-tx attempt, and on any non-success falls back to the client-side relay so the swap still completes. If you drive the swap steps yourself — createIntent, then relay submission, then postExecution — you get neither the backend path nor the fallback. This guide shows how to adopt both.

Who needs this

You can stop reading if your code calls sodax.swaps.swap() or sodax.swaps.createLimitOrder(). Both already run the backend attempt and the fallback for you, and the shape of SwapResponse is unchanged. This guide is for you if you call createIntent and then handle the relay and solver notification yourself — a bot, a backend orchestrator, or a frontend that persists each step. That code still works, but it now takes the slow path every time and misses the backend’s server-side relay.

What changed

swap() runs these phases:
  1. createIntent — build, sign and broadcast the intent transaction on the source chain. Unchanged.
  2. Backend submit-tx attempt — hand the broadcast transaction to sodax.api.swaps.submitTx. The backend verifies, relays and post-executes server-side; the SDK polls getSubmitTxStatus until the swap is solved. Controlled by swaps.useBackendSubmitTx, default true.
  3. Client-side fallback — on any non-success in step 2: verify the transaction landed, relay it to the hub and wait for the packet, then call postExecution. This is the path your manual code already implements.
Step by step, against the flow you have today:
The orchestration helpers are internal. SwapService.submitTx and SwapService.fallbackSwapSteps are private, and the attempt budget and poll loop behind them are not exported from @sodax/sdk. The pieces they call — sodax.api.swaps.submitTx, getSubmitTxStatus, relayTxAndWaitPacket, postExecution — are public, so Option B rebuilds the loop in your own code rather than importing it.

Option A — hand the whole flow to swap()

The shortest migration, and the one to prefer unless you have a concrete reason to own the steps. Delete the manual relay and post-execution code and call swap():
To keep exactly the old behaviour — client-side relay only, no backend attempt — opt out at construction:
That flag is a client-side runtime option, not part of the backend-fetched config. See CONFIGURE_SDK.md.

Option B — keep manual control

Use this when you must own the steps: a bot that persists every transition, a backend with its own signing boundary, or a UI that reports each phase. You reimplement what swap() does internally.

Step 1 — create the intent

Unchanged. Keep all three returned values — the submit-tx request needs each of them. The bindings below are shared by every step in this section:

Step 2 — build the submit-tx request

Two fields are easy to get wrong: relayData is relayData.payload, and walletAddress is the source address that signed the intent, not the destination. Persist request before you submit it. If the process dies after broadcasting, this record is what lets you resume; re-submitting the same (txHash, srcChainKey) is idempotent and answers data.status: 'duplicate'. Never sign a second deposit to recover. request.intent carries bigint fields (intentId, inputAmount, minOutputAmount, deadline, srcChain, dstChain), so a plain JSON.stringify(request) throws — in the exact crash window this step exists to survive. Serialize them explicitly and restore them on the way back in:
The SDK’s own wire client does the same thing internally, which is why passing intent straight to submitTx needs no conversion from you — only your own storage does.

Step 3 — open the attempt budget, then submit

The attempt starts before the POST, not after it: the submit request draws on the same budget as the poll, so a stalled POST costs the attempt rather than silently extending it. Every backend request is capped at the budget left, but never above the API service’s own timeout.
Do not call verifyTxHash before this. The backend runs its own verification, so waiting for a client-side confirmation delays every backend success by the source chain’s confirmation time and can fail a swap the backend would have completed. Verification belongs to the fallback only.

Step 4 — poll until solved

Same budget, same cap on every request — and no sleep that the attempt cannot outlast, since that would be dead wait standing between you and the fallback.
Every way out of that loop other than solved means “the backend did not finish it” — fall back.

Step 5 — fall back to the client-side relay

This is the flow you already have, with two additions: the relay gets its own fresh timeout, and it returns the same SwapResponse the backend path does — so callers handle completion through one contract no matter which path ran, exactly as swap() does.
Note data: relayData here takes the whole RelayExtraData object, while the submit-tx request in Step 2 takes relayData.payload.

Two budgets, never one

timeout is a per-attempt budget, not an end-to-end deadline. The backend attempt gets one; if it does not complete, the fallback relay gets a fresh one. Give your manual flow the same shape — a single shared deadline leaves the fallback only what the backend did not spend, which is how a relay that needs longer than the leftovers ends in a timeout. Phases bounded by different things: Read the constants from source rather than memorising them: DEFAULT_RELAY_TX_TIMEOUT, DEFAULT_BACKEND_API_TIMEOUT and per-chain pollingConfig live in @sodax/types; RELAY_FALLBACK_FLOOR_MS lives in IntentRelayApiService.ts. The full breakdown is in SWAPS.md § How timeout bounds each attempt.

Why falling back is safe

Re-relaying and re-posting an already-processed swap is idempotent: the relay deduplicates and returns the existing executed packet, and the solver re-affirms the intent rather than filling it twice. This is covered by the SDK’s live relay end-to-end test. The fallback is also load-bearing rather than belt-and-braces. The backend keeps processing at its own pace after your poll gives up, so your fallback relay may race the backend’s — and that is fine, for the same reason.

Outcome → action


Chain-specific notes

  • Solana and Bitcoin deposits commit only a hash of the relay payload on-chain, so the relayer can correlate a submission only with the exact original bytes. Keep the relayData that createIntent returned. If it is gone, recover byte-identical data with sodax.swaps.getIntentSubmitTxExtraData({ txHash }) — note that txHash there is the hub-chain transaction hash, not the source-chain spokeTxHash used everywhere else in this guide, because the lookup reads the intent off the hub. From a populated Intent you already hold, sodax.swaps.reconstructRelayData(intent) derives the same bytes offline with no RPC call.
  • Sonic as the source chain has no relay leg — the spoke transaction already is the hub transaction. The fallback must skip the relay and go straight to postExecution.
  • Stellar destinations still need a trustline before the swap; see STELLAR_TRUSTLINE.md.

Status and recovery

Once a swap is in flight, read its state from the source-chain transaction hash:
getDetailedStatus routes to the backend record or the solver, whichever can answer, which replaces hand-rolled “try the backend, then try the solver” logic. getStatus and getSolvedIntentPacket are unchanged.

Checklist

  • Decided between Option A and Option B
  • Submit-tx request built with relayData.payload and the source wallet address
  • The request is persisted before submitTx is called
  • Both result.ok and result.value.success are checked
  • Poll treats only solved (with both result fields) as success
  • Poll bails on failed / abandonedAt and on 401/403
  • Every non-success path reaches the fallback
  • No verifyTxHash before the backend submit — only in the fallback
  • The fallback relay gets its own fresh timeout, floored at RELAY_FALLBACK_FLOOR_MS
  • Hub-source swaps skip the relay leg
  • Solana/Bitcoin keep or recover the exact relayData bytes

See also