Skip to content

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

  1. Resolve fromChain / toChain against the Network collection by id, and both tokens against the Token collection. Native assets need multi-address resolution — resolveTokenAddress handles 0x0, 0xeee…, and Polygon's 0x…1010.
  2. Set SSE headers.
  3. 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 meta goes 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 under meta.id for five minutes.
  • Each provider is independent. AggregatorFactory.getQuotes does Promise.all over the filtered aggregators and swallows per-aggregator errors, so one failure cannot kill the stream.

Default excludes

ListMembers
excludeSwapsymbiosis, one_inch, open_ocean (+ icecream_swap on Polygon)
excludeBridgeicecream_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

  1. Read the cached { data, meta, restProps } from Redis by id.
  2. Merge any req.query overrides into quotePayload (this is how a changed slippage or recipient is applied without re-quoting).
  3. Reconstruct a Quote and call quote.getTransactionData(), which dispatches to the right aggregator's getTransactionData.
  4. 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 }):

  1. Read relayerContract.inPercentFee and enableFees.
  2. Read tokenContract.allowance(user, relayerAddresses(chainId)).
  3. 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

  1. Load the RPC URL for chainId from the Network collection.
  2. Pick the signer key: POL_TX_SIGNER for chain 137, TX_SIGNER otherwise.
  3. 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:

  1. require(block.timestamp <= deadline).
  2. require(nonce == nonces[msg.sender]), then increment.
  3. Recover the signature; require a non-zero stored signer and an exact match.
  4. Compute the fee (see fees).
  5. ERC-20: record balanceOf(this), safeTransferFrom(user, this, amount), forceApprove(recipient, amount), and if there is a fee a second safeTransferFrom(user, this, fee). Native: require(msg.value >= nativeValue + fee).
  6. If data.length > 0: targetContract.call{value: nativeValue}(data), bubbling the revert reason.
  7. ERC-20 cleanup: forceApprove(recipient, 0); refund any tokens above balanceBefore + fee to the caller (partial fill).
  8. Refund native overpayment above nativeValue + (isNative ? fee : 0).
  9. Emit MetaTransactionExecuted.

Stage 8 — record and reconcile

sendTransactionRaw on the frontend:

  1. Create a transaction record with a UUID as txHash.
  2. Push ?hash=<uuid> into the URL so a reload recovers the in-flight trade.
  3. tradeSdk.triggerTransaction(provider, {...}).
  4. 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:

FunctionReturns
TradeManager.sendSignTxDataRaw(relayerTxData){ approvalData, executeData, value, to }
RelayerFactory.getMetaTransactionByteDataThe same, at the lower level
GET /defi/relayer/tx/dataThe 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

SymptomLikely cause
Fewer routes than expectedOne or more provider APIs failing; errors are swallowed by design
"Quote not found" on confirmRedis key expired past 5 minutes
"Transaction expired" revertThe 100-second deadline elapsed
"Invalid nonce" revertA concurrent transaction from the same wallet consumed the nonce
"Invalid signature" revertSigner mismatch — wrong key for the chain, or the relayer's signer was rotated
Insufficient-allowance failureApproval did not cover amount + fee
Transaction succeeds, no output tokenThe route's calldata was an approve() step, not a swap
relayerAddresses throwsNo relayer on that chain — Ethereum and Etherlink by design

Detail on each: errors & failure modes.

Key files

ConcernFile
SSE controllerbz-backend/controllers/defi/swap.js
Fan-out servicebz-backend/services/swap.service.js
Signingbz-backend/controllers/defi/transaction.controller.js
Routesbz-backend/routes/defi.routes.js
SDK entryswap-sdk/src/index.ts
Aggregator factoryswap-sdk/src/aggregator.factory.ts
Relayer builderswap-sdk/src/relayer.ts
Addressesswap-sdk/src/utils/constants.ts
Contractbz-backend/contract-deployment/contracts/BlazpayRelayer.sol
FE wrapperdefi-dex/src/utils/trade.ts
FE pagesdefi-dex/src/pages/defi/{swap,bridge}.tsx

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