swaps property of a Sodax instance:
sodax.swapsvssodax.api.swaps. This page documentssodax.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) issodax.api.swaps(SwapsApiService); seeSWAPS_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 theconfig 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.
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 throughsodax.swaps:
Quote & Fee Methods
getQuote(payload)— Request a price quote from the solver APIgetPartnerFee(inputAmount)— Calculate the partner fee for a given input amountgetSolverFee(inputAmount)— Calculate the solver protocol fee (0.1%) for a given input amountgetSwapDeadline(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 onlycreateIntent(params)— Create an intent on the source spoke chain; supports both signed (raw: false) and raw (raw: true) modescreateLimitOrder(params)— Full end-to-end limit order (no deadline, must be cancelled manually); signed execution onlycreateLimitOrderIntent(params)— Create a limit order intent only (no relay/solver notify); supports raw and signed modessubmitIntent(payload)— Submit a spoke tx to the relay API (low-level, called automatically byswapon its fallback path)postExecution(request)— Notify the solver that an intent is live on the hub chain (low-level, called automatically byswapon its fallback path)
swap? See Migrate a manual swap to the backend submit-tx flow.
Backend 2-step submit
By defaultswap() 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:
timeout terms are yours to tune. Opting out with useBackendSubmitTx: false drops the backend-attempt term entirely.
Intent Management
getIntent(txHash)— Retrieve anIntentfrom a hub-chain transaction hashgetFilledIntent(txHash)— Retrieve the fill state of an intent from the solver’s fill tx hashgetIntentSubmitTxExtraData(params)— Rebuild the relay extra data (address+payload) for a Solana/Bitcoin intent from a hub-chain tx hash or anIntent; byte-identical to therelayDatathatcreateIntentreturnedreconstructRelayData(intent)— The same relay extra data, derived offline from a fully-populatedIntent(no RPC call)getSolvedIntentPacket(params)— Poll the relayer until a solved intent’s fill packet arrives on the destination chaingetIntentHash(intent)— Compute the keccak256 hash of an intent (its on-chain ID)getStatus(request)— Poll the solver API for current intent execution statusgetDetailedStatus(params)— Read a swap’s status from its source-chain tx hash; routes to the backend record or the solver, whichever can answercancelIntent(params)— Cancel an active intent and wait for hub confirmationcreateCancelIntent(params)— Build (and optionally broadcast) only the cancel tx; supports raw and signed modesgetCancelIntentRelayData(intent)— Relay extra data (address+payload) for manually relaying a Solana cancel txcancelLimitOrder(params)— Alias forcancelIntentwith domain-specific naming
Token Approval
isAllowanceValid(params)— Check if the spender contract has sufficient token allowanceapprove(params)— Approve token spend (EVM/Sonic/Stellar); supports raw and signed modes
Utility Methods
getSupportedSwapTokensByChainId(chainId)— Get supported swap tokens for a spoke chaingetSupportedSwapTokens()— Get all supported swap tokens per chainestimateGas(params)— Estimate gas for a raw transaction on any spoke chaingetSwapSpeedTier({ 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 awalletProvidermatching the source chain type; signs and broadcasts the transaction; returns a tx hash.raw: true—walletProvidermust be absent (passing one is a compile error); returns an unsigned raw transaction payload.
WalletProviderSlot<K, Raw> discriminated union:
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>>:
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:
- Discriminate on
error.code— never onerror.message(the message is a human-readable explanation, not a stable contract). error.causewalks the underlying error chain (ES2022). Loggers like Sentry/Pino/Datadog walk this automatically.error.contextcarries 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 incontextare coerced to strings, cause walked depth-3, no circular hazards).- Use
isSodaxError(e)instead ofinstanceof SodaxErrorin 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 helpersrelayTxAndWaitPacket 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 viasodax.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.
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
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 asCreateIntentParams.deadline.
deadline: 0n directly to createIntent (or use createLimitOrder / createLimitOrderIntent which force 0n automatically).
Get Swap Speed Tier
Offline, rule-based estimate of how fast asrcToken → 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
Swap (Recommended)
Theswap method is the recommended way to perform a complete cross-chain swap. It orchestrates the full lifecycle automatically:
- Calls
createIntentto submit the intent transaction on the source spoke chain - Backend attempt (default,
swaps.useBackendSubmitTx): hands the broadcast tx tosodax.api.swaps.submitTx— which verifies, relays and post-executes server-side — then polls submit-tx status until the intent issolved - 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
postExecutionto notify the solver, triggering it to fill the intent
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.
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
UsecreateIntent 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 withdeadline = 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:cancelIntentandcancelLimitOrderreturnResult<TxHashPair, Error | unknown>— they were not migrated to theSodaxError<C>family. Don’tswitchonerror.codehere; treat the error as an opaqueErrorand useinstanceof Error/error.messagefor diagnostics. The rest of this module (swap, createIntent, postExecution, createLimitOrder, createLimitOrderIntent) usesSodaxError<SwapErrorCode>— see Error Handling.
Build Cancel Intent (raw or signed — no relay wait)
UsecreateCancelIntent when you need only the cancel transaction (e.g. for gas estimation or manual relay):
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 byswap — 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 returnedRelayExtraData 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 byswap 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 anIntent from the IntentCreated event on the hub chain.
Get Filled Intent
Retrieve the fill state of an intent from theIntentFilled event log, emitted when a solver fills an intent on the hub chain.
IntentState fields:
exists— whether the intent exists on-chainremainingInput— unfilled input amountreceivedOutput— output tokens received so farpendingPayment— whether a payment is pending
Get Intent Status
Poll the solver API for the current execution status of an intent. Theintent_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:
@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:
- Read the backend record; return it while it is still in play.
- Otherwise resolve the hub tx hash — the source tx itself for hub-source swaps, else the delivered relay packet’s
dst_tx_hash— and returngetStatus’s answer.
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 aftergetStatus 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>: