Skip to main content
Back to Blog
Product Update

How we built Blockchain0x's chain-adapter architecture

An engineering deep-dive into the decision to ship a single Base adapter at MVP behind a multi-chain interface. The trade-offs, the interface, the EVM base class, and the seven caveats that are painful to change later.

How we built Blockchain0x's chain-adapter architecture
May 15, 2026
13 min read
#Product Update#Engineering#Architecture#Base

The decision in one paragraph

Blockchain0x MVP ships on one chain - Base. We could have written the payment service against Base's specifics directly, saved two days, and gone faster. We did not. Instead, on day one of the implementation, we put a ChainAdapter interface in front of the chain layer, wrote an AbstractEVMAdapter base class, and built BaseAdapter as a concrete child of it. Everything above - the public REST API, the webhook contract, the SDKs in Node and Python, the dashboard, the docs - is chain-agnostic by construction. This post is the engineering rationale for that choice, the shape of the interface itself, and the seven caveats that turned out to be load-bearing.

If you are building agent-payments infrastructure and trying to decide whether to encode the multi-chain abstraction now or refactor later, the short answer is: now is cheap; later is expensive. The long answer is below. For the user-facing surface this architecture supports, see /product/payment-api.

Why a chain-coupled MVP is the wrong call

The argument for shipping chain-coupled - just write the payment service against Base, refactor when you need a second chain - is a YAGNI argument. "You aren't going to need it." It is usually correct, and we follow it elsewhere in the codebase. It is wrong here because of three things specific to the agent-payments domain.

Solana is non-EVM. It is also, by 2026, the second-biggest concentration of AI agent activity after Base. We will not be on Base only. Adding Solana to a Base-coupled codebase means rewriting transaction monitoring, balance lookup, address validation, webhook normalization, explorer URL construction, and wallet-deep-link building - all in a non-EVM idiom. With adapters, Solana becomes one new module that drops into a registry; without, it becomes a refactor that touches every layer.

EVM chains share ~90% of their logic, and the other 10% bites. Ethereum, Base, Polygon, Arbitrum all share opcodes, signature schemes, and roughly the same RPC surface. They differ in finality rules (Base ~3 confirmations, Ethereum mainnet 32 slots), gas pricing, native USDC contract addresses, and explorer URL formats. A BaseAdapter that hard-codes "3 confirmations is finalized" is fine for Base; reusing it for Ethereum is wrong. An AbstractEVMAdapter that asks its subclass for those per-chain values makes adding a third or fourth EVM chain a half-day of work.

API stability is the product. The pitch we make to a developer is: integrate against POST /v1/payment-requests once, and your code keeps working across every chain we support, now and in the future. That is not marketing copy; it is a contract. The only way to keep it is to push every chain-specific concern below an interface that the public API consumes uniformly. If the API ever surfaces something chain-dependent - even an error code that means different things on different chains - the contract breaks. The adapter pattern is what enforces this on the implementation side.

The cost of doing this in the original MVP is small. Two or three dev-days to design the interface, write the abstract EVM base class, and refactor BaseAdapter to extend it. The cost of doing it after Base is in production and there is pressure to ship Solana in three weeks is on the order of weeks. We took the small cost.

The interface

The full ChainAdapter interface is one file. The fact that it fits on a screen is the point.

TYPESCRIPT
interface ChainAdapter {
  // Identity
  id: ChainId                          // 'base' | 'ethereum' | 'solana' | 'polygon' | 'arbitrum'
  displayName: string
  family: 'evm' | 'solana' | 'other'
  supportedCurrencies: CurrencySpec[]  // USDC at MVP; expandable to USDT, EUROe, etc.

  // Address operations
  isValidAddress(addr: string): boolean
  formatAddressForDisplay(addr: string): string                    // truncated form
  computeReceiveAddress(wallet: string, currency: string): string  // Solana ATA / EVM passthrough

  // Monitoring
  watchAddress(addr: string, currency: string): Promise<WatchHandle>
  parseProviderWebhook(payload: unknown, signature: string): NormalizedTransaction | null

