Skip to main content
The swap module provides abstractions for interacting with cross-chain Intent Smart Contracts, the solver API, and the Relay API. All swap operations are accessed through the swaps property of a Sodax instance:
sodax.swaps vs sodax.api.swaps. This page documents sodax.swaps (SwapService) — the end-to-end intent orchestrator that creates, relays, and finalizes swaps on-chain. The lower-level typed HTTP client for the backend Swaps API (quote, create-intent, submit-tx, status, fees — 21 endpoints) is sodax.api.swaps (SwapsApiService); see SWAPS_API.md.

Using SDK Config and Constants

The SDK includes predefined configurations of supported chains, tokens, and other relevant information. All configurations are reachable through the config property of the Sodax instance.

RWA classification and token logos

XToken.isRwa === true marks a registered tokenized stock, ETF or commodity, including registered cross-chain representations. An omitted flag means no RWA classification is declared; it is not a general-purpose crypto/stablecoin taxonomy. Chain membership and symbol spelling do not determine RWA status. Use isRealWorldAsset({ chainKey, address }) to resolve metadata from the packaged SDK registry when your token data comes from an API without the flag. It returns false for unknown chains or addresses, ignores symbols, and compares EVM addresses case-insensitively while preserving non-EVM identifier casing. It does not read custom constructor config or validate swap/money-market support. New registry metadata requires an SDK update; it does not automatically update backend payloads.
For API responses using xChainId, pass it as chainKey alongside the token’s on-chain address. Resolve each chain/address before grouping directory rows; keep feature support and UI visibility filters separate from classification. tokenLogo(symbol) serves shared PNGs from the SDK repository’s main branch. Robinhood equity/ETF entries use the Robinhood mark; xStocks retain their own artwork. Image replacements become available after merge, subject to caching, without an SDK release. Consumers must use these URLs to receive the replacements.

Available Methods

All swap methods are accessible through sodax.swaps:

Quote & Fee Methods

  • getQuote(payload) — Request a price quote from the solver API
  • getPartnerFee(inputAmount) — Calculate the partner fee for a given input amount
  • getSolverFee(inputAmount) — Calculate the solver protocol fee (0.1%) for a given input amount
  • getSwapDeadline(offset?) — Compute an absolute deadline timestamp for an intent

Intent Creation & Execution

  • swap(params) — Full end-to-end swap (recommended — handles all steps automatically); signed execution only
  • createIntent(params) — Create an intent on the source spoke chain; supports both signed (raw: false) and raw (raw: true) modes
  • createLimitOrder(params) — Full end-to-end limit order (no deadline, must be cancelled manually); signed execution only
  • createLimitOrderIntent(params) — Create a limit order intent only (no relay/solver notify); supports raw and signed modes
  • submitIntent(payload) — Submit a spoke tx to the relay API (low-level, called automatically by swap on its fallback path)
  • postExecution(request) — Notify the solver that an intent is live on the hub chain (low-level, called automatically by swap on its fallback path)
Driving these steps yourself instead of calling swap? See Migrate a manual swap to the backend submit-tx flow.

Backend 2-step submit

By default swap() uses a backend-driven 2-step flow (swaps.useBackendSubmitTx, default true): after createIntent broadcasts the intent tx, swap() hands the tx hash to the backend (sodax.api.swaps.submitTx), which relays + post-executes server-side; the SDK polls submit-tx status and returns the same SwapResponse. The SDK does not verify the tx on-chain first — the backend runs its own verification, so waiting for a client-side confirmation would delay every backend success by the source chain’s confirmation wait and could fail a swap the backend would have completed. verifyTxHash therefore runs only on the client-side path. Set new Sodax({ swaps: { useBackendSubmitTx: false } }) to force the fully client-side relay path. On any non-success (submission rejected, terminal failed/abandoned, or poll timeout) swap() falls back to the client-side relay so the swap still completes — identical SwapResponse either way. This is safe: re-relaying / re-posting an already-processed swap is idempotent — the relay dedups and returns the existing executed packet, and the solver re-affirms the intent (no double-fill), verified live by e2e-tests/e2e-relay.test.ts. It also matters in practice: the backend keeps processing at its own pace after the SDK gives up, so the two relays can race. Each attempt gets its own timeout — see How timeout bounds each attempt. useBackendSubmitTx lives on swaps alongside partnerFee (not part of the backend SodaxDefaultConfig). See CONFIGURE_SDK.md.

How timeout bounds each attempt

timeout (defaults to DEFAULT_RELAY_TX_TIMEOUT) is a per-attempt budget, not an end-to-end deadline. The backend attempt — the submit POST plus the status poll — gets it, and if that attempt does not complete the client-side relay wait gets a fresh one. A stalled backend therefore cannot shorten the fallback’s relay wait, and raising timeout grows both. Each phase is bounded by a different thing: 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, and RELAY_FALLBACK_FLOOR_MS in IntentRelayApiService.ts. Verification timeouts differ widely by chain — Stacks alone is several times Sui’s — so derive them per chain from chains.ts. On the per-request bound. api.timeout is configurable — new Sodax({ api: { timeout } }) moves every backend service, and an api.swapsApiConfig slice moves swaps alone (there is no bridgeApiConfig slice; see CONFIGURE_SDK.md). The packaged default is DEFAULT_BACKEND_API_TIMEOUT, and a non-positive value disables the backend submit-tx path outright. Which side of the min binds decides whether a stalled request retries: while api.timeout is the smaller bound the poll retries within the attempt, but once the attempt’s remaining budget drops below it, a single stalled request can consume the rest of the attempt. The clamp guarantees a request can never be configured to outlive the attempt — not that the attempt survives a slow request. Worst-case wall-clock, when the backend accepts the submission and then never finishes:
Only the two timeout terms are yours to tune. Opting out with useBackendSubmitTx: false drops the backend-attempt term entirely.

Intent Management

  • getIntent(txHash) — Retrieve an Intent from a hub-chain transaction hash
  • getFilledIntent(txHash) — Retrieve the fill state of an intent from the solver’s fill tx hash
  • getIntentSubmitTxExtraData(params) — Rebuild the relay extra data (address + payload) for a Solana/Bitcoin intent from a hub-chain tx hash or an Intent; byte-identical to the relayData that createIntent returned
  • reconstructRelayData(intent) — The same relay extra data, derived offline from a fully-populated Intent (no RPC call)
  • getSolvedIntentPacket(params) — Poll the relayer until a solved intent’s fill packet arrives on the destination chain
  • getIntentHash(intent) — Compute the keccak256 hash of an intent (its on-chain ID)
  • getStatus(request) — Poll the solver API for current intent execution status
  • getDetailedStatus(params) — Read a swap’s status from its source-chain tx hash; routes to the backend record or the solver, whichever can answer
  • cancelIntent(params) — Cancel an active intent and wait for hub confirmation
  • createCancelIntent(params) — Build (and optionally broadcast) only the cancel tx; supports raw and signed modes
  • getCancelIntentRelayData(intent) — Relay extra data (address + payload) for manually relaying a Solana cancel tx
  • cancelLimitOrder(params) — Alias for cancelIntent with domain-specific naming

Token Approval

  • isAllowanceValid(params) — Check if the spender contract has sufficient token allowance
  • approve(params) — Approve token spend (EVM/Sonic/Stellar); supports raw and signed modes

Utility Methods

  • getSupportedSwapTokensByChainId(chainId) — Get supported swap tokens for a spoke chain
  • getSupportedSwapTokens() — Get all supported swap tokens per chain
  • estimateGas(params) — Estimate gas for a raw transaction on any spoke chain
  • getSwapSpeedTier({ srcToken, dstToken }) — Offline estimate of how fast a token pair will settle

Core Concepts

srcChainKey / dstChainKey

All action params use srcChainKey and dstChainKey (not srcChain / dstChain). These are SpokeChainKey strings from ChainKeys.*. The on-chain Intent struct has Intent.srcChain / Intent.dstChain as IntentRelayChainId (bigint relay IDs) — these are different from the action param fields and should not be confused with them.

Signed vs Raw Mode (raw: true / false)

Methods that accept a raw flag return different types depending on the value:
  • raw: false (default) — requires a walletProvider matching the source chain type; signs and broadcasts the transaction; returns a tx hash.
  • raw: true — walletProvider must be absent (passing one is a compile error); returns an unsigned raw transaction payload.
TypeScript enforces this at compile time via the WalletProviderSlot<K, Raw> discriminated union:
Methods with raw support: createIntent, createLimitOrderIntent, createCancelIntent, approve Methods without raw support (signed execution only): swap, createLimitOrder, cancelIntent, cancelLimitOrder

