Abstract
A payment is only trustworthy if its paid state corresponds to a real on-chain settlement. OneLink enforces this with a single principle — settlement before status — implemented as a server that decodes and matches the on-chain event emitted by the OneLinkCollect contract before it writes a paid (or cancelled) record.
The client can request a state change; only the chain can justify it. Every receipt therefore anchors to a verifiable Arc transaction, and every claim on this page maps to a hash you can re-check on Arcscan.
Settlement before status
The design principle holds across the whole product:
- The front-end never self-certifies a payment.
- An API route under /api/payments/* fetches the transaction receipt from Arc, decodeEventLogs it against the contract ABI, and confirms the event arguments match the link's expected values before persisting.
- This holds for three transitions: invoice creation, final paid, and final cancelled.
There is no code path in which the UI alone can fabricate a settlement.
System architecture
Four layers, with the contract — not the browser — as the source of truth.
Creator wallet ──signs createLink──▶ Arc (OneLinkCollect)
│ │ emits PaymentLinkCreated
▼ ▼
/api/payments/create ──verifies──▶ Supabase (payment_links)
│ [demo mode → localStorage]
Payer wallet ──approve + payLink / bridge+settle──▶ Arc
│ │ emits PaymentCompleted
▼ ▼
/api/payments/reconcile ──verifies──▶ status = paid
│
▼
/receipt/[id] ── renders the verified Arcscan tx- Client — Next.js 15 App Router, React 19. Server components by default; wallet flows are client components on wagmi/viem/RainbowKit.
- Settlement — the OneLinkCollect contract on Arc Testnet holds authoritative link state and emits the events the server verifies.
- Verification & persistence — Vercel serverless API routes verify events and write state with a service-role client; RLS prevents unauthenticated tampering.
- Demo mode — with no contract or Supabase configured, the app runs from localStorage with 0xDEM0… pseudo-hashes, explicitly labeled and never used in production.
The OneLinkCollect contract
Solidity ^0.8.28 (MIT), built and tested with Foundry (optimizer_runs 200), settling USDC via IERC20.transferFrom.
Functions
- createLink(linkId, recipient, amount, expiresAt)
- Register an invoice link.
- payLink(linkId)
- Pay a registered invoice link.
- payRecipient(paymentId, recipient, amount)
- Profile (handle) payment to a recipient.
- cancelLink(linkId)
- Creator-only cancellation of an open link.
- getLink(linkId) view
- Read link state.
Events the server verifies
- PaymentLinkCreated(linkId, creator, recipient, amount, expiresAt)
- PaymentCompleted(linkId, payer, recipient, grossAmount, feeAmount)
- PaymentLinkCancelled(linkId, creator)
Fee model (hard-capped)
- feeBps is bounded — the constructor and setFeeConfig revert FeeTooHigh if feeBps > 100, a protocol-enforced 1% maximum (≤100 bps).
- Fee math: feeAmount = (amount * feeBps) / 10_000, deducted at settlement. PaymentCompleted carries both grossAmount and feeAmount.
Invariants enforce unique link ids, creator-only cancellation, no double-pay, expiry handling, valid recipient/amount, the fee cap, and a checked token transfer (NotCreator, LinkAlreadyPaid, FeeTooHigh, TransferFailed, and others). Link identity is keccak256("onelink:" + slug), re-derived and matched server-side so a forged invoice cannot be persisted.
Server-verified settlement model
/api/payments/create is representative of the verify-then-write pattern used by every money-touching route:
- 1Require HAS_CONTRACT + Supabase env, else return 503 (demo mode handles this client-side via localStorage).
- 2Build a viem createPublicClient({ transport: http(ARC_RPC_URL) }).
- 3Fetch the submitted transaction receipt and decodeEventLog it against oneLinkCollectAbi.
- 4Match the decoded PaymentLinkCreated args against the request and the URL-derived link id.
- 5Only then upsert into the Supabase payment_links table with the service-role client.
reconcile and cancel apply the same pattern for the final paid and cancelled states. All routes are rate-limited (e.g. create is 20 requests / 60s) and return generic, non-leaking error messages.
Payment routes
Arc-direct
Live-provenPayer holds USDC on Arc. Two transactions: approve then payLink(linkId). The server verifies PaymentCompleted before marking paid.
Bridge · Circle CCTP + App Kit
Live-provenNative USDC is burned on the source and minted on Arc, then settled in the same flow — surfacing approve → burn → fetchAttestation → mint. Base Sepolia → Arc is live-proven; Ethereum Sepolia, Arbitrum Sepolia, and Polygon Amoy are beta.
Unified balance · Circle Gateway
GatedHand-rolled EIP-712 burn-intents against the Gateway API. Implemented end-to-end but gated behind NEXT_PUBLIC_ENABLE_GATEWAY — disabled in checkout until a funded deposit/burn/mint flow is proven.
Arc integration
USDC is Arc's native gas token, so a payer never needs ETH — the same USDC being sent also covers the fee. Constants live in lib/arc.ts:
- ARC_CHAIN_ID
- 5042002
- ARC_RPC_URL
- https://rpc.testnet.arc.network
- ARC_EXPLORER_URL
- https://testnet.arcscan.app
- ARC_USDC_ADDRESS
- 0x3600000000000000000000000000000000000000
- USDC_DECIMALS
- 6 (ERC-20); native gas is USDC (18 decimals)
Circle integration
- CCTP · App Kit — lib/circle-payments.ts dynamically imports @circle-fin/app-kit + @circle-fin/adapter-viem-v2 and calls kit.bridge(…) with to: { chain: "Arc_Testnet" }, surfacing live step events for the burn-and-mint.
- Gateway — hand-rolled in lib/gateway.ts (no SDK): EIP-712 burn-intents (domain { name: "GatewayWallet" }) against the testnet Gateway API, Arc destination domain 26. Gated until a funded proof is run.
Data & identity
- Persistence — Supabase for cross-device metadata, gated by server-side verification; a localStorage fallback powers demo mode. Migrations enforce the same invariants from the database side (anonymous standard-invoice insertion is rejected; unpaid profile rows are hidden from the dashboard) — 0 Supabase security advisor lints.
- Demo mode — HAS_CONTRACT / IS_DEMO_MODE derive from the contract env. A production-safety throw blocks silent demo-mode deploys unless NEXT_PUBLIC_ALLOW_DEMO=true.
- Profile claims — a permanent freelancer handle is claimed with an EIP-712 typed-data signature (domain { name: "OneLink Collect", chainId: 5042002 }, ~600s TTL). The server verifies the signature, binds owner == recipient, enforces freshness, and is rate-limited, so a captured signature is not trivially replayable.
Security model
- Contract — capped fee, custom-error invariants, checked transfers, creator-only cancellation, no double-pay; 27 passing Foundry tests.
- Server trust boundary — final state requires a verified on-chain event; forged anonymous invoice creation and forged cancellation are rejected (proven in QA).
- API hardening — per-IP rate limiting on payment/gateway/profile routes; generic error responses with no raw RPC leakage; the Gateway route validates the Arc destination domain.
- App headers — nosniff, X-Frame-Options: DENY, a restrictive Permissions-Policy, and HSTS with preload.
- Repository — CodeQL with 0 open alerts, secret scanning + push protection, Dependabot, and required status checks on a protected main.
- Accessibility — maximumScale: 5; pinch-zoom is preserved (WCAG 1.4.4).
Verified scope & limits
Proven on the live deployment:
- Arc-direct payment and browser-wallet end-to-end.
- WalletConnect QR pairing and signed Arc payment.
- Base Sepolia → Arc bridge via Circle CCTP / App Kit.
- Permanent profile handle and payer-initiated profile payment.
- Server-verified creator cancellation and failure-state recovery.
- A 5-viewport visual QA sweep.
Not claimed:
- Mainnet
- Not in scope; Arc Testnet only.
- Solana
- Not implemented.
- Circle Gateway checkout
- Feature-gated; no funded proof yet.
- Other bridge sources
- Base Sepolia proven; others beta.
- Arbitrary-wallet auto-pay
- Not claimed.
On-chain transaction proofs
Every claim above has a hash you can re-check. These are real Arc Testnet transactions — open any of them on Arcscan.