Skip to main content
All contracts are deployed on Arc Testnet (chain 5042002) and verifiable on ArcScan. No admin, no pause, no upgrade path: funds are only ever with the user or the contract.

Addresses

CtrlArcZ was deployed at block 51326557 (CTRL_ARCZ_DEPLOY_BLOCK) and StealthAnnouncer at 53756547 (STEALTH_ANNOUNCER_DEPLOY_BLOCK); event scans start there rather than at 0, because Arc caps an eth_getLogs range at 10,000 blocks.
SpendPolicyFactory was redeployed on 2026-08-24 to add the msg.sender == vault gate on sweepExpired. The superseded factory is still deployed and still answers, so a hardcoded address silently keeps working against the old contract. Import SPEND_POLICY_FACTORY_ADDRESS instead. Boxes created under the retired factory keep working: their pay and pull calldata is unchanged, and the co-signer’s owner index scans SPEND_POLICY_FACTORY_PRIOR_ADDRESSES too.

CtrlArcZ: protected transfer

The escrow. Registers per-integrator configs and holds transfers until claimed, cancelled or reclaimed.

SpendPolicyFactory + SpendPolicyAccount

The payer-side shield. The factory clones a policy-bound account per intent; the account enforces the policy and gates every spend on the co-signer. The CREATE2 salt is keccak256(abi.encode(ownerHash, userSalt, keccak256(abi.encode(params)))), so the address commits to the entire policy. Change the target, the cap or the expiry and it is a different address, which is what lets a funder check that the box it is about to pay into is the box it specified.
Both sweeps require msg.sender == vault. Passing the vault address is not enough; the transaction has to come from it. Earlier builds gated only on the commitment, and that was wrong: the vault address is crackable from vaultHash on a transparent chain, so anyone who cracked it could call sweepExpired(vault) and write the vault-to-box link on chain that the payer never chose to reveal. It is still an escape hatch, because the payer holds the vault key.The practical consequence: bind a box to an address you can transact from. In the reference app the vault is the payer’s own wallet, or the stealth address for a stealth box, not the Vault contract, which has no function that calls a box back.
A spend needs only the co-signer’s signature. The funds are hard-locked to the pre-set target and capped by maxAmount, so the co-signer can only push the payment the payer set up, never steal or redirect. The payer signs nothing blind; their only on-chain action is a normal USDC transfer to fund the account. init takes (token, cosigner, vaultHash, target, maxAmount, perPullMax, expiry, interval, mode). There is no owner and no vault stored: the owner is bound only through the salt (ownerHash) and the return address only through the commitment vaultHash = keccak256(abi.encode(vault)). The readable getters are token, cosigner, target, vaultHash, maxAmount, perPullMax, expiry, interval, mode, spent and nonce; there is no owner() or vault() getter at all, so a merchant paid from the account cannot read who funded it. Signatures are EIP-712 typed data over Spend(address target,uint256 amount,uint256 nonce,uint8 action), where action is 0 for pay and 1 for pull so a pay authorization can never be replayed as a pull. The domain binds the account and chain, so a signature cannot be replayed across accounts or chains. Use spendTypedData / spendDigest in the SDK to build the exact bytes, or read the account’s spendDigest(amount, action) getter.

Vault

Holds the owner’s USDC, funds accounts, receives sweeps. deposit(amount) pulls in against an approval, fundAccount(account, amount) and withdraw(to, amount) are owner-only, and balance() reads the holding. Every outflow goes through one private _moveOut seam, which is where the APS confidential transfer slots in.
The Vault contract is the vault-funded flow, not a requirement. A box binds its return address through vaultHash, and since both sweeps now need msg.sender == vault, the bound address has to be one that can send a transaction. The reference app binds the payer’s own wallet for a private payment and the stealth address for a subscription. Use the Vault contract when you want one shared treasury that funds many boxes, and bind sweeps to an address you control.

StealthAnnouncer

An ERC-5564 announcement registry (schemeId 1), standalone from the factory and the account. A payer publishes a box’s ephemeral public key here so only their own viewing key can rediscover it later. Nothing in the announcement identifies the payer to anyone else, and the SDK scans it from STEALTH_ANNOUNCER_DEPLOY_BLOCK forward. See Stealth boxes.
On Arc a USDC ERC-20 transfer moves the recipient’s native balance, so the account and vault implement receive() payable. Without it, funding a contract on Arc would revert; a plain EVM never surfaces this.