Skip to content
0xFlydevPublic

About

Protected, Reversible & Private USDC Payments on Arc ( Web + SDK & Android App )

Topics

Resources

Contributing

Security policy

Stars

7 stars

Watchers

0 watching

Forks

Repository files navigation

Ctrl+ArcZ

Screened before it is signed. Recallable until it is claimed. Repeatable without handing over your wallet.

Watch the demo Live app npm Android app Watch the Android demo Docs Arc Testnet Tests Custody

USDC payments on Arc with the three things a plain transfer does not have: a firewall that refuses a bad recipient before anything is signed, a lock the sender can undo until the recipient proves the money was meant for them, and a bounded spend box that lets a merchant or an agent charge you again without ever touching your wallet. One SDK, one contract, no custody.

Three surfaces on one backend: ctrlarcz.xyz is the web app, @ctrl-arcz/sdk is the engine behind it and is published on npm, and the Android app is a native Kotlin client on Google Play. The Android app is fed by the same apps/api endpoints and the same deployed contracts the SDK drives, and packages/sdk/parity-vectors.json holds both implementations to one specification.

npm install @ctrl-arcz/sdk viem

Turkish version

Contents

In one look

Network Arc Testnet, chain id 5042002
Asset USDC, which on Arc is both the gas token and the thing you are sending. Private payments can also be sent in EURC or cirBTC; the gas is still USDC
Protection Pre-send risk firewall, code-gated claim, sender cancel, automatic expiry refund
Custody None. Funds are with the user or in the contract. No owner, no pause, no upgrade path
Product An SDK any wallet, exchange or payments app embeds. Not another wallet
Tests 850 in total: 124 Foundry, 439 SDK, 186 demo-kit, 57 api, 33 keeper, 11 sender, plus live testnet runs

Two clients: web and Android

The same contract and the same API are driven by two full clients. The web app is the reference integration of the SDK. The Android app is a first-class product, not a wrapper: a native Kotlin/Compose application with its own risk engine, its own stealth cryptography and its own CCTP and Gateway clients, calling the same apps/api and the same deployed contracts.

Get it on Google Play

Firewall verdict and the real cost Subscriptions by merchant A bridge that gives the money back
Send confirmation on Android Merchant picker on Android Returned bridge transfer on Android

Three things the app does that a browser cannot:

Notifications with no server anywhere. The app reads the contract's events straight from Arc RPC: TransferCreated filtered on the indexed recipient topic, every 15 seconds while it is open and on a background job when it is not. There is no push service, nothing to register with, and no server that learns who is watching which address. The cursor lives on the device and does not advance for an event it could not deliver, so nothing is silently lost.

A screen that refuses to be photographed. The one screen that shows a claim code sets FLAG_SECURE, so it is blank in a screenshot and blank in the recents list. Since the clipboard is not the only honest way to keep a code, the same screen offers Copy, Save and Share, and says in plain words what saving costs: the QR lands in your photo library, where anything that can read your photos can read it.

Parity that is tested, not asserted. Gas reserve maths, claim code encoding, stealth derivation and the risk rules are two implementations of one specification. packages/sdk/parity-vectors.json, generated by packages/sdk/scripts/gen-parity-vectors.ts, is asserted by both the TypeScript and the Kotlin suites, so a port that drifts fails a test rather than a payment.

The Android source is not public, which is exactly why the vectors are not a convenience here. docs/android/ParityVectorsTest.kt is a byte-identical copy of the Kotlin test that runs in that project: no application code, no keys, no endpoints, only the assertions that hold the two implementations to one specification. The part you cannot read is pinned to the part you can. What it covers, and how to check it.

The problem

A stablecoin transfer is final, blind and one-shot, and every product built on one inherits all three.

Final. Sign it and it is gone. No undo, no dispute, no cancelling while it is pending, because there is no pending. The most common way to lose money on chain is not a hack; it is a correct signature over a wrong address.

Blind. Nothing between the user and the chain reads the recipient before they commit. Address poisoning is the sharpest version of this, and it works because of a detail every wallet shares: addresses are shown abbreviated, as 0x64Ea…Fe3F. The attacker grinds an address whose first and last characters match one you already pay, sends you a zero-value transfer from it so it lands in your history, and waits for you to copy it back out. The defining property is that the victim sends to the wrong address on purpose: no unexpected signature, no malicious contract, nothing downstream behaving abnormally. It is why the ritual of sending one dollar first proves nothing: the test payment confirms perfectly against the poisoned address. And it is why an escrow alone does not help: locking the funds for the wrong recipient just locks them for the attacker. Something has to refuse the send.

One-shot. A transfer pays one address, one time. Anything that repeats, a subscription, an allowance, an agent that pays its own bills, has to be built on top, and the two ways it is normally built are an unlimited token approval or a shared key. Both are a blank cheque, and both write this wallet pays that merchant, on this schedule onto a public ledger where it stays.

Ctrl+ArcZ answers the three in one place. A firewall refuses a bad recipient before anything is signed. The transfer that follows is locked behind a code the sender hands over out of band, recallable at any time until it is claimed and refunded on its own if it never is. And what repeats runs from a spend box whose policy is on chain (this merchant, this much, this often, until this date), owned by a one-time address, so what the chain records is a box, not a person.

