Phase 1: Contract-Based Payment Links on EVM
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.PaymentLinkReceiveris an EIP-1167 minimal clone. It accepts native or ERC-20, emitsPaid, and callsFeeSplitter.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 theuseOnChainReceiverflag.
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 fromlinkId(the existing payment-link UUID, hashed tobytes32).deployAndPay(bytes32 linkId, Split split, address token, uint256 amount)— lazy deploy + pay in one transaction. The factory clones thePaymentLinkReceiverimplementation, initializes it with theSplit, 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
Splitstruct and theFeeSplitteraddress. pay(address token, uint256 amount)— handles native and ERC-20 in one entry point. UsesSafeERC20so non-standard tokens (USDT) work. EmitsPaid(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:
- Looks up the
paymentLinkIdfrom the event (indexed inbytes32). - Upserts a
PaymentTransactionrow — same shape as today. - 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
maxLinkValuein the factory for the first 30 days. - USDT and other non-standard ERC-20s. Mitigation:
SafeERC20in bothPaymentLinkReceiverandFeeSplitter; 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+FeeSplitterimplemented - Fuzz + invariant tests for split math (merchant always gets ≥ expected minus fees)
- USDT / non-standard ERC-20 fork tests passing
-
useOnChainReceiverfeature flag wired inapi/src/payments -
evm-paid-event.service.tsindexer 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
