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 pattern — tx: 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/:id → triggerTransaction 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.