  // Lookups
  getTransaction(hash: string): Promise<NormalizedTransaction | null>
  getBalance(addr: string, currency: string): Promise<bigint>     // raw token units
  getConfirmations(hash: string): Promise<number>
  isFinalized(hash: string): Promise<boolean>

  // User-facing helpers
  getExplorerTxUrl(hash: string): string
  getExplorerAddressUrl(addr: string): string
  buildPaymentQRString(addr: string, amount: bigint, currency: string): string
  buildWalletDeepLink(wallet: SupportedWallet, args: PaymentArgs): string | null
}

interface NormalizedTransaction {
  chain: ChainId
  hash: string
  from: string
  to: string
  amountRaw: bigint
  currency: string
  status: 'pending' | 'confirmed' | 'finalized' | 'failed'
  confirmations: number
  blockNumberOrSlot: number
  timestamp: Date
}

A handful of design choices in here are non-negotiable:

Every adapter returns NormalizedTransaction in the same shape. Status is a 4-state lifecycle: pending, confirmed, finalized, failed. EVM uses block-count confirmations to determine finalized; Solana uses commitment levels (processed, confirmed, finalized); both map cleanly to the same lifecycle. The service layer never sees the chain-specific variants.

Amounts are bigint in raw token units. USDC has 6 decimals, ETH would have 18 if ever supported. We never use floating point for money. The API serializes amounts as decimal strings, not numbers, on the way out. The database stores raw token units in a NUMERIC(78,0) column.

computeReceiveAddress is the abstraction that makes Solana possible. On EVM, the address USDC payments go to is the same as the wallet's main address - the function is identity. On Solana, USDC is an SPL token, and the payments go to the wallet's Associated Token Account (ATA), which is a deterministic derivation of the main address plus the token mint. Without this abstraction, every layer that thinks about "where does the payment land" has to special-case Solana.

buildWalletDeepLink is the abstraction that makes wallet UX consistent. On EVM, the right deep link is a WalletConnect URI or a Reown-formatted intent. On Solana, it is a Phantom or Solflare specific format. The dashboard never knows; it asks the adapter and renders the result.

The EVM base class

The AbstractEVMAdapter is where the 90% sharing happens. It is a class, not an interface, because EVM chains share concrete logic that benefits from inheritance: signature scheme, RPC call shape, balance-lookup contract calls, USDC token address resolution. Each concrete EVM adapter only overrides the per-chain bits.

TYPESCRIPT
abstract class AbstractEVMAdapter implements ChainAdapter {
  abstract id: ChainId
  abstract displayName: string
  abstract chainIdNumeric: number              // 8453 for Base, 1 for Ethereum, etc.
  abstract rpcUrl: string
  abstract usdcAddress: string                 // per-chain USDC contract
  abstract explorerBase: string                // "https://basescan.org"
  abstract finalityConfirmations: number       // Base: 3, Ethereum: 32, etc.

  family = 'evm' as const
  supportedCurrencies = [{ symbol: 'USDC', decimals: 6 }]

  isValidAddress(addr: string): boolean {
    return /^0x[a-fA-F0-9]{40}$/.test(addr)
  }

  formatAddressForDisplay(addr: string): string {
    return `${addr.slice(0, 6)}...${addr.slice(-4)}`
  }

  computeReceiveAddress(wallet: string, _currency: string): string {
    return wallet                              // EVM: payments land on the main address
  }

  getExplorerTxUrl(hash: string): string {
    return `${this.explorerBase}/tx/${hash}`
  }

  getExplorerAddressUrl(addr: string): string {
    return `${this.explorerBase}/address/${addr}`
  }

  async getBalance(addr: string, _currency: string): Promise<bigint> {
    // Standard ERC-20 balanceOf call against this.usdcAddress.
    return await callERC20BalanceOf(this.rpcUrl, this.usdcAddress, addr)
  }

  async getConfirmations(hash: string): Promise<number> {
    return await rpcConfirmations(this.rpcUrl, hash)
  }

  async isFinalized(hash: string): Promise<boolean> {
    return (await this.getConfirmations(hash)) >= this.finalityConfirmations
  }

