Skip to content

Repository files navigation

Pay on Arc

Subscriptions that collect themselves.

A $5 monthly plan cannot survive on a chain where collecting it costs more than it earns. On Arc a charge costs a rounding error and gas is USDC, so a subscription can pull its own money every period with nobody clicking anything — and a billing agent living in the page can do the pulling.

That is the product. Around it: payments with an on-chain note, pay-links, 20-way splits, recallable payments, Chainlink CCIP cross-chain messaging, and a Pyth-fed treasury agent that rebalances USDC against EURC.

Single static page — index.html — plus a vendored copy of ethers and a Playwright test suite.

The tabs

Tab What it does
Subscriptions Approve USDC once; each period is pulled only when due. Cancel on-chain from either side.
Billing Agent A burner-key worker in the page that scans the contract every 15s and charges what is due, unattended. Testnet only.
Pay & Link A payment with a note written on-chain, or a shareable pay-link that prefills it.
Split One transaction, equal shares to up to 20 wallets, dust returned.
Recallable The recipient can claim; if they never do, the sender takes it back after the window.
Cross-Chain A message through the CCIP router that lands and runs code on the far chain.
Treasury Reads the Pyth EUR/USD feed and pays a keeper to settle drift back into band.
Receipts Your payments, read from the contract's own events.

Running locally

npm install          # test tooling only; the page itself has no build step
npm run serve        # http://127.0.0.1:8080
npm test             # contract tests, then UI tests
npm run test:contracts
npm run test:ui

Contracts compile with the solc pinned in devDependencies rather than a binary fetched at build time, so the same compiler is used on every machine, offline. hardhat.config.js overrides Hardhat's compiler-download subtask to point at it.

The page can also be opened straight from a static host; nothing is compiled.

Switching networks

Every chain constant lives in one place: the NETWORKS map at the top of the inline script in index.html. Nothing else in the file hardcodes a chain ID, an RPC, an explorer or a contract address.

const ACTIVE_NETWORK = 'arc-testnet';   // ← the only line to change

To go live, fill in the arc-mainnet profile and flip that constant:

Key What it needs
chainId, rpc, scan Arc mainnet chain ID, RPC endpoint, block explorer
native Symbol and decimals of the native gas token
contracts.pay Pay on Arc payments contract
contracts.subs Subscriptions contract
contracts.treasury Treasury agent contract
contracts.usdc, contracts.eurc Stablecoin token addresses
contracts.ccipRouter Chainlink CCIP router on Arc
contracts.pyth Pyth contract on Arc
ccip.destSelector, ccip.receiver, ccip.destRpc, ccip.destScan The far side of the CCIP lane

configProblems() runs at boot and refuses to start on a profile with blanks, listing exactly what is missing — a half-configured build cannot quietly send real money to the zero address.

What changes automatically on a non-testnet profile

The billing agent keeps a burner private key in localStorage. That is a reasonable trade for a testnet demo and a bad one for real funds, so the whole tab disables itself when testnet: false. Merchants collect with the "Collect all due payments" button, or by running a keeper from a server they control. Do not re-enable the in-browser agent for mainnet without moving the key somewhere it belongs.

Dependencies

ethers 6.13.2 is loaded from cdnjs with a Subresource Integrity hash and crossorigin="anonymous", falling back to the same-origin copy in vendor/ if the CDN is unreachable or the hash does not match. Deploy vendor/ alongside index.html.

To bump the version: replace vendor/ethers-<v>.umd.min.js, update both the CDN URL and the integrity attribute, and recompute the hash with

echo -n "sha384-$(openssl dgst -sha384 -binary vendor/ethers-<v>.umd.min.js | openssl base64 -A)"

The test suite serves the vendored bytes in place of the CDN, so a stale integrity attribute fails tests/smoke.spec.js rather than production.

Contracts

contracts/ArcPayV2.sol and contracts/ArcSub.sol are the payments and subscription contracts, deployed at the pay and subs addresses in the network profile. contracts/TreasuryAgent.sol is the treasury. All three deployed contracts now have their sources here.

Review notes on ArcPayV2

Read as part of wiring the frontend to it. Not an audit — an audit is still required before any mainnet profile is filled in.

Sound

  • claim() and recall() both set status before the external call, so a re-entering recipient hits the status == 0 guard. No drain path.
  • A recipient contract that rejects the transfer cannot strand a recallable payment: claim() reverts, but the sender can still recall() after the window.
  • No owner, no pause, no upgrade path — nothing to trust.
  • The recall window is bounded to 60s–30 days.

Worth changing

  • splitPay() reverts the whole batch if any recipient rejects the transfer (require(ok) inside the loop). One recipient contract without a payable fallback — deliberate or accidental — blocks the entire split. A pull-payment pattern, or crediting failed shares for later withdrawal, removes the griefing vector.
  • splitPay() ignores a failed dust refund (ok2;). The remainder is then stranded in the contract permanently, and the contract balance no longer equals the sum of pending recallables.
  • sentBy() / receivedBy() return unbounded arrays. Fine today; for a very active address these view calls will eventually outgrow a node's response limits, and a paginated variant would age better.

Review notes on ArcSub

Sound

  • Allowance-pull, never custody: the contract holds no funds at any point, so there is nothing in it to drain.
  • charge() advances nextCharge before calling transferFrom, so a re-entering token cannot double-charge — the block.timestamp >= nextCharge guard is already false. The token address is immutable, so it cannot be swapped for a malicious one.
  • Missed periods do not pile up into a debt: if several intervals elapsed, the next charge is scheduled one interval from now, not from the backlog.
  • Only msg.sender can subscribe themselves, so an open USDC approval to this contract can only ever be pulled by subscriptions the approver created. Unlimited approval is bounded in practice by that.
  • The amount is fixed at creation with no setter, so a merchant cannot raise the price on an existing subscriber. Either party can cancel.

