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.
| 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. |
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:uiContracts 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.
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 changeTo 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.
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.
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/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.
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()andrecall()both setstatusbefore the external call, so a re-entering recipient hits thestatus == 0guard. No drain path.- A recipient contract that rejects the transfer cannot strand a recallable
payment:
claim()reverts, but the sender can stillrecall()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.
Sound
- Allowance-pull, never custody: the contract holds no funds at any point, so there is nothing in it to drain.
charge()advancesnextChargebefore callingtransferFrom, so a re-entering token cannot double-charge — theblock.timestamp >= nextChargeguard is already false. The token address isimmutable, 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.sendercan 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.transferFromis called through a plainIERC20and itsboolis checked. Circle's USDC returns one, so this is correct here; aSafeERC20-style wrapper would survive a token that returns nothing.listBySubscriber()/listByMerchant()return unbounded arrays, same ageing problem as ArcPayV2's indexes.labelis 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.
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
maxConfBpsdefers the rebalance instead of trading through a stressed market.getPriceNoOlderThanbounds 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
usdcandeurccan be deposited; all three token addresses and the feed id areimmutable.
Worth changing
deposit()is open to anyone;withdraw()isonlyOwner. 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/maxInbound.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. AmaxIn/minOutparameter would close this. The frontend mitigates it by approving a bounded amount rather thanMaxUint256, but that is a workaround, not a fix. - The excess-fee refund happens before the token transfers.
rebalance()callsmsg.senderto 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 anonReentrantguard, removes the question entirely. _sellUsdc/_sellEurcclamp 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,maxPriceAgeand 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/ 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 |
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 testtests/shot.js renders the page against the same stubs and writes PNGs:
node tests/static-server.js &
node tests/shot.js ./shots