The rest of the product is the same three properties applied elsewhere: private payments through a disposable account, subscriptions and agent wallets on the same box, and CCTP and Gateway to bring the USDC in, every one of them screened by the same firewall before its button will arm.

How it compares

On the first two properties there is a crowded field, and it splits cleanly: the tools that see the problem coming cannot stop it, and the tools that can recover the money need somebody to arbitrate and hold it first.

Stops the send Funds recoverable after the fact Needs an arbiter Takes custody Works for plain P2P
Wallet address-book warning No No No No Yes
Poisoning detection service Warns only No No No Yes
Commerce escrow No Yes, by dispute Yes Yes No
Circle Refund Protocol No Yes, by mediator Yes Yes No
Ctrl+ArcZ Yes* Yes, by the sender No No Yes

* Blocks by default, and stops the send in the SDK as well as the UI. A user who insists can get past it, but only by looking at the two addresses side by side first, and the money is still recoverable if they were wrong. See the escape hatch.

Circle's Refund Protocol solves a different problem on purpose. It is a commerce escrow built around an arbiter who sets the lockup window and authorizes refunds for buyer and seller disputes. Ctrl+ArcZ is P2P wrong-address safety: the sender holds the cancel right, the expiry refund is automatic, and no third party can move the money. Adding an arbiter would break the one property that makes a protected-transfer contract worth trusting.

Most contracts that lock stablecoins are built for commerce: invoice links, freelance delivery, marketplace settlement. All of them assume the parties know each other and are arguing about delivery. Wrong-address safety assumes the opposite, and needs a different shape.

System architecture

flowchart LR
    I["Integrator<br/>wallet, exchange, payments app"]

    subgraph SDK["@ctrl-arcz/sdk"]
        RISK["risk/<br/>Layer 1 firewall<br/>pure rule engine"]
        TR["transfer/<br/>send, claim, cancel, reclaim"]
        HIST["history/<br/>Layer 3 clean history"]
        SHIELD["shield/<br/>Layer 4 spend boxes<br/>stealth, co-signer"]
        BRIDGE["bridge/<br/>CCTP and Gateway<br/>signed by the user"]
    end

    SCOUT["ArcScan<br/>Blockscout REST API"]
    MEMO["Memo predeploy<br/>EOA wrapper"]
    CIRCLE["Circle<br/>attestation and mint"]

    subgraph C["CtrlArcZ.sol"]
        SM["sendProtected, claim,<br/>cancel, reclaimExpired<br/>isVerifiedRecipient"]
    end

    BOX["SpendPolicyAccount<br/>target, caps, interval, expiry"]
    USDC["USDC ERC-20<br/>0x3600…0000, 6 decimals"]

    I -->|sendProtected| TR
    TR ==>|firewall, before any funds move| RISK
    I -.->|check, optional, for a pre-send UI| RISK
    I -->|getCleanHistory| HIST
    I -->|subscriptions, private pay| SHIELD
    I -->|bring USDC to Arc| BRIDGE
    RISK -.->|reads| SCOUT
    TR -->|viem| MEMO
    MEMO --> C
    C --> USDC
    SHIELD ==>|every spend, firewall then co-sign| RISK
    SHIELD --> BOX
    BOX --> USDC
    BRIDGE --> CIRCLE
    CIRCLE -->|mints into| BOX
    CIRCLE --> USDC
    SM -.->|RecipientVerified| RISK
Loading

One deployment, many tenants. An integrator calls createConfig once and gets a configId that encodes its own behaviour: recall window, claim mode, optional fee, minimum amount worth protecting. An exchange withdrawal screen and a P2P wallet can want very different things and still share this contract and this SDK.

The three layers

Layer 1: the firewall, before anything is signed

check(sender, target) returns a graded verdict. It is a pure rule engine, so the same input always produces the same verdict, with no network call inside the decision itself.

Rule Verdict Why
LOOKALIKE_ADDRESS block The target shares the first and last four hex characters with an address this sender has actually paid
ZERO_VALUE_BAIT block The target sent this sender a zero-value transfer. Sending someone zero tokens has no other purpose
FRESH_ADDRESS warning First seen less than 24 hours ago. Poisoning addresses are minted for the attack
NEW_ADDRESS warning No on-chain history at all
VERIFIED_RECIPIENT safe A protected transfer to this address settled before, claimed with a code
KNOWN_COUNTERPARTY safe This exact address has been paid before

Two properties matter more than the rule list.

A positive signal never overrides a block. An address you paid last week does not make its lookalike safe. That is the whole attack.

The firewall fails closed. If the sender's payment history cannot be fetched, the lookalike rule could not run, so a lookalike cannot be ruled out. An unverified target is blocked rather than downgraded to a warning the user clicks through. A firewall that waves traffic through when its data source is down is worse than no firewall, and the report is never silently marked safe.

You do not have to remember to call it. sendProtected runs the scan itself and throws RiskBlockedError before any funds move, so installing the SDK is what makes a send protected. A separate call an integrator can forget is not a defense.