ChainKeys.* Constants

All chain identifiers come from ChainKeys:

Result<T> — No Throws Across Service Boundaries

Every public async method returns Promise<Result<T, E>>:
Check result.ok before accessing result.value or result.error. For the swap module’s user-facing methods, the error type is narrowed to SodaxError<NarrowCode> per method — see “Error Handling” below.

Error Handling

The swap module’s three core methods (swap, createIntent, postExecution) return a deterministic, narrow SodaxError union. createLimitOrder / createLimitOrderIntent inherit the same shape because they delegate.

The canonical error: SodaxError<C>

All swap-module errors are instances of SodaxError, exported from @sodax/sdk:
Rules:
  • Discriminate on error.code — never on error.message (the message is a human-readable explanation, not a stable contract).
  • error.cause walks the underlying error chain (ES2022). Loggers like Sentry/Pino/Datadog walk this automatically.
  • error.context carries structured metadata: srcChainKey, dstChainKey, phase, plus per-code extras (solverCode, relayCode, field, …).
  • error.toJSON() is the canonical logger surface: JSON.stringify(error) invokes it automatically and produces a logger-safe payload (bigints in context are coerced to strings, cause walked depth-3, no circular hazards).
  • Use isSodaxError(e) instead of instanceof SodaxError in dapp/app code — it survives duplicate-bundle and dual-package scenarios.

Per-method error code unions

Important: postExecution alone never emits relay/verify codes — those appear only on swap because only swap orchestrates verify + relay. Don’t write a unified switch that handles both with the same union. Note that swap orchestrates verify + relay only on the client-side path (the fallback, or useBackendSubmitTx: false), so TX_VERIFICATION_FAILED and phase: 'verify' never surface on a swap the backend completes.

Standard context fields

Discrimination example

Relay-layer contract

The lower-level relay helpers relayTxAndWaitPacket and submitTransaction (in packages/sdk/src/shared/services/intentRelay/IntentRelayApiService.ts) emit three stable error message strings on failure: 'SUBMIT_TX_FAILED', 'RELAY_TIMEOUT' and 'RELAY_POLLING_FAILED'. These are exported as RELAY_ERROR_CODES and form a public contract. Only dex consumes them directly; every other feature module maps them into a typed SodaxError via mapRelayFailure. The swap module wraps these via the unified mapRelayFailure, surfacing the original code on error.context.relayCode so swap callers don’t need to inspect error.cause.message.

Migration from pre-SodaxError (breaking)

If you were on the previous Error.message-based pattern: The full SolverErrorResponse payload is preserved on error.context.solverDetail, so anything you read from .detail.* previously is still reachable. Other swap methods (getQuote, getStatus, submitIntent, cancelIntent, etc.) and other modules (moneyMarket, bridge, dex, …) retain their legacy error shapes in this release — they still use the Error | unknown / SolverErrorResponse patterns documented per-module, rather than migrating to SodaxError.

Request a Quote

Requesting a quote requires the user’s input amount scaled by the token’s decimals. All token addresses and decimals are available via sodax.config. The quoting API supports a single quote type, 'exact_input' — the user specifies the amount to swap and the solver returns the amount they receive. There is no “exact output” mode; the API rejects any other value.
Note: getQuote automatically deducts the configured partner fee from payload.amount before forwarding to the solver, so the returned quoted_amount reflects the net output the user actually receives.

Intent Parameters

CreateIntentParams<K>

CreateLimitOrderParams<K>

Same as CreateIntentParams but deadline is optional (it is forced to 0n by createLimitOrder / createLimitOrderIntent):

Get Fees

Partner Fee

The partner fee is deducted from the input amount before the intent is created. If no partner fee is configured on the Sodax instance, getPartnerFee returns 0n.

Solver Fee


Get Swap Deadline

Fetches the current hub-chain (Sonic) block timestamp and adds a deadline offset. Pass the result as CreateIntentParams.deadline.
For limit orders, pass deadline: 0n directly to createIntent (or use createLimitOrder / createLimitOrderIntent which force 0n automatically).

Get Swap Speed Tier

Offline, rule-based estimate of how fast a srcToken → dstToken swap will settle. It is derived purely from SDK config — no network, on-chain, or backend call — so it is safe to call synchronously while rendering a quote. Tokens whose vault is a money-market-reserve (sodaAsset) settle faster, and an Ethereum leg adds a fixed penalty.
estimatedSeconds is the source of truth; tier is bucketed from it. The rules: a fast base (15s) applies when either token’s vault is a money-market reserve, otherwise the base is 35s; an Ethereum leg on either side adds a fixed penalty. The check is on XToken.vault, not XToken.hubAsset — the two differ for most tokens, and only the vault is a reserve address. See estimateSwapSpeedTier in the SDK source for the exact constants.

Token Approval Flow

swap() and createIntent() do not approve the input token for you. Before executing, call isAllowanceValid() and approve() when it returns false. On EVM chains this is an ERC-20 allowance; on Stellar it is a trustline; on other chains it returns true and no approval is needed. Native gas tokens on EVM need no approval.
  • Hub (Sonic): checks allowance against the intents contract
  • EVM spoke chains: checks allowance against the spoke’s asset manager
  • Stellar: checks trustline sufficiency
  • Other chains (Solana, NEAR, etc.): always returns true — no on-chain allowance concept

Raw Approval Transaction

approve({ raw: true }) always returns exactly one transaction. That is not enough for an ERC-20 of the 2017 TetherToken lineage — Ethereum USDT is the one in the SODAX token list today — which rejects an allowance change from one non-zero value to another: a wallet holding a stale allowance has to send approve(0) first. Use buildApproveTxs when you build unsigned transactions and want that case handled:
resetTx is absent for every other token and for a wallet with nothing approved yet, so the common path is a single transaction. The transactions are named rather than ordered — there is no index to map and no way to broadcast them the wrong way round.

Stellar Trustline

For Stellar as the source chain, isAllowanceValid checks trustline balance sufficiency and approve adds/increases the trustline. For Stellar as the destination chain, frontends must manually establish trustlines before executing swaps. See packages/sdk/docs/STELLAR_TRUSTLINE.md for details.

Estimate Gas for Raw Transactions


The swap method is the recommended way to perform a complete cross-chain swap. It orchestrates the full lifecycle automatically:
  1. Calls createIntent to submit the intent transaction on the source spoke chain
  2. Backend attempt (default, swaps.useBackendSubmitTx): hands the broadcast tx to sodax.api.swaps.submitTx — which verifies, relays and post-executes server-side — then polls submit-tx status until the intent is solved
  3. On any non-success, falls back to the fully client-side path: verifies the spoke transaction landed on-chain, then for non-hub source chains submits the spoke tx to the relayer and waits for the relay packet to land on the hub (Sonic), then calls postExecution to notify the solver, triggering it to fill the intent
Both paths return an identical SwapResponse. See Backend 2-step submit for the flag, the fallback conditions and why re-relaying is safe. swap is signed-only (no raw: true mode) — use createIntent if you need raw transaction data. If you orchestrate the steps yourself instead of calling swap, see Migrate a manual swap to the backend submit-tx flow — the backend attempt and the fallback are not automatic on that path.
The optional extras slot carries per-action overrides: extras.partnerFee replaces the configured swap partner fee for this action, and extras.apiKey replaces the configured backend API key (x-api-key) for this action’s backend submit-tx leg. Both fall back to the Sodax config when omitted — see CONFIGURE_SDK.md § API key. Pass extras.apiKey only from server-side code: a key in a browser bundle is public — see API key good practices.

Create Intent Only

Use createIntent when you need raw transaction data or want to control the relay step yourself. For the full lifecycle, prefer swap.

Signed execution

Raw transaction


Limit Orders

A limit order is an intent with deadline = 0n — it stays active indefinitely until filled at minOutputAmount or manually cancelled.

Create Limit Order (full lifecycle)

Create Limit Order Intent (intent tx only — no relay/solver notify)

Cancel Limit Order / Cancel Intent

