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