The firewall blocking a lookalike address A graded caution verdict
A lookalike of an address this wallet has paid. The send button will not arm. Verdicts are graded, and an incomplete scan is stated out loud rather than rounded down to safe.

A refusal is not a dead end. A rule engine can be wrong about a real payment: the lookalike rule fires on eight matching hex characters, which two unrelated addresses can share by accident, and the zero-value rule fires on a transfer anyone can send you. A block you cannot get past means the app sometimes simply cannot pay a colleague, so there is a way through, and its shape is the whole point. Not a button beside the refusal, which the one person it must stop would press without reading: the escape hatch is the comparison. Both addresses are shown in full, one above the other, with the four characters at each end dimmed and the differing middle left bright, because those four characters at each end are exactly what every wallet abbreviates to and exactly what the attacker matched. A victim sees it. A false positive glances and moves on.

Only then is there a checkbox, and only then a button. And the decision has to survive the SDK, which runs its own guard and re-scans: it takes the verdict the user actually looked at, not a flag, so it cannot be reused for another recipient, cannot outlive the session, and cannot carry through a verdict that got worse or gained a reason while the user was deciding. The UI cannot grant permission the SDK has not agreed to.

The cost of being wrong is bounded here in a way it is not in a browser's "proceed anyway": the money goes into the contract behind a claim code, the sender can cancel it at any time, and it comes back on its own when the window lapses. Verified end to end on Arc testnet: a lookalike blocked, overridden through the comparison, sent, and cancelled back to the sender. On the bridge, where there is no recall, the same panel says so instead.

Every path that moves money runs it. Sending, bridging to someone else, paying privately and authorising a subscription all take an address a person typed, so all four run the same check against one policy, from one module, and none of them arms its button until the answer is in. That last clause is stricter than it sounds: a verdict that is still forming is not a verdict, and a screen that arms while the scan is running can dispatch a payment that the answer would have stopped. The same rule decides for all of them, so a new way to send cannot quietly ship with a weaker door.

The firewall refusing a merchant address on the subscription form

A subscription is not one payment to an address, it is standing permission to pull from a funded box on a schedule, so the address matters more here than anywhere. The verdict lands under the field while you are still typing, and the create button never arms.

Layer 2: the protected transfer

The money is locked in the contract and released only against a proof the recipient holds.

stateDiagram-v2
    [*] --> PENDING: sendProtected
    PENDING --> CLAIMED: claim with the right code
    PENDING --> LOCKED: five wrong guesses
    PENDING --> CANCELLED: cancel, sender only
    LOCKED --> CANCELLED: cancel, sender only
    PENDING --> RECLAIMED: reclaimExpired, anyone
    LOCKED --> RECLAIMED: reclaimExpired, anyone
    CLAIMED --> [*]
    CANCELLED --> [*]
    RECLAIMED --> [*]
Loading

The proof is a single 80-bit code, sixteen characters the sender hands to the recipient: A4K7-9QMX-2PR6-TH8D. The chain only ever sees its hash.

Two decisions are behind that shape. It has to survive an offline brute force, because in a poisoning attack the recipient recorded on-chain is the attacker: they hold the hash and can grind it for as long as they like. A six-digit code is twenty bits, a million guesses, milliseconds of work. And it has to travel as one piece. Splitting the proof and delivering half of it by address, in a link the app fetches, in an on-chain ciphertext, through a backend, hands that half to the attacker too, because the address is theirs. The code reaches a person through a channel the attacker is not in, and that is the whole of the second factor.

The alphabet is Crockford base32, so there is no I, L, O or U to confuse with 1 and 0, and what the recipient types is normalised before it is checked.

Two design decisions in the contract are worth knowing:

A wrong code does not revert, it returns false. An attempt limiter cannot be built on a reverting call, because the revert would roll back the very counter that records the failed guess, leaving the guesses unbounded and the limiter decorative. The failed attempt has to commit. claim returns a boolean and writes the attempt to storage; five wrong guesses freeze the transfer, and the SDK reads the receipt and throws WrongClaimCodeError rather than treating a mined transaction as a successful claim.

Anyone may submit a claim, and the funds always go to the recipient recorded at send time. That makes a claim front-run-safe (replaying a revealed proof merely settles the transfer for its intended recipient) and it is what makes the gasless path possible.

Layer 3: a history worth trusting

Poisoning only works because the fake address is sitting in the victim's history, one tap from being copied. getCleanHistory removes that surface with two rules: drop zero-value transfers, and show only known tokens (a campaign usually mints a lookalike token so its row reads like a real USDC line). Nothing is deleted; the filtered rows are returned separately, so a UI can still offer "show spam" and the SDK stays honest about what it hid.

The layer then feeds back into layer 1. Every settled claim emits RecipientVerified, and those addresses are folded into the set the lookalike rule compares against. Pay someone once through a protected transfer, and from then on the firewall blocks their twin.

Flows by case

Case A: a protected send that settles

sequenceDiagram
    participant S as Sender
    participant SDK as SDK
    participant C as CtrlArcZ
    participant R as Recipient

    S->>SDK: check(target)
    SDK-->>S: safe
    SDK->>SDK: generateClaimCode() -> secret, hash
    S->>C: sendProtected(configId, to, amount, hash)
    C->>C: USDC pulled in, transfer PENDING
    S-->>R: the claim code, handed over directly
    R->>C: claim(id, secret)
    C->>C: verifier checks the commitment
    C->>R: USDC released
    C-->>SDK: RecipientVerified
Loading
The send form Sent and locked, with the claim code The claim screen
Paste the recipient. The firewall runs as you type, on a debounce. The funds are locked. One code comes out, and it is shown once. The recipient claims, paying their own gas or having it sponsored.
Received The received history, searchable and filterable
The recipient never had to hold gas: a zero-balance wallet claimed this without sending a transaction. Everything ever sent to this wallet, read from chain rather than from this browser. Search, a date range, days grouped as days, and every address and transaction copyable.

The recipient does not have to be watching. An arriving transfer announces itself within a few seconds wherever the user is standing, and the badge on the Receive side counts what is actually claimable: a transfer whose window has closed stops being counted, because pressing claim on it would only spend gas to revert.

Settlement is immediate, because Arc has sub-second deterministic finality. There is no pending limbo for the recipient to sit in.

Case B: the firewall refuses

The Send form does the whole attack in one click. It reads who this wallet has actually paid, crafts a real lookalike of one of them (same first and last four hex characters, random middle), and drops it into the recipient field, so the firewall you are about to trust is the one that judges it. Nothing is sent; the verdict appears where every verdict appears.

A crafted lookalike, blocked by the firewall

Both addresses render as 0x64Ea…Fe3F in any wallet. The firewall blocks the second one, and the send never happens.

Case C: the sender changes their mind

cancel is available to the sender at any point before a claim lands, inside or outside the window, and even on a transfer that has been frozen by wrong guesses. Unclaimed money belongs to the sender, so there is no deadline on getting it back.

Active transfers with the claim code and a cancel button

Case D: the recipient never claims

When the recall window lapses, reclaimExpired returns the funds to the sender. It is callable by anyone, and the money can only ever go back to the sender. That is what makes the refund automatic: a recipient who disappears cannot strand the funds, and the sender does not have to be online at the right moment.

The recipient gets that button too. A payment you never wanted, arriving from someone you do not know, is the one case where the receiving side used to have nothing to do but wait, and the contract already allowed the fix: reclaimExpired pays the sender and nobody else, so handing anyone the button costs nothing. The row says the window has lapsed and offers to send it back. The keeper (apps/keeper) presses the same button on a schedule for transfers nobody is looking at.

Case E: the recipient holds no USDC at all

On Arc, gas is USDC, so a brand new recipient with an empty wallet cannot normally pay to claim. Because claim is permissionless and always pays the recorded recipient, a relayer can submit it and cover the gas. The recipient receives the full amount without ever sending a transaction. This is verified on-chain: a fresh, zero-balance, nonce-zero address received the whole transfer and its nonce stayed at zero.

The recipient just presses Claim without gas. The claim is signed server-side so no relayer or Circle key reaches the browser, and with Circle Gas Station configured the gas is sponsored rather than paid by anyone in this project: measured on Arc testnet, the claim ran through EntryPoint v0.7 from a Circle Smart Account holding no USDC, the paymaster paid 0.0062 USDC of gas, and the relayer's own balance moved by zero. Without Gas Station credentials the same route falls back to a relayer signing and paying out of its own balance, so the recipient's experience is identical either way.

Moving USDC in: CCTP or Gateway

A protected transfer needs USDC on Arc. Both of Circle's cross-chain routes are wired in, and the choice is a single tab.

The bridge tab with CCTP selected The explainer comparing CCTP and Gateway
Pick the route, the source and destination chain, and the amount. The app says plainly which route fits which habit.
CCTP Gateway
Model Burn on the source, mint on the destination Deposit once into a unified balance, then spend from it
First transfer About a minute The deposit, then the spend
Repeat transfers About a minute, every time Seconds, no deposit
Best for A one-off move Sending often
Chains on testnet 20 11
Sources per transfer One chain Up to 16, under one signature
Destination gas None needed, Circle forwards the mint None needed, Circle forwards the mint
flowchart LR
    subgraph CCTP["CCTP, one shot"]
        A1[approve] --> A2[burn on source] --> A3[Circle attestation] --> A4[mint on Arc]
    end
    subgraph GW["Gateway, deposit once"]
        B1[deposit into unified balance] --> B2[sign spend] --> B3[attestation] --> B4[mint on Arc]
        B2 -. repeat transfers skip the deposit .-> B4
    end
Loading

The Gateway deposit is the whole cost of using it, and it is wildly uneven: a deposit on Arc counts in about a second, one from Base takes up to nineteen minutes by Circle's own confirmation counts. The app says which it is before you commit, and offers the cheaper source when your balance is already on one. After that, spending is the same few seconds from any chain, including one your wallet has never had a transaction on.

