Skip to content

Add a liquidity provider

Integrating a new swap or bridge aggregator. Most providers need only swap-sdk; add a backend proxy only when a secret is involved.

There is also a self-contained integration guide in the SDK repo at swap-sdk/INTEGRATION_GUIDE.md, written for someone with no other context.

Decide first

QuestionConsequence
Does it need an API key or a registered referrer?If yes, add a backend proxy. If no, call it directly
Does it broadcast the transaction itself?If yes, follow the Kima patterntx: null plus a frontend branch
Does it fill asynchronously (intent/solver)?It works for interactive bridging but not for automation. Say so in the PR
Swap, bridge, or both?Decides which exclude list it belongs in

Step 1 — add the enum member

swap-sdk/src/enums/aggregator.enum.ts

ts
export enum AGGREGATORS {

  MY_PROVIDER = 'my_provider',
}

Snake-case string value; it appears in meta.aggregator and in exclude lists.

Step 2 — write the aggregator class

swap-sdk/src/aggregators/MyProvider.aggregator.ts

ts
import Base from './base.aggregator.js'
import Quote from '../utils/quote.js'
import { apiCall } from '../utils/axios.js'
import { AGGREGATORS } from '../enums/aggregator.enum.js'
import { IQuoteParams } from '../@types/index.js'

export default class MyProviderAggregator extends Base {
  async getQuotes(params: IQuoteParams): Promise<Quote | Quote[]> {
    const res = await apiCall({
      method: 'GET',
      url: `https://api.myprovider.xyz/quote`,
      params: { /* … */ },
    })

    const meta = {
      id: crypto.randomUUID(),
      aggregator: AGGREGATORS.MY_PROVIDER,
      route: 'MyProvider',
      amount: Number(res.outputAmount),
      usdAmount: Number(res.outputUsd ?? 0),
      networkFee: res.gasUsd ?? 0,
      platformFee: res.feeUsd ?? 0,
      priceImpact: Number(res.priceImpact ?? 0),
      slippage: params.slippage ?? 0.5,
      allowanceTo: res.spender,
      timeEstimate: res.etaSeconds,          // omit for same-chain swaps
    }

    const restProps = {
      srcWalletAddress: params.srcWalletAddress,
      dstWalletAddress: params.dstWalletAddress,
      slippageTolerance: params.slippage ?? 0.5,
      fromChain: { id: params.fromChain.id, name: params.fromChain.name },
      toChain:   { id: params.toChain.id,   name: params.toChain.name },
      quotePayload: { /* whatever the build step needs */ },
      type: params.type,
    }

    return new Quote(res, meta, restProps)
  }

  async getTransactionData(data: any, restProps: any, meta?: any) {
    const built = await apiCall({ /* the provider's build endpoint */ })
    return {
      tx: { to: built.to, data: built.data, value: built.value ?? '0' },
      spender: built.spender,
    }
  }

  async getTxStatus(chainId: number, hash: string) {
    const res = await apiCall({ /* … */ })
    return { status: res.done ? 'success' : 'pending', hash }
  }
}

getTransactionData must return tx.to, tx.data and tx.value

That is exactly what RelayerFactory wraps into a MetaTransaction. A missing value on a native swap produces a silent failure.

Use Base's helpers

HelperUse
this.isNativeAddresss(address)Recognises 0x0…0, 0xeee…eee and Polygon's 0x…1010. Do not hand-roll a native check
this.getGasPrice(chainId)OpenOcean v4 gas price
this.senderAddress, this.senderTronNitro, this.tronFeeLimitWallet context, including TRON

IQuote fields worth getting right

FieldWhy it matters
amountThe sort key. Wrong units puts your provider top or bottom of every list
usdAmountFeeds the platform-fee display. Reporting 0 is why OKX and Nordstern needed backend price enrichment
allowanceToThe spender. Becomes MetaTransaction.recipient
timeEstimateOptional. Omit for same-chain swaps rather than sending 0

Step 3 — export it

swap-sdk/src/aggregators/index.ts

ts
export { default as MyProviderAggregator } from './MyProvider.aggregator.js'

Explicit .js extensions

The SDK is ESM. Every relative import ends in .js, even from a .ts source.

Step 4 — register it

swap-sdk/src/index.ts, in TradeManager's constructor:

ts
this.aggregatorFactory.register(AGGREGATORS.MY_PROVIDER, new MyProviderAggregator())

This constructor is the authority on what is live

26 aggregator classes exist, 21 are registered. When a provider's upstream API dies, comment the registration out with the reason inline — that is the established convention and it is how anyone reading the file knows why the count differs from the file count.

Step 5 — add the status URL

swap-sdk/src/utils/constants.ts, in routers:

ts
[AGGREGATORS.MY_PROVIDER]: 'https://api.myprovider.xyz/status',