  // ... watchAddress, parseProviderWebhook, etc. are also implemented here
  // because they share the same Alchemy-Notify scheme across EVM chains.
}

Concrete adapters are then trivial. BaseAdapter is essentially configuration:

TYPESCRIPT
class BaseAdapter extends AbstractEVMAdapter {
  id = 'base' as const
  displayName = 'Base'
  chainIdNumeric = 8453
  rpcUrl = process.env.BASE_RPC_URL!
  usdcAddress = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
  explorerBase = 'https://basescan.org'
  finalityConfirmations = 3

  buildPaymentQRString(addr: string, amount: bigint, _c: string): string {
    return `ethereum:${this.usdcAddress}@${this.chainIdNumeric}/transfer?address=${addr}&uint256=${amount}`
  }

  buildWalletDeepLink(wallet: SupportedWallet, args: PaymentArgs): string | null {
    if (wallet === 'coinbase-wallet') return buildCoinbaseWalletURI(args, this.chainIdNumeric)
    if (wallet === 'rainbow') return buildRainbowWCURI(args, this.chainIdNumeric)
    return null
  }
}

Adding EthereumAdapter, PolygonAdapter, or ArbitrumAdapter later is a half-day each - same shape, different chainIdNumeric, usdcAddress, explorerBase, finalityConfirmations, and buildWalletDeepLink mapping. The shared logic in AbstractEVMAdapter is what makes this fast.

The non-EVM case: where the abstraction earns its keep

SolanaAdapter is a separate concrete adapter that implements ChainAdapter directly, not through the EVM base class. This is where the discipline of the interface pays back. Solana differs from EVM in five ways that matter:

ATAs. USDC on Solana lives in Associated Token Accounts. The wallet's main address is a Solana public key; the address USDC payments actually settle to is findAssociatedTokenAddress(walletPublicKey, USDC_MINT). The adapter's computeReceiveAddress returns the ATA; the service layer stores both the main address (for wallet identity) and the receive address (for monitoring).

Commitment levels. Solana has three: processed, confirmed, finalized. The adapter maps these to the lifecycle: processedpending, confirmedconfirmed, finalizedfinalized. The webhook payment.confirmed fires only at finalized.

Slots, not blocks. Solana orders transactions by slot. The blockNumberOrSlot field in NormalizedTransaction carries either - the adapter knows the difference, nothing else does.

Provider differences. EVM adapters use Alchemy Notify; the Solana adapter uses Helius webhooks. Both implement parseProviderWebhook to map their provider's payload + signature into a clean NormalizedTransaction. Outside the adapter, the rest of the service has no idea which provider is on the other end.

Wallet integrations. Phantom and Solflare are Solana-native; Reown and Coinbase Wallet are EVM-native. buildWalletDeepLink returns the right format per wallet, and the dashboard renders whatever the adapter gives back.

The point is not that Solana is hard; the point is that it is structurally different in ways that would be terrible to discover in a refactor. The interface design pays for itself the first time you need a second chain family.

What stays chain-agnostic by construction

Once the adapter abstraction is in place, everything above it stops thinking about chains. The promise we make to developers - integrate once, use any chain - is a direct consequence of which surfaces are chain-agnostic by construction.

Surface Behavior across chains
Public REST API /v1/* Identical. Returns NormalizedTransaction. Chain id is metadata.
Webhook contracts Identical. Same event names, same payload shape.
SDKs (Node + Python) Identical. Same methods regardless of chain.
Developer docs (core reference) Identical. Per-chain quirks live in a small "Chain notes" appendix.
Dashboard UI Mostly identical. Chain badge on agents and transactions.
Public agent page /a/{slug} Mostly identical. Address format, QR string, explorer link from adapter.
Database core fields Chain-agnostic. chain is a column on Wallet and Transaction.
Confirmation rules Inside the adapter. Service trusts isFinalized().

The boundary is sharp: anything that takes or returns a NormalizedTransaction is chain-agnostic; anything that constructs an address, signs a payload, or talks to a provider lives inside an adapter.

Seven caveats that were painful to get right

These are the design decisions we burned the most time on before writing code. Each one would have been expensive to change after the interface shipped.

1. ATAs and the two-address model. A Solana wallet has a main address (used as identity, for wallet-connect handshakes, for display) and an ATA per token (where SPL tokens actually land). EVM has one address. The interface's computeReceiveAddress returns the right one; the database stores both. The Wallet table has columns for address (main) and receive_address (ATA on Solana, equal to address on EVM). Getting this right at day one avoided a database migration when Solana ships.

2. Lifecycle normalization. EVM uses confirmation counts; Solana uses commitment levels. The 4-state lifecycle (pending, confirmed, finalized, failed) is what every consumer sees. The chain-specific status numbers stay inside the adapter. payment.confirmed fires only at finalized, regardless of chain.

3. Webhook signature schemes. Alchemy Notify uses one HMAC scheme with one header name; Helius uses another; QuickNode another. Each adapter validates against its provider's scheme and returns a parsed NormalizedTransaction or null. The rest of the service never touches provider signatures.

4. Reorg policy. Base reorgs are rare but non-zero. Solana commitment levels mean "finalized" is genuinely final. The adapter's isFinalized() is the only place this policy lives; the service treats it as authoritative. If a chain's finality model later changes, the change is one method override.

5. Gas sponsorship hook (deliberately omitted at MVP). Non-custodial wallets pay their own gas in MVP. When Coinbase Smart Wallet on Base or session keys ship in V2, we add a paymaster() method to the interface; we did not retrofit it earlier because designing for a feature you don't have leads to wrong abstractions. The interface is small enough that adding one method later is harmless.

6. No agent chain-switching post-creation. This is a policy, not just code. Once an agent is created on Base, it stays on Base. If a user wants the same agent on Solana, they create a new agent with the same slug variant. This keeps the API simple, avoids reorg-during-migration edge cases, and matches how customers think about it anyway (a Solana agent is a different operational entity than an EVM agent).

7. Bigint amounts, decimal-string serialization. Internally, every amount is a bigint in raw token units. On the wire, the API serializes amounts as decimal strings ("5.00"), not numbers, so JavaScript's float precision cannot truncate them. The SDK helpers convert in both directions. This is one of those rules that seems excessive until it bites once.

The implementation cost, settled

The actual cost of putting this in at MVP:

  • Two days for two engineers to design the interface, the lifecycle normalization, and the EVM base class.
  • One additional day refactoring the originally chain-coupled monitoring code into the new shape.
  • Roughly zero ongoing cost; the abstraction is small enough that it does not slow feature work.

We have shipped one EVM chain (Base) on this architecture. Solana is in design for V2. Ethereum, Polygon, and Arbitrum are V3 by demand. The promise that the public API stays identical across all of them is something we can make without crossing our fingers. That is the part that matters.

What this bought the API

The thing this architecture sells to developers is not "we have an adapter pattern". It is "the API you integrate against today will not change when you want a second chain." The integration is POST /v1/payment-requests with an agent_id. The agent already knows its chain. The response shape, the webhook events, the SDK calls - all of them stay identical when the agent's chain is base, solana, polygon, or anything else we ever ship. That is a real promise, enforced by the fact that no chain-specific concept ever crosses the adapter boundary upward.

For the user-facing surface of all of this, see /product/payment-api. The architectural details live here; the API itself is the deliberately boring rectangle on top.

Key Takeaways

  • We ship one adapter (Base) at MVP behind a multi-chain interface. The extra cost is two to three dev-days. It buys us months.
  • The non-EVM case (Solana ATAs, commitment-level finality, different signature schemes) forces the abstraction; without it, the V2 Solana lift becomes a refactor across every chain-touching surface.
  • Everything above the adapter - REST API, webhooks, SDKs, dashboard, docs - is chain-agnostic by construction.
  • Seven caveats are non-negotiable to nail on day one: ATAs, commitment vs confs, webhook signature schemes, reorg policy, gas-sponsorship hook, no agent chain-switching, bigint amounts.
Toshendra Sharma

Auther

Toshendra Sharma