Worth changing

  • chargeMany() deliberately swallows every failure (try this.charge(ids[i]) {} catch {}). That is the right behaviour — one subscriber with a revoked approval must not block the batch — but it means a mined transaction proves nothing about whether anyone was charged, and nothing on-chain reports which ones were skipped. An event per skipped id, or a returned count, would let a caller tell success from silence.
  • transferFrom is called through a plain IERC20 and its bool is checked. Circle's USDC returns one, so this is correct here; a SafeERC20-style wrapper would survive a token that returns nothing.
  • listBySubscriber() / listByMerchant() return unbounded arrays, same ageing problem as ArcPayV2's indexes.
  • label is arbitrary caller-controlled text, and the contract cannot sanitise it. Any frontend must escape it — this is exactly the stored-XSS path that was fixed in the agent terminal.

Review notes on TreasuryAgent

This is the only contract of the three that actually holds funds, so it carries the most risk.

Sound

  • The confidence guard is real risk management, not decoration: a Pyth confidence interval wider than maxConfBps defers the rebalance instead of trading through a stressed market. getPriceNoOlderThan bounds staleness, and the exponent is range-checked before being used as a divisor.
  • The keeper is paid from the treasury in the same transaction, so there is no IOU and no settlement risk.
  • Only usdc and eurc can be deposited; all three token addresses and the feed id are immutable.

Worth changing

  • deposit() is open to anyone; withdraw() is onlyOwner. There is no path that returns a third party's deposit. Anyone but the owner who funds this treasury has made an irreversible transfer. This is the most serious finding, and the UI now says so in plain words next to the Fund buttons and asks for confirmation before a non-owner deposits.
  • The keeper has no minOut / maxIn bound. rebalance() decides the trade size from drift at the price of the update the keeper just submitted; the keeper cannot cap what gets pulled from their wallet, and a price move between simulation and execution changes the amounts. A maxIn/minOut parameter would close this. The frontend mitigates it by approving a bounded amount rather than MaxUint256, but that is a workaround, not a fix.
  • The excess-fee refund happens before the token transfers. rebalance() calls msg.sender to refund, then reads the price and executes. A keeper contract can re-enter there. No profitable path is obvious — the inner call rebalances and the outer then finds drift inside the band — but refunding last, or a nonReentrant guard, removes the question entirely.
  • _sellUsdc / _sellEurc clamp the payout to the treasury balance without reducing what the keeper supplies, so in the clamped case the keeper is silently underpaid. Reachable only at extreme parameters, but it should revert rather than shortchange.
  • The owner can withdraw everything at any time, and can move targetUsdcBps, maxPriceAge and the bonus with no timelock. Worth stating wherever the agent is described as autonomous.
  • receive() accepts native currency but nothing can send it back out — any plain transfer to this contract is stuck permanently.

Used by the frontend

sentBy() and receivedBy() are the authoritative list of a user's recallable payments, so the Recallable tab reads them directly rather than scanning logs. Notes live only in RecallableCreated, so they are fetched best-effort and a missing log costs a note rather than the whole row.

Tests

tests/ drives the real page in headless Chromium against a stubbed Arc RPC and a stubbed EIP-1193 wallet — no chain, no funds, no network.

File Covers
smoke.spec.js Boot, SRI, CDN fallback, tabs, config-driven markup, label/input association
validation.spec.js Amount parsing (incl. comma decimals) and every form's rejection paths
paylink.spec.js Pay-link generation and consumption
security.spec.js Escaping of chain- and URL-sourced strings, script pinning
chain-guard.spec.js Wrong-network refusal, chain add, disconnect cleanup
config.spec.js Unconfigured-network refusal
navigation.spec.js Landing pitch, tab folding, deep links, keyboard tablist, live pricing

Contract tests

test/ runs the contracts on a local EVM — 60 tests. They exist to prove what the review claims, in both directions: the guards that hold, and the findings that are real.

File Notable cases
ArcPayV2.t.js A recipient contract re-entering claim() takes exactly what it is owed and cannot touch another payment. A recipient that rejects ETH cannot strand funds — the sender still recalls. splitPay() reverts the whole batch when one recipient refuses, which is the griefing vector. Dust returns to the sender; the window bounds hold.
ArcSub.t.js A token that re-enters charge() cannot double-charge. Ten missed periods charge once, not ten. chargeMany() charges what it can and silently skips the rest — the test asserts the schedules are the only way to tell who paid, which is what the UI now reports. Only msg.sender can subscribe themselves, which is what bounds an unlimited approval.
TreasuryAgent.t.js The confidence guard defers instead of trading, and nothing moves. Rebalancing pays the keeper a bonus and lands the mix inside the band. Anyone can deposit but only the owner can withdraw, asserted with a stranger's money. rebalance() has no maxIn, asserted by showing the same call pulls ten times as much from a treasury ten times larger. Native currency sent in cannot come out.

Findings are marked FINDING: in the test files. They assert current behaviour, so if a contract is ever fixed and redeployed, the matching test fails and points at what changed.

If Playwright cannot download its own browser, point it at an existing one:

PLAYWRIGHT_CHROMIUM_EXECUTABLE=/path/to/chrome npm test

Screenshots

tests/shot.js renders the page against the same stubs and writes PNGs:

node tests/static-server.js &
node tests/shot.js ./shots

About

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages