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-20Adding 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:
- New row in the assets config table.
- New address derivation module.
- New chain watcher conforming to the
ChainWatcherinterface (onTransfer,confirmationsFor,currentTip). - New row in the confirmations policy table.
- 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.