App and Wallet Integration
Everything in this guide is platform neutral: it uses only fetch (or an
injected httpRequestor), globalThis.crypto, timers, and adapters you pass
in. No Node, DOM, or chrome.* API is touched, so the same code runs in a
browser page, a Manifest V3 service worker, Node.js, and React Native.
| Platform piece | Adapter you provide |
|---|---|
| HTTP | httpRequestor (default: global fetch) |
| Signing | a GalaChainSigner (PrivateKeySigner, GalaWalletSigner, BrowserWalletSigner, or your own), imported from @gala-chain/gala-core-sdk |
| Pending-swap persistence | a StorageAdapter |
| Realtime stream | a webSocketImpl constructor |
Write keys
Every write carries a fresh uniqueKey of the form <prefix><uuid>. The
prefix defaults to galaswap-operation- (what the Gala Wallet and the gSwap
web app send) and is configurable with new GSwap({ uniqueKeyPrefix }). It
must be at most 92 characters of A-Z a-z 0-9 . _ : -, so the key can always
be read back from apply-results.
To know the key before signing (to remember it, to show it, to retry the same
attempt after a lost response), pass uniqueKey to any write: the options of
swap / swapShadow / buildSwap, or the uniqueKey field of the position
and pool methods. It must match ^[A-Za-z0-9._:-]{1,128}$; reuse a key only to
retry the very same attempt.
The key's UUID is minted by @gala-chain/gala-core-sdk from
crypto.getRandomValues (never crypto.randomUUID, which React Native/Hermes
lacks). On a platform without getRandomValues the mint throws
CSPRNG_UNAVAILABLE; on React Native install react-native-get-random-values.
The helpers createUniqueKey, assertUniqueKey, resolveUniqueKeyPrefix,
isValidUniqueKey and DEFAULT_UNIQUE_KEY_PREFIX are exported by core.
Build, show, then sign: the pure DTO builders
buildRequestTradeDto, buildAddLiquidityDto, buildRemoveLiquidityDto,
buildCollectPositionFeesDto and buildCreatePoolDto are the only code that
builds a v2 write DTO (the Swaps and Positions services call them). They
take canonical inputs (token0 < token1, ticks and prices in that orientation;
a reversed pair is refused as NON_CANONICAL_PAIR), validate, write decimals as
BigNumber#toFixed() and fix the key order. Use them to put the exact DTO in an
approval popup before it is signed:
import { swapRouteFromQuote } from '@gala-chain/gswap-sdk';
const route = swapRouteFromQuote(quote); // tokenIn, tokenOut, fee and pool, verbatim from the quote
const dto = await gSwap.swaps.buildSwap(
route.tokenIn,
route.tokenOut,
route.fee,
{ exactIn: '100', amountOutMinimum: '14' },
{ pool: route.pool, uniqueKey },
);
showApproval(dto);
const submitted = await gSwap.swaps.submitSwap(dto); // signs and submits exactly `dto`
submitSwap, submitSwapShadow, submitAddLiquidity, submitRemoveLiquidity,
submitCollectPositionFees and submitCreatePool refuse any DTO that is not
exactly the builder's output (assertCanonicalV2Dto: no signature, canonical
decimals, builder key order), so what was shown is what is signed. A position
write built this way keeps its own confirm().
Matching an app's own requests
An app that already talks to the backend with its own HTTP stack can move to the
SDK without changing what goes on the wire. Every option is on an object the
app constructs (ChainGateway, Quoting, Pools, Positions), and on GSwap:
wire: { queryEncoding: 'readable' }keeps$ | : ,unescaped in a query (token0=GALA$Unit$none$none); the default'standard'isURLSearchParams. Anything that would change a query's meaning (& = # % +) is always escaped.wire.userAgentis a string, orfalsefor none;wire.headersadds headers (for example axios'Accept). They apply to every request: reads, thesubmit/submitShadowPOSTs and the apply-results polls. A GET never sendsContent-Type; a POST sendsContent-Type: application/jsonunlesswire.headerssets one, and always ends withX-Wallet-Address(andX-Shadow: truefor a shadow write), whichwire.headerscannot replace.- A queued RequestTrade acknowledgement must echo the signed
uniqueKey; without it (MISSING_UNIQUE_KEY) or with another (UNIQUE_KEY_MISMATCH)submitthrows that code. A returnedSubmittedTransactionhasmode,status('queued'or'executed'),uniqueKeyandenvelope;isSubmitQueued,isSubmitExecuted,isSubmitRejectedandclassifySubmitEnvelopeclassify a raw envelope. eip1193ProviderFromEthers(browserProvider)letsBrowserWalletSignersign through an ethersBrowserProvider;httpRequestorFromAxios(instance, { rejectOnHttpError })sends the SDK's requests through an axios instance. Both, like the signers, are imported from@gala-chain/gala-core-sdk.symbolResolution: 'canonical'onGSwap(ornew CanonicalSymbols()for a service built by hand) makes noGET /v2/trade/symbols: writes take the canonical trading symbols the caller already holds from a quote, estimate or position, and reads send tokens (composite keys included) and ticks as given, leaving the pair's orientation to the backend.chainCallTimeoutMs: Infinityarms no timer, for an app whose HTTP stack owns request lifetimes.new ChainGateway({ isTransportFailure, answerOfRejection })teach the SDK what your requestor's rejections mean:isTransportFailurenames a rejection that proves no answer arrived (a write is thenSUBMISSION_OUTCOME_UNKNOWN;isFetchTransportFailure, from@gala-chain/gala-core-sdk, fits afetchwrapper), andanswerOfRejectionmaps a rejection that IS an HTTP answer (an axios interceptor, a version wall) to{ status, body, headers? }, so the write is classified exactly as if that answer had been resolved. See Error Handling.onInvalidEntryreceives every row of a pool, symbol or position list that was skipped as unusable ({ source, index, entry, reason }); one bad row never fails the list.GalaWalletSigner(account, { selectAddress })accepts0x+40 hex,eth|+40 hex,client|+24 hex or the bare 24 hex, passes a 130-hex signature through bare or0x-prefixed, and callswindow.gala.setAddress(selectAddress)before each signature.- Failures carry a stable
error.codeand flaterror.paramsthat an app localizes (see Error Handling), pluserror.status,error.body(parsed JSON),error.bodyText(a non-JSON answer, never inmessage) and, for a missing market,error.notFound(HTTP 404,NO_POOL_AVAILABLE,UNKNOWN_TOKEN).error.messageis English for logs: never show it, never match on it.
Confirming a swap: confirmAppliedTrade
SubmittedTransaction.confirm() and swaps.confirmSwap() use this function;
call it directly when you only hold a stored uniqueKey (a resumed swap):
import { AppliedTradeError, confirmAppliedTrade } from '@gala-chain/gswap-sdk';
const controller = new AbortController();
try {
const applied = await confirmAppliedTrade(
{ dexBackendBaseUrl: 'https://dex-backend-prod1.defi.gala.com', httpRequestor: galaFetch },
{ uniqueKey, requestTransactionId },
{ initialDelayMs: 0, timeoutMs: 60_000, signal: controller.signal },
);
} catch (error) {
if (!(error instanceof AppliedTradeError)) throw error;
switch (error.code) {
case 'CONFIRMATION_ABORTED': // the signal aborted; the outcome is unknown
case 'CONFIRMATION_TIMEOUT': // the trade may still apply; confirm again later, never resubmit
case 'CONFIRMATION_FAILED': // the read got a terminal HTTP status; the outcome is unknown
case 'CONFIRMATION_MALFORMED': // the answer was not an apply result; the outcome is unknown
case 'CONFIRMATION_KEY_MISMATCH': // the answer was for another key; the outcome is unknown
break;
case 'CHAIN_REJECTED': // the chain refused the trade; params.errorKey is its ErrorKey
console.log(error.params.errorKey);
break;
default: // INVALID_UNIQUE_KEY: nothing was read
break;
}
}
It polls GET /v1/chain/apply-results/{uniqueKey}?channel=asset&contract=dex-contract
after initialDelayMs (default 3 s), retrying 404, 429, 500, 502, 503, 504 and
transport errors with jittered backoff (Retry-After honored as seconds or an
HTTP date) until timeoutMs. Any other status is terminal (CONFIRMATION_FAILED
with params.status). AppliedTradeError extends GSwapSDKError; its
uniqueKey field is always set and, where known, so are requestTransactionId,
applyTransactionId, blockNumber, status, body, url and the last
transport cause. confirmationStateOf(error) ('aborted', 'timeout',
'failed') and isConfirmationAborted / isConfirmationTimeout read the
state without matching codes; every one of them is an unknown outcome.
Remembering pending swaps: PendingSwapStore
Persist the uniqueKey of every accepted swap so its outcome can be confirmed
after the UI that submitted it is gone. The store persists structured values
through a StorageAdapter (get(key) / set(key, value), both async):
import {
PendingSwapStore,
confirmAppliedTrade,
storageAdapterFromChromeStorage,
webStorageAdapter,
} from '@gala-chain/gswap-sdk';
// Chrome extension: the value is stored as is, under the key `pendingSwaps.v1`. Pass a function
// to look the area up at each call, so a module imported where `chrome` is absent still loads.
const store = new PendingSwapStore(storageAdapterFromChromeStorage(() => chrome.storage.local));
// A web app that keeps a JSON string in localStorage, with its own key, flag name and order.
const webStore = new PendingSwapStore(
webStorageAdapter(() => localStorage),
{
storageKey: 'galaswap:pending-swaps:v1',
darkFlagField: 'shadow',
insertAt: 'start',
listOrder: 'newest-first',
},
);
await store.remember({
uniqueKey,
requestTransactionId,
wallet,
token0Symbol: 'GALA',
token1Symbol: 'GUSDC',
isDarkSwap: false,
createdAt: Date.now(),
});
for (const swap of await store.list(wallet)) {
await confirmAppliedTrade(client, swap, { initialDelayMs: 0 }); // then store.forget(swap.uniqueKey)
}
Persistence is best effort and never throws, operations of one store are
serialized, and entries expire after seven days (list prunes them). The
defaults are the Chrome extension's shape: key pendingSwaps.v1, isDarkSwap,
new entries at the end, list in stored order; the options above reproduce the
web app's. A get that rejects is treated as "storage unavailable" and makes
remember a no-op, so an adapter must return corrupt data as a value instead of
rejecting; webStorageAdapter does (a missing key is undefined, corrupt JSON
comes back as the raw string, a blocked or full storage rejects) and
storageAdapterFromChromeStorage stores the structured value as is. Both are
structural (WebStorageLike, ChromeStorageAreaLike), so a React Native
key-value store with getItem / setItem fits webStorageAdapter. Two stores
over the same storage (popup and service worker) are not coordinated.
new PendingSwapStore(adapter, { onPrune }) hears every entry the store drops,
with the reason (PENDING_SWAP_PRUNE_REASONS: expired, invalid_unique_key
for a key confirmAppliedTrade would always refuse with INVALID_UNIQUE_KEY,
chain_rejected, unconfirmable); remember refuses to save an invalid key.
onPrune is diagnostics only: what it does or throws never changes the store.
Resolving a swap after a failed confirmation: pendingSwapFateFor and settle
When confirmAppliedTrade fails, the SDK says what to do with the stored swap, so an app keeps no
table of its own: pendingSwapFateFor(error) is 'forget' (the chain rejected it: drop it and tell the
user), 'forget_quietly' (the key is refused, so it can never be confirmed: drop it, claim nothing) or
'keep' (every other outcome, and anything the SDK cannot judge: it may have been applied, look again
later). store.settle(uniqueKey, error) applies it and returns the fate:
for (const swap of await store.list(wallet)) {
try {
await confirmAppliedTrade(client, swap, { initialDelayMs: 0 });
await store.forget(swap.uniqueKey);
} catch (error) {
if ((await store.settle(swap.uniqueKey, error)) === 'forget') tellTheUserItFailed(swap);
}
}
onPrune hears chain_rejected (forget) and unconfirmable (forget_quietly) with the swap; keep
changes nothing. pendingSwapFateOfOutcome(outcome) is the same table for an app that already holds the
outcome. pendingSwapFateFor also accepts a plain-object failure view of an SDK error.
Parsing a token class key without a try: tryParseTokenClassKey
(tryParseTokenClassKey and tryParseCompositeKey are imported from
@gala-chain/gala-core-sdk.) tryParseTokenClassKey(value) is parseTokenClassKey that returns undefined instead of throwing, for
untrusted input (a stored token, a URL parameter, a wallet row); it validates an object (four non-empty
strings) instead of copying it. tryParseCompositeKey(value) reads ONLY the $ form
(Collection$Category$Type$AdditionalKey): a | stays part of a segment there, whereas
parseTokenClassKey and tryParseTokenClassKey switch to the pipe form when the key contains one. Both
read from the right, so a collection that contains $ ($GMUSIC) stays whole.
Fee quotes: DexFees
A wallet asks the backend what a DEX operation will cost by signing a DryRun
wrapper ({ callerPublicKey, method, dto, uniqueKey }, the inner DTO unsigned)
and posting it to
/wallet/connect/galachain/api/{channel}/{contract}/{method}/fee:
import { DexFees, isDexFeeMethod } from '@gala-chain/gswap-sdk';
import { PrivateKeySigner } from '@gala-chain/gala-core-sdk';
const fees = new DexFees(
{ dexBackendBaseUrl, httpRequestor: galaFetch },
new PrivateKeySigner(key),
);
if (isDexFeeMethod(method)) {
// The EIP-712 approval message can be passed as is: the signing envelope is stripped.
const quote = await fees.quote(publicKey, method, message, { contract: 'dex-contract' });
const feeInGala = quote?.fees[0]?.feeInGala; // null when the backend was unreachable
}
The client never invents a fee: the backend serves source: 'default' when the
chain cannot price the operation. The one local default mirrors the wallet: a
dex-contract quote answered 403 or 404 resolves a zero default. Transport
failures resolve null; only a signer error (for example a locked wallet) is
thrown. gSwap.fees is the same service bound to the client's signer.
Dark (shadow) swaps
const outcome = await gSwap.swaps.swapShadow('GALA', 'GUSDC', 3000, { exactIn: '100' });
if (outcome.ok) {
await gSwap.swaps.confirmSwap(outcome.queued.uniqueKey); // `queued` is always there on a success
} else {
// 'unavailable' (retryable), 'disabled', 'rejected' (outcome.message), 'unknown'
}
Shadow Swaps are enabled in every environment, so disabled does not mean the
feature is off. It is the answer to a shadow request the route refused with a
400 (SHADOW_UNSUPPORTED_TYPE, a shadow request for a method that is not a
swap, or any other 400 such as a validation failure): the request will not
succeed as sent and is not retryable. The kinds are SHADOW_FAILURE_KINDS; the
body codes behind them are SHADOW_SUBMIT_CODES.
A successful ShadowSubmitOutcome always carries queued (a RequestTrade answer that does not queue
the signed key throws SHADOW_NOT_QUEUED instead), so there is no undefined branch to write.
readShadowSubmitOutcome for another method may also return a ShadowSyncSuccess (a synchronous
liquidity write: nothing queued, queued absent).
swapShadow signs the same RequestTrade and posts it with X-Wallet-Address
and X-Shadow: true, so the backend marks it private before it reaches the
contract (a walletAddress is required). Unlike swap, every HTTP status
resolves, because shadow failures are classified from the status together with
the body's code. For other methods use gateway.submitShadow(method, signedDto)
and readShadowSubmitOutcome(method, signedDto, result). Never fall back to a
public swap when a shadow swap fails.
Realtime stream and authentication
gSwap.stream (or new GdexStreamService({ url, webSocketImpl })) speaks
JSON-RPC 2.0 over the gdex.jsonrpc.v1 WebSocket subprotocol, with automatic
reconnect and resubscribe (see the realtime stream guide).
It uses the platform WebSocket unless you inject a constructor.
Authentication. Public data needs none. The server serves a privileged view
(shadow swaps with the wallet unredacted) only to connections whose upgrade
request carries a valid x-api-key header; a missing or wrong key is
indistinguishable from no key. The server reads no other credential (not the
query string, not a message), and a browser WebSocket cannot set request
headers. So:
-
Browsers (and anything using the global
WebSocket, including Node 22's): public view only. Do not try to smuggle a key through the URL. -
Node.js and React Native: pass the constructor that accepts headers as its third argument and the key:
import { GdexStreamService } from '@gala-chain/gswap-sdk';import { WebSocket } from 'ws';const stream = new GdexStreamService({webSocketImpl: WebSocket,apiKey: process.env.GDEX_API_KEY,});// or: new GSwap({ webSocketImpl: WebSocket, streamApiKey: process.env.GDEX_API_KEY })apiKeyrequires an explicitwebSocketImpl(the constructor throws otherwise), so a key is never silently dropped by a platform that ignores headers. Use it from trusted server-side code: a key shipped inside an app bundle is not a secret.
Signing primitives for wallets
A wallet that holds the key itself (the Gala Wallet extension) signs with these instead of
carrying its own crypto. They are exported by @gala-chain/gala-core-sdk (since 2.0.0 this
package no longer re-exports them). Keys are 0x-optional hex, base64 or 32 raw bytes; every call
normalizes a private copy and zeroes it before returning.
import {
createLazyPrivateKeySigner,
resolvePersonalSignChainDtoPayload,
serialize,
signChainDtoNative,
signChainDtoPersonalSign,
signTypedDataPayload,
stripEip712DomainType,
} from '@gala-chain/gala-core-sdk';
// Native: keccak256 of the exact string the user reviewed.
const native = signChainDtoNative(key, serialize(dto));
// Personal-sign (`gala_signChainDto`): refuse BEFORE signing, then sign what was reviewed.
resolvePersonalSignChainDtoPayload(data); // throws GalaSDKError PERSONAL_SIGN_NOT_JSON, ...
const personal = signChainDtoPersonalSign(key, data);
// A dApp's typed-data approval (no ethers needed); resolves to `0x` + r + s + v.
const typed = await signTypedDataPayload(key, {
domain,
types: stripEip712DomainType(types),
message,
});
// A signer for GSwap that never keeps the key: it is fetched per signature and zeroed.
const signer = createLazyPrivateKeySigner(() => readSessionKey(), {
expectedAddress: account.address, // refuse if the key slot now belongs to another account
});
resolvePersonalSignChainDtoPayload fails closed on malformed JSON, non-objects, a prefix that is
not an EIP-191 "Ethereum Signed Message" header, signing-envelope fields (signature, multisig,
trace, types, domain, stringsInstructions) and any string that is not already the canonical
serialization of what it parses to. The personal-sign payload it returns is { prefix, message }:
the dApp's own prefix and the DTO serialized WITHOUT it, which is what the chain recovers from.
signTypedDataPayload infers the primary type like ethers and is stricter about coercion (see
packages/gala-core-sdk/src/utils/typed_data.ts). A JavaScript string key cannot be wiped; pass the key as a Uint8Array
if you can. Other exports: signRawObject (raw strip-signature-then-sign for transfers and bridge
DTOs), canonicalDecimalString, tokenClassDollarKey, hashTypedData, withPrivateKey.
Behavior notes for apps that wrote these helpers themselves
An app that already has its own confirmation polling, fee-quote, pending-swap or requestor code will see these deliberate, user-invisible differences when it moves to the SDK modules.
- Apply-results: a non-JSON 4xx or 5xx body (for example a proxy's HTML 502) is
retried rather than failing at once with
CONFIRMATION_FAILED. Timeout, abort and invalid-key failures are codes (CONFIRMATION_TIMEOUT,CONFIRMATION_ABORTED,INVALID_UNIQUE_KEY); the wording is the app's.pollIntervalMshas a 500 ms floor. - Fee quotes: a transport error, or a 426 an injected requestor throws, resolves
nullinstead of propagating, so one fallback covers both. - Pending swaps: the defaults suit a wallet; a web app that needs another layout passes
{ storageKey, darkFlagField: 'shadow', insertAt: 'start', listOrder: 'newest-first' }. Entries exactly seven days old are expired,listprunes storage, and JSON key order inside an entry is not preserved. - Requestors: every request goes through the injected
httpRequestor, so base URL, interceptor headers (such as an extension version header) and credentials must come from it. ForsubmitShadowthe requestor must resolve every HTTP status; an axios instance that rejects 4xx and 5xx must be wrapped (or replaced by afetch-based requestor) rather than used directly. - Signing primitives are public (see "Signing primitives for wallets"). Their error type is
GalaSDKError(from@gala-chain/gala-core-sdk) with one code per refusal;error.messageis English for logs (seed your own strings fromgalaErrorTemplates, never match on the text;isGSwapErrorandisGalaErrorboth recognise it). The old-name to new-name table for code written against the 1.x exports is in Migration.