Error Handling
Every error the SDK throws is a GSwapSDKError with a stable code and a flat object of named
params. The SDK owns no user-facing text: an app maps code + params to its own localized
string.
GSwapSDKError extends GalaSDKError, the base class in @gala-chain/gala-core-sdk. Errors the
core layer throws itself (a signer, the fetch requestor's TRANSPORT_ERROR / REQUEST_TIMEOUT, the
uniqueKey checks) are plain GalaSDKErrors, not instanceof GSwapSDKError. Do not test
instanceof GSwapSDKError: use isGSwapError(error, code?) from this package or
isGalaError(error, code?) from core. Both recognise every error of either catalog.
| Field | What it is | Stable? |
|---|---|---|
code | The catalog code (NON_CANONICAL_PAIR, RATE_LIMITED, ...). The only thing to branch on. | yes |
params | Named placeholders of that code: only string, number and boolean, never an object, never a secret. A param may be a closed set of string tokens (INVALID_PRIVATE_KEY.reason, SUBMISSION_OUTCOME_UNKNOWN.reason): the matching const array (INVALID_PRIVATE_KEY_REASONS, ...) lists every member so you can map each one. Feed them to your i18n library. | yes, per code |
message | English, generated from the catalog template and params, for logs and developers. | no: never show it, never match on it |
status, url, uniqueKey, requestTransactionId, applyTransactionId, blockNumber, retryAfterMs | Facts about the failure that are not placeholders. | yes |
body, bodyText | The failing response: parsed JSON in body, raw text of a non-JSON answer in bodyText. Diagnostics only. | server-defined |
cause | The error that caused this one (a transport failure, a wallet rejection). | - |
notFound | true for HTTP 404, NO_POOL_AVAILABLE and UNKNOWN_TOKEN. | yes |
phase | 'pre_send', 'in_flight' or 'post_send': how far the operation got (see "Where the failure happened"). isPreSendError(error) reads it. | yes, per code |
shadow | true when the error came from the dark (shadow) path (see "Dark swaps"). | yes |
The server's own English (a gateway or chain message) reaches you in two places only: the raw
body, and the serverMessage param of every code that relays it (HTTP_ERROR,
GATEWAY_REPORTED_ERROR, the gateway bounces, CHAIN_REJECTED, CONFIRMATION_FAILED, ...).
serverMessage is read from the failing body in one fixed order (message, Message,
error.message, error.Message; the first non-empty string). It is DATA from the server, not SDK
text: it is English, it can change, and it is the empty string when the server sent no text or a
non-JSON page (the SDK never writes English in its place). An app may relay it next to its own
string or ignore it; do not match on it.
The codes that do not carry serverMessage have no server text to carry: a 2xx acknowledgement
that is missing something (GATEWAY_DATA_MISSING, GATEWAY_MODE_INVALID,
GATEWAY_SYNC_REQUEST_TRADE, MISSING_UNIQUE_KEY, UNIQUE_KEY_MISMATCH, SHADOW_NOT_QUEUED,
CONFIRMATION_MALFORMED, CONFIRMATION_KEY_MISMATCH) is judged by its shape, and the quote
classifications (NO_POOL_AVAILABLE, INSUFFICIENT_LIQUIDITY, UNKNOWN_TOKEN) are made from
the server's text but state their facts as structured params.
Deciding: classifyFailure
classifyFailure(error) answers "may I offer Retry?" and "could the write have been applied?" from
the code, so an app does not re-derive it per code. It returns { outcome, retryable }:
outcome | Meaning | retryable |
|---|---|---|
rejected | The chain refused (CHAIN_REJECTED, a 422 CHAIN_DISPATCH_FAILED): a new request is safe. | yes |
unknown | The request may have been applied (SUBMISSION_OUTCOME_UNKNOWN, any CONFIRMATION_*, a 2xx that proves nothing, a 409 or 5xx dispatch failure, a write's 5xx). Keep error.uniqueKey and confirm it; never submit under a fresh key. | never |
not_queued | The gateway refused the write before queueing it (the bounces, RATE_LIMITED, a write's 4xx, UPDATE_REQUIRED). | yes, except SHADOW_NOT_QUEUED, UPDATE_REQUIRED and a write's non-429 4xx |
not_sent | The request never left, for a reason outside the call itself: the signer or wallet failed (locked, unreachable, the person declined), the key provider or a token resolver failed, or no key was available yet. SIGNING_FAILED, KEY_PROVIDER_FAILED, TOKEN_RESOLUTION_FAILED, INVALID_PRIVATE_KEY with reason: 'no_key_provided'. | yes |
invalid_input | The call cannot work as written (validation, missing signer or wallet, unknown token or pool, INVALID_UNIQUE_KEY, a READ's UPDATE_REQUIRED). | never |
transient | A read or connection failed with no state change. | yes |
Anything that is not a GSwapSDKError or a GalaSDKError is unknown and not retryable. That is only what
escapes AFTER the request left (an app requestor that rejects with its own error, a callback of
yours that throws once bytes were sent), or an error your own code raised before it called the SDK:
handle those first. retryable is the SDK's floor (a repeat cannot double-apply); an app may still
withhold it. The per-code decision is in src/utils/failure_classification.ts and pinned by
tests/failure_classification.test.ts; the table above lists the codes per outcome.
Asking about a wrapper, not just an SDK error: failure views
Apps wrap SDK errors in their own classes. classifyFailure and every guard in this section accept a
GSwapSDKError or a plain-object failure view and decide from the same fields, so you do not
have to keep the original error beside the wrapper to ask:
import type { FailureOutcome } from '@gala-chain/gala-core-sdk';
interface FailureView {
code: string; // required: a catalog code
params?: Record<string, unknown>; // e.g. { status } of an HTTP_ERROR, { reason } of a SIGNING_FAILED
status?: number; // the HTTP status of the answer
phase?: 'pre_send' | 'in_flight' | 'post_send'; // absent: the catalog's phase for `code`
uniqueKey?: string; // present: it is a WRITE's failure
write?: boolean; // false: it is a READ's failure (see below); absent or true: a write
outcome?: FailureOutcome; // an APP error's declared meaning; read only for a code the catalog does not define
retryable?: false; // withhold-only: takes Retry away from the declared outcome, never grants it
shadow?: boolean;
notFound?: boolean;
}
A class like class V2Failure extends Error { code; params; status; uniqueKey; write } is already one;
toFailureView(value) (from @gala-chain/gala-core-sdk, as are the FailureView type, isWriteView
and FAILURE_OUTCOMES) shows what the SDK reads (a field of the wrong type reads as absent). The guards
that take a view: classifyFailure, isRelayableRefusal, isUnprovenAcknowledgement,
isUninterpretedAnswer, isPreSendError, isShadowError, isWalletUserRejection, isNoMarket,
mayHaveBeenQueued, pendingSwapFateFor. A value with no string code, a code the catalog does not
define (an ethers 'ACTION_REJECTED', an app-only code) or an HTTP_ERROR view with no status is
unknown and not retryable. isRelayableRefusal narrows to GSwapSDKError<...> only when called with
an SDK error; with a view it answers boolean.
A view is a WRITE unless it says it is a read (fail closed). A view with no uniqueKey and no
write: false is judged a write: its 5xx stays unknown and not retryable. Dropping the uniqueKey can
therefore never turn a write's 5xx into a retryable transient (a retry would submit a second trade).
Only write: false without a uniqueKey is a read; GSwapSDKError.write is false for every failure
of an SDK read and true otherwise. Copy code, params, status, uniqueKey and write from the SDK
error when you wrap it.
An error your app raises declares what it means. A code the catalog does not define is unknown
(the SDK cannot say what happened, and mayHaveBeenQueued is true: a wrong "failed" invites a
duplicate). An error raised BEFORE anything was signed or sent says so with outcome:
{ code: 'POSITION_STALE', outcome: 'not_sent' } classifies as not_sent (retryable) and
mayHaveBeenQueued is false. retryable: false withholds Retry from the declared outcome
({ code: 'DID_NOT_QUEUE', outcome: 'not_queued', retryable: false } is NOT_QUEUED_FINAL); true is
never read, so a view cannot grant Retry. The declared outcome is read only for a code the catalog
does not define: for a catalog code the SDK's own table decides.
After the write is queued: afterQueued
classifyFailure(error, { afterQueued: true }) is for the step AFTER the gateway acknowledged the
write (confirming it, reading its apply result): a new request would be a second trade, so retryable
is false for every code, and outcome is unchanged. The one exception is rejected (the chain
evaluated the queued write and refused it: CHAIN_REJECTED, a 422 dispatch): that proves it was not
applied, so a new request under a fresh key cannot double it and retryable keeps its answer (true).
Where a confirmation stopped: confirmationStateOf(error) is 'aborted' (your AbortSignal ended the
wait), 'timeout' or 'failed' (or undefined when it is not a failed confirmation), and
isConfirmationAborted / isConfirmationTimeout are the two guards; a screen never matches
CONFIRMATION_* codes itself. All of them are unknown outcomes: confirm the same key.
Two questions every app asks: isNoMarket and mayHaveBeenQueued
isNoMarket(error) is true for NO_POOL_AVAILABLE, INSUFFICIENT_LIQUIDITY, UNKNOWN_TOKEN and a
READ answered 404 (error.notFound): show "no market for this pair", not an error. A write that got a
404 is a missing route, not a missing market. mayHaveBeenQueued(error) is
classifyFailure(error).outcome === 'unknown': keep error.uniqueKey and confirm it, never submit
again under a fresh key (a value the SDK cannot judge is true too).
An HTTP status you read yourself: GSwapSDKError.fromStatus
For a resolved HTTP answer the SDK did not read (a v1 endpoint, an interceptor's rejection),
GSwapSDKError.fromStatus(status, { url?, uniqueKey?, write?, serverMessage?, body?, retryAfterMs? })
builds the error the SDK would have thrown: UPDATE_REQUIRED for 426, HTTP_ERROR otherwise, phase
post_send. A uniqueKey makes it a write (classifyFailure: a 5xx is unknown, a 426 not_queued);
without one it is a read (write: false on the error: a 5xx is transient, a 426 invalid_input).
write: true with no key throws INVALID_UNIQUE_KEY.
Classifying a step of your own: classification and notSentClassification
An app step that runs before it calls the SDK (a quote refresh before signing) is not an SDK call,
so classifyFailure cannot classify it. Build its classification with the SDK's type and values,
not a literal:
import { classification, notSentClassification, type FailureClassification } from '@gala-chain/gswap-sdk';
const refreshFailed: FailureClassification = notSentClassification(); // { outcome: 'not_sent', retryable: true }
const bad: FailureClassification = classification('invalid_input'); // { outcome: 'invalid_input', retryable: false }
classification(outcome) sets retryable to the SDK's default for that outcome: false for
unknown and invalid_input, true for the others. FailureClassification is exported by this
package; FailureOutcome and FAILURE_OUTCOMES are exported by @gala-chain/gala-core-sdk, so an app can type a Record<FailureOutcome, ...> and have a new
outcome fail to compile until it decides it.
An app may take Retry away from an outcome, never grant it:
classification('not_queued', { retryable: false }), exported frozen as NOT_QUEUED_FINAL, is the
SDK's own value for a refusal the gateway will repeat (SHADOW_NOT_QUEUED, the version wall, a write's
4xx). Use it as it is instead of spreading classification('not_queued') and overriding retryable.
Where the failure happened: error.phase and isPreSendError
Every GSwapSDKError carries phase, measured against the request the operation exists to send
(for a write, the POST with the signed DTO):
phase | Meaning | Consequence |
|---|---|---|
pre_send | NOTHING was transmitted: validation, symbol resolution, the signer, the wallet, a key provider failed. | The write cannot have been queued. The outcome is invalid_input (change the input) or not_sent (repeat is safe). It is never unknown or rejected. |
in_flight | The request left (or may have) and no answer was read: a timeout, a broken connection. | For a write the outcome is unknown; for a read it is transient. |
post_send | An answer was read, or the write was already accepted: a refusal, an acknowledgement that proves nothing, an unusable body, any confirmation failure. | Decided by the code (rejected, not_queued, unknown). |
Each catalog entry declares the phase of its code (the table below has a column for it), and a throw
site that can see the same code in another phase refines it. isPreSendError(error) reads it.
An app never needs an error class of its own to say "nothing was sent". Every point where the
SDK calls code the app supplied before the request leaves wraps what that code throws into a
catalogued pre-send error with the original as cause:
| App-supplied code | Failure becomes |
|---|---|
signer.signObject (swaps, every position write, the fee DryRun) | SIGNING_FAILED with params.method and params.reason (user_rejected when the wallet says the person declined: code 4001, ACTION_REJECTED, or the wallet's own "User rejected ..." text; else failed), and error.walletCode (the wallet's own code: a number such as 4001 or a string such as 'ACTION_REJECTED', undefined when the wallet set none; a typed field on the error, not a param, and never in message) |
the key provider of createLazyPrivateKeySigner (from @gala-chain/gala-core-sdk) | KEY_PROVIDER_FAILED; a provider that returns nothing is INVALID_PRIVATE_KEY with reason: 'no_key_provided'. Both are not_sent. |
a TokenResolver / the registry read it makes through your requestor | TOKEN_RESOLUTION_FAILED with params.token |
| a signed body that cannot be serialized | SIGNING_FAILED (checked before the POST, so a transport hook can never call it "maybe queued") |
An SDK error those callbacks throw themselves (for example SIGNER_ADDRESS_MISMATCH) keeps its
code. Nothing is wrapped AFTER the request left: a rejection of your own httpRequestor that the SDK
cannot read stays what it is (use isTransportFailure and answerOfRejection below). The pending-swap
store never throws and the SDK never calls it, and there is no uniqueKey generator hook, so neither
needs a wrapper.
Relaying the gateway's answer: isRelayableRefusal and isUninterpretedAnswer
isRelayableRefusal(error) is true for the errors whose params.serverMessage is the gateway's own
answer to this write and may be shown as sent: GATEWAY_REPORTED_ERROR, the gateway bounces
(METHOD_NOT_ALLOWED, SIGNATURE_INVALID, SIGNER_MISMATCH, DTO_INVALID, BOUNDS_VIOLATION,
SYMBOL_CONFLICT, RATE_LIMITED, GATEWAY_REJECTED) and CHAIN_DISPATCH_FAILED. Called with an SDK
error it narrows it, so error.params.serverMessage is typed (with an unknown or a view it answers
boolean). Show it when it is not empty and your own line when
it is; never the SDK's English message. CHAIN_REJECTED (worded by where it is shown),
HTTP_ERROR and the five unproven acknowledgements are not relayable. A CHAIN_DISPATCH_FAILED
with status 409 is the "uniqueKey already consumed" replay (classifyFailure: unknown): an app
that words it itself asks isUniqueKeyConsumed(error) first.
import { isGSwapError, isRelayableRefusal } from '@gala-chain/gswap-sdk';
function present(error: unknown): void {
if (isGSwapError(error) && isRelayableRefusal(error)) show(error.params.serverMessage || t('swap.requestFailed'));
}
isUninterpretedAnswer(error) answers a different question: did the gateway ANSWER 2xx with
something the SDK refused to interpret, so that the answer itself (error.body / error.bodyText)
is the thing to read? It is true for the five unproven acknowledgements and GATEWAY_REPORTED_ERROR,
and replaces an app's own isUnprovenAcknowledgement(e) || isGSwapError(e, 'GATEWAY_REPORTED_ERROR').
Did the person decline? isWalletUserRejection
For a signature the SDK asks for, read SIGNING_FAILED and params.reason === 'user_rejected'.
For wallet code of your own (a connect or registration flow, a v1 write path, an analytics hook),
isWalletUserRejection(value) runs the same test the SDK applies to what a signer throws, so you
keep no matcher of your own:
import { isWalletUserRejection } from '@gala-chain/gala-core-sdk';
try {
await wallet.connect();
} catch (error) {
if (!isWalletUserRejection(error)) report(error); // a decline is the person's choice, not a failure
}
It decides from the wallet's own fields first: code 4001 (EIP-1193), 'ACTION_REJECTED' (ethers)
or 'USER_REJECTED', or name === 'UserRejectedRequestError' (viem). Only when those are absent
does it read the wallet's text (message, Message, shortMessage, details, errorDetails,
reason, three levels deep, or a bare string) for "user rejected", "user denied", "user cancelled",
"request rejected", "rejected by user", "rejected the request", "cancelled" / "canceled" or a
"signature ... cancel" sentence. The wording fallback is a last resort for wallets that do not set
codes: it reads a third party's text and is deliberately broad, so wherever you control the wallet,
set a code. An SDK error is never read by its text (SIGNING_FAILED with reason user_rejected is
a decline; every other SDK error is not, and the same holds for a plain-object view of one:
{ code: 'SIGNING_FAILED', params: { reason: 'user_rejected' } }). walletDeclineCode(value) reads the
wallet's code the same way error.walletCode is filled.
Import it from the light subpath in app-shell code. @gala-chain/gala-core-sdk/wallet holds only the
dependency-free wallet helpers (isWalletUserRejection, walletDeclineCode, isGalaErrorLike) and
never loads the catalog or the rest of the SDK (under 2 kB, against 85 kB for the same function through
the rc.6 main entry). The @gala-chain/gswap-sdk/wallet subpath was removed in 2.0.0:
import { isWalletUserRejection } from '@gala-chain/gala-core-sdk/wallet';
The main entry's browser build is tree-shakeable and every failure decider (classifyFailure,
mayHaveBeenQueued, pendingSwapFateFor, isNoMarket, isRelayableRefusal, ...) is shell-safe
too, each with a measured byte budget. Bundle size lists what each helper costs an
app shell that also lazy-loads the whole SDK, and how the budgets are tested.
Dark swaps: error.shadow and isShadowError
Everything swapShadow, submitSwapShadow and ChainGateway.submitShadow throw has
error.shadow === true (validation, the signer, the transport, SHADOW_NOT_QUEUED), and
isShadowError(error) also answers for a non-SDK object that escaped (your requestor's own
rejection; a thrown primitive cannot be remembered). The classification is the same as on the
public path, so an app that never retries or falls back publicly after a dark swap applies that on
top: retryable && !isShadowError(error). A shadow gateway refusal that resolves
({ ok: false, kind }) is an outcome, not an error; its kinds are
SHADOW_FAILURE_KINDS (unavailable, the only retryable one; disabled, the
route answered 400, for example SHADOW_UNSUPPORTED_TYPE for a method that is not
a swap; rejected, with the gateway's message; unknown). Shadow Swaps are
enabled in every environment, so disabled never means the feature is off.
Reading an HTTP answer out of a rejection: answerOfRejection
A requestor on an HTTP client that rejects every non-2xx (an axios interceptor, a version wall)
loses the answer the SDK reads. new ChainGateway({ answerOfRejection }) maps such a rejection to
{ status, body, headers? } and the write is handled exactly as if the answer had been resolved:
DTO_INVALID, RATE_LIMITED, CHAIN_DISPATCH_FAILED, ... or HTTP_ERROR for an uncoded body,
carrying the signed uniqueKey and classified as a write. isTransportFailure is asked first.
For a requestor that wraps fetch and rejects with its own errors, pass isTransportFailure: isFetchTransportFailure (from @gala-chain/gala-core-sdk): a TypeError (any engine's wording) or a NetworkError means no answer
arrived; an AbortError or TimeoutError never does (the request layer reports those itself).
isUnprovenAcknowledgement(error) is true for the five 2xx codes that do not prove a write was
queued (one line of copy, outcome unknown).
Narrowing by code
isGSwapError(error, code) narrows error.params to that code's params. It accepts a code of
either catalog and any GalaSDKError, so it also answers for an error core threw:
import { isGSwapError } from '@gala-chain/gswap-sdk';
try {
await gSwap.swaps.swap('GALA', 'GUSDC', 3000, { exactIn: '100' });
} catch (error) {
if (isGSwapError(error, 'RATE_LIMITED')) {
wait(error.retryAfterMs ?? 60_000);
} else if (isGSwapError(error, 'NON_CANONICAL_PAIR')) {
show(t('errors.NON_CANONICAL_PAIR', error.params)); // { token0, token1 }
} else if (isGSwapError(error)) {
// error.code is the union of every catalog code: switch on it exhaustively.
show(t(`errors.${error.code}`, error.params));
} else {
throw error;
}
}
isGSwapError(error) with no code narrows to AnyGSwapError, the union of one GSwapSDKError<C>
per code, so error.code === 'INVALID_FEE' narrows error.params to { field, value, allowed }.
Localizing: i18next
gswapErrorTemplates maps every DEX code to its English template, {placeholders} included, and
galaErrorTemplates (from @gala-chain/gala-core-sdk) does the same for the signing, transport,
wallet, typed-data and uniqueKey codes; merge the two. Use them
as the English seed of your resource file and translate it; i18next interpolates with {{name}},
so convert the markers once when you build the seed:
import i18next from 'i18next';
import { isGSwapError, gswapErrorTemplates } from '@gala-chain/gswap-sdk';
import { galaErrorTemplates } from '@gala-chain/gala-core-sdk';
// en/errors.json, generated once from the SDK's templates:
const en = Object.fromEntries(
Object.entries({ ...galaErrorTemplates, ...gswapErrorTemplates }).map(([code, template]) => [
code,
template.replace(/\{(\w+)\}/gu, '{{$1}}'),
]),
);
// de/errors.json, hand-written:
// { "NON_CANONICAL_PAIR": "Das Paar {{token0}}/{{token1}} ist nicht in kanonischer Reihenfolge." }
function errorText(error: unknown): string {
if (!isGSwapError(error)) return i18next.t('errors.UNKNOWN');
return i18next.t(`errors.${error.code}`, {
...error.params,
defaultValue: i18next.t('errors.UNKNOWN'),
});
}
Localizing: ICU MessageFormat (FormatJS)
ICU uses {name} exactly as the SDK's templates do, so the English seed is usable as is, and
plurals or selects are possible where a language needs them:
import { createIntl } from '@formatjs/intl';
import { gswapErrorTemplates, isGSwapError } from '@gala-chain/gswap-sdk';
import { galaErrorTemplates } from '@gala-chain/gala-core-sdk';
const templates: Record<string, string> = { ...galaErrorTemplates, ...gswapErrorTemplates };
const intl = createIntl({
locale: 'fr',
messages: {
NON_CANONICAL_PAIR: 'La paire {token0}/{token1} n’est pas dans l’ordre canonique.',
INVALID_FEE:
'{field} : {value} n’est pas un palier de frais valide ({allowed}).',
},
});
function errorText(error: unknown): string {
if (!isGSwapError(error)) return intl.formatMessage({ id: 'UNKNOWN', defaultMessage: 'Something went wrong' });
return intl.formatMessage(
{ id: error.code, defaultMessage: templates[error.code] ?? error.code },
error.params,
);
}
params never contain a nested object or an Error, so they can be passed straight through. A
per-field validation error carries the field name and the offending value or limits
({ field: 'fee', value: 250, allowed: '0,500,3000,10000' }, lists pre-joined into a string), so a
form can attach the message to the right input.
Mapping a chain refusal: chainErrorKeyOf and the gateway's errorKey
A refusal by the chain reaches you in two shapes, and both carry the chain's own machine-readable
ErrorKey so an app maps the key and never the English:
CHAIN_REJECTED: a queued write's apply result withStatus: 0(or a synchronous write the chain refused).params.errorKeyis the apply result'sErrorKey(CHAIN_ERRORwhen it carried none) andparams.serverMessageitsMessage.CHAIN_DISPATCH_FAILED: the gateway failed the dispatch itself. The swap backend puts the chain's key in a top-levelerrorKeyfield of its failure body, present only when the chain supplied one, and the SDK copies it toparams.errorKey. It is the empty string when the body has none (an older deployment, or a failure the chain gave no key), so treat''as "no key", not as a key.
chainErrorKeyOf(error) reads the key from either, from an SDK error or a failure view: it is
params.errorKey of a CHAIN_REJECTED or CHAIN_DISPATCH_FAILED, and undefined for any other code and
for the empty key. It is shell-safe (no catalog), so a presenter can map a key without the SDK:
import { chainErrorKeyOf, isGSwapError } from '@gala-chain/gswap-sdk';
function chainRefusalText(error: unknown): string | undefined {
const key = chainErrorKeyOf(error);
if (key === undefined) return undefined; // not a chain refusal, or the gateway sent no key
const serverMessage = isGSwapError(error, 'CHAIN_REJECTED') ? error.params.serverMessage : '';
return t(`chain.${key}`, { defaultValue: serverMessage });
}
What an app should treat as what
- Rejected on chain: only
CHAIN_REJECTED.params.errorKeyis the chainErrorKey; fix the business condition and never retry the same key. - Outcome unknown, never resubmit, confirm
error.uniqueKeylater:SUBMISSION_OUTCOME_UNKNOWN,CONFIRMATION_TIMEOUT,CONFIRMATION_ABORTED,CONFIRMATION_FAILED,CONFIRMATION_MALFORMEDandCONFIRMATION_KEY_MISMATCH. Branch on "is itCHAIN_REJECTED?", not on "is it one of the unknown ones": any code you did not list must not be treated as a rejection. - Not queued (the write did not happen):
MISSING_UNIQUE_KEY,UNIQUE_KEY_MISMATCH,GATEWAY_SYNC_REQUEST_TRADE,GATEWAY_MODE_INVALID,GATEWAY_DATA_MISSING,GATEWAY_REPORTED_ERRORand the gateway bounces (METHOD_NOT_ALLOWED,SIGNATURE_INVALID,SIGNER_MISMATCH,DTO_INVALID,BOUNDS_VIOLATION,SYMBOL_CONFLICT,RATE_LIMITED,GATEWAY_REJECTED). The exception isCHAIN_DISPATCH_FAILEDwitherror.status === 409: theuniqueKeywas already consumed, so confirm the original key and do not retry. - Transport:
TRANSPORT_ERROR(nothing answered; the original error iscause),REQUEST_TIMEOUT,HTTP_ERROR(params.status). A caller's own abort is not wrapped: it comes back as the abort it is.
Rate limiting
The write limiter allows 30 writes per recovered signer in a sliding 60-second window, matching
signer-rate-limiter.ts in the defi-backend. The SDK does not automatically retry writes. Honor
the server's Retry-After header through error.retryAfterMs; if it is absent, wait for the
window to pass before trying again with the same intent.
SIGNER_MISMATCH is only relevant to contract operations that explicitly name an actor, such as a
token or fee operation. The v2 DEX operations have no signerField: their identity is the signer
recovered from the signature. X-Wallet-Address is an attribution hint, not an identity assertion.
The catalog
Generated from src/classes/gswap_error_catalog.ts (npm run docs:error-catalog); a test fails
while this table differs from the catalog. The template column is the English the SDK's message
is generated from, shipped as gswapErrorTemplates for use as a seed or fallback. "App flows" is
the flow of the two Gala apps that maps the code today.
Since 2.0.0 this table lists gswap-sdk's own DEX codes. The signing, transport, wallet, typed-data
and uniqueKey codes (INVALID_PRIVATE_KEY, SIGNING_FAILED, KEY_PROVIDER_FAILED,
REQUEST_TIMEOUT, TRANSPORT_ERROR, INVALID_TIMEOUT, PERSONAL_SIGN_*, EIP712_*,
TYPED_DATA_*, INVALID_UNIQUE_KEY, INVALID_UNIQUE_KEY_PREFIX, CSPRNG_UNAVAILABLE, ...) moved
with the signing code to @gala-chain/gala-core-sdk, which documents them in its
docs/error-catalog.md and ships their templates as galaErrorTemplates. A GSwapSDKError can
carry a code of either catalog, classifyFailure answers for both, and isGSwapError recognises an
error core threw itself (a signer, the fetch requestor) as well as a GSwapSDKError.
{/* error-catalog:start (generated by npm run docs:error-catalog) */}
| code | phase | params | English template (seed only) | when thrown |
|---|---|---|---|---|
HTTP_ERROR | post_send | status:number, url:string, serverMessage:string (not in message) | Unexpected HTTP error {status} from {url}. | An HTTP failure with no readable relay or chain answer (a proxy 502, a WAF page, an empty 500); the raw text is bodyText. |
UPDATE_REQUIRED | post_send | status:number | The backend refused this app version and requires an update (HTTP {status}); nothing was queued. | The backend answered HTTP 426: it refuses this app or extension version (the version wall). Thrown for a 426 on any read or write request, whatever the body says; the body is error.body. A write is not_queued and a read invalid_input, never retryable: only an update helps. A confirmation read that gets a 426 stays CONFIRMATION_FAILED (params.status 426) because the write it confirms may be applied. |
REQUEST_FAILED | in_flight | operation:string | The {operation} request failed. | A read failed in a way that is not an SDK error (an unreadable body, an app requestor's own rejection); params.operation is pool, quote or position. The failed error is cause. |
METHOD_NOT_SUPPORTED | pre_send | method:string | The method {method} is not supported; swaps are queued through RequestTrade. | The retired synchronous Trade method was submitted. |
SUBMISSION_OUTCOME_UNKNOWN | in_flight | reason:timeout|transport | The submission outcome is unknown ({reason}): the request may have reached the gateway. Confirm before retrying. | The write POST got no answer: params.reason is timeout (it exceeded the gateway timeout) or transport (the connection failed or the body broke off; also a rejection the app names in ChainGatewayOptions.isTransportFailure). The swap may have been accepted: confirm error.uniqueKey; whether to offer Retry (a new key) is the app's policy. Reads keep TRANSPORT_ERROR. |
GATEWAY_REPORTED_ERROR | post_send | serverMessage:string | The gateway reported an error for this write: {serverMessage} | A 2xx answer whose envelope says error: true; the envelope is error.body. |
GATEWAY_DATA_MISSING | post_send | - | The gateway response is missing its data object. | A 2xx answer without a data object. |
GATEWAY_MODE_INVALID | post_send | - | Gateway response must contain data.mode="sync" with data.result, or data.mode="queued". | A 2xx answer whose data.mode / result / ids are not a valid sync or queued acknowledgement. |
GATEWAY_SYNC_REQUEST_TRADE | post_send | - | The gateway executed RequestTrade synchronously, which this SDK refuses; RequestTrade must be queued. | RequestTrade answered as executed synchronously, which the SDK refuses. |
MISSING_UNIQUE_KEY | post_send | - | The gateway acknowledged RequestTrade without echoing its uniqueKey, so it cannot be tied to the signed request. | A queued RequestTrade acknowledgement that does not echo a uniqueKey. |
UNIQUE_KEY_MISMATCH | post_send | - | Gateway response data.uniqueKey must match the signed RequestTrade uniqueKey. | A queued RequestTrade acknowledgement for another uniqueKey than the signed one. |
SHADOW_NOT_QUEUED | post_send | - | The swap backend did not queue the shadow request. | A shadow RequestTrade success that does not queue the signed key. |
SHADOW_SUBMIT_NEEDS_WALLET_ADDRESS | pre_send | - | A shadow submission needs a walletAddress (the X-Wallet-Address header): pass one to the GSwap constructor or to this call. | submitShadow without a wallet address. |
METHOD_NOT_ALLOWED | post_send | serverMessage:string | The gateway does not expose this contract method: {serverMessage} | Gateway bounce 403: the contract method is not exposed. |
SIGNATURE_INVALID | post_send | serverMessage:string | The gateway could not verify the signature: {serverMessage} | Gateway bounce 401: the signature cannot be verified. |
SIGNER_MISMATCH | post_send | serverMessage:string | The recovered signer does not match an actor named by the operation: {serverMessage} | Gateway bounce 403: recovered signer differs from a named actor. |
DTO_INVALID | post_send | serverMessage:string | The gateway rejected the DTO shape: {serverMessage} | Gateway bounce 400: DTO shape or field validation failed. |
BOUNDS_VIOLATION | post_send | serverMessage:string | The gateway rejected a numeric, tick, symbol or range bound: {serverMessage} | Gateway bounce 400: a numeric, tick, symbol or range bound failed. |
SYMBOL_CONFLICT | post_send | serverMessage:string | The CreatePool symbol is already owned by another class: {serverMessage} | Gateway bounce 409: the CreatePool symbol belongs to another class. |
RATE_LIMITED | post_send | serverMessage:string | The gateway rate-limited this signer: {serverMessage} | Gateway bounce 429: 30 writes / 60 s exceeded; error.retryAfterMs when sent. |
CHAIN_DISPATCH_FAILED | post_send | errorKey:string, serverMessage:string | The chain dispatch failed (chain error key "{errorKey}"): {serverMessage} | The chain refused or failed the dispatch. params.errorKey is the chain's own key when the gateway body carries errorKey ("" when it does not, as an older gateway): map it, not the text. HTTP 409 means the uniqueKey was already consumed (confirm the original key, never retry with a new one); 422 is a definite refusal; another 5xx is an upstream outage and the dispatch may have landed. |
GATEWAY_REJECTED | post_send | gatewayCode:string, serverMessage:string | Gateway rejected {gatewayCode}: {serverMessage} | Any gateway bounce code the SDK has no entry for; params.gatewayCode carries it. |
CHAIN_REJECTED | post_send | errorKey:string, serverMessage:string | GalaChain error {errorKey}: {serverMessage} | The chain refused: a Status: 0 apply result, a sync write result, or an HTTP error body with ErrorKey. params.errorKey is the chain key. |
INVALID_CHAIN_RESPONSE | post_send | resource:string | Backend returned an invalid {resource}. | A read answer that is not the expected shape; params.resource names it, the answer is error.body. |
INVALID_POSITION_RESPONSE | post_send | reason:string | Backend returned an invalid position ({reason}). | A /position answer that is not a position. |
CONFIRMATION_TIMEOUT | post_send | - | Transaction confirmation timed out; the trade may still apply. Confirm the same uniqueKey again later instead of resubmitting. | No terminal apply result before the deadline. The trade may still apply: confirm the same key later, never resubmit. |
CONFIRMATION_ABORTED | post_send | - | Transaction confirmation was aborted. | The caller's signal aborted confirmation; the outcome is unknown. |
CONFIRMATION_FAILED | post_send | status:number, serverMessage:string (not in message) | Transaction confirmation failed with HTTP {status}. | The apply-results read got a terminal HTTP status (params.status); the outcome is unknown. |
CONFIRMATION_MALFORMED | post_send | - | Transaction confirmation returned a malformed body. | A 200 apply result that is not a usable body; the outcome is unknown. |
CONFIRMATION_KEY_MISMATCH | post_send | - | Apply result key did not match the requested key. | A 200 apply result for another key; the outcome is unknown. |
NO_SIGNER | pre_send | - | This method requires a signer. Please provide a signer to the GSwap constructor. | A write on a client built without a signer. |
NO_WALLET_ADDRESS | post_send | - | Cannot confirm this write: no walletAddress was provided to the GSwap constructor, so the resulting position cannot be looked up to confirm it landed on chain. | Confirming a liquidity write without a configured walletAddress. |
NO_POOL_AVAILABLE | post_send | tokenIn:string, tokenOut:string, fee:string | No pool is available for {tokenIn}/{tokenOut} (fee tier {fee}). | A quote the backend answered with 404 / "no pools" / "pool does not exist": the pair has no market. notFound is true. A token with no trading symbol is UNKNOWN_TOKEN instead. |
INSUFFICIENT_LIQUIDITY | post_send | tokenIn:string, tokenOut:string, fee:string | The selected pool has insufficient liquidity for {tokenIn}/{tokenOut} (fee tier {fee}). | A quote the backend answered with 400 "Insufficient liquidity". notFound is false. |
UNKNOWN_TOKEN | pre_send | token:string | Token {token} is not registered on the DEX contract: it has no trading symbol. | A token the symbol registry does not know: a symbol lookup, or a quote the backend refused with Token <token> has no trading symbol on the new DEX contract (params.token is the token as the caller gave it; the answer is error.status / error.body). notFound is true. |
SYMBOL_NOT_FOUND | pre_send | token:string | Token {token} could not be resolved to a trading symbol. | A resolver that returned no trading symbol. |
STREAM_API_KEY_UNUSABLE | pre_send | - | GdexStreamConfig.apiKey needs a non-empty key and an explicit webSocketImpl that accepts upgrade headers as its third argument (the "ws" package, or React Native). A browser WebSocket cannot send x-api-key, so the key would be silently dropped. | GdexStreamConfig.apiKey without a usable webSocketImpl. |
STREAM_WEBSOCKET_UNAVAILABLE | pre_send | - | GdexStreamService requires a standards-compatible WebSocket constructor. Node.js versions before 22 must provide one with config.webSocketImpl (for example, from the "ws" package). | No WebSocket constructor for the realtime stream. |
STREAM_NOT_CONNECTED | pre_send | - | GDEX stream is not connected. Call connect() first. | A stream request before connect(). |
STREAM_DISCONNECTED | in_flight | - | GDEX stream disconnected. | disconnect() while requests were pending. |
STREAM_REQUEST_TIMEOUT | in_flight | method:string, timeoutMs:number | GDEX request "{method}" timed out after {timeoutMs} ms. | A stream request got no answer in time. |
STREAM_CONNECT_FAILED | in_flight | url:string | Failed to connect to GDEX stream at {url}. | The socket errored before opening. |
STREAM_CLOSED_BEFORE_CONNECT | in_flight | closeCode:number | GDEX stream closed before connecting (code {closeCode}). | The socket closed before opening. |
STREAM_CONNECTION_CLOSED | in_flight | closeCode:number | GDEX stream connection closed (code {closeCode}). | The socket closed while requests were pending. |
TOKEN_RESOLUTION_FAILED | pre_send | token:string | Token {token} could not be looked up; nothing was sent. | A token resolver, or the HTTP stack it reads the registry through, threw something that is not an SDK error while a swap or position write was being built. params.token names the token (a pair is a/b); the original error is cause. not_sent: Retry is safe. |
MISSING_WALLET_ADDRESS | pre_send | - | Invalid wallet address: No wallet address provided. Either provide a wallet address to the function you are calling, or set one when instantiating GSwapSDK. | No wallet address was given where one is required. |
NON_CANONICAL_PAIR | pre_send | token0:string, token1:string | The pair {token0}/{token1} is not in canonical order: token0 must sort before token1. | A pair not in canonical order (token0 must sort before token1) was about to be signed. |
UNORDERABLE_SYMBOLS | pre_send | received:string | Unable to order trading symbols: expected a pair of symbols, got {received}. | A pair-ordering helper got something that is not a pair. |
INVALID_TOKEN_CLASS_KEY | pre_send | tokenClassKey:string | Invalid token class key "{tokenClassKey}": expected a collection followed by category, type and additionalKey | A token class key string that is not collection/category/type/additionalKey. |
INCOMPLETE_TOKEN_CLASS_KEY | pre_send | compositeKey:string, part:string | Token class key "{compositeKey}" is missing {part}. | A class key object with an empty part; params.part names it. |
INVALID_TRADING_SYMBOL | pre_send | symbol:string | Invalid trading symbol "{symbol}": must be 1-32 ASCII letters and digits and not a reserved word (Token, Unit, none). | A proposed symbol that is not 1-32 ASCII letters/digits or is reserved. |
NO_VALID_TRADING_SYMBOL | pre_send | classKey:string, displaySymbol:string | No valid trading symbol for token class {classKey}: "{displaySymbol}" is not an acceptable symbol and none is registered | No registered symbol and the display symbol is not proposable. |
TOKEN_SYMBOL_NOT_REGISTERED | pre_send | token:string | Token {token} is not registered in the trading-symbol registry yet; pass token0Symbol/token1Symbol (the token class symbol) to propose one. A symbol cannot be derived from a class key. | CreatePool on an unregistered token without a supplied symbol. |
SYMBOL_DISAGREES_WITH_REGISTRY | pre_send | token:string, suppliedSymbol:string, registeredSymbol:string | Supplied symbol {suppliedSymbol} for {token} disagrees with the registered trading symbol {registeredSymbol}. | A supplied symbol that differs from the registered one. |
CREATE_POOL_NEEDS_CLASS_KEY | pre_send | token:string | createPool needs each token as a class key; {token} is a bare trading symbol, which does not carry one. | CreatePool given a bare symbol instead of a class key. |
AMOUNT_IS_JS_NUMBER | pre_send | field:string | Invalid {field}: use a decimal string or BigNumber, not a JavaScript number | A JavaScript number was given where a decimal string is required; params.field names it. |
AMOUNT_NOT_FINITE | pre_send | field:string, value:string | Invalid {field}: must be a finite number (got {value}) | A non-finite or unparseable amount; params.field, params.value. |
AMOUNT_NOT_POSITIVE | pre_send | field:string, value:string | Invalid {field}: must be positive (got {value}) | A zero amount where a positive one is required. |
AMOUNT_NEGATIVE | pre_send | field:string, value:string | Invalid {field}: must not be negative (got {value}) | A negative amount. |
PRICE_VALUES_IS_JS_NUMBER | pre_send | - | Invalid price values: use decimal strings or BigNumber values, not JavaScript numbers | validatePriceValues got JavaScript numbers. |
INVALID_PRICE_VALUES | pre_send | spotPrice:string, lowerPrice:string, upperPrice:string | Invalid price values: all prices must be finite and positive (spot {spotPrice}, lower {lowerPrice}, upper {upperPrice}) | A price that is not finite and positive. |
INVALID_PRICE_RANGE | pre_send | lowerPrice:string, upperPrice:string | Invalid price range: lower price {lowerPrice} must be less than or equal to upper price {upperPrice} | lowerPrice above upperPrice. |
INVALID_PRICE_BOUNDS | pre_send | minPrice:string, maxPrice:string | Price bounds must be finite and positive (minPrice {minPrice}, maxPrice {maxPrice}). | addLiquidityByPrice min/max not finite and positive. |
PRICE_BOUNDS_ORDER | pre_send | minPrice:string, maxPrice:string | minPrice {minPrice} must be less than or equal to maxPrice {maxPrice}. | addLiquidityByPrice minPrice above maxPrice. |
INVALID_STARTING_PRICE | pre_send | value:string | Starting price must be finite and positive (got {value}). | A CreatePool starting price that is not finite and positive. |
INVALID_SQRT_PRICE | pre_send | sqrtPrice:string | Cannot invert a non-positive sqrt price ({sqrtPrice}). | A non-positive sqrt price could not be inverted. |
INVALID_TOKEN_DECIMALS | pre_send | field:string, value:number | Invalid {field}: must be a non-negative integer (got {value}) | Token decimals that are not a non-negative integer. |
TICKS_NOT_INTEGERS | pre_send | tickLower:number, tickUpper:number | Invalid tick values: ticks must be integers (got {tickLower} and {tickUpper}) | Tick bounds that are not integers. |
INVALID_TICK_RANGE | pre_send | tickLower:number, tickUpper:number | Invalid tick range: tickLower {tickLower} must be less than tickUpper {tickUpper} | tickLower not below tickUpper. |
TICKS_OUT_OF_BOUNDS | pre_send | tickLower:number, tickUpper:number, minTick:number, maxTick:number | Invalid tick range: ticks {tickLower} and {tickUpper} must be between {minTick} and {maxTick} | Ticks outside the chain's global min/max. |
TICKS_NOT_ALIGNED | pre_send | tickLower:number, tickUpper:number, spacing:number | Ticks {tickLower} and {tickUpper} must be aligned to spacing {spacing}. | Ticks not aligned to the fee tier's spacing. |
INVALID_TICK_SPACING | pre_send | value:number | Invalid tick spacing: must be a positive integer (got {value}) | A tick spacing that is not a positive integer. |
INVALID_FEE | pre_send | field:string, value:number, allowed:string | Invalid {field}: {value} is not a supported fee tier; must be one of {allowed}. | A fee tier that is not 0, 500, 3000 or 10000; params.field names the option. |
EXACT_AMOUNT_REQUIRED | pre_send | field:string | {field} is required for this swap. | A swap request without exactIn / exactOut. |
EXACTLY_ONE_OF_REQUIRED | pre_send | first:string, second:string | Provide exactly one of {first} or {second}. | Neither or both of two mutually exclusive fields. |
AT_MOST_ONE_OF_ALLOWED | pre_send | first:string, second:string | Provide at most one of {first} or {second}. | Both of two mutually exclusive optional fields. |
POOL_MISMATCH | pre_send | tokenIn:string, tokenOut:string, poolToken0:string, poolToken1:string | tokenIn {tokenIn} and tokenOut {tokenOut} must be the two tokens of the pinned pool {poolToken0}/{poolToken1}. | tokenIn/tokenOut are not the two tokens of the pinned pool. |
INVALID_DTO | pre_send | method:string, field:string, expected:string | {method} DTO field {field} is invalid (expected {expected}). | A DTO handed to submit* has a field of the wrong shape; params.field, params.expected. |
DTO_NOT_CANONICAL | pre_send | method:string | The {method} DTO is not in the form the SDK builders produce; build it with the matching build function and submit that. | A DTO handed to submit* is not exactly what the builder produces. |
SEARCH_TOO_LONG | pre_send | length:number, maxLength:number | search must be at most {maxLength} characters (got {length}). | Pool list search over 64 characters. |
INVALID_SORT_BY | pre_send | value:string, allowed:string | sortBy "{value}" is not allowed; it must be one of {allowed}. | Pool list sortBy not allowed. |
INVALID_SORT_ORDER | pre_send | value:string | sortOrder "{value}" is not allowed; it must be asc or desc. | Pool list sortOrder not asc/desc. |
INVALID_PAGE | pre_send | value:number | page must be an integer of at least 1 (got {value}). | page not an integer of at least 1. |
INVALID_LIMIT | pre_send | value:number, min:number, max:number | limit must be an integer from {min} to {max} (got {value}). | limit outside params.min..params.max. |
INVALID_MARKET_TOKEN | pre_send | token:string | A market read names a token by a symbol or class key of 1 to 120 letters, digits and . _ $ + / : | - characters (got "{token}"). | A market token string outside the backend asset-id alphabet or over 120 characters. |
INVALID_TIMEFRAME | pre_send | value:string, allowed:string | timeframe "{value}" is not allowed; it must be one of {allowed}. | market.getPriceSeries timeframe not 1D / 1W / 1M / 1Y / ALL. |
INVALID_SEASON_ID | pre_send | value:string | seasonId must be an integer of at least 1 (got {value}). | leaderboard.getLeaderboard seasonId not an integer of at least 1. |
INVALID_POLL_INTERVAL | pre_send | value:string | intervalMs must be a finite number of milliseconds of at least 1 (got {value}). | pools.watchNewPools intervalMs not a finite number of at least 1. |
{/* error-catalog:end */}