Only needed if the provider has a status endpoint. getTransactionStatus and bridgeStatusCron use it.

Step 6 — backend proxy (only if a secret is involved)

Controller

bz-backend/controllers/defi/myprovider.js

js
import axios from 'axios'

export const myProviderQuote = async (req, res) => {
  try {
    const { data } = await axios.post('https://api.myprovider.xyz/quote', req.body, {
      headers: { 'X-Api-Key': process.env.MY_PROVIDER_API_KEY },
    })
    res.json(data)
  } catch (e) {
    res.status(e?.response?.status || 500).json(e?.response?.data || { error: e.message })
  }
}

Route

bz-backend/routes/defi.routes.js

js
import { myProviderQuote } from '../controllers/defi/myprovider.js'
router.post('/myprovider/quote', myProviderQuote)

Point the SDK at the proxy

In the aggregator class, call ${baseUrl}/myprovider/quote instead of the provider's URL. baseUrl comes from utils/constants.ts / configure({ baseApiUrl }).

Add the key on the VM

The env var does not travel with the deploy

.env is excluded from the rsync. Add MY_PROVIDER_API_KEY on the VM manually and restart. UNISWAP_API_KEY is the canonical example of forgetting this — the code deployed and the provider silently produced no routes.

If the key is safe to hold client-side, add it to configure() in services/swap.service.js instead of proxying.

Step 7 — decide the default exclusions

bz-backend/services/swap.service.js

Provider typeAction
Same-chain onlyAdd to excludeBridge
Bridge onlyAdd to excludeSwap
Both, and goodLeave out of both
Both, but poor pricing on one sideExclude from that side

Step 8 — release and bump

bash
# in swap-sdk
npm run build && git tag v1.13.0 && git push origin v1.13.0

# in EACH consumer
npm install github:blazpay/swap-sdk#v1.13.0 --package-lock-only
# commit BOTH package.json and package-lock.json

Both files, both consumers

package-lock.json pins by resolved commit, and npm install obeys it even after rm -rf node_modules/swap-sdk. And there are two consumers (bz-backend, defi-dex) that pin independently and can drift.

Full procedure: swap-sdk releases.

Step 9 — frontend, only if special-cased

The existing SSE → /defi/swap/:idtriggerTransaction path works with no frontend change for any relayer-backed provider.

You only touch defi-dex when:

  • The provider broadcasts itself (see below).
  • It needs a non-EVM path (Solana, TRON).
  • It needs bespoke UI.

In that case add a branch in src/utils/trade.ts and in the relevant page's swap() switch.

Self-broadcasting providers

The Kima pattern, for a provider that submits the transaction on its own backend:

  1. getTransactionData returns tx: null plus a metaData payload.
  2. Add sendTransaction<Provider> in defi-dex/src/utils/trade.ts.
  3. Add a branch in the page's swap() switch.
  4. The frontend records the returned id and polls getTxStatus.

Reference implementation: swap-sdk/src/aggregators/Kima.aggregator.ts.

Intent and solver providers

Say so explicitly if the provider fills asynchronously

Relay and Nordstern take the input and a solver fills the order outside the transaction. That is fine for interactive bridging where the user watches a status poll. It is unusable for automation: both vaults measure the balance delta inside the transaction, see zero, and revert with OutputBelowMinimum.

The route probe filters them out — but only if someone knows to expect it. Note the behaviour in the aggregator's file header the way Nordstern's does.

The relayer contract

No change needed. The provider just needs a router contract the relayer can .call(targetContract, data) after funds land in recipient.

Verification checklist

□ Quote appears in the SSE stream on the target chain
□ Output amount and units are correct (compare against the provider's own UI)
□ usdAmount populated, so the fee display is not $0
□ allowanceTo is the real spender
□ Approval for amount + fee succeeds
□ simulateTx / sortQuotes gas estimate passes
□ A real trade completes and the output arrives
□ getTxStatus resolves pending → success
□ Errors do not break the stream (kill the provider's API and re-quote)
□ Excluded correctly from the side it does not serve
□ If proxied: env var on the VM, restart done
□ If a solver: documented, and the automation probe rejects it
□ Both consumers' package.json AND package-lock.json bumped

Common mistakes

MistakeSymptom
Hand-rolled native-address checkWorks on one chain, fails on Polygon
Missing .js in an importBuild fails at prepare — which breaks consumers at install time
amount in the wrong unitsYour provider always wins or always loses the sort
Forgot usdAmountPlatform fee displays $0
Forgot the VM env varDeployed, and silently produces no routes
Bumped package.json onlyThe old SDK version stays installed
Registered but no routers entryStatus polling never resolves
Returned tx.value: undefinedNative swaps fail opaquely

Internal + developer documentation. Usage figures are point-in-time snapshots — re-verify before publishing them.