cancelLimitOrder is a domain-specific alias for cancelIntent. Both take an object with srcChainKey and intent. Important: cancelIntent takes { params: CancelIntentParams<K>, walletProvider } — not positional arguments. You must supply srcChainKey explicitly because Intent.srcChain is a bigint relay ID that cannot narrow to a SpokeChainKey at the type level.
cancelIntent relays the cancel the same way swap() relays the intent: on Solana it submits the cancel payload alongside the spoke tx (the tx itself carries only the payload hash), and on Bitcoin it relays the signed on-demand payload — no extra input is needed from the caller. On Bitcoin no spoke transaction is broadcast, so srcChainTxHash is the relay’s derived od:<hash> identifier rather than a chain tx hash. The cancel message is sent from the intent’s srcAddress by default. A Bitcoin intent created in TRADING mode stores the trading address, while the cancel must be signed from the personal wallet — signed cancels read that address from walletProvider, so nothing changes for cancelIntent. Pass params.srcAddress only to override it (required for a raw Bitcoin cancel, see below).
Error-type note: cancelIntent and cancelLimitOrder return Result<TxHashPair, Error | unknown> — they were not migrated to the SodaxError<C> family. Don’t switch on error.code here; treat the error as an opaque Error and use instanceof Error / error.message for diagnostics. The rest of this module (swap, createIntent, postExecution, createLimitOrder, createLimitOrderIntent) uses SodaxError<SwapErrorCode> — see Error Handling.

Build Cancel Intent (raw or signed — no relay wait)

Use createCancelIntent when you need only the cancel transaction (e.g. for gas estimation or manual relay):
Relaying a cancel tx yourself follows Submit Intent to Relay API, with one difference per chain family. On Solana the spoke tx carries only the payload hash, so pass data from getCancelIntentRelayData(intent) — the create-intent helpers (getIntentSubmitTxExtraData, reconstructRelayData) encode a different payload and do not match a cancel tx. On Bitcoin createCancelIntent returns the signed on-demand payload JSON rather than a tx hash; submit it under the literal withdraw tx hash with the parsed payload as data, and poll the derived od:<hash> id — BitcoinSpokeService.getOnDemandRelayIdentity (sodax.spoke.bitcoin) returns all three.

Submit Intent to Relay API

Called automatically by swap — but only on its fallback path; the backend attempt relays server-side instead. Use this manually if you called createIntent separately. If you are orchestrating the steps yourself, try sodax.api.swaps.submitTx first and keep this as your fallback — see Migrate a manual swap to the backend submit-tx flow.

Get Intent Submit Tx Extra Data

Required only when the source chain is Solana or Bitcoin. Pass the returned RelayExtraData as data in submitIntent (or relayTxAndWaitPacket). Those deposits commit only a hash of the relay payload on-chain, so the relayer can correlate a submission only with the exact original bytes. For an intent created by createIntent (or swap / createLimitOrderIntent) the payload returned here is byte-identical to the relayData that call returned — raw createIntent calldata for a Sonic-hub source, the [approve, createIntent] multicall for any spoke source — which makes this the recovery path when that runtime relayData is no longer available. One intent shape cannot be reconstructed this way: a leverage-yield vaultSwap / createVaultIntent intent with hubWalletSwap. Its srcChain is the hub while the relayed payload is the spoke multicall sent through sendMessage, so passing that intent to getIntentSubmitTxExtraData({ intent }) or reconstructRelayData yields raw createIntent calldata that will not match. Keep the relayData the leverage-yield call returned, or use sodax.api.leverageYield.getIntentSubmitTxExtraData.

Post Execution to Solver API

Called automatically by swap after the relay packet lands on the hub — on its fallback path only; the backend attempt post-executes server-side. Use this manually when orchestrating the swap steps yourself; Migrate a manual swap to the backend submit-tx flow shows where it belongs in the two-path model.

Get Intent

Retrieve an Intent from the IntentCreated event on the hub chain.

Get Filled Intent

Retrieve the fill state of an intent from the IntentFilled event log, emitted when a solver fills an intent on the hub chain.
IntentState fields:
  • exists — whether the intent exists on-chain
  • remainingInput — unfilled input amount
  • receivedOutput — output tokens received so far
  • pendingPayment — whether a payment is pending

Get Intent Status

Poll the solver API for the current execution status of an intent. The intent_tx_hash must be the hub-chain tx hash where the intent was registered. If the solver returns NOT_FOUND or the request fails, the backend’s durable intent record is checked for a recorded fill — see SOLVER_API_ENDPOINTS.md.

Get Detailed Status

getDetailedStatus answers “what is the status of this swap?” from the source-chain tx — the one identifier you always hold. It does not define a new status: it routes to whichever of the two existing sources can answer, and returns that source’s payload unmodified.
DetailedSwapStatus is discriminated on source, so it narrows on its own — no type guards needed:
A point-in-time read — poll it yourself, or use @sodax/dapp-kit’s useDetailedStatus.

Why it exists

sodax.api.swaps.getSubmitTxStatus cannot answer for every swap, in two different ways. Sometimes there is no record, and it reads 404 — you opted out with useBackendSubmitTx: false, or the submit itself never landed. More often the record exists but is stale: backend 2-step submit POSTs the tx first and only falls back to the client-side relay once that path stalls, so a fallback-completed swap leaves behind whatever state the backend last reached — pending, relaying, or a record it abandoned outright. Neither shape reflects what actually happened to the swap. sodax.swaps.getStatus can answer for it, but needs the hub tx hash, which the caller may not have. So the caller had to know which path ran and pick an API. This method makes that choice instead:
  1. Read the backend record; return it while it is still in play.
  2. Otherwise resolve the hub tx hash — the source tx itself for hub-source swaps, else the delivered relay packet’s dst_tx_hash — and return getStatus’s answer.
A record the backend gave up on (failed, or abandonedAt set) takes step 2, and on the default path this is the common branch rather than an edge case: the record almost always exists, so abandonment — not a 404 — is what usually signals the fallback ran. It never self-heals, so keeping it would report failed for a swap the fallback went on to complete. A success: false envelope takes step 2 as well — that is the wire contract’s “no record found”, whatever data carries. A transport or server error routes on too, so a transient backend outage does not fail a swap the solver can still report on. One failure does not route on: a rejected API key. The swaps apiguard covers POST /swaps/*, so this GET does not normally see a 401/403 — unlike the bridge sibling, where the apiguard reads every /bridge/* route. Wherever one does arrive, it is a terminal configuration problem rather than a source that had nothing to say. Degrading to the solver would bury it behind a relay or solver error and leave a poller retrying a request only a corrected key can satisfy, so it surfaces directly, with context.status set for isAuthFailure. The transient key-verification 503 is not this case: it routes on like any other outage. Both payloads are already documented — the submit-tx record in SWAPS_API.md, the solver response under Get Intent Status. Nothing is translated between them, so no field is dropped and no status code is reinterpreted.

When it fails

LOOKUP_FAILED, and only that. It means no source could answer — most often the relay has not delivered the packet, so there is no hub tx hash for the solver to be asked about. That is a miss, not a lifecycle step: the method will not invent an early status, and it will not fall back to a stale abandoned record. If you poll this yourself, branch on error.context.reason. It equals DETAILED_STATUS_NOT_DELIVERED when the backend answered (a record, or a definitive 404) and the relay has no packet for the source tx — whether it answers 404 for a tx it has not indexed, or returns no matching delivered packet. You cannot tell that apart from “still in flight”, so bound it with a retry budget. Any other LOOKUP_FAILED is a dependency failing right now — relay 5xx or unreachable, malformed response, solver down, or a backend outage that left the relay miss unprovable. Keep retrying those, since retrying is how the read recovers. A rejected key is the exception: isAuthFailure(error) is true and no budget applies, because only a corrected key changes the answer — and none would ever be spent, since an unanswered backend leaves the relay miss untagged. useDetailedStatus applies exactly this split. Because it is meant to be polled, each dependency read it makes — the relay packet lookup and the solver status call — carries its own budget and gives up rather than hanging. An expiry lands in that second group: it is a dependency failing right now, so it stays retryable and does not consume a not-delivered budget.

Get Solved Intent Packet

Poll the relayer until the solver’s fill tx has been delivered to the destination chain. Call this after getStatus returns SolverIntentStatusCode.SOLVED.

Get Intent Hash

Compute the keccak256 hash of an intent (its unique ID on the hub chain).

Error Handling Examples

The full reference is in Error Handling above. The examples below show the common discrimination patterns end-to-end.

Handling swap / createLimitOrder Errors

These methods perform multiple operations in sequence. On failure, result.error is a SodaxError<SwapErrorCode> — discriminate on result.error.code:

Handling createIntent Errors

createIntent returns Result<CreateIntentResult, SwapCreateIntentError>. The narrow union is 'USER_REJECTED' | 'VALIDATION_FAILED' | 'INTENT_CREATION_FAILED' | 'UNKNOWN':

Solver API Errors

postExecution errors are wrapped as SodaxError<PostExecutionErrorCode> (EXECUTION_FAILED | EXTERNAL_API_ERROR | UNKNOWN). The original SolverErrorResponse.detail is preserved on error.context.solverDetail:
getQuote and getStatus retain the legacy error shape in this release — they still return Result<T, SolverErrorResponse>: