Skip to main content

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 pieceAdapter you provide
HTTPhttpRequestor (default: global fetch)
Signinga GalaChainSigner (PrivateKeySigner, GalaWalletSigner, BrowserWalletSigner, or your own), imported from @gala-chain/gala-core-sdk
Pending-swap persistencea StorageAdapter
Realtime streama 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' is URLSearchParams. Anything that would change a query's meaning (& = # % +) is always escaped. wire.userAgent is a string, or false for none; wire.headers adds headers (for example axios' Accept). They apply to every request: reads, the submit / submitShadow POSTs and the apply-results polls. A GET never sends Content-Type; a POST sends Content-Type: application/json unless wire.headers sets one, and always ends with X-Wallet-Address (and X-Shadow: true for a shadow write), which wire.headers cannot replace.
  • A queued RequestTrade acknowledgement must echo the signed uniqueKey; without it (MISSING_UNIQUE_KEY) or with another (UNIQUE_KEY_MISMATCH) submit throws that code. A returned SubmittedTransaction has mode, status ('queued' or 'executed'), uniqueKey and envelope; isSubmitQueued, isSubmitExecuted, isSubmitRejected and classifySubmitEnvelope classify a raw envelope.
  • eip1193ProviderFromEthers(browserProvider) lets BrowserWalletSigner sign through an ethers BrowserProvider; 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' on GSwap (or new CanonicalSymbols() for a service built by hand) makes no GET /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: Infinity arms no timer, for an app whose HTTP stack owns request lifetimes.
  • new ChainGateway({ isTransportFailure, answerOfRejection }) teach the SDK what your requestor's rejections mean: isTransportFailure names a rejection that proves no answer arrived (a write is then SUBMISSION_OUTCOME_UNKNOWN; isFetchTransportFailure, from @gala-chain/gala-core-sdk, fits a fetch wrapper), and answerOfRejection maps 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.
  • onInvalidEntry receives 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 }) accepts 0x+40 hex, eth|+40 hex, client|+24 hex or the bare 24 hex, passes a 130-hex signature through bare or 0x-prefixed, and calls window.gala.setAddress(selectAddress) before each signature.
  • Failures carry a stable error.code and flat error.params that an app localizes (see Error Handling), plus error.status, error.body (parsed JSON), error.bodyText (a non-JSON answer, never in message) and, for a missing market, error.notFound (HTTP 404, NO_POOL_AVAILABLE, UNKNOWN_TOKEN). error.message is 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 })

    apiKey requires an explicit webSocketImpl (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. pollIntervalMs has a 500 ms floor.
  • Fee quotes: a transport error, or a 426 an injected requestor throws, resolves null instead 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, list prunes 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. For submitShadow the requestor must resolve every HTTP status; an axios instance that rejects 4xx and 5xx must be wrapped (or replaced by a fetch-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.message is English for logs (seed your own strings from galaErrorTemplates, never match on the text; isGSwapError and isGalaError both recognise it). The old-name to new-name table for code written against the 1.x exports is in Migration.