Gateway supports fewer chains than CCTP, so the pickers narrow themselves when you switch to it rather than offering a route that cannot run. Every picker in the app works that way: it is told what job it is for and offers only the networks that can do it, so a chain that would lead to a dead end is not on the list at all.

One transfer, several networks

A Gateway balance is one figure spread over several chains, but a transfer spends it per chain: every burn intent names one source and draws only on what was deposited there. A wallet holding enough in total could still be unable to pay. Circle accepts up to 16 intents in one request and EVM signs them together, so a transfer can draw on several networks at once and the user still signs once.

The division is the app's job, not the user's. Type what you are sending and the networks below say which carries what; add one and the payment re-divides around it, or type into a row to fix that chain and let the rest settle around your number. Which network to add is not a guess either. Each candidate is priced by running the split it would produce, because the useful order is not the cheapest per leg: a cheap chain holding too little forces a third leg and ends up dearer than one that costs more and finishes the job in two.

Every figure here is USDC, fees included. Circle takes its fee out of the amount being sent rather than out of the chain's own gas token, which is why a Gateway spend needs no gas on any chain and the deposit is the only step that does.

A Gateway transfer drawing on two networks, with the fee broken down per network The Gateway network picker, showing the balance and the per-leg fee on each chain
Four USDC carried by two networks under one signature, with the fee opened into what each one costs. Every network Gateway serves, with what is on it and what a leg there costs. A chain that cannot cover its own fee is shown and refused rather than hidden.

Both routes are signed by the user's own wallet. Nothing in this project ever holds a key that could move somebody's USDC: the burn, the Gateway deposit and the Gateway spend are all transactions or EIP-712 signatures the wallet produces, and Circle's own attestation service does the rest. There is no operator balance to fund and nothing to trust with custody, which is the only version of a bridge worth shipping inside a product about not losing money to the wrong address.

That costs a little more work than calling Circle's Node-first kits from a server, and the SDK carries it: packages/sdk/src/bridge speaks to the CCTP and Gateway contracts and REST APIs directly, queues transactions per signer so two flows on one wallet cannot race a nonce, checks that the source chain can actually pay for its own burn, and can pick a stalled transfer back up after a reload from its burn hash on CCTP or its transfer id on Gateway.

A transfer that does not arrive is not money lost, and the row says so. A Gateway spend does not burn on the source chain when the intent is accepted: Circle debits its own ledger and settles later, so a mint that fails means the burn never ran and what left the balance was a hold. Circle lets it go, measured twice at under ten minutes with the fee included. Calling that "failed" would tell someone their money is gone while it is on its way back, so the row reads returning and then returned. Circle's status stays failed for good and can never report the release, so the app watches the balance instead, against the figure it wrote down before the spend.

Why Arc

The lock-then-claim mechanic needs two transactions. That is exactly what has kept it off other chains, and it is what Arc removes.

  • Gas is USDC, cheap and predictable. The second transaction is economical, and there is no separate gas token to acquire before you can move your money.
  • Sub-second deterministic finality. The transfer settles the moment the code is entered. The recipient never watches a spinner.
  • The primitives are already there. Permit2 removes the per-send approve. CCTP and Gateway bring the USDC in. Circle publishes a refund primitive of its own. The pieces exist; what is missing is a product that puts refusal, locking, claiming and refunding into one flow.

Smart contracts

Contract Address Role
CtrlArcZ 0x8dAb7148cdc31DAcad6d7e12161AA3DEDb572Dca Config registry, protected transfers, verified recipients
CodeClaimVerifier 0x2C0f268DE2Aa8BB2ab27F2Ea5Ae8a0f9a0E068c4 Checks keccak256(salt, code) for ClaimMode.CODE
USDC (Arc predeploy) 0x3600000000000000000000000000000000000000 The asset, and the gas

Deploy block 51326557. Everything here is Arc Testnet. Mainnet follows the audit.

Function Caller Purpose
createConfig(window, mode, feeBps, feeTo) Integrator Register a behaviour, get a deterministic configId
sendProtected(configId, to, amount, hash) Sender Lock USDC against a claim commitment
sendProtectedWithPermit(..., signature) Sender The same, pulled through Permit2, so no separate approve tx
claim(id, code, salt) Anyone Release to the recorded recipient. Returns false on a wrong code
cancel(id) Sender only Take the money back, any time before a claim lands
reclaimExpired(id) Anyone Refund an expired transfer. Only ever to the sender
isVerifiedRecipient(sender, recipient) Anyone Layer 3, read by the firewall

The contract is ownerless: no owner, no pause, no proxy, no upgrade path, no admin function that can touch a locked transfer. A protected-transfer contract that an admin can drain protects nobody. There are 63 Foundry tests on this contract alone and 124 across the contract suite, including fuzz tests for value conservation, the fee split, cancel, and the property that a valid proof only ever pays the recorded recipient. Coverage across every contract is 100 percent of lines, statements, branches and functions; pnpm --filter @ctrl-arcz/contracts coverage prints the table.

Security

The full audit lives in SECURITY.md. The short version:

  • No key is hardcoded anywhere. Every signing key is read from the environment, and both Vite configs refuse a production build that would inline one, unless the operator explicitly acknowledges it.
  • Nothing signs for the user but the user, and every server-signed path is fenced in by something already on chain. The protected transfer, the CCTP burn and both halves of a Gateway move are the wallet's own signatures. The gasless claim settles only to the recipient recorded at send time, and the browser posts the transfer id, code and salt while never holding a relayer or Circle key. The co-signer can authorise a pull only inside the policy its box was deployed with and only to the target locked into it, which the box enforces itself. The relayer deploys boxes, announces them and tops a stealth address up with gas out of its own balance, and touches no user's USDC.
  • The firewall fails closed rather than degrading to "looks fine" when a data source is down.
  • Every path that moves money runs the firewall, from one module, and none of them arms its button before the verdict is in. Four screens deciding this for themselves is how one of them ends up with a weaker door than the others.
  • Claim receipts are bound to the contract address and the exact transfer id, so an unrelated or planted event in a batched receipt cannot decide a victim transfer's outcome.

Tech stack

Layer Choice
Contract Solidity 0.8.24, Foundry, OpenZeppelin (SafeERC20, ReentrancyGuard)
SDK TypeScript, viem, tsup (ESM, CJS and types), vitest
Risk data ArcScan (Blockscout REST), behind an IDataProvider seam
Cross-chain Circle CCTP and Circle Gateway, both signed by the user's own wallet
Gasless Permissionless claim, sponsored by Circle Gas Station, relayer fallback
Approvals Permit2, for single-signature sends
Apps React, Vite, a shared design system in @ctrl-arcz/demo-kit

Every list of past things in the app is one component. Sent transfers, the plain history, received transfers, bridges and subscriptions had each grown their own search box, their own pager and their own idea of what a row looks like, and they drifted the way copies do: only some values were copyable, only some rows carried the transaction, and none of them could be narrowed by date. HistoryList and HistoryRow in @ctrl-arcz/demo-kit/ui are the one vocabulary all five now speak, so adding a date filter was one change in one place rather than five. The rule that shaped the row: anything you would paste somewhere else carries its own copy button, and anything you would only read does not. An address and a transaction hash are the first; a two-character row number is the second.

Repository layout

Path What
packages/contracts CtrlArcZ.sol, CodeClaimVerifier, IClaimVerifier, Foundry tests
packages/sdk @ctrl-arcz/sdk, the thing an integrator actually installs
packages/demo-kit Shared wallet session, the design system, and the server-side helpers
apps/sender The web app, port 5173. Sending and receiving are two modes of it
apps/api The backend: co-signer, relayer, gasless claim, discovery, investigator
apps/keeper The keeper agent: returns expired transfers, paid from a bounded box
examples A standalone Node quickstart, no framework
docs The Mintlify sources behind docs.ctrlarcz.xyz

docs.ctrlarcz.xyz is a static site, not a service. pnpm docs:export runs the Mintlify CLI over docs/ and writes docs-export.zip; unpacking that zip into the web root is the whole deployment. The export is roughly 57 MB, nearly all of it the theme's own JavaScript, and it is rewritten wholesale on every CLI release, so it is built rather than tracked. The one thing an export cannot carry is search: it ships no index, and the search box reads "Login into CLI to enable search" whether or not the CLI is logged in. Search is a hosted service, so a self-hosted copy does without it.

The Android client is a separate native Kotlin/Compose app whose source is not public. It is held to the TypeScript implementation by packages/sdk/parity-vectors.json, and the Kotlin test that does the holding is published here: docs/android/ParityVectorsTest.kt. See Two clients: web and Android.

Every address, RPC and chain constant lives in exactly one file, packages/sdk/src/chains/arcTestnet.ts. The Foundry deploy script reads a JSON file generated from it, so no address is written down twice.

Getting started

git clone --recurse-submodules https://github.com/Farukest/Ctrl-ArcZ.git
cd Ctrl-ArcZ
pnpm install

cp .env.example .env      # fill in throwaway testnet wallets

USDC is both gas and the asset on Arc, so fund the wallets with Arc Testnet USDC from faucet.circle.com. Foundry is required for the contract: https://getfoundry.sh

Command What it does
pnpm build Build every package
pnpm test Foundry plus vitest
pnpm contracts:test Contract tests only
pnpm lint ESLint across the workspace
pnpm typecheck tsc --noEmit in every package
pnpm deploy:testnet Deploy CtrlArcZ to Arc Testnet
pnpm dev:api The backend on http://localhost:8787
pnpm dev:sender The web app on http://localhost:5173

Run both: the web app calls the backend for the gasless claim, the co-signer, the stealth relay and the investigator, and without it those answer 404 from a page that otherwise looks perfectly healthy. The backend refuses any browser origin not listed in its CORS_ORIGINS, so a local run needs http://localhost:5173 in there. See apps/api/.env.example.

Using the SDK is three calls, and the firewall is one of them whether you ask for it or not:

import {
  defineConfig,
  registerConfig,
  generateClaimCode,
  approveUsdc,
  sendProtected,
  RiskBlockedError,
} from '@ctrl-arcz/sdk';

