Skip to main content
@ctrl-arcz/sdk provides protected USDC transfers on Arc. It is organized in layers, each usable on its own:
  1. 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.
  2. Protected transfer - code-gated claim, sender cancel, automatic refund. This replaces the “send $1 first and wait” ritual.
  3. Clean history - a spam-free history, so a fake address is never sitting there waiting to be copied.
  4. Payer-side shield - disposable, policy-bound payment addresses settled under an enclave co-signer’s veto.
The SDK is viem-based. Read clients are viem PublicClient, write clients are viem WalletClient, and most write calls take a ClientPair:
The shield layer uses 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. Since 0.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

The same ticker is a different contract on every network, which is why the token list takes a chain id rather than being a constant. Reaching for ADDRESSES.USDC on Base reads an address that is not a contract there.

Arc’s own constants

Everything under chains/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):
Import these; do not copy them. SPEND_POLICY_FACTORY_ADDRESS was redeployed on 2026-08-24 and the retired factory is still live on chain, so a hardcoded address keeps working while pointing at the wrong contract. SPEND_POLICY_FACTORY_PRIOR_ADDRESSES lists the retired ones, which is how AccountOwnerIndex still finds a box created under an older factory.
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

Reads event logs in 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. Run check before every send. It is also run automatically inside sendProtected unless you opt out.

check

Fetches send history, target activity, zero-value baits and verified-recipient signals, then runs the pure rule engine. Returns a 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.

Rule engine and lookalikes

These let you run the rules without the network, or generate a lookalike for a demo.
  • evaluateRisk is a pure function: same input, same verdict, no network. It takes a RiskInput (all data pre-fetched) and returns a RiskReport.
  • isLookalike is true when candidate shares the first and last AFFIX_LENGTH hex characters with known but is a different address.
  • craftLookalike mints a genuine lookalike of target (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.
Implement IDataProvider yourself to back the firewall with a different indexer, an internal exchange database or a cache, and pass it as CheckOptions.provider.
getOutgoingCounterparties must reject when the sender’s history cannot be fetched. The firewall relies on that to fail closed; returning an empty list on an error would silently disable lookalike detection and report safe. The complete flag carries the softer case, a scan that ran but hit its page cap, which keeps the report from claiming safe without pretending the source was down.

CachingDataProvider

Wraps any provider and caches the counterparty scan, so a screen that re-checks as the user types does not re-hit the explorer on every keystroke. Concurrent calls for the same sender are de-duplicated into one request. Only a successful, complete scan is cached, which keeps the fail-closed contract intact.

VerifiedRecipientIndex

The contract’s own known-good list, which feeds 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

A warning 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.
The acknowledgement covers the new report only when all of these hold: same sender, same target, less than 15 minutes old, the new level is no worse than the acknowledged one, and every reason code in the new report was already in the acknowledged one.
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).
  • buildDossier adds 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.
  • findNearMisses reports partial collisions (2 or more shared characters at both ends, but not a full AFFIX_LENGTH match at both, which is the rule engine’s job). Evidence for a human or a model, not a verdict.
  • clampVerdict is the whole security argument for putting a language model near a payment. The rule engine’s verdict is a floor: the only operation available is max. 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.
More: The firewall.

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

All of the entropy is in the one string. The secret is 16 Crockford base32 characters, 80 bits, and the salt is keccak256("ctrl-arcz:salt:v1" || secret) rather than independent randomness. So there is exactly one thing to deliver, and fromSecret(whatTheUserTyped) reconstructs the rest.
Deliver the secret to a person, never to an address. In a poisoning attack the recipient recorded on chain is the attacker, and they can grind claimHash offline for as long as they like. 80 bits is not grindable; the ~20 bits of a 6-digit code is. Any channel keyed to the address (on-chain ciphertext, a backend push, a wallet inbox) hands the secret to the attacker and collapses two factors into one, which is why the salt is derived rather than delivered. Send the string over the channel you would use to tell someone their table is ready.Never reuse a secret: a settled claim publishes it in calldata forever.
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

Locks the funds (requires a prior 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.
A supplied 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

Same as 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

  • claim releases a transfer to its recipient. Returns the tx hash on success.
  • cancel takes the money back. Sender only, allowed until a claim lands.
  • reclaimExpired refunds 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.
For gasless / ERC-4337 flows, build and interpret the claim yourself:
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.
The limits are measured on Arc rather than guessed: a private payment’s three transactions came to 444,362 gas, and 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

Two rules destroy the poisoning surface: drop 0-value transfers, and show only known tokens (an allowlist, since a poisoning campaign ships its own lookalike token). Nothing is deleted; filtered carries the hidden rows so a UI can still offer “show spam”. A clean history 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

An IntegratorConfig makes protected transfers behave differently per integrator (an exchange withdrawal screen versus a P2P wallet) on top of the shared contract.
  • defineConfig fills 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.
  • registerConfig registers the behaviour on-chain and returns its configId. 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.
  • recommendTransferMode returns 'plain' under minProtectedAmount, where a second protection transaction costs more than it saves.
  • shouldBlockSend is the single decision point the UI and the send guard share: block always stops; warning stops only when onWarning === '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

  • predictEphemeral returns the deterministic address createEphemeral will occupy, so it can be funded before it exists. The address is bound to ownerHash(policy.owner), the salt and a hash of every policy field, not to msg.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.
  • createEphemeral deploys 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.
  • fundEphemeral transfers 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-20 transfer: funding an address that already exists always succeeds, so a direct transfer silently skips the check.
  • fundFromVault moves USDC from the Vault contract 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 / submitPull submit a PUSH / PULL spend with the co-signer’s signature. Anyone may submit (relayer-safe); the funds are locked to the target and cap.
  • sweepToVault sends the account home to its vault at any time; sweepExpired does the same once expiry has 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 stored vaultHash. 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

For an existing, funded PUSH account: reads the on-chain policy, asks the co-signer to authorize (it may veto), then settles with its signature alone.

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 the CoSigner interface.
  • LocalCoSigner runs in-process (tests, and inside the server). Fail-closed: any policy breach, risk block, or unavailable risk data withholds the signature. With no riskCheck configured it vetoes every target.
  • RemoteCoSigner lives 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 about target, nonce, remaining or expiry is ignored by design.
Request and result types:
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

A RemoteCoSigner can prove who is asking, so the server can rate-limit and audit per payer instead of answering anyone.
The signature is bound to the scope of this request, not just to the owner and a timestamp. Binding only 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.
This is safe for one specific reason: the CREATE2 salt commits the full policy to the address. The box that can be deployed at box is only ever this exact box, and nonce 0 is valid for its first and only spend.
The server must recompute box from (factory, ownerHash, salt, policy) and reject a mismatch. Trusting the client’s predicted address is what turns this from a batching optimisation into a signature for an arbitrary contract.

settlePrivatePaymentBatched

The counterfactual path end to end: one authorization covering a box that is created, funded and spent in the same flow, instead of three round trips with a wallet prompt between each.

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.
  • assertBoxFundable refuses 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.
  • from and to are both required and neither has a default. from matters 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. to matters because boxes are no longer only on Arc.
  • awaitBoxFunded polls the box’s balance, since the mint arrives from Circle rather than from a transaction the caller sent.
  • isBoxFunding lets 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

The domain binds 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.
The keys come from one signature over a fixed message, deterministically (RFC 6979), so they are recoverable on any device with nothing stored. The private keys never leave the client.

Stealth boxes

Use the stealth address as both the box’s owner and its vault, so neither 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

The 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 extend CtrlArcZError, 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.