@ctrl-arcz/sdk provides protected USDC transfers on Arc. It is organized in
layers, each usable on its own:
- Firewall (risk) - the pre-send poisoning check. A victim sends to a lookalike on purpose, so locking funds afterwards does not help; only refusing does.
- Protected transfer - code-gated claim, sender cancel, automatic refund. This replaces the “send $1 first and wait” ritual.
- Clean history - a spam-free history, so a fake address is never sitting there waiting to be copied.
- Payer-side shield - disposable, policy-bound payment addresses settled under an enclave co-signer’s veto.
PublicClient,
write clients are viem WalletClient, and most write calls take a ClientPair:
ShieldClients ({ publicClient, walletClient }) without
the contractAddress field.
Amounts are always USDC base units (
bigint, 6 decimals on Arc’s USDC ERC-20
interface). Use viem’s parseUnits('1.50', 6). The 18-decimal native interface is
never used, and the two must not be mixed.Chains and addresses
The SDK began on Arc and one export carried every address. Since0.2.0 the same
contracts are deployed on several testnets, so the addresses come from a registry
keyed by chain id. The Arc constants below are still exported and still correct
for Arc; on any other chain, ask the registry.
The registry
ADDRESSES.USDC
on Base reads an address that is not a contract there.
Arc’s own constants
Everything underchains/arcTestnet is re-exported at the top level.
Address constants
ADDRESSES is a single map of every Arc predeploy and token. The Ctrl+ArcZ
deployment addresses are separate top-level constants.
ADDRESSES also carries CCTP and StableFX addresses (CCTP_TOKEN_MESSENGER_V2,
CCTP_MESSAGE_TRANSMITTER_V2, GATEWAY_WALLET, GATEWAY_MINTER, FX_ESCROW),
unused by Ctrl+ArcZ v1.
Ctrl+ArcZ and shield deployment addresses (Arc Testnet, chainId 5042002):
Other exported constants:
USDC_DECIMALS (6), ARC_CCTP_DOMAIN (26),
BLOCKLISTED_TEST_ADDRESS (a seeded address whose transfers always revert),
CTRL_ARCZ_DEPLOY_BLOCK (51326557n) and STEALTH_ANNOUNCER_DEPLOY_BLOCK
(53756547n, the blocks their event scans start from), and MAX_LOG_RANGE
(10000n, Arc’s eth_getLogs block-range cap).
RPC_URLS and SIGNING_RPC_URLS carry the fallback endpoint lists;
TOKENS_BY_CHAIN, ARC_TOKENS, DEFAULT_TOKEN, tokenByAddress,
tokenBySymbol and defaultTokenFor are the token registry behind the pickers.
DEPLOYMENTS is the raw registry map and ChainDeployment its entry type.
Explorer helpers
Event helper
MAX_LOG_RANGE-sized chunks from CTRL_ARCZ_DEPLOY_BLOCK
forward. Arc rejects a from-block-0 eth_getLogs and caps the range at 10,000
blocks, so full-history scans must be chunked. DecodedLog<TArgs> is
{ args: TArgs; blockNumber: bigint | null; transactionHash: Hex | null }.
Layer 1: Firewall (risk)
The firewall is the poisoning defense. Runcheck before every send. It
is also run automatically inside sendProtected unless you opt
out.
check
RiskReport.
check is fail-closed. Data sources that fail are recorded, not swallowed: an
incomplete check can return warning (or block when a lookalike cannot be ruled
out), but it will never return safe. Always read report.complete and
report.level, not just the reasons.RiskReport and related types
Rule engine and lookalikes
These let you run the rules without the network, or generate a lookalike for a demo.evaluateRiskis a pure function: same input, same verdict, no network. It takes aRiskInput(all data pre-fetched) and returns aRiskReport.isLookalikeis true whencandidateshares the first and lastAFFIX_LENGTHhex characters withknownbut is a different address.craftLookalikemints a genuine lookalike oftarget(same visible ends, random middle). Used by the poisoning demo.
RiskInput, AddressActivity, Counterparty, ZeroValueBait and IDataProvider
are exported types describing the rule-engine inputs.
BlockscoutDataProvider
The default risk data source, reading ArcScan’s Blockscout REST API.IDataProvider yourself to back the firewall with a different indexer,
an internal exchange database or a cache, and pass it as CheckOptions.provider.
CachingDataProvider
VerifiedRecipientIndex
VERIFIED_RECIPIENT back into the
firewall so a lookalike of someone you reached through a protected transfer is
caught too. GET /api/verified-recipients is a server-side wrapper around the same
data; both read public events, so neither is privileged.
Risk acknowledgement
Awarning a person looked at and chose to proceed past. The point is to let a
user’s decision stand without letting it stand for something else.
That last condition is the one that matters. A user who accepted “this address is
new” has not accepted “this address just sent you a zero-value bait”. A fresh code
in the report voids the acknowledgement even when the level did not change, so
proceeding past one warning can never carry a different warning through with it.
Dossier and the investigator
check returns a verdict; a dossier is the evidence behind it, gathered for a
second opinion (POST /api/investigate).
buildDossieradds what no rule looks for, most usefully whether the target is a contract: sending USDC to a contract that cannot forward it is a permanent loss. Every lookup is best-effort and independently guarded, so a dossier missing a field is still useful and a failed lookup can never change a verdict.findNearMissesreports partial collisions (2 or more shared characters at both ends, but not a fullAFFIX_LENGTHmatch at both, which is the rule engine’s job). Evidence for a human or a model, not a verdict.clampVerdictis the whole security argument for putting a language model near a payment. The rule engine’s verdict is a floor: the only operation available ismax. A wrong, confused or prompt-injected advisory can refuse a payment that would have been fine, which is annoying, recoverable and the direction this product already errs in. It cannot approve anything, cannot un-block a lookalike, and cannot turn a caution into a green light.
Layer 2: Protected transfer
A protected transfer locks USDC in the CtrlArcZ contract; the recipient releases it with a code, the sender can cancel until it lands, and it auto-refunds after expiry.Claim secrets
keccak256("ctrl-arcz:salt:v1" || secret)
rather than independent randomness. So there is exactly one thing to deliver, and
fromSecret(whatTheUserTyped) reconstructs the rest.
normaliseSecret accepts what people actually type, any grouping, any case, and
the Crockford aliases (I and L for 1, O for 0), and returns null when
the input is not a well-formed secret. The alphabet omits I, L, O and U:
the first three because they are unreadable next to 1 and 0, and U so that no
random string spells something the sender has to read out loud.
Allowance and approval
approveUsdc approves CtrlArcZ to pull amount USDC and returns null when the
existing allowance already covers it (no transaction sent).
sendProtected
approveUsdc) and returns the new
transferId. Wrapped in Arc’s Memo predeploy by default so the transfer is
reconcilable from Memo events. Runs the risk firewall first.
The firewall is on by default.
sendProtected runs check and
throws RiskBlockedError before any funds move when the verdict blocks.
Turning it off (skipRiskCheck: true) removes the poisoning defense from the send
path entirely; if you already ran check yourself, pass the report as report
instead to reuse the scan without giving up the guard.report is used only when it matches both sender and target and is
younger than MAX_REPORT_AGE_MS; otherwise the guard silently re-scans, because a
report is a snapshot and a bait transfer could have landed since.
sendProtectedWithPermit
sendProtected but pulls USDC via a Permit2 signature,
so there is no per-send approve transaction. The one-time prerequisite is
approvePermit2(clients). This path is a single on-chain transaction and is not
wrapped in Memo (routing through Memo would break Permit2’s spender check).
claim / cancel / reclaimExpired
claimreleases a transfer to its recipient. Returns the tx hash on success.canceltakes the money back. Sender only, allowed until a claim lands.reclaimExpiredrefunds an expired, unclaimed transfer. Anyone may call; the money goes to the sender.
A wrong code does not revert on-chain (the attempt counter must survive for the
5-guess lockout to bind), so a mined transaction is not proof of success.
claim
inspects the receipt and throws a typed error: WrongClaimCodeError
(with attemptsRemaining), TransferLockedError after 5 wrong guesses,
or ClaimOutcomeUnknownError if no outcome event is found.interpretClaimReceipt binds to both the emitting contract and the exact
transferId, so an unrelated or attacker-planted event in a batched transaction
cannot decide the outcome.
getTransfer / watchTransfer
watchTransfer fires onUpdate on every state change. Arc finalises on inclusion,
so a single confirmation is final and there is no “confirming” limbo.
Permit2 helpers
approvePermit2 is the one-time approval (returns null when the allowance
already covers amount); after it, every send needs only a signature.
signPermit2Transfer signs a PermitTransferFrom authorising spender (CtrlArcZ)
to pull amount once.
Status helpers
The chain records five statuses; a person sorts transfers into four groups. These translate, so every screen answers the question the same way.LOCKED buckets with pending, and that is the honest reading: the money is still
in the contract and the sender can still cancel it. A locked transfer has lost its
claim path, not its funds.
Gas reserve
On Arc gas is USDC, so the balance a transfer spends is the balance that pays for it. Offering the whole balance as “max” produces an amount that cannot pay for its own transaction.PAY_GAS_LIMIT is twice that.
readGasReserve falls back to the floor instead of throwing, because a screen that
cannot reach the node should still be able to offer a sane maximum.
More: Protected transfer.
Layer 3: Clean history
filtered carries the hidden rows so a UI can still
offer “show spam”.

kind is 'transfer' | 'mint' | 'burn', derived from the addresses rather than
from the indexer’s own label. It matters more than it sounds: a mint has no sender
and a burn has no recipient, so both arrive with 0x0000...0000 on one side.
Rendering that as an address tells the holder they were paid by nobody, when on Arc
it is almost always their own money arriving over a bridge (CCTP settles as
receiveMessage, Gateway as gatewayMint). Read kind before showing
counterparty.
Integrator config
AnIntegratorConfig makes protected transfers behave differently per integrator
(an exchange withdrawal screen versus a P2P wallet) on top of the shared contract.
defineConfigfills sensible P2P-wallet defaults: a one-hour recall window, no fee, protect above 10 USDC, warnings advisory (onWarning: 'warn'). It validates bounds and throws on an out-of-range fee or window.registerConfigregisters the behaviour on-chain and returns itsconfigId. It is idempotent: the contract derives the id from the parameters and caller, so an app can call it on every boot and get the same id back.recommendTransferModereturns'plain'underminProtectedAmount, where a second protection transaction costs more than it saves.shouldBlockSendis the single decision point the UI and the send guard share:blockalways stops;warningstops only whenonWarning === 'block'.
SIGNATURE and REGISTERED claim modes are reserved. defineConfig throws for
any claimMode other than 'CODE' in v1.Layer 4: Payer-side shield
The payer-side shield pays a merchant from a disposable, policy-bound address whose every spend is authorized by an enclave co-signer that can veto. See Payer-side shield and Private Pay.Honest privacy model. The shield account stores no owner - there is no
owner() or vault() getter. A spend needs only the co-signer’s signature
(this is not a 2-of-2 owner+cosigner scheme; the payer signs nothing). Funds are
hard-locked to the pre-set target and cap, the vault is a hashed commitment
revealed only at sweep, and PULL has a per-pull cap. All spend signatures are
EIP-712.Honest ceiling. Full payer privacy needs Arc Privacy Sector confidential
transfers, which are not live. The funding transaction is a plain transfer on a
transparent chain today, so an observer can still trace the payer to the ephemeral
address through it. The account surface reveals nothing; the funding leg does.ShieldClients is { publicClient, walletClient }. SpendMode is MODE_PUSH
(0) or MODE_PULL (1).
Account lifecycle
predictEphemeralreturns the deterministic addresscreateEphemeralwill occupy, so it can be funded before it exists. The address is bound toownerHash(policy.owner), the salt and a hash of every policy field, not tomsg.sender, so no one can front-run its slot and no two policies share an address. That is why it takes the whole policy rather than just an owner.createEphemeraldeploys and initializes the account (a minimal-proxy clone). Anyone may submit it, so an integrator can deploy through a relayer to keep the payer off the deployment transaction. It reads the deployed policy back and verifies it before returning.fundEphemeraltransfers USDC into the box from the connected wallet, but only after re-reading the deployed policy and comparing every field against the one you passed. Prefer it over a bare ERC-20transfer: funding an address that already exists always succeeds, so a direct transfer silently skips the check.fundFromVaultmoves USDC from theVaultcontract into the account (owner only). This is the leg that becomes APS-confidential in production.
Reads
readAccount is a getter-first read (no reliance on event logs, APS-ready) and is
the authoritative policy source the co-signer validates against. There is no owner
or vault getter by design.
Settlement
submitPay/submitPullsubmit a PUSH / PULL spend with the co-signer’s signature. Anyone may submit (relayer-safe); the funds are locked to the target and cap.sweepToVaultsends the account home to its vault at any time;sweepExpireddoes the same onceexpiryhas passed (the keeper / refund path). Both require that the wallet client’s account is the vault (msg.sender == vault) and that its address match the storedvaultHash. No signature is involved, and the vault stays hidden until it sweeps. Bind a box to an address you can transact from, not to a contract with no callback.
One-call checkout
The co-signer
“The Machine” authorizes every spend. It does not merely warn: it withholds the signature, so a spend that fails policy or the risk firewall is impossible, not a click-through. Both implementations satisfy theCoSigner interface.
LocalCoSignerruns in-process (tests, and inside the server). Fail-closed: any policy breach, risk block, or unavailable risk data withholds the signature. With noriskCheckconfigured it vetoes every target.RemoteCoSignerlives behind an HTTP endpoint, the real shape: The Machine runs server-side (a TEE), never in the browser. It forwards only the trust-agnostic fields; the server re-reads the policy from chain, so anything the browser claims abouttarget,nonce,remainingorexpiryis ignored by design.
precheck runs only the firewall on the intended target before any account
exists, so a bad payment is vetoed before anything is created or funded.
authorize validates the chain-sourced request (amount positive, within
remaining, not expired), re-runs the firewall on the chain target, then signs
the exact EIP-712 spend the contract will verify.
Owner authentication
ARemoteCoSigner can prove who is asking, so the server can rate-limit and audit
per payer instead of answering anyone.
owner + ts would let a captured signature authorize a
different spend for the same owner: a bigger amount, a PULL instead of a PAY, or
another account entirely. Field order in the message is fixed so the server can
reconstruct the exact string from the request body.
Counterfactual authorization
Ask the co-signer to sign a PUSH pay for a box that does not exist yet, so create, fund and pay can be batched into one flow.box is only ever this exact box, and
nonce 0 is valid for its first and only spend.
settlePrivatePaymentBatched
Funding a box from Gateway
Funding a box with a plain transfer writes “this wallet paid that box” on chain. Funding it from the payer’s Circle Gateway balance writes a mint from Circle’s minter instead, so the box’s funding transaction does not name the payer’s wallet at all.assertBoxFundablerefuses a box that is not deployed, then re-reads and compares every policy field. Call it first; funding an address that exists always succeeds, so skipping the check fails silently.fromandtoare both required and neither has a default.frommatters because Circle spends the balance per chain, so a box funded from a chain the payer has nothing on is an intent Circle refuses after it is signed.tomatters because boxes are no longer only on Arc.awaitBoxFundedpolls the box’s balance, since the mint arrives from Circle rather than from a transaction the caller sent.isBoxFundinglets a history view recognise its own box-funding transfers and label them, instead of showing them as payments to a stranger.
Discovery indexes
AccountOwnerIndex is what lets a box created under a retired factory still be
recognised: it scans SPEND_POLICY_FACTORY_PRIOR_ADDRESSES alongside the current
one, so a redeploy does not orphan an existing subscription.
Explorer health
ExplorerOptions configures the endpoint and timeouts, ExplorerHealth reports
the lag, IndexedAnnouncement and CreatedAccount are the row shapes, and
clearExplorerState resets the cached health between tests.
Reading history from an explorer is faster than eth_getLogs, and an explorer that
has fallen behind is worse than no explorer, because it answers confidently with a
stale view. explorerUsable reports whether it is within
EXPLORER_MAX_LAG_BLOCKS of the chain head, so a caller can fall back to logs
instead of showing a user a list that is quietly missing their newest box.
EIP-712 digest helpers
chainId and verifyingContract = account, so a signature is
un-replayable across accounts or chains; the nonce makes it un-replayable within an
account; and action keeps a pay authorization from standing in for a pull. Both
ownerHash and vaultHash are one-way: an observer reading the account learns
nothing, and only someone who already knows the address can recompute the hash.
More: The co-signer.
Stealth addresses (ERC-5564)
Bind a box to a fresh address only the payer can rediscover.schemeId 1 over
secp256k1. Full guide: Stealth boxes.
Stealth boxes
ownerHash nor vaultHash carries a link to the payer. recognizeAnnouncements
is pure, so recognition can run over a pre-fetched batch with no network.
The label rides in the announcement rather than in the payer’s browser. Kept
locally, 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
chain. It is plaintext, so name boxes the way you would say them out loud.
Layer 5: Bridging USDC
Non-custodial, wallet-side, no server. Full guide: Bridge USDC.CCTP
undefined from either mint lookup means “not yet”, never “gone”: the burn is on
chain and the attestation does not expire. Persist burnTxHash the moment you have
it, because it is the only thing needed to recover a transfer later.
Gateway
transferId is the receipt, exactly as burnTxHash is for CCTP: recovering a
spend needs it and nothing else, with no wallet and no signature. Persist it from
onTransferId, which fires the instant Circle accepts the intent, not at the end.
gatewayBalance counts only deposits that have reached Circle’s required
confirmations, which range from about a second on Arc to nineteen minutes on
Ethereum. Read DEPOSIT_CONFIRMATION_SECONDS rather than showing one number for
all of them.
from === to is a withdrawal, not an error: Circle debits the balance and mints
back on the same chain, which is the only quick exit a depositor has.
Refusing before a signature
code is a key into your own strings, so the SDK ships no user-facing English.
fix is the point: a refusal names the action that resolves it, so a UI can offer
a button rather than a paragraph.
Check here (the source chain’s balance), never total. Circle reads the balance
as one figure but spends it per chain, so checking the sum lets the check pass, the
user sign, and Circle refuse afterwards.
Errors
All SDK errors extendCtrlArcZError, so catch (e) { if (e instanceof CtrlArcZError) ... } catches every one.
TransferUnavailableReason is 'not_pending' | 'expired' | 'unknown' | 'not_sender'. Full handling patterns are in
Errors and results.
ABIs
For direct contract calls, the SDK exports the ABIs it uses:ctrlArcZAbi,codeClaimVerifierAbi,memoAbi- the protected-transfer stack.spendPolicyFactoryAbi,spendPolicyAccountAbi,vaultAbi- the payer-side shield.stealthAnnouncerAbi- the ERC-5564 announcement registry.
Next
Private Pay
Pay a merchant from a disposable address under the co-signer’s veto.
Protected transfer
Code-gated claim, sender cancel, automatic refund.
Contracts
The on-chain surface behind the SDK.