const config = defineConfig({ recallWindow: 3600 });
const { configId } = await registerConfig(clients, config);
const secret = generateClaimCode(); // secret, code, salt, claimHash

await approveUsdc(clients, amount);

try {
  // Layer 1 runs inside this call. A lookalike or a zero-value baiter throws
  // before a single unit of USDC moves. There is no separate call to forget.
  const { transferId } = await sendProtected(
    clients,
    { configId, to: recipient, amount, claimHash: secret.claimHash },
    { config },
  );
} catch (e) {
  if (e instanceof RiskBlockedError) showRiskCard(e.report);
  else throw e;
}

The recipient claims with claim(clients, transferId, code, salt), where both come from fromSecret(typed). The sender can cancel(clients, transferId) at any time before that. Full signatures, and how to reuse a report your own UI already fetched, are in packages/sdk/README.md.

The demos run without MetaMask if you drop a .env.local into each app; the wallet is then a local test signer that still broadcasts real transactions to Arc Testnet. See .env.example.

Subscriptions and agent wallets

Sending once is easy. The hard part is letting something spend repeatedly without handing it your wallet. A subscription, an allowance, an AI agent that pays its own bills: each needs a budget that renews, not a blank cheque.

Ctrl+ArcZ solves this with a disposable spend box. You do not pay the merchant directly. You create a tiny on-chain account, fund it with a budget, and lock a policy into it: this merchant only, this much per pull, this often, until this date. An off-chain co-signer ("The Machine") firewall-checks every pull and refuses to sign anything outside the policy. The box's own code enforces the same limits, so even a leaked co-signer key or a misbehaving merchant can never take more than the budget, or send it anywhere else.

You stay invisible (the merchant sees the box, never your wallet), you stay bounded (the worst case is the budget you funded), and you can cancel any time (sweep the box, funds come home, the pulls stop).

The co-signer is a gatekeeper, not a custodian. Bringing the money home (sweepToVault, or sweepExpired once the date passes) needs only your own key, never the co-signer's, so if The Machine goes offline or turns hostile it can stall a pull but can never hold your funds. Its role is liveness, not custody: worst case you sweep and the subscription simply ends. There is no timezone or clock trick to exploit either, since the on-chain caps (per-pull and total) bound the loss independently of time, and the contract compares a plain UTC block timestamp against an interval in seconds.

Create a subscription. Pick the merchant from a list rather than typing its name, point it at an address, then say the two things anyone actually has in mind: how much each charge is, and how many of them. The name is picked because it is not local trivia: it is packed into the box's stealth announcement, so a typo would follow the box to every other device. The budget is shown rather than asked, because it is an answer, and it is shown beside what Circle charges to move it into the box and what the two come to together. Authorising a subscription is a payment, and it was the last payment screen in the app that did not say what would leave the wallet.

Create a subscription

The form used to ask for a per-pull cap, an interval, a total budget and an expiry as four independent fields, and left the arithmetic between them to the person filling it in. Nobody thinks "0.02 every minute against a 0.1 budget"; they think "one a month, twelve times". Deriving the budget removed a whole class of quiet mistake with it: funding 0.1 against 0.03 charges gave three pulls and stranded 0.01 with nothing on screen saying so, and the "budget must be at least one charge" error is now unreachable rather than merely unlikely. The contract sees the same numbers either way.

Manage them all in one place. Every box you created, read straight from chain, with live status, search, status filters, sorting and pagination. It is the same list component as every other history in the app, with one difference that matters: a subscription's date is when it ends, so the date filter narrows forwards ("ends within 7 days") instead of backwards:

Your subscriptions

Full detail per box. How much has been pulled, what is left, when the next pull is allowed, and the box address on ArcScan, all live:

Subscription detail

The name travels with the box, not with the browser. It is packed into the box's stealth announcement, which the app already fetches in bulk, so it costs no extra request and reads the same on every device. It used to live in localStorage, which meant a subscription called "Netflix" on one machine was an unnamed address on every other one, and it was the only thing about a subscription not read live from the chain. A browser can still rename one locally, instantly and for free, and clearing that override falls back to the announced name rather than blanking it.

Every screenshot above is a real subscription on Arc Testnet. The boxes were deployed and funded on-chain, and cancelling really sweeps the box home. The same box in MODE_PULL powers the agent wallet case: hand an autonomous agent one tightly-scoped box and it can transact on its own, but never past the policy.

Discovery is served, not surrendered. The announcer is a single global registry and carries no owner tag, because not having one is the point. So finding your own boxes means testing every announcement ever made against your viewing key, and doing that from the browser meant reading 2.19 million blocks in 219 chunked requests on every visit, growing by about 168,000 blocks a day. That cost is the shape of the query, not the size of the data: 219 requests to find nineteen records, because eth_getLogs is asked by block range and Arc caps that range at 10,000.

Two sources answer it in one request instead. GET /api/announcements serves the list from an index that backfilled once and follows the chain, and reports the head it is complete to. If that is unreachable, the chain's own explorer has indexed the same events and pages by result rather than by block, so it has no range to chunk. Neither is believed without proof: the index must say complete, and the explorer must report a finished backfill and a head within 250 blocks of the chain's. When both decline, the browser reads the chain itself. Slow is an acceptable answer on this screen; short is not, because a missing announcement is a funded box that never appears.

