Integrate Gibs into a wallet or app

This guide shows you how to add Gibs Finance bridge routes to your wallet, app or script. You search for routes, plan the transactions, send them with your own signer, track the transfer, and claim it when the bridge needs a claim.

The guide covers the @gibs/* packages at version 1.16.0. Check npm for the current version before you install. Never install 1.12.0 or older: those versions do not load.

The packages never sign and never hold funds. Your wallet signs every transaction. A mobile wallet such as Internet Money uses the packages this way, and so can any other app.

Terms used once and then kept:

  • API means application programming interface. HTTP means Hypertext Transfer Protocol. JSON means JavaScript Object Notation.
  • An RPC endpoint is a node's remote procedure call address, for example https://your-node.example.
  • A section is one wallet signature in a route. A route with a crossing and then a swap has two sections.
  • A crossing is one omnibridge transfer between PulseChain and Ethereum, or between PulseChain and BNB Smart Chain.
  • Delivery means that a paid service submits the last transaction of a crossing on the destination chain, so the user does not have to come back and claim.

Contents

  1. What the packages do
  2. Install and set up
  3. Search for routes
  4. Plan the transactions
  5. Track a transfer
  6. Claim a transfer
  7. Defaults you replace
  8. The public Gibs services
  9. Example A: ETH on Ethereum to PLS on PulseChain
  10. Example B: an ERC-20 token from PulseChain to Ethereum, delivery off
  11. Fees and attribution
  12. Known limits

The two examples are complete TypeScript files. They typecheck against the published packages:

File What it holds
examples/shared.ts The signer interface, a web3.js v4 adapter (in a comment), viem clients, the allowance and balance readers, the sender, the status poller
examples/example-a-ether-to-pulse.ts Example A
examples/example-b-token-pulsechain-to-ethereum.ts Example B
examples/tsconfig.json Strict compiler settings with moduleResolution: "Bundler"

The files ship inside @gibs/bridge-sdk, in node_modules/@gibs/bridge-sdk/docs/examples/. To check them, install the packages and typescript, copy the folder into your project, and run npx tsc -p examples/tsconfig.json. The examples never ran against a live chain.


1. What the packages do

Step Function Package
Find and rank routes searchBridgeRoutes @gibs/quotes/search
Get the live quote an aggregator section signs fetchLiveSectionQuote, fetchLiveQuote @gibs/quotes/search
Turn a route into transactions planCandidateExecution @gibs/bridge-sdk/execution
Read a fresh PulseX swap quote readPulseXSectionQuote @gibs/bridge-sdk/execution
Read how a token maps across the bridge tokenBridgeInfo @gibs/bridge-sdk/chain-info
Track a transfer fetchTransferStatus @gibs/bridge-indexer
Read the record a claim needs fetchClaimRecords @gibs/bridge-indexer
Build a claim buildClaimTransaction @gibs/bridge-sdk/claims

An aggregator is a third-party bridge service. The packages ask three of them: LI.FI, Relay and NEAR Intents. The omnibridge is the PulseChain bridge to Ethereum and to BNB Smart Chain. PulseX is the exchange on PulseChain.


2. Install and set up

Packages

npm install @gibs/quotes @gibs/bridge-sdk @gibs/bridge-indexer viem

@gibs/quotes pulls in @gibs/bridge-sdk, @gibs/common and @gibs/abis. @gibs/bridge-indexer pulls in @gibs/bridge-client and graphql-request. Every package needs Node 20 or later when it runs on Node.

viem

The peer range is >=2.35.0 <3. The packages are tested against exactly 2.35.0, the floor. Keep one copy of viem in your bundle.

Module formats

Every package ships two builds: ECMAScript modules in dist/ and CommonJS in dist/cjs/. The exports map has react-native, import and require conditions. Any bundler that reads exports picks the right build.

React Native 0.77

The packages work in React Native 0.77 and later with no Metro setting. They are tested in a React Native 0.77.3 app with viem 2.35.0 and the template's own metro.config.js, babel.config.js and jest.config.js. Every public entry point is bundled for iOS and Android, compiled with the Hermes compiler, and loaded under jest.

Metro in React Native 0.77 ignores exports. So every package also ships one folder per subpath, such as @gibs/bridge-sdk/execution/package.json. Its main and react-native fields point at the CommonJS build. You import the subpath as usual.

You do not need:

  • a resolveRequest hook in metro.config.js;
  • transformIgnorePatterns in your jest configuration. The React Native jest preset loads the CommonJS build untransformed;
  • unstable_enablePackageExports. Do not turn it on for these packages. They do not need it, and it can break other libraries in your app, such as web3.js.

React Native's own URL is enough

You need no URL polyfill. React Native 0.77's own URL supports only href and toString(). Its other getters throw "not implemented", and its searchParams is always empty. Its URLSearchParams can only append and serialize. No package reads either class. The packages build and read request addresses with @gibs/common/url, which needs only encodeURIComponent and decodeURIComponent.

The tests load every public entry point under React Native's own URL and URLSearchParams. If your app installs a URL polyfill for other code, the packages still work.

TON

TON is The Open Network. Most wallets never load its code:

  • @gibs/bridge-sdk/ton-connect needs no TON library and no Buffer.
  • @gibs/bridge-sdk/ton needs @ton/core, a global Buffer and expo-crypto. Install buffer and set globalThis.Buffer before the import. Without expo-crypto, Metro cannot bundle it.
  • npm still installs @ton/core and @ton/crypto, because they are regular dependencies of @gibs/bridge-sdk. Metro bundles them only when you import @gibs/bridge-sdk/ton.

A wallet that bridges only between Ethereum-style chains never pays for TON.

Import narrow subpaths

Metro does not drop unused exports. Import @gibs/bridge-sdk/execution, not @gibs/bridge-sdk. See the bundle-size note.


3. Search for routes

searchBridgeRoutes in @gibs/quotes/search does the whole search in one call. It loads the provider lists, the omnibridge pairs, prices, gas prices and balances. Then it runs the route engine and decides which routes it may serve. loadBridgeablePairs in the same subpath lists the pairs the omnibridge carries.

It takes:

  • origin and destination: the chain, the token, the amount, the sending account and the recipient;
  • clientForChain: your viem client for each chain, or null for a chain you have no endpoint for. The search skips what it cannot read;
  • fetch, an AbortSignal, and timeouts;
  • your keys and your attribution. See Fees and attribution.

A cancelled search rejects with RouteSearchCancelledError.

PulseChain never reaches an aggregator. PulseChain is chain 369, and its testnet is chain 943. No request that names either chain goes to LI.FI, Relay or NEAR Intents. Three layers refuse it, the last one at the wire.

Your LI.FI key. Pass lifiApiKey. It travels only as the x-lifi-api-key header, on every LI.FI request the search makes: the chain list, the token lists, the prices and every quote. It never enters a URL, a body or a log, and no other provider receives it. The search refuses any request that would carry it in a URL or a body.

LI.FI advises against shipping a key in client code. If you keep the key on a server, point lifiApiOrigin at your own proxy, and let the proxy add the header. You can combine the two. Without either, LI.FI answers at its lower keyless rate limit.


4. Plan the transactions

planCandidateExecution in @gibs/bridge-sdk/execution turns a served route into an ordered list of PlannedTx, one entry per transaction:

type PlannedTx = {
  readonly chainId: number;
  readonly kind: 'approve' | 'bridge' | 'swap' | 'wrap' | 'unwrap' | 'claim';
  readonly to: Hex;
  readonly data: Hex;
  readonly value: bigint;
  readonly gasLimit: bigint | null; // null: the wallet estimates
  readonly waitForReceipt: boolean; // true on every transaction except the last
};

What the plan guarantees:

  • Approvals are exact. The plan approves only the amount a transaction spends. It skips an approval that the current allowance covers. It resets a short, non-zero allowance to zero first.
  • Spenders are checked. The plan approves only these spenders: the omnibridge mediator and the PulseX router from the package configuration, the LI.FI Diamond (with the LI.FI execution guard), and the Relay depository (relayDepositoryByChainId). The plan refuses a quote that names any other spender or deposit target.
  • Only live quotes are signed. An aggregator section needs the live quote asked for your wallet, in sections[i].aggregator. The route's own display quote is never signed. Without a live quote, the plan refuses with live-quote-required.
  • Deposits match the request amount. The LI.FI guard checks the decoded swap amount. Relay must call depositNative or depositErc20 with the requested token, value and amount. NEAR Intents must pay exactly the requested amount to the quote's own deposit address. The plan refuses a quote that disagrees with the request, with amount-mismatch or recipient-mismatch.
  • Delivery is your explicit choice. Each crossing takes delivery: { shouldDeliver: false } or { shouldDeliver: true, fee }. There is no default.

Plan one section at a time

A route with two sections has a crossing, and then something on the far chain. The second section must spend what actually arrived, not what the search predicted. So plan each section when it is about to run:

  • Pass firstSectionIndex. Then sections[0] plans that section. Refusal indices and delivery indices stay absolute.
  • Plan section 1 after section 0's crossing arrived, with the amount that arrived. Every check above still applies to the later section.

A fresh swap floor

A PulseX section takes swap.quotedAmountOut, a fresh quote of its output. The slippage floor then comes from this quote, not from the search-time figure. readPulseXSectionQuote({ hop, amountIn, client }) reads it along the exact path the swap signs, on the hop's own router. Pass a fresh swap.deadline too.

The live quote

fetchLiveSectionQuote in @gibs/quotes/search builds the request from the section itself, and asks the section's own provider. It returns { quote, request }, which is exactly sections[i].aggregator.

  • It handles three shapes: a direct last section, a section that another section follows (it lands in your own wallet), and a section that fuses with an omnibridge entry.
  • For the fused shape, it reads the token mapping through your clientForChain. It shrinks the entry amount when the cost of the entry call would make the spend exceed amountIn. It asks at most three times. It never returns a quote that spends more than amountIn.
  • It takes your fetch, keys (lifiApiKey, relayApiKey, nearIntentsApiKey, each sent only as its own header), attribution, lifiApiOrigin and signal.

fetchLiveQuote is the lower-level call: one provider, and one ConsolidatedQuoteRequest you built yourself. Both calls send through the same wire guard as the search. No request that names PulseChain, and no request that carries a key outside a header, ever leaves.


5. Track a transfer

fetchTransferStatus in @gibs/bridge-indexer takes { chainId, txHash } for the transaction that started a transfer. It returns:

  • state: one of sent, confirmed-not-final, validators-confirming, ready-delivery-service, ready-your-turn, delivered, entry-reached, stranded, failed and unknown. transferStateNames holds the words to show.
  • claimable: true in both ready states.
  • destinationTxHash: the transaction that settled the destination side.
  • aggregatorStatus: the aggregator's own answer, for a route that starts with an aggregator.

Every option is optional: fetch, endpoint, signal, aggregator, aggregatorFinalLeg and originClient. The function works around every known indexer defect: it lowercases hex filters, and it avoids the one-way joins and the relations that are always null.

What you pass:

  • originClient, your viem client on the origin chain. Without it, a transaction that the indexer has not recorded yet is unknown, not sent.
  • aggregator, for a route that starts with an aggregator. Relay's status endpoint answers 400 without your Relay key.

6. Claim a transfer

A crossing out of PulseChain with delivery off ends in the ready-your-turn state. The user then claims the tokens on the destination chain, and pays the gas there.

Read the record

fetchClaimRecords in @gibs/bridge-indexer returns the bridge requests a claim is built from, oldest first, in the shape buildClaimTransaction takes:

const records = await fetchClaimRecords({ source: { chainId: 369, txHash } });
// or: fetchClaimRecords({ messageHash })

It asks for exactly the claim fields plus messageHash. It lowercases every hash, reads delivered as a flag, and never asks for a relation that the indexer leaves null. It makes no RPC read and writes no log. It takes fetch, endpoint and signal, like fetchTransferStatus. An empty list means that the indexer has not recorded the request yet.

It is a separate call, not a field of fetchTransferStatus, for three reasons:

  • You poll the status often, and signatures and encodedData are large.
  • One transaction can start several requests. The status reports the slowest, but a claim is per request.
  • A claim can start from a message hash, as in a history list, with no status call at all.

Build the claim

buildClaimTransaction in @gibs/bridge-sdk/claims takes the record and a via choice: { kind: 'direct' } or { kind: 'router', destinationRouter, validator }. It returns { kind: 'claim', transaction }, where the transaction is a PlannedTx of kind claim on the destination chain. Or it returns a typed refusal:

Refusal Meaning What your app does
wrong-type The request is not a signature request. The validators execute it themselves. Nothing to claim.
already-delivered The release already ran. Show "delivered".
not-signed-yet The validators have not finished. Wait, then ask again.
signatures-incomplete The indexer stored fewer signatures than the bridge needs. Carries stored and needed. Stop. Waiting does not fix it. Report it to Gibs Finance.
malformed-request A field has the wrong shape. Carries field. Stop. Report it.

See the submitSignature case for the cause of signatures-incomplete.


7. Defaults you replace

The packages send nothing on your behalf, and they use no private service unless you ask:

  • RPC endpoints. chainsMetadata carries only public endpoints. A shared demo key, vk_demo, lives only in demoRpcUrls and demoRpcUrlsFor (@gibs/bridge-sdk/chains). Every reader takes your own clients. Use your own nodes in production.
  • No default LI.FI integrator. No integrator, referrer, referral or fee is sent unless you pass one.
  • No fee or referral unless you ask. @gibs/bridge-sdk/referral holds Gibs Finance's own values. No builder reads them. Do not pass them: they pay Gibs Finance.
  • NEAR Intents User-Agent. The request builders (nearIntentsRequestHeaders) name Gibs Finance by default. searchBridgeRoutes sends none unless you pass nearIntentsUserAgent. Name your own app.
  • Clients built for you. fetchBridgeTransactions and @gibs/bridge-client still build their own clients on the demo endpoints. Do not use them in production. The examples do not.

The @gibs/bridge-sdk README has a section, "Which fees apply, and how you control each one".


8. The public Gibs services

The quote service

https://quotes.gibs.finance answers display quotes for anyone, including a native app that sends no Origin header:

  • POST https://quotes.gibs.finance/v1/display-quotes with the body { "asks": [ ... ] }, 1 to 24 asks.
  • It shares one limit per client (60 a minute, bursts of 20) and one provider budget with every other caller.

A display quote shows what to expect. It is never the transaction to sign. The forwarding routes of the service carry Gibs Finance's provider keys and return calldata that you could sign. They accept only Gibs Finance's own sites, and answer 403 to your app.

So execute routes through searchBridgeRoutes and planCandidateExecution, with your own LI.FI and Relay keys or with none. Use the quote service for display only, and do not depend on it for signing. To run your own quote service, use @gibs/quotes.

The indexer

@gibs/bridge-indexer reads https://next-indexer.gibs.finance by default. The Gibs Finance interface reads the same indexer, so you share its load and its upgrades. The production indexer at https://indexer.gibs.finance can lag the schema, and a query there can fail with an unknown field.

  • Set the endpoint once at startup with setIndexerEndpoint(...), or pass endpoint to each fetchTransferStatus and fetchClaimRecords call. Both default to indexerEndpoint(), so one setIndexerEndpoint covers both.
  • The indexer has known data defects until a re-index runs. fetchTransferStatus and fetchClaimRecords work around them. If you query the indexer yourself, follow the rules in the @gibs/bridge-indexer README: lowercase every hex filter, read delivered and not delivery, and read Completion and Delivery by messageHash.

The signed release list

https://ipfs.gibs.finance/version.json lists every interface release, with its content id and channel. https://ipfs.gibs.finance/version.json.sig is a detached Ed25519 signature over the exact bytes of that file. The public keys are in public-keys.json. To link your users to the Gibs Finance interface, read the latest release from this list, and check the signature over the raw bytes first.

Token and chain images

https://gib.show serves token and chain logos and token lists. It needs no key. @gibs/bridge-sdk/image-links builds its addresses.


9. Example A: ETH on Ethereum to PLS on PulseChain

The full file is examples/example-a-ether-to-pulse.ts. The helpers are in examples/shared.ts. This section walks through the steps.

The signer

The packages do not depend on web3.js or on any wallet library. The examples expect this interface:

export type WalletTransactionRequest = {
  readonly chainId: number;
  readonly from: Hex;
  readonly to: Hex;
  readonly data: Hex;
  readonly value: bigint;
  readonly gas?: bigint; // set only when the plan names a gas limit
};

export type WalletSigner = {
  readonly address: Hex;
  /** Broadcasts on `request.chainId` and resolves with the hash, before mining. */
  readonly sendTransaction: (request: WalletTransactionRequest) => Promise<Hex>;
};

Here is a web3.js v4 adapter, with one Web3 instance per chain. In web3.js v4, web3.eth.sendTransaction returns a PromiEvent. Awaiting it gives the mined receipt. The transactionHash event fires first, and this signer resolves with it:

const signer: WalletSigner = {
  address: account,
  sendTransaction: (request) =>
    new Promise((resolve, reject) => {
      const web3 = web3ByChainId.get(request.chainId);
      if (web3 === undefined) return reject(new Error(`no web3 for chain ${request.chainId}`));
      web3.eth
        .sendTransaction({
          from: request.from,
          to: request.to,
          data: request.data,
          value: request.value,
          ...(request.gas === undefined ? {} : { gas: request.gas }),
        })
        .on('transactionHash', (hash) => resolve(hash as Hex))
        .on('error', reject);
    }),
};

This adapter is not compiled with the examples, because they do not install web3.js. The examples wait for receipts through the viem clients, and only where the plan says waitForReceipt.

Step 1: one viem client per chain, from your own RPC endpoints

const clientForChain = createClientLookup(
  new Map([
    [1, 'https://your-ethereum-node.example'],
    [369, 'https://your-pulsechain-node.example'],
  ]),
);
const requireClient = requireClientWith(clientForChain);

createClientLookup calls viem's createPublicClient with http(url) for each chain. It answers null for a chain that you have no endpoint for.

const search = await searchBridgeRoutes({
  origin: {
    chainId: 1,
    token: { address: zeroAddress, decimals: 18, symbol: 'ETH' },
    amount: amountWei,
    account: signer.address,
  },
  destination: {
    chainId: 369,
    token: { address: zeroAddress, decimals: 18, symbol: 'PLS' },
    recipient: signer.address,
  },
  clientForChain,
  fetch: (input, init) => globalThis.fetch(input, init),
  signal,
  timeouts: { requestMs: 15_000, searchMs: 60_000 },
  nearIntentsUserAgent: 'your-wallet/1.0',
});

The native coin is the zero address on both sides.

Step 3: pick a route

search.routes holds the routes the search may serve, one per provider, best first. The best is search.routes[0]. When the list is empty, search.priceImpactRefusal says whether price impact is the reason, and search.refusals lists every refusal.

A served route has candidate (what you plan), plan (the engine's hops and signature groups), route (display data) and routeSignatureCount. For ETH to PLS, the usual best route is a crossing into PulseChain and then a PulseX swap: two sections.

Step 4: build ONE section's inputs, from what it spends now

The example plans and sends one section at a time. The first hop of a section names its carrier. The example builds one SectionExecutionInputs for it:

  • Omnibridge ('pulsechain' or 'tokensex'): read the token mapping with tokenBridgeInfo, then state the delivery choice. When the crossing enters PulseChain, the bridge executes it itself. So { shouldDeliver: false } is the only accepted choice, and the plan refuses { shouldDeliver: true, ... } with delivery-not-offered.

    const assetLink = await tokenBridgeInfo({
      bridgeKey: [carrier, toChain(from.chainId), toChain(to.chainId)],
      assetIn: search.resolveToken(from),
      isProd: true,
      fromChainClient: requireClient(from.chainId),
      toChainClient: requireClient(to.chainId),
    });
    const inputs = {
      amountIn,
      recipient,
      omnibridge: { isProd: true, assetLink, delivery: { shouldDeliver: false }, unwrap: false },
    };
    
  • PulseX: read a fresh quote just before signing, for the amount that arrived. Then set a slippage floor below it, and a deadline that starts now.

    const quotedAmountOut = await readPulseXSectionQuote({ hop: firstHop, amountIn, client: requireClient(369) });
    const inputs = { amountIn, recipient, swap: { slippageBasisPoints: 100n, deadline: deadlineIn(20 * 60), quotedAmountOut } };
    
  • Wrap: { amountIn, recipient } only.

  • Any aggregator: the live quote, asked now for this wallet, this recipient and this amount.

    const live = await fetchLiveSectionQuote({
      plan: served.plan,
      sectionIndex,
      resolveToken: search.resolveToken,
      amountIn,
      account: signer.address,
      recipient,
      clientForChain, // reads the token mapping of a section that fuses with an omnibridge entry
      fetch: (input, init) => globalThis.fetch(input, init),
      keys: { lifiApiKey, relayApiKey },
      signal,
    });
    if (live.kind !== 'quoted') throw new Error(`no live quote (${live.kind})`);
    const inputs = { amountIn, recipient, aggregator: { quote: live.quote, request: live.request } };
    

The first section spends amountWei. Each later section spends what actually arrived from the section before it. The example reads the next section's token balance on its chain before the section runs, and again after the crossing is delivered. The difference is what arrived.

Step 5: plan that section alone

const plan = await planCandidateExecution({
  candidate: served.candidate,
  account: signer.address,
  readAllowance: allowanceReaderWith(requireClient), // (chainId, token, owner, spender) => Promise<bigint>
  resolveToken: search.resolveToken,
  firstSectionIndex: sectionIndex,
  sections: [inputs],
});
if (plan.kind === 'refused') {
  // plan.sectionIndex (absolute) and plan.refusal.reason say which section and why.
}

Show plan.deliveries and the fees before the user signs. For the usual route, the two sections plan to:

Section Order Chain Kind Waits for receipt
0 1 Ethereum bridge (wrapAndRelayTokens, value = the ether) no (last of its plan)
1 1 PulseChain approve (wrapped ether from Ethereum, to the PulseX router, exact amount) yes
1 2 PulseChain swap no

Native ether needs no approval.

Step 6: send, then wait for arrival before the next section

sendPlannedTransactions (in shared.ts) sends each transaction of the section in order. It broadcasts through your signer, waits for the receipt where waitForReceipt is true, and throws on a revert. The example then waits for the last receipt of the section.

A receipt is not arrival. It only says that the crossing started. So before the example plans the next section, it polls fetchTransferStatus on the crossing transaction until the state is delivered. Then it measures what arrived.

Step 7: poll until delivered

const status = await waitForTransferStatus({
  source: { chainId: 1, txHash: crossingHash },
  statusOptions: { signal, originClient: requireClient(1) },
  isDone: (answer) => answer.state === 'delivered',
  intervalMs: 15_000,
});

waitForTransferStatus throws TransferEndedError on failed or stranded. If the route started with an aggregator, the example adds aggregator to the status options: { provider: 'lifi' }, { provider: 'relay', requestId: quote.quoteId, apiKey } or { provider: 'near-intents', depositAddress: quote.quoteId }. A route that ends with a swap on PulseChain is done when the receipt of that swap succeeds.


10. Example B: an ERC-20 token from PulseChain to Ethereum, delivery off

The full file is examples/example-b-token-pulsechain-to-ethereum.ts. The token is PulseX (PLSX), which is native to PulseChain.

const assetLink = await tokenBridgeInfo({
  bridgeKey: [Providers.PULSECHAIN, Chains.PLS, Chains.ETH],
  assetIn: plsx,
  isProd: true,
  fromChainClient: pulsechainClient,
  toChainClient: ethereumClient,
});

assetLink.assetOutAddress is the token's address on Ethereum. A null value is a normal answer: the token has not crossed before, and the first transfer creates it. The example stops there, because the search needs a destination address.

Then the example calls searchBridgeRoutes with PulseChain as the origin and Ethereum as the destination. It picks the served route that has one section whose hop is the omnibridge (plan.hops[0].edge.carrier === Providers.PULSECHAIN).

Step 2: plan with delivery off

const plan = await planCandidateExecution({
  candidate: crossing.candidate,
  account: signer.address,
  readAllowance: allowanceReaderWith(requireClient),
  resolveToken: search.resolveToken,
  sections: [
    {
      amountIn: amount,
      recipient: signer.address,
      omnibridge: { isProd: true, assetLink, delivery: { shouldDeliver: false }, unwrap: false },
    },
  ],
});

The plan is approve and then bridge (relayTokens), both on PulseChain. The approval is for the exact amount, to the PulseChain mediator. The plan skips it when the allowance covers the amount. plan.deliveries is [{ legIndex: 0, status: 'not-requested' }].

With delivery off, nobody is paid to release the tokens on Ethereum. The user claims them there and pays the Ethereum gas. Leaving PulseChain over Ethereum also costs the bridge fee. Read it with loadBridgeFees.

Step 3: poll until claimable

const ready = await waitForTransferStatus({
  source: { chainId: 369, txHash: bridgeHash },
  statusOptions: { signal, originClient: pulsechainClient },
  isDone: (status) => status.claimable || status.state === 'delivered',
  intervalMs: 30_000,
});

With delivery off, the ready state is ready-your-turn. If the state is already delivered, somebody else released the tokens, and there is nothing to claim.

Step 4: build the claim, and handle every refusal

The example reads the record with fetchClaimRecords. The function lowercases the hash for you, and asks for exactly the fields a claim reads:

const records = await fetchClaimRecords({ source: { chainId: 369, txHash: bridgeHash }, signal });
const [request] = records; // a plain relayTokens starts exactly one request
const claim = buildClaimTransaction({ request, via: { kind: 'direct' } });

The example stops with an error unless the transaction started exactly one request.

via: { kind: 'direct' } sends the claim straight to the Ethereum bridge and pays no tip. That is correct with delivery off. The router choice is for a request that carries a delivery fee.

The example maps each refusal to an action in an exhaustive switch. A new refusal reason in a later version fails the typecheck:

  • already-delivered: done. Poll until delivered to get destinationTxHash.
  • not-signed-yet: wait 30 seconds and read the record again. This can happen when the status and the record are read a moment apart.
  • signatures-incomplete, wrong-type, malformed-request: stop with a ClaimRefusedError. Do not retry.

Step 5: send the claim on Ethereum

claim.transaction.chainId is 1. The example sends it through the same signer, waits for the receipt, and polls fetchTransferStatus until delivered. destinationTxHash is then the transaction of the claim.


11. Fees and attribution

Nothing is added for you. No builder, search or plan adds a fee, a payout address or a Gibs Finance label unless you pass it. Every fee below is an on-chain setting you read, a charge of a provider that you show, or a fee that you chose.

Fee When it applies How your app sees it How your app controls it
Bridge fee Every omnibridge crossing. It is an on-chain rate for each direction. At the time of writing: 0% entering PulseChain over Ethereum, 0.3% leaving it; 2.97% each way over BNB Smart Chain. candidate.omnibridgeFeeAmount on the route. loadBridgeFees (@gibs/bridge-sdk/chain-info) for the rate, then bridgeCost. It cannot be turned off. Show it.
Delivery fee Only when a crossing out of PulseChain has shouldDeliver: true. plan.deliveries[i].maximumFee and feeToken. deliveryFee in @gibs/bridge-sdk/settings. Choose { shouldDeliver: false } (the user claims and pays gas), or set the fee terms yourself in QuotedDeliveryFee.
Provider fees (gas, solver, protocol) Every aggregator section. The fees of the live quote (QuoteFee[]). A fee with includedInFromAmount: false is paid in addition to fromAmount. Not yours to control. Show them.
NEAR Intents keyless surcharge Every NEAR Intents quote without a partner key: 25 basis points. The NEAR Intents parser reports it as a charge. Get a partner key and pass nearIntentsApiKey.
PulseX swap Every PulseX section. The pool fee and the price impact are inside the quoted output. The quoted output of the hop, and minAmountOut in the swap calldata. swap.slippageBasisPoints sets the floor.
Aggregator cushion A route that enters the omnibridge through an aggregator asks for an exact output. The amount that the aggregator does not need stays in the wallet on the entry chain. Compare the fromAmount of the live quote with what arrives. By design. Tell the user that it is theirs.
Your LI.FI fee Only when you pass lifiFee and your own lifiIntegrator. The fee field of the LI.FI request. Leave it out to charge nothing. A positive fee without your integrator throws.
Your Relay fee Only when you pass relayAppFee: { recipient, basisPoints }. The appFees field of the Relay request. Leave it out to charge nothing.
Gas Every transaction. search.gasCostFor and search.isGasless on the search result. Your wallet's gas settings. gasLimit: null means that you estimate.

Bridge fees are a rate per 1e18. Aggregator referral fees are in basis points.

Your attribution. Pass it in one place for the search, and in the same fields of the ConsolidatedQuoteRequest for the live quote:

await searchBridgeRoutes({
  // ...
  attribution: {
    lifiIntegrator: 'your-wallet',
    lifiFee: 0.001, // optional: a tenth of a percent, paid to your LI.FI integrator account
    relayReferrer: 'your-wallet', // Relay then requires your key
    relayAppFee: { recipient: '0xYourFeeWallet', basisPoints: 10n }, // optional
  },
  relayApiKey: yourRelayKey,
  nearIntentsApiKey: yourNearKey,
  nearIntentsUserAgent: 'your-wallet/1.0',
});

Each attribution field is either left out (nothing is sent) or your own string. Do not pass anything from @gibs/bridge-sdk/referral. Those values pay Gibs Finance.


12. Known limits

The submitSignature case

The indexer stores a validator's signature only when the validator calls submitSignature directly on the home bridge. The indexer decodes the input of the transaction, not the event. A validator that signs through a multisig wallet, a proxy or a batching contract still emits SignedForUserRequest. The indexer counts that signature in confirmedSignatures, but it does not store it in signatures. CollectedSignatures still sets finishedSigning to true.

So the record says "signed" with too few signatures, and a claim built from it reverts. buildClaimTransaction catches this case and refuses with signatures-incomplete, with stored and needed. Waiting does not fix it, because the indexer never goes back for the missing signature. Tell the user that the transfer is safe but that your app cannot claim it yet, and report it to Gibs Finance. The indexer defect is not fixed in 1.16.0.

Relay deposits must use depositNative or depositErc20

The plan accepts a Relay deposit only when it calls depositNative or depositErc20 on the depository from relayDepositoryByChainId, with exactly the requested token, value and amount. The check is strict on purpose. Relay can change its deposit form, add a chain, or move a depository. Until a new package version updates the table, the plan then refuses those Relay routes with deposit-shape or deposit-target-not-allowed. The table was read from Relay's chains API on 2026-10-07. A refused section does not lose funds. Fall back to the next route in search.routes.

Backward-solved routes

A route that enters the omnibridge through an aggregator is solved backwards. The omnibridge entry call needs an exact amount, so the provider is asked for an exact output, and it works out the input. The request then names no input of yours. So aggregatorOriginCalls cannot compare the deposit with a figure that the user chose. It can only check that the quote, the approval and the deposit agree with each other.

Before the user signs, show the fromAmount of the live quote, and compare it with what the user expects. Refuse it in your own code if it is outside your tolerance.

Bundle size with exports turned on

React Native 0.77's Metro ignores exports, and the packages load through the subpath folders and the CommonJS build. With exports turned on (the default from React Native 0.79), a bundle of every wallet entry point grew from 1.75 MB to 2.69 MB in our measurements on version 1.15.0. The full cause is not traced. With exports on, Metro gives import statements the ECMAScript module build and require calls the CommonJS build. Code that is reached both ways can then be bundled twice. Measure your own bundle before you turn exports on.

A token that never crossed

A token that was never bridged has no address on the far side yet. tokenBridgeInfo then returns assetOutAddress: null. This is normal, not an error. The first transfer creates the token on the far side.

Example files

Package guides

  • @gibs/abis llms.txt: Contract interfaces for the Gibs Finance bridge, as viem-ready constants.
  • @gibs/bridge-client llms.txt: Browser-side endpoints, clients, and caching for a Gibs Finance bridge interface.
  • @gibs/bridge-indexer llms.txt: Reads a Gibs Finance bridge indexer: transfer archive, delivery receipts, and live crossing status.
  • @gibs/bridge-sdk llms.txt: Build and price cross-chain transfers between PulseChain, Ethereum, and Binance Smart Chain.
  • @gibs/common llms.txt: Low-level blockchain helpers shared across the Gibs Finance packages: caching, multicall, and serialization.
  • @gibs/quotes llms.txt: Isomorphic core for asking cross-chain bridge aggregators (LI.FI, Relay, NEAR Intents) for display and execution quotes — the wire contract, refusal reading, and the shared guards a host wraps every upstream call in. Runs unchanged in a browser or in Node.

Files for AI tools

  • llms.txt: an index of these docs, for AI tools
  • llms-full.txt: the guide, the examples and every package guide in one file