@bufinance/fx
v0.2.0
Published
Typed client for the BU.FI StableFX (Pasillo) API: list assets + routes, quote, and execute spot stablecoin FX swaps (incl. cross-pair multihop) with an X-API-Key. Mirrors the defi-web-app integration.
Downloads
1,334
Maintainers
Readme
@bufinance/fx
Typed client for the BU.FI StableFX (Pasillo) API — spot stablecoin FX swaps (USDC ⇄ EURC / MXNB / QCAD / AUDF / cirBTC / JPYC). Same contract the BU.FI defi-web-app integrates against.
Two quote surfaces:
quote()— legacy single-provider (Circle RFQ). USD-pegged stables only.quoteIntent()— the multi-venue intent router: every on-chain pool and Circle RFQ, with the best route picked across venues. This is the only surface that quotes cirBTC / JPYC and cross-pair multihop (e.g.EURC→QCAD,AUDF→cirBTC), settled atomically in one tx when possible. See All routes.
npm i @bufinance/fx
# or: bun add @bufinance/fxRuns anywhere fetch exists: Node 18+, browsers, Cloudflare Workers, Bun.
Auth
Every call sends your key as x-api-key. Mint a key in the BU.FI dashboard
(Integrations → API keys) with the fx scope.
GET /fx/pairs,GET /fx/balance,POST /fx/quote,POST /fx/intent/quote→ need only thefxscope.POST /fx/trade,POST /fx/intent/execute→ also need a KYB-approved customer.
Sandbox keys (sk_test_* instance) → use environment: 'testnet'; production keys → production.
Quickstart
import { BufiFx } from '@bufinance/fx';
const fx = new BufiFx({
apiKey: process.env.BUFI_API_KEY!,
environment: 'testnet', // 'testnet' | 'production' | 'staging' | 'development', or pass baseUrl
});
// 1. Discover pairs
const pairs = await fx.listPairs();
// 2. Quote a spot swap (sell 1000 USDC for EURC)
const quote = await fx.quote({
from: { currency: 'USDC', amount: '1000' },
to: { currency: 'EURC' },
recipientAddress: wallet.address, // 0x...
});
console.log(quote.to.amount, quote.effectiveRate, quote.expiresAt);Cross-pair routes (e.g. EURC → QCAD)
Live on
environment: 'testnet'since v0.1.6.
StableFX is USDC↔stablecoin, so a non-USDC pair is auto-routed through USDC.
You quote it like any other pair — the response carries the hops in route and
each leg in legs:
const q = await fx.quote({
from: { currency: 'EURC', amount: '1000' },
to: { currency: 'QCAD' },
recipientAddress: wallet.address,
});
q.route; // ['EURC','USDC','QCAD']
q.to.amount; // net QCAD after both legs (fees included)
q.legs; // [ FxQuote(EURC→USDC), FxQuote(USDC→QCAD) ]The quote reflects both legs. Execution settles leg by leg — sign + trade
q.legs[0], then q.legs[1]. (Cross-pair quoting requires from.amount.)
All routes (cirBTC, JPYC, cross-pair multihop)
Intent router. Live on
environment: 'testnet'since v0.2.0.
quoteIntent() fans out across every venue (on-chain vault pools, Circle
RFQ, LiFi, Uniswap) and returns the best executable route. It's the surface that
covers cirBTC / JPYC and cross-pairs that hop through USDC — and when both
ends are non-USDC on-chain pools, the hop settles atomically in a single
keeper-relayed tx (FxMultihopExecutor on Arc) instead of leg-by-leg.
// Enumerate every quotable route locally (no network call) — for a pair picker.
import { listFxRoutes } from '@bufinance/fx';
listFxRoutes(); // [{ sell:'USDC', buy:'EURC', path:['USDC','EURC'], multihop:false }, …]
// …incl { sell:'EURC', buy:'QCAD', path:['EURC','USDC','QCAD'], multihop:true }
// Live cross-pair quote across all venues:
const res = await fx.quoteIntent({
chainId: 5042002, // Arc Testnet
sell: 'EURC', buy: 'QCAD',
sellAmount: '100',
recipient: wallet.address, // 0x… where tokens land
taker: wallet.address, // 0x… who signs
});
if (!res.winner) throw new Error('no route'); // e.g. pool has no inventory yet
res.winner.buyAmountExpected; // net QCAD out
res.winner.route; // ['EURC','USDC','QCAD']
res.winner.signingRequest.kind; // how to execute (see below)
res.alternatives; // every venue's quote, scoredExecute per winner.signingRequest.kind:
eip712-fx-multihop-intent(atomic cross-pair) — sign bothintentTypedDataandpermitTypedData, then:await fx.executeIntent({ venue: res.winner.venue, venueQuoteId: res.winner.venueQuoteId, multihop: { intent, intentSig, permit, permitSig }, }); // → { executionMode: 'multihop-submitted', txHash, route }eip712-stablefx-permit2-witness— signtypedData, pass thestablefxblock (same shape as/fx/trade).tx-request(LiFi / Uniswap) — broadcasttxyourself.
Branch the result on executionMode (multihop-submitted · submitted ·
client-broadcast · settled · pending-approval · unsupported).
Asset metadata (icons, decimals, addresses)
Static + local (no network call). Token icons are served from the BU.FI Vercel Blob CDN — handy for rendering selectors and balances.
import { getFxAsset, listFxAssets } from '@bufinance/fx';
const usdc = getFxAsset('USDC');
// usdc.icon → https://…blob.vercel-storage.com/tokens/icons/usdc_token_icon.svg
// usdc.decimals → 6
// usdc.deployments→ [{ chainId: 5042002, network: 'Arc Testnet', address: '0x3600…' }, …]
listFxAssets(); // USDC, EURC, MXNB, QCAD, AUDF, CIRBTC, JPYC (or fx.listAssets())Decimals are not uniform: USD-pegged stables are 6, but cirBTC is 8 and JPYC is 18. Always read
asset.decimals— never hard-code 6.
Executing a swap
StableFX quotes settle via Permit2 (rfq-permit2): the quote includes an
EIP-712 signingRequest you sign with your own wallet, then submit. With
viem:
import { quoteToSnapshot } from '@bufinance/fx';
const { typedData } = quote.signingRequest!; // { kind: 'eip712', typedData }
const signature = await walletClient.signTypedData(typedData as any);
const trade = await fx.tradeFromQuote({
quote,
callerAddress: wallet.address,
signedPayload: { message: (typedData as any).message, signature },
});
// poll until terminal
const latest = await fx.getTrade(trade.id); // status: submitted → funded → settledtradeFromQuote derives providerQuoteId, pair, and quoteSnapshot from the
quote for you. Need full control? Use fx.trade(req) with an explicit
FxTradeRequest (build the snapshot via quoteToSnapshot(quote)).
Bonded mode
If the environment returns a bonded quote, quote() rejects with a 422
unless you opt in:
const quote = await fx.quote({ /* ... */, acceptBondedRisk: true });
// inspect quote.worstCaseLoss / quote.followupDeliverBy; fund before maturity.Errors
All failures throw BufiFxError with code and status:
import { BufiFxError } from '@bufinance/fx';
try {
await fx.quote({ /* ... */ });
} catch (e) {
if (e instanceof BufiFxError) {
console.error(e.code, e.status, e.message); // e.g. 'http_403', 403
}
}Common: 401 invalid/expired/revoked key · 403 key lacks fx scope (or
customer not KYB-approved) · 422 bonded quote without acceptBondedRisk.
API
| Method | Endpoint | Notes |
| --- | --- | --- |
| listAssets() / getAsset(sym) | — | static, local (icons/decimals/addresses) |
| listRoutes(chainId?) | — | static, local — every quotable route incl. multihop |
| listPairs() | GET /fx/pairs | legacy Circle-RFQ catalog |
| balance(currency) | GET /fx/balance | v1 returns zeros |
| quote(req) | POST /fx/quote | legacy single-provider rate + signingRequest |
| quoteIntent(req) | POST /fx/intent/quote | all venues + all routes (cirBTC/JPYC/multihop) |
| executeIntent(req, idemKey?) | POST /fx/intent/execute | settle a chosen route on its venue |
| trade(req) | POST /fx/trade | execute signed legacy quote (KYB) |
| tradeFromQuote({quote, callerAddress, signedPayload}) | POST /fx/trade | ergonomic wrapper |
| getTrade(id) | GET /fx/trade/:id | poll status |
POST calls auto-attach an X-Idempotency-Key (override per call).
License
MIT
