Skip to main content

Phase 1: Contract-Based Payment Links on EVM

· 5 min read
OrcaRail
Crypto payment rails for web2 apps

April 20, 2026 — Phase 1 is the real engineering start of the non-custodial roadmap. We replace per-link HD-derived EOAs and Alchemy smart accounts with a factory + minimal-clone receiver pattern on EVM. Funds are split atomically on the pay transaction; no sweep, no custody.

TL;DR

  • PaymentLinkFactory.computeAddress(linkId) returns a deterministic CREATE2 address. We show that address to the payer before anything is deployed.
  • PaymentLinkReceiver is an EIP-1167 minimal clone. It accepts native or ERC-20, emits Paid, and calls FeeSplitter.split() in the same transaction.
  • api/src/payments/wallet.service.ts stops generating HD wallets for EVM links; it calls the factory view instead.
  • The sweep path (drainSmartAccountWallet, getSmartAccountClientForDepositWallet) is bypassed for links created under the useOnChainReceiver flag.

What this phase changes

The "after" side has no box we control sitting on the funds.

Contracts introduced

All under a new package contracts/evm/.

PaymentLinkFactory

  • computeAddress(bytes32 linkId) view returns (address) — deterministic CREATE2 address derived from linkId (the existing payment-link UUID, hashed to bytes32).
  • deployAndPay(bytes32 linkId, Split split, address token, uint256 amount) — lazy deploy + pay in one transaction. The factory clones the PaymentLinkReceiver implementation, initializes it with the Split, and forwards funds.
  • isDeployed(bytes32 linkId) view returns (bool) — helper used by the indexer and UI.
  • Upgradeable behind a UUPS proxy, owned by the deployer multisig (3-of-5 Safe), 48-hour timelock on upgradeTo.

PaymentLinkReceiver (clone)

  • Immutable after initialization. Stores the Split struct and the FeeSplitter address.
  • pay(address token, uint256 amount) — handles native and ERC-20 in one entry point. Uses SafeERC20 so non-standard tokens (USDT) work. Emits Paid(linkId, payer, token, amount).
  • Calls FeeSplitter.split() atomically. If the split reverts, the pay reverts.
  • Explicit refund(bytes32 txId) callable only by the merchant keeper EOA or deployer multisig.

FeeSplitter

  • split(Split s, address token, uint256 amount) — distributes by basis points. Remaining wei goes to the merchant (no dust loss).
  • Emits Split(txId, merchant, platform, referral, bridgeFee) for indexer parity with legacy webhook payloads.

API changes

The single biggest code change is in api/src/payments/wallet.service.ts. Today, getOrCreateAddressForPaymentLink derives from hd_wallet_seed, computes the Alchemy counterfactual address, and stores it in wallet_address. After Phase 1, for EVM chains under the feature flag, that function becomes:

async function getOrCreateAddressForPaymentLink(paymentLinkId, network) {
if (isEvm(network) && featureFlags.useOnChainReceiver(network.chainId)) {
const address = await factory.computeAddress(hashLinkId(paymentLinkId))
return { address, walletAddressId: null, derivationPath: null }
}
// legacy path unchanged
}

No HD derivation. No private material in the database for new links.

A new service, payments/indexer/evm-paid-event.service.ts, subscribes to Paid events on each enabled chain via viem.watchContractEvent. On match, it:

  1. Looks up the paymentLinkId from the event (indexed in bytes32).
  2. Upserts a PaymentTransaction row — same shape as today.
  3. Emits the same webhooks today's sweep-completion path does (api/src/webhooks/webhooks.service.ts).

The feature flag useOnChainReceiver is scoped per chain id. A link created while the flag is off stays on the legacy sweep path forever — we do not migrate in-flight links.

Deprecations

Inside this phase we bypass but do not delete:

  • SmartAccountWalletService.drainSmartAccountWallet — no longer called for flagged chains.
  • SmartAccountWalletService.getSmartAccountClientForDepositWallet — not needed when addresses come from the factory.
  • Rows in wallet_address — not written for flagged EVM chains.

Actual deletion happens in Phase 7, once all legacy links on the chain are settled.

Migration plan

  • New links on Base: get a factory-computed CREATE2 address. The UI displays it the same as today.
  • Existing open links on Base: stay on the legacy sweep path until they expire or settle. No forced migration.
  • Other EVM chains: legacy until Phase 3 flips their flag.

Risks and mitigations

  • CREATE2 address shown before any code exists. Mitigation: document that recoverability depends on the factory deployer. Cap maxLinkValue in the factory for the first 30 days.
  • USDT and other non-standard ERC-20s. Mitigation: SafeERC20 in both PaymentLinkReceiver and FeeSplitter; Foundry fuzz tests against a USDT mainnet fork.
  • Fee-on-transfer tokens. Mitigation: token allowlist; tokens outside it fall back to legacy path.
  • Chain indexer lag. Mitigation: dual-subscribe (Alchemy + fallback public RPC); reconcile jobs run hourly; idempotent upsert.
  • Audit finding during development. Mitigation: audit gated; no mainnet deploy before fix-all report.

Status checklist

  • Foundry project scaffolded at contracts/evm/
  • PaymentLinkFactory + PaymentLinkReceiver + FeeSplitter implemented
  • Fuzz + invariant tests for split math (merchant always gets ≥ expected minus fees)
  • USDT / non-standard ERC-20 fork tests passing
  • useOnChainReceiver feature flag wired in api/src/payments
  • evm-paid-event.service.ts indexer shipping and emitting existing webhooks
  • Audit kicked off, fixes landed
  • Base mainnet deploy from multisig
  • Canary cohort of merchants on the flag

What is next

With Base merchants on contract receivers, Phase 2 does the same for subscriptions: Allowance-pull subscriptions.


Reference docs: Phase 1 status · Contract reference · Architecture