Appearance
Quote → settle pipeline
The full journey from a user picking two tokens to an on-chain settlement. This is the single most important flow in the platform; four layers cooperate, and every one of them can fail in a way the user sees.
The four layers
defi-dex (React)
│ EventSource SSE → /defi/quotes
│ axios → /defi/swap/:id (build tx)
│ axios → /defi/sign (server-signed EIP-712)
│ ethers v5 → BlazpayRelayer.executeMetaTransactionSwap()
▼
bz-backend (Node/Express)
│ runs swap-sdk's TradeManager server-side
│ proxies providers that need secret keys
│ signs EIP-712 with TX_SIGNER / POL_TX_SIGNER
│ caches built quotes in Redis by uuid for 5 min
▼
swap-sdk (TypeScript, ethers v6)
│ TradeManager + AggregatorFactory
│ one class per aggregator
│ RelayerFactory (signs/executes via BrowserProvider)
▼
BlazpayRelayer.sol (UUPS, per chain)
executeMetaTransactionSwap({...}, signature)Stage 1 — quote fan-out over SSE
Endpoint: GET /api/defi/quotes · Controller: bz-backend/controllers/defi/swap.js → getQuotes
- Resolve
fromChain/toChainagainst theNetworkcollection byid, and both tokens against theTokencollection. Native assets need multi-address resolution —resolveTokenAddresshandles0x0,0xeee…, and Polygon's0x…1010. - Set SSE headers.
- Call
swapService.getQuotes({...}).
services/swap.service.js:
js
const trade = new TradeManager() // from swap-sdk
await trade.getQuotes({
...params,
excludeSwap, // defaults below
excludeBridge,
onNewQuote: async (quote) => {
await redis.set(quote.meta.id, JSON.stringify(quote), 'EX', 300)
res.write(`data: ${JSON.stringify(quote.meta)}\n\n`)
},
onLastQuote: () => res.end(),
})Two things matter here:
- Only
metagoes to the browser. The full{ data, meta, restProps }is far too large — raw provider responses include route graphs, per-hop details and token metadata. It is cached in Redis undermeta.idfor five minutes. - Each provider is independent.
AggregatorFactory.getQuotesdoesPromise.allover the filtered aggregators and swallows per-aggregator errors, so one failure cannot kill the stream.
Default excludes
| List | Members |
|---|---|
excludeSwap | symbiosis, one_inch, open_ocean (+ icecream_swap on Polygon) |
excludeBridge | icecream_swap, one_inch, butter_network, open_ocean |
The frontend can override both.
Stage 2 — the user picks
The frontend appends each arriving quote and re-sorts by output descending (+b.amount - +a.amount), pre-selecting the best so far. An end event closes the stream.
Optionally, TradeManager.sortQuotes(provider, relayerTxs[]) runs relayerContract.executeMetaTransactionSwap.estimateGas against each candidate and keeps only the ones that do not revert. A route that prices well but reverts on-chain never reaches the user.
Stage 3 — build the transaction
Endpoint: GET /api/defi/swap/:id
- Read the cached
{ data, meta, restProps }from Redis byid. - Merge any
req.queryoverrides intoquotePayload(this is how a changed slippage or recipient is applied without re-quoting). - Reconstruct a
Quoteand callquote.getTransactionData(), which dispatches to the right aggregator'sgetTransactionData. - Return
{ tx, spender, metaData? }.
A five-minute-old quote fails here, not earlier
If the Redis key has expired the build returns an error. That is why a user who leaves the confirm screen open and then clicks confirm sees a failure rather than a stale price. Re-quote on focus is the correct product fix; today it is not implemented.
restProps carries quotePayload, the chains, the wallet addresses and slippageTolerance — everything needed for the second round-trip that is not in meta.
Stage 4 — allowance
TradeManager.getAllowance({ quoteData, provider }):
- Read
relayerContract.inPercentFeeandenableFees. - Read
tokenContract.allowance(user, relayerAddresses(chainId)). - Compare against
amount + fee.
The approval target is the relayer, not the aggregator's router. And it must cover the fee, because an ERC-20 swap pulls the fee as a second transferFrom.
approveRelayer calls setAllowance(token, relayerAddresses(chainId), provider, chainId, amount + fee, route).
Stage 5 — signing
Endpoint: POST /api/defi/sign · Controller: transaction.controller.js → signTx
- Load the RPC URL for
chainIdfrom theNetworkcollection. - Pick the signer key:
POL_TX_SIGNERfor chain 137,TX_SIGNERotherwise. signer._signTypedData(domain, signatureType, metaTx)where:
js
domain = {
name: 'BlazpayRelayer',
version: '1',
chainId,
verifyingContract: relayerAddresses(chainId),
}signatureType is the EIP-712 MetaTransaction definition exported from utils/constants.js.
What the backend is attesting
The relayer requires ECDSA.recover(metaTxHash, signature) == signer, where signer is the contract's stored platform address (0x75a8b522FC3195e3a3570F11f111AC89c0D35975 on all deployed chains).
So the signature says "Blazpay built this route". The user's authority is separate: they are msg.sender, funds are pulled from them, and they pay gas. Both are required. See custody.
Stage 6 — the MetaTransaction
swap-sdk/src/relayer.ts builds it from IRelayerTxData:
js
{
targetContract: tx.to, // the aggregator router
data: tx.data,
recipient: spender || 0x0, // NOTE: field is 'recipient', value is the spender
amount,
token,
isNative: token === 0x0 || 0xeee…,
nonce: await relayer.nonces(user),
deadline: now + 100, // seconds
nativeValue: tx.value,
}Then: POST ${baseUrl}/sign { metaTx, chainId } → signature → compute txValue = enableFees ? value + fee : value → estimate gas → either staticCall (simulate) or send (execute) executeMetaTransactionSwap(metaTx, signature).
The 100-second deadline
deadline = now + 100 is tight. A user who takes longer than that to approve the wallet prompt gets a "Transaction expired" revert. Signing happens after the user has already approved the quote, so the window usually suffices — but on a slow wallet or a congested chain it does not.
Stage 7 — on-chain execution
Inside executeMetaTransactionSwap:
require(block.timestamp <= deadline).require(nonce == nonces[msg.sender]), then increment.- Recover the signature; require a non-zero stored
signerand an exact match. - Compute the fee (see fees).
- ERC-20: record
balanceOf(this),safeTransferFrom(user, this, amount),forceApprove(recipient, amount), and if there is a fee a secondsafeTransferFrom(user, this, fee). Native:require(msg.value >= nativeValue + fee). - If
data.length > 0:targetContract.call{value: nativeValue}(data), bubbling the revert reason. - ERC-20 cleanup:
forceApprove(recipient, 0); refund any tokens abovebalanceBefore + feeto the caller (partial fill). - Refund native overpayment above
nativeValue + (isNative ? fee : 0). - Emit
MetaTransactionExecuted.
Stage 8 — record and reconcile
sendTransactionRaw on the frontend:
- Create a transaction record with a UUID as
txHash. - Push
?hash=<uuid>into the URL so a reload recovers the in-flight trade. tradeSdk.triggerTransaction(provider, {...}).PATCH /defi/transaction/hash/<uuid>with the real hash.
Status is refreshed by GET /defi/txn/:address on demand and by bridgeStatusCron in the background.
Headless variant
For flows without a BrowserProvider — the agent path, server-built transactions — there are two headless entry points:
| Function | Returns |
|---|---|
TradeManager.sendSignTxDataRaw(relayerTxData) | { approvalData, executeData, value, to } |
RelayerFactory.getMetaTransactionByteData | The same, at the lower level |
GET /defi/relayer/tx/data | The backend endpoint exposing it |
That route has a typo
routes/defi.routes.js registers router.get('relayer/tx/data', …) — without a leading slash. Express resolves it relative to the mount in a way that makes the intended /api/defi/relayer/tx/data unreachable. Worth fixing.
Failure modes
| Symptom | Likely cause |
|---|---|
| Fewer routes than expected | One or more provider APIs failing; errors are swallowed by design |
| "Quote not found" on confirm | Redis key expired past 5 minutes |
| "Transaction expired" revert | The 100-second deadline elapsed |
| "Invalid nonce" revert | A concurrent transaction from the same wallet consumed the nonce |
| "Invalid signature" revert | Signer mismatch — wrong key for the chain, or the relayer's signer was rotated |
| Insufficient-allowance failure | Approval did not cover amount + fee |
| Transaction succeeds, no output token | The route's calldata was an approve() step, not a swap |
relayerAddresses throws | No relayer on that chain — Ethereum and Etherlink by design |
Detail on each: errors & failure modes.
Key files
| Concern | File |
|---|---|
| SSE controller | bz-backend/controllers/defi/swap.js |
| Fan-out service | bz-backend/services/swap.service.js |
| Signing | bz-backend/controllers/defi/transaction.controller.js |
| Routes | bz-backend/routes/defi.routes.js |
| SDK entry | swap-sdk/src/index.ts |
| Aggregator factory | swap-sdk/src/aggregator.factory.ts |
| Relayer builder | swap-sdk/src/relayer.ts |
| Addresses | swap-sdk/src/utils/constants.ts |
| Contract | bz-backend/contract-deployment/contracts/BlazpayRelayer.sol |
| FE wrapper | defi-dex/src/utils/trade.ts |
| FE pages | defi-dex/src/pages/defi/{swap,bridge}.tsx |