BchainPay logoBchainPay
EngineeringArchitectureMulti-chainAPI

One API, five chains: designing a multi-chain crypto payment gateway

How BchainPay normalizes Ethereum, Polygon, BNB Chain, Solana and Tron behind a single REST API — confirmation policies, address derivation and the trade-offs we made.

By Cipher · Founding engineer, BchainPay5 min read
Illustration for One API, five chains: designing a multi-chain crypto payment gateway

Multi-chain is hard. Not because any one chain is hard — they're all RPC + signing + a transfer event — but because the combinations are hard. Five chains is fifteen pairwise edges, three different signing schemes, two unrelated address formats, and at least four "finalization" definitions that all mean different things.

This post is the design doc behind the BchainPay payment-intent API: how we made POST /v1/payment-intents produce one response shape no matter which chain a customer ultimately pays on.

Goal: hide the chain, not the choice#

Our north star: a developer who knows nothing about block headers should be able to ship in an afternoon. A developer who knows everything about block headers should be able to drop down to chain- specific knobs without the SDK getting in the way.

Concretely:

POST /v1/payment-intents
Content-Type: application/json
Authorization: Bearer ...
 
{
  "amount":  { "value": 25.00, "currency": "USD" },
  "accept":  ["USDC.polygon", "USDC.solana", "USDT.tron"],
  "metadata": { "order_id": "ord_42" },
  "expires_in": 1800
}

…should return:

{
  "id":     "pi_01HV…",
  "status": "awaiting_payment",
  "deposit_addresses": {
    "USDC.polygon": "0xAb…",
    "USDC.solana":  "F8…",
    "USDT.tron":    "TJ…"
  },
  "hosted_url": "https://pay.bchainpay.com/i/pi_01HV…"
}

One request, three deposit addresses, one webhook contract.

The five things that vary per chain#

1. Address format and derivation#

EVM chains share a derivation scheme (BIP-32 + secp256k1) and a 0x-prefixed 20-byte address. Solana derives ed25519 keypairs and formats them as base58 32-byte strings. Tron uses secp256k1 like EVM but bases the address on the Keccak-256 of the public key with a 0x41 prefix re-encoded as base58check.

We store one HD seed per merchant per chain family — evm, solana, tron — and derive deterministic addresses per payment intent. The seeds live in a hardware-backed key vault; the worker that derives addresses never sees the seed material in clear.

2. Token contract / mint vs native transfer#

Native ETH / SOL / TRX transfers are just balance moves on the chain. ERC-20 / SPL / TRC-20 stablecoins are contract events. We unify both behind a single Asset enum:

type Asset =
  | `ETH.${EvmNetwork}`     // native
  | `USDC.${EvmNetwork}`    // ERC-20
  | `USDT.${EvmNetwork}`
  | 'SOL.solana'
  | 'USDC.solana'           // SPL
  | 'TRX.tron'
  | 'USDT.tron';            // TRC-20

Adding a new asset is a config row, not a code path.

3. Confirmation semantics#

This is where naïve gateways get burned. "1 confirmation" on Solana means almost nothing — processed, confirmed and finalized are all different statuses. On Polygon the chain re-orgs more often than Ethereum and you want 64 blocks. On Tron a single confirmation is already firmer than 6 on Bitcoin.

We model this as a per-asset, per-amount-tier policy:

Asset <$10 <$1k ≥$1k
USDT.tron 1 conf 19 conf 19 conf
USDC.solana confirmed confirmed finalized
USDC.polygon 12 conf 64 conf 128 conf
USDC.ethereum 6 conf 12 conf 24 conf

The dashboard shows this policy on every intent so merchants always know what "succeeded" means in context.

4. Mempool watching vs RPC polling#

EVM chains all expose eth_subscribe over WebSocket, but reliability varies wildly between RPC providers. We run a fan-in topology with three providers per chain and a watchdog that flips traffic if any one provider falls behind tip by more than two blocks.

Solana exposes WebSocket subscriptions for token-account changes, which is cheaper than polling but quirkier (notifications are best-effort and can drop). We treat WS as an accelerator and always have a 30-second polling backup.

Tron's HTTP RPC is simple and reliable; we poll. No need to over- engineer.

5. Fee economics#

Native transfers cost gas; ERC-20 transfers cost more gas (≈65k vs 21k); SPL token transfers are nearly free; TRC-20 transfers can be free if the sender stakes TRX for energy.

When BchainPay sweeps funds from a per-intent deposit address to your treasury, we pay that gas — and we can't pass it on without a fixed buffer. So our pricing model is:

  • 0.7% per successful payment (passed-through gas included)
  • Network fees on customer→intent transfers are paid by the customer (and shown clearly on the hosted page)
  • Withdrawal-time gas is included in the 0.7%

The webhook contract: same shape, different metadata#

The hard-won unifying decision: every chain emits the same webhook event types in the same order:

payment_intent.created
payment_intent.detected     ← seen on chain, not yet confirmed
payment_intent.succeeded    ← confirmation policy met
payment_intent.invalidated  ← re-org or chain failure (rare)
payment_intent.expired      ← no payment within expires_in

Chain-specific data lives under event.data.transaction:

{
  "type": "payment_intent.succeeded",
  "data": {
    "id": "pi_01HV…",
    "amount_received": { "value": "25.00", "currency": "USDC" },
    "asset": "USDC.polygon",
    "transaction": {
      "chain": "polygon",
      "hash": "0xabc…",
      "block_number": 71234567,
      "confirmations": 64,
      "from_address": "0xdef…"
    }
  }
}

Your handler can switch on data.asset if it cares about the chain, or ignore it and just trust payment_intent.succeeded.

What we don't try to hide#

Two things we deliberately surface instead of papering over:

  • Refund destinations. A USDC.polygon refund must go to a USDC.polygon address — we won't auto-bridge. Cross-chain bridging has its own risk model and we're not going to silently take it on behalf of a merchant.
  • Withdrawal addresses. When you withdraw from a pocket, you pick the destination address per chain. We don't maintain a single "primary wallet" that hides the chain — that's a footgun for anyone doing real treasury work.

Adding chain #6#

When we add a chain (Base and Aptos are next), the work is roughly:

  1. New row in the assets config table.
  2. New address derivation module.
  3. New chain watcher conforming to the ChainWatcher interface (onTransfer, confirmationsFor, currentTip).
  4. New row in the confirmations policy table.
  5. SDK gets a new union member; everything else just works.

That's about 800 lines of code on the watcher side and a one-line API addition on the merchant side. The whole point of the unifying API is that step 5 is boring — and boring is the goal.

Takeaways#

  • Multi-chain is mostly configuration, not code, if you build the right primitives.
  • Confirmation policy is a business decision, not a chain decision — expose it, don't hide it.
  • A unified webhook contract is more valuable than a unified address scheme. Devs care about events.
  • Surface chain-specific footguns; hide chain-specific busywork.

If you're integrating multiple chains today and your code has more if (chain === ...) than business logic, that's the smell that says it's time to put a normalizing layer in front. We built one so you don't have to.


Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading