Appearance
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
| Question | Consequence |
|---|---|
| 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
| Helper | Use |
|---|---|
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.tronFeeLimit | Wallet context, including TRON |
IQuote fields worth getting right
| Field | Why it matters |
|---|---|
amount | The sort key. Wrong units puts your provider top or bottom of every list |
usdAmount | Feeds the platform-fee display. Reporting 0 is why OKX and Nordstern needed backend price enrichment |
allowanceTo | The spender. Becomes MetaTransaction.recipient |
timeEstimate | Optional. 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 type | Action |
|---|---|
| Same-chain only | Add to excludeBridge |
| Bridge only | Add to excludeSwap |
| Both, and good | Leave out of both |
| Both, but poor pricing on one side | Exclude 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.jsonBoth 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:
getTransactionDatareturnstx: nullplus ametaDatapayload.- Add
sendTransaction<Provider>indefi-dex/src/utils/trade.ts. - Add a branch in the page's
swap()switch. - 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 bumpedCommon mistakes
| Mistake | Symptom |
|---|---|
| Hand-rolled native-address check | Works on one chain, fails on Polygon |
Missing .js in an import | Build fails at prepare — which breaks consumers at install time |
amount in the wrong units | Your provider always wins or always loses the sort |
Forgot usdAmount | Platform fee displays $0 |
| Forgot the VM env var | Deployed, and silently produces no routes |
Bumped package.json only | The old SDK version stays installed |
Registered but no routers entry | Status polling never resolves |
Returned tx.value: undefined | Native swaps fail opaquely |