Nothing in either path names a wallet. The announcement list is undirected by design, and the factory list is filtered for your own ownerHash after it arrives rather than as a topic in the request, so neither the server nor the explorer learns who is asking.

The endpoint takes no address and returns identical bytes to every caller, verified by hashing two responses. It has to: recognising which announcements are yours needs the viewing key, the key comes from a wallet signature and never leaves the browser, and the matching happens there. An endpoint that accepted a viewing key would be shorter to write and would hand over the exact thing the stealth addresses exist to protect. When the index is unreachable or still backfilling it says so, and the browser reads the chain itself rather than trust a partial list, because a missing announcement is a missing subscription and on that screen it looks identical to having none.

A box is owned by a one-time address, and the payer stays off its transactions. The owner and vault of each box is a fresh ERC-5564 stealth address, announced so that only the payer's viewing key can rediscover it; that is how the list above is built, without an identity or a wallet-derived tag on chain. The two transactions that would otherwise name the payer are relayed: the deploy, and the announcement (StealthAnnouncer indexes msg.sender, so announcing from your own wallet would publish "this wallet made a stealth box"). So is the small gas top-up a stealth address needs before it can sweep itself, since paying that from your own wallet would write the exact link the stealth address exists to avoid. None of the relayed calls can move a user's funds.

The budget arrives from Circle, not from your wallet. Funding used to be an ordinary transfer from the payer into the box, and that single line undid everything above it: both ends of an ERC-20 transfer are indexed, so anyone could take a wallet's outgoing transfers, intersect them with the announcer's metadata, and recover its boxes without a viewing key at all. Measured on a real wallet: eight boxes out of eight, no false positives. The box is now funded by a Circle Gateway mint, so what Arc records is Circle's minter paying the box, and the payer is not in it. There is deliberately no fallback to the old transfer, because a second route is a second way to write that line, and it is the one taken when something else has already gone wrong.

docs/privacy.md traces every USDC movement around one real box and states exactly what is and is not hidden.

The keeper: an agent with a wallet, bounded by the chain

reclaimExpired is permissionless and always pays the original sender, so the refund needs no trusted party, but permissionless is not automatic. Until something calls it, an unclaimed transfer just sits in the contract. The keeper (apps/keeper) is that something, and it is where the agent-wallet claim stops being a diagram.

It is not handed a funded wallet and trusted to behave. It is paid the way this product pays any recurring payee: from a SpendPolicyAccount in PULL mode whose policy is on chain: target locked to the keeper, a per-pull ceiling, a minimum interval, a total budget, an expiry. Its entire blast radius is a number the operator chose and can read back from chain, and on Arc that number is in the same asset it spends, because gas is USDC.

Verified on Arc Testnet with the keeper's real key: it reclaimed transfer #50 and the TransferReclaimed event records caller as the keeper and sender as the original sender, while the recorded recipient received nothing. Then four attacks against its own budget (asking the co-signer for ten times the per-pull cap, forging the co-signer signature, sweeping the box to itself, and sweeping it to the correct vault) were each refused, and the operator revoked the remainder in one sweepToVault. Details and the full trace: apps/keeper/README.md.

The investigator: the judgement a rule cannot make

The firewall answers one question at a time, the same way every time. That is what makes it worth trusting, and it is also its ceiling: it says "this address has no on-chain history" about a colleague's fresh wallet and about a contract that would swallow the payment, because from any single rule those are identical. A real dossier from Arc Testnet shows the gap plainly. The rules rate the CtrlArcZ contract itself safe / KNOWN_COUNTERPARTY, because the sender has interacted with it, while isContract: true means a direct USDC transfer there is gone.

POST /api/investigate gathers the signals a rule cannot combine and reports what they add up to. It can only ever tighten. Every answer is clamped to the rule engine's verdict before it leaves the server, so a wrong or prompt-injected reply can refuse a good payment but cannot approve a bad one or un-block a lookalike: the only operation available to it is max. And it is optional: with no API key, on a timeout, or on a malformed reply, the route returns the unchanged rule verdict and the send is decided on the rules alone. The screen says so rather than hiding it: the deep check reports "could not be reached" beside the rule result, because the half of the firewall that did not run is the half worth knowing about.

Known limits

  • The contract has not been audited. Testnet only.
  • The firewall depends on a single indexer (ArcScan). If it cannot be reached, the report degrades to a warning, or to a block when a lookalike cannot be ruled out. It never degrades to safe.
  • The announcement index lives in one server process's memory. It rebuilds on restart, and until it finishes the browser falls back to reading the chain. A deployment with more than one instance would want it in shared storage.
  • The keeper spends its own gas to return other people's money, so it never profits. It is a service the operator runs, not an incentivised keeper network.

About

Protected, Reversible & Private USDC Payments on Arc ( Web + SDK & Android App )

Topics

Resources

Contributing

Security policy

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages