> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ctrlarcz.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# SDK API reference

> Every exported function, type and constant in @ctrl-arcz/sdk, grouped by layer, with real signatures read from source.

`@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](https://viem.sh)-based. Read clients are viem `PublicClient`,
write clients are viem `WalletClient`, and most write calls take a `ClientPair`:

```ts theme={null}
interface ClientPair {
  publicClient: PublicClient;
  walletClient: WalletClient;
  /** Overrides the deployed CtrlArcZ address (tests, alternative deployments). */
  contractAddress?: Address;
}
```

The shield layer uses `ShieldClients` (`{ publicClient, walletClient }`) without
the `contractAddress` field.

<Note>
  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.
</Note>

## 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

```ts theme={null}
import { deploymentFor, deployedChainIds, tokensFor, spendableTokensFor } from '@ctrl-arcz/sdk';

const deployment = deploymentFor(chainId);
// undefined on a chain this build knows nothing about, which is the honest answer
// and the thing to branch on before offering a feature.

deployment?.ctrlArcZ;     // the contract on that chain
deployment?.usdc;         // that chain's USDC, which is not Arc's
deployment?.rpcUrls;      // public endpoints for reads
deployment?.explorerApi;  // undefined where the chain publishes no readable history

deployedChainIds();       // every chain this build can work on
tokensFor(chainId);       // that chain's tokens: address, decimals, search names
spendableTokensFor(chainId); // the same list without the ones an allowlist refuses
```

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.

```ts theme={null}
import { arcTestnet as viemArcTestnet } from 'viem/chains';

const arcTestnet = viemArcTestnet;              // viem built-in (needs viem >= 2.38)
const ARC_TESTNET_CHAIN_ID = 5042002;

const RPC_URL = 'https://rpc.testnet.arc.network';
const WS_URL = 'wss://rpc.testnet.arc.network';
const EXPLORER_URL = 'https://testnet.arcscan.app';
const EXPLORER_API_URL = 'https://testnet.arcscan.app/api/v2';
const FAUCET_URL = 'https://faucet.circle.com';
```

### Address constants

`ADDRESSES` is a single map of every Arc predeploy and token. The Ctrl+ArcZ
deployment addresses are separate top-level constants.

| Constant | Value | Notes |
| - | - | - |
| `ADDRESSES.USDC` | `0x3600000000000000000000000000000000000000` | ERC-20 interface, 6 decimals |
| `ADDRESSES.EURC` | `0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a` | |
| `ADDRESSES.MEMO` | `0x5294E9927c3306DcBaDb03fe70b92e01cCede505` | Memo predeploy; EOA-only caller |
| `ADDRESSES.MULTICALL3_FROM` | `0x522fAf9A91c41c443c66765030741e4AaCe147D0` | preserves `msg.sender` |
| `ADDRESSES.PERMIT2` | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | |
| `ADDRESSES.MULTICALL3` | `0xcA11bde05977b3631167028862bE2a173976CA11` | |
| `ADDRESSES.CREATE2_FACTORY` | `0x4e59b44847b379578588920cA78FbF26c0B4956C` | |

`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`):

| Constant | Value |
| - | - |
| `CTRL_ARCZ_ADDRESS` | `0x8dAb7148cdc31DAcad6d7e12161AA3DEDb572Dca` |
| `CODE_CLAIM_VERIFIER_ADDRESS` | `0x2C0f268DE2Aa8BB2ab27F2Ea5Ae8a0f9a0E068c4` |
| `SPEND_POLICY_FACTORY_ADDRESS` | `0x9B3b30573D7f1d65d1cFeeb52E3a6E69a5792161` |
| `SPEND_POLICY_ACCOUNT_IMPL_ADDRESS` | `0x047870050bA2a20665F053797140044C004970c5` |
| `STEALTH_ANNOUNCER_ADDRESS` | `0x9b9F9F8b98Dd7a74889725e79591B3E69BdC991D` |
| `SHIELD_VAULT_ADDRESS` | `0xc8185AF46b882368b771E8E8a1C8abe1C8e4127f` |

<Warning>
  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.
</Warning>

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

```ts theme={null}
function explorerTxUrl(hash: string): string;       // `${EXPLORER_URL}/tx/${hash}`
function explorerAddressUrl(address: string): string; // `${EXPLORER_URL}/address/${address}`
```

### Event helper

```ts theme={null}
function getLogsChunked<TArgs = Record<string, unknown>>(
  client: PublicClient,
  params: ChunkedEventsParams,
): Promise<Array<DecodedLog<TArgs>>>;

interface ChunkedEventsParams {
  address: Address;
  abi: Abi;
  eventName: string;
  args?: Record<string, unknown>;
  fromBlock?: bigint; // defaults to CTRL_ARCZ_DEPLOY_BLOCK
}
```

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`](#check) before every send. It
is also run automatically inside [`sendProtected`](#sendprotected) unless you opt
out.

### check

```ts theme={null}
function check(
  sender: Address,
  target: Address,
  options?: CheckOptions,
): Promise<RiskReport>;

interface CheckOptions {
  /** Defaults to ArcScan/Blockscout. */
  provider?: IDataProvider;
  /** Used to read `isVerifiedRecipient` from the contract. Optional. */
  client?: PublicClient;
  /** Overrides the deployed CtrlArcZ address. */
  contractAddress?: Address;
  /** Injected in tests to make the freshness rule deterministic. */
  now?: Date;
}
```

Fetches send history, target activity, zero-value baits and verified-recipient
signals, then runs the pure rule engine. Returns a `RiskReport`.

<Note>
  `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.
</Note>

```ts theme={null}
import { check } from '@ctrl-arcz/sdk';

const report = await check(sender, target, { client: publicClient });
if (report.level === 'block') refuseTheSend(report.reasons);
```

### RiskReport and related types

```ts theme={null}
type RiskLevel = 'safe' | 'warning' | 'block';

type RiskRuleCode =
  | 'LOOKALIKE_ADDRESS'   // mimics an address you have paid
  | 'ZERO_VALUE_BAIT'     // target sent you a 0-value transfer
  | 'NEW_ADDRESS'         // no on-chain history
  | 'FRESH_ADDRESS'       // first activity < 24h ago
  | 'VERIFIED_RECIPIENT'  // a protected transfer here already settled
  | 'KNOWN_COUNTERPARTY'  // you have paid this exact address
  | 'DATA_UNAVAILABLE';   // a data source did not answer

interface RiskReason {
  code: RiskRuleCode;
  severity: RiskLevel;
  message: string;           // human-readable English fallback
  lookalikeOf?: Address;     // the counterparty a lookalike was matched against
  count?: number;            // number of 0-value baits (ZERO_VALUE_BAIT)
  sources?: string[];        // data sources that did not answer (DATA_UNAVAILABLE)
}

interface RiskReport {
  target: Address;
  sender: Address;
  level: RiskLevel;          // the worst severity across all reasons
  reasons: RiskReason[];
  complete: boolean;         // true only when every data source answered
  checkedAt: Date;           // a report is a snapshot in time
}
```

### Rule engine and lookalikes

These let you run the rules without the network, or generate a lookalike for a
demo.

```ts theme={null}
function evaluateRisk(input: RiskInput, now?: Date): RiskReport;
function isLookalike(candidate: Address, known: Address): boolean;
function craftLookalike(target: Address, randomBytes?: (length: number) => Uint8Array): Address;

const AFFIX_LENGTH = 4;                    // chars compared at each end of an address
const FRESH_ADDRESS_MAX_AGE_MS = 86400000; // 24h "fresh address" window
```

* `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.

```ts theme={null}
class BlockscoutDataProvider implements IDataProvider {
  constructor(options?: BlockscoutProviderOptions);
  getOutgoingCounterparties(sender: Address): Promise<CounterpartyScan>;
  getAddressActivity(address: Address): Promise<AddressActivity>;
  countZeroValueTransfers(from: Address, to: Address): Promise<number>;
}

interface CounterpartyScan {
  counterparties: Address[];
  /** False when the scan hit its page cap before exhausting history. */
  complete: boolean;
}

interface BlockscoutProviderOptions {
  apiUrl?: string;    // defaults to Arc Testnet's ArcScan
  timeoutMs?: number; // abort a slow explorer; default 15000 ms
  fetchFn?: typeof fetch;
}
```

Implement `IDataProvider` yourself to back the firewall with a different indexer,
an internal exchange database or a cache, and pass it as `CheckOptions.provider`.

<Warning>
  `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.
</Warning>

### CachingDataProvider

```ts theme={null}
class CachingDataProvider implements IDataProvider {
  constructor(inner: IDataProvider, options?: CachingProviderOptions);
}

interface CachingProviderOptions {
  ttlMs?: number;       // how long a successful, complete scan stays fresh. Default 60000
  now?: () => number;   // injectable clock, for tests
}
```

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

```ts theme={null}
class VerifiedRecipientIndex {
  // Everyone a sender has completed a protected transfer to, read from events.
}
```

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**.

```ts theme={null}
function acknowledgementCovers(
  acknowledgement: RiskAcknowledgement,
  actual: RiskReport,
  now?: number,
): boolean;

const MAX_ACKNOWLEDGEMENT_AGE_MS = 900000; // 15 minutes

interface RiskAcknowledgement {
  report: RiskReport;  // exactly what was on screen when they decided
  at: number;          // epoch ms of the decision itself
}
```

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.

<Note>
  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.
</Note>

### Dossier and the investigator

`check` returns a verdict; a dossier is the evidence behind it, gathered for a
second opinion (`POST /api/investigate`).

```ts theme={null}
function buildDossier(report: RiskReport, opts: BuildDossierOptions): Promise<Dossier>;
function findNearMisses(target: Address, counterparties: Address[]): Array<{
  counterparty: Address;
  sharedPrefix: number;
  sharedSuffix: number;
}>;
function clampVerdict(ruleLevel: RiskLevel, advisory: Advisory): Advisory;

interface BuildDossierOptions {
  publicClient: PublicClient;
  provider: IDataProvider;
  usdcAddress: Address;
  counterparties?: Address[];  // if the caller already has them
  now?: Date;
}

interface Advisory {
  level: RiskLevel;
  headline: string;
  points: string[];
}
```

* `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](/guides/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

```ts theme={null}
function generateClaimCode(): ClaimSecret;
function fromSecret(secret: string): ClaimSecret;   // rebuild from what a user typed
function normaliseSecret(input: string): string | null;
function formatSecret(secret: string): string;      // groups into fours for display
function saltFromSecret(secret: string): Hex;
function hashClaim(salt: Hex, code: string): Hex;

const CLAIM_SECRET_BITS = 80;  // 16 chars x 5 bits

interface ClaimSecret {
  secret: string;    // the one string a human carries: 'A4K7-9QMX-2PR6-TH8D'
  code: string;      // the same value normalised; what `claim` sends
  salt: Hex;         // derived from the secret, not separate entropy. Never transmitted
  claimHash: Hex;    // keccak256(abi.encodePacked(salt, code)) - the only on-chain part
}
```

**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.

<Warning>
  **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.
</Warning>

`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

```ts theme={null}
function getAllowance(clients: ClientPair, owner: Address): Promise<bigint>;
function approveUsdc(clients: ClientPair, amount: bigint): Promise<Hash | null>;
```

`approveUsdc` approves CtrlArcZ to pull `amount` USDC and returns `null` when the
existing allowance already covers it (no transaction sent).

### sendProtected

```ts theme={null}
function sendProtected(
  clients: ClientPair,
  params: SendProtectedParams,
  options?: SendProtectedOptions,
): Promise<SendProtectedResult>;

interface SendProtectedParams {
  configId: Hex;
  to: Address;
  amount: bigint;    // base units (6 decimals)
  claimHash: Hex;
  /** Wrap in Arc's Memo predeploy for reconcilable events. Default true. */
  memo?: boolean;
}

interface SendProtectedResult {
  transferId: bigint;
  txHash: Hash;
  deadline: Date;
}
```

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.

<Note>
  The firewall is **on by default**. `sendProtected` runs [`check`](#check) and
  throws [`RiskBlockedError`](#errors) 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.
</Note>

```ts theme={null}
interface SendProtectedOptions {
  skipRiskCheck?: boolean;       // removes the poisoning defense; use with care
  config?: IntegratorConfig;     // its onWarning policy decides what a warning does
  report?: RiskReport;           // reuse a report you already ran (see below)
  checkOptions?: CheckOptions;   // forwarded to check(); client/contract default from clients
  onReport?: (report: RiskReport) => void; // called when the scan does NOT block
}

const MAX_REPORT_AGE_MS = 120000; // a supplied report older than 2 min is re-scanned
```

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

```ts theme={null}
function sendProtectedWithPermit(
  clients: ClientPair,
  params: SendProtectedParams,
  options?: SendProtectedOptions,
): Promise<SendProtectedResult>;
```

Same as `sendProtected` but pulls USDC via a [Permit2](#permit2-helpers) 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

```ts theme={null}
function claim(clients: ClientPair, transferId: bigint, code: string, salt: Hex): Promise<Hash>;
function cancel(clients: ClientPair, transferId: bigint): Promise<Hash>;
function reclaimExpired(clients: ClientPair, transferId: bigint): Promise<Hash>;
```

* `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.

<Note>
  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`](#errors)
  (with `attemptsRemaining`), [`TransferLockedError`](#errors) after 5 wrong guesses,
  or [`ClaimOutcomeUnknownError`](#errors) if no outcome event is found.
</Note>

For gasless / ERC-4337 flows, build and interpret the claim yourself:

```ts theme={null}
function encodeClaimCall(transferId: bigint, code: string, salt: Hex): Hex;
function interpretClaimReceipt(
  receiptLogs: Log[],
  contractAddress: Address,
  transferId: bigint,
  txHash: Hash,
): Hash; // returns txHash on success; throws the same typed errors as claim()
```

`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

```ts theme={null}
function getTransfer(
  clients: Pick<ClientPair, 'publicClient' | 'contractAddress'>,
  transferId: bigint,
): Promise<ProtectedTransfer>;

function watchTransfer(
  publicClient: PublicClient,
  transferId: bigint,
  options: WatchTransferOptions,
): () => void; // returns an unsubscribe function

interface WatchTransferOptions {
  onUpdate: (transfer: ProtectedTransfer) => void;
  contractAddress?: Address;
}

interface ProtectedTransfer {
  transferId: bigint;
  sender: Address;
  to: Address;
  amount: bigint;
  deadline: Date;
  attempts: number;
  status: TransferStatus;
  claimHash: Hex;
  configId: Hex;
}

type TransferStatus = 'NONE' | 'PENDING' | 'CLAIMED' | 'CANCELLED' | 'RECLAIMED' | 'LOCKED';
```

`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

```ts theme={null}
function getPermit2Allowance(clients: ClientPair, owner: Address): Promise<bigint>;
function approvePermit2(clients: ClientPair, amount?: bigint): Promise<Hex | null>; // defaults to maxUint256
function signPermit2Transfer(
  clients: ClientPair,
  amount: bigint,
  spender: Address,
  options?: { nonce?: bigint; deadlineSeconds?: number; now?: number },
): Promise<Permit2Signature>;

interface Permit2Signature {
  nonce: bigint;
  deadline: bigint;
  signature: Hex;
}
```

`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.

```ts theme={null}
const TERMINAL_STATUSES: ReadonlySet<TransferStatus>; // CLAIMED, CANCELLED, RECLAIMED

function isTerminal(status: TransferStatus): boolean;
function isOpen(status: TransferStatus): boolean;      // PENDING or LOCKED
function statusBucket(status: TransferStatus): StatusBucket;

function isClaimable(
  transfer: Pick<ProtectedTransfer, 'status' | 'deadline'>,
  now?: number,
): boolean;   // PENDING and not past the deadline

function isReturnable(
  transfer: Pick<ProtectedTransfer, 'status' | 'deadline'>,
  now?: number,
): boolean;   // open and past the deadline

type StatusBucket = 'pending' | 'claimed' | 'cancelled' | 'expired';
```

`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.

```ts theme={null}
function gasReserve(gasPrice: bigint, gasLimit?: bigint): bigint;
function readGasReserve(client: { getGasPrice(): Promise<bigint> }, gasLimit?: bigint): Promise<bigint>;
function spendableAfterGas(balance: bigint, reserve: bigint): bigint;

const SEND_GAS_LIMIT = 700000n;  // a protected send
const PAY_GAS_LIMIT = 900000n;   // deploy + fund + pay, the three a private payment sends
const MIN_GAS_RESERVE = 50000n;  // 0.05 USDC floor
```

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](/guides/protected-transfer).

## Layer 3: Clean history

```ts theme={null}
function getCleanHistory(
  address: Address,
  options?: GetCleanHistoryOptions,
): Promise<CleanHistory>;

interface GetCleanHistoryOptions {
  apiUrl?: string;
  fetchFn?: typeof fetch;
  /** Tokens that may appear in the clean view. Defaults to Arc's USDC + EURC. */
  allowedTokens?: Address[];
}

interface CleanHistory {
  entries: HistoryEntry[];
  /** What was hidden, and why. Shown behind a "spam" toggle rather than destroyed. */
  filtered: FilteredEntry[];
}

interface HistoryEntry {
  txHash: `0x${string}`;
  direction: 'in' | 'out';
  /** `0x0000...0000` when `kind` is not `transfer`. Read `kind` first. */
  counterparty: Address;
  kind: EntryKind;      // 'transfer' | 'mint' | 'burn'
  amount: bigint;       // base units, in the token's own decimals
  decimals: number;
  tokenAddress: Address;
  tokenSymbol: string;
  timestamp: Date;
}

interface FilteredEntry extends HistoryEntry {
  reason: 'ZERO_VALUE' | 'UNKNOWN_TOKEN';
}
```

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".

<img src="https://mintcdn.com/ctrlarcz/pKCJPq_LT-Wjc1hI/screens/14-received-history.png?fit=max&auto=format&n=pKCJPq_LT-Wjc1hI&q=85&s=065523a733a61a50c7b2c2aa68304ce1" alt="A clean history" width="673" height="1231" data-path="screens/14-received-history.png" />

`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.

```ts theme={null}
function defineConfig(input?: DefineConfigInput): IntegratorConfig;
function registerConfig(clients: ClientPair, config: IntegratorConfig): Promise<RegisterConfigResult>;
function recommendTransferMode(config: IntegratorConfig, amount: bigint): 'protected' | 'plain';
function shouldBlockSend(config: IntegratorConfig, level: RiskLevel): boolean;

const MAX_FEE_BPS = 100;               // max integrator fee: 1%
const MAX_RECALL_WINDOW_SECONDS = 604800; // 7 days

interface IntegratorConfig {
  recallWindow: number;        // seconds before an unclaimed transfer is refundable
  claimMode: ClaimMode;        // only 'CODE' ships in v1
  feeBps: number;              // 0..100 (max 1%)
  feeRecipient: Address;
  minProtectedAmount: bigint;  // below this, recommendTransferMode returns 'plain'
  onWarning: 'warn' | 'block'; // what a `warning` verdict does
}

type ClaimMode = 'CODE' | 'SIGNATURE' | 'REGISTERED';
type DefineConfigInput = Partial<IntegratorConfig>;

interface RegisterConfigResult {
  configId: Hex;
  txHash: Hash;
}
```

* `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'`.

<Note>
  `SIGNATURE` and `REGISTERED` claim modes are reserved. `defineConfig` throws for
  any `claimMode` other than `'CODE'` in v1.
</Note>

## 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](/concepts/payer-shield) and [Private Pay](/guides/private-pay).

<Note>
  **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.
</Note>

`ShieldClients` is `{ publicClient, walletClient }`. `SpendMode` is `MODE_PUSH`
(`0`) or `MODE_PULL` (`1`).

### Account lifecycle

```ts theme={null}
function predictEphemeral(
  publicClient: PublicClient,
  factory: Address,
  salt: Hex,
  policy: EphemeralPolicy,
): Promise<Address>;

function createEphemeral(
  clients: ShieldClients,
  factory: Address,
  salt: Hex,
  policy: EphemeralPolicy,
): Promise<{ account: Address; txHash: Hex }>;

function fundEphemeral(
  clients: ShieldClients,
  account: Address,
  amount: bigint,
  policy: EphemeralPolicy,
): Promise<Hex>;

function fundFromVault(
  clients: ShieldClients,
  vault: Address,
  account: Address,
  amount: bigint,
): Promise<Hex>;

interface EphemeralPolicy {
  token: Address;
  owner: Address;      // bound only as a hash (the salt); never written to the account
  cosigner: Address;
  vault: Address;      // bound only as a commitment; address revealed only at sweep
  target: Address;
  maxAmount: bigint;
  perPullMax?: bigint; // PULL only: max per single pull. 0 = no tighter cap than maxAmount
  expiry: number;      // unix seconds; no outbound payment after this
  interval: number;    // PULL only: min seconds between pulls. 0 for PUSH
  mode: SpendMode;
}
```

* `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

```ts theme={null}
function readAccount(publicClient: PublicClient, account: Address): Promise<AccountState>;

interface AccountState {
  nonce: bigint;
  spent: bigint;
  remaining: bigint;
  target: Address;
  perPullMax: bigint;
  expiry: number;
  mode: SpendMode;
}
```

`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

```ts theme={null}
function submitPay(clients: ShieldClients, account: Address, amount: bigint, cosignerSig: Hex): Promise<Hex>;
function submitPull(clients: ShieldClients, account: Address, amount: bigint, cosignerSig: Hex): Promise<Hex>;
function sweepToVault(clients: ShieldClients, account: Address, vault: Address): Promise<Hex>;
function sweepExpired(clients: ShieldClients, account: Address, vault: Address): Promise<Hex>;
```

* `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

```ts theme={null}
function settlePrivatePayment(
  clients: ShieldClients,
  account: Address,
  amount: bigint,
  cosigner: CoSigner,
  ctx: { owner: Address },
): Promise<PrivatePayOutcome>;

interface PrivatePayResult {
  account: Address;
  txHash: Hex;
}

type PrivatePayOutcome =
  | { ok: true; result: PrivatePayResult }
  | { ok: false; vetoed: true; reason: string; riskReasons?: string[] };
```

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.

```ts theme={null}
const outcome = await settlePrivatePayment(clients, account, amount, cosigner, { owner });
if (!outcome.ok) console.warn('Vetoed:', outcome.reason);
else console.log('Paid:', outcome.result.txHash);
```

### 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.

```ts theme={null}
interface CoSigner {
  precheck(req: PrecheckRequest): Promise<PrecheckResult>;
  authorize(req: AuthorizeRequest): Promise<AuthorizeResult>;
  readonly address: Address;
}

class LocalCoSigner implements CoSigner {
  constructor(privateKey: Hex, opts?: LocalCoSignerOptions);
}

class RemoteCoSigner implements CoSigner {
  constructor(endpoint: string, address: Address, fetchImpl?: typeof fetch);
}

interface LocalCoSignerOptions {
  riskCheck?: RiskCheck;         // if omitted, every target is vetoed (fail-closed)
  vetoOn?: 'block' | 'warning';  // veto threshold; default 'block'
}

type RiskCheck = (owner: Address, target: Address) => Promise<RiskVerdict | null>;

interface RiskVerdict {
  level: 'safe' | 'warning' | 'block';
  complete: boolean;
  reasons?: string[];
}
```

* `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:

```ts theme={null}
interface PrecheckRequest {
  owner: Address;
  target: Address;
  amount: bigint;
}
type PrecheckResult = { approved: true } | { approved: false; reason: string; riskReasons?: string[] };

interface SpendRequest {
  account: Address;
  owner: Address;
  amount: bigint;
  action: SpendAction;
}

interface AuthorizeRequest extends SpendRequest {
  target: Address;
  nonce: bigint;
  chainId: number;
  remaining: bigint;
  expiry: number;  // unix seconds
  now?: number;    // injectable for tests
}
type AuthorizeResult =
  | { approved: true; signature: Hex }
  | { approved: false; reason: string; riskReasons?: string[] };
```

`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.

```ts theme={null}
class RemoteCoSigner implements CoSigner {
  constructor(
    endpoint: string,
    address: Address,
    fetchImpl?: typeof fetch,
    auth?: RemoteCoSignerAuth,
  );
}

interface RemoteCoSignerAuth {
  address: Address;
  sign: (message: string) => Promise<Hex>;
}

function cosignAuthMessage(owner: Address, ts: number, scope: CosignAuthScope): string;

interface CosignAuthScope {
  target?: Address;   // precheck binds the intended target (no account exists yet)
  account?: Address;  // authorize binds the account being spent from
  amount: bigint;
  action?: SpendAction; // authorize binds PAY vs PULL; precheck omits it
}
```

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.

```ts theme={null}
interface CounterfactualRequest {
  factory: Address;
  ownerHash: Hex;
  salt: Hex;
  policy: CounterfactualPolicy;
  box: Address;      // client-predicted; the server recomputes and must match
  owner: Address;
  amount: bigint;
  chainId: number;
}
```

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.

<Warning>
  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.
</Warning>

### settlePrivatePaymentBatched

```ts theme={null}
function settlePrivatePaymentBatched(/* ... */): Promise<PrivatePayOutcome>;
```

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.

```ts theme={null}
function assertBoxFundable(publicClient: PublicClient, account: Address, policy: EphemeralPolicy): Promise<void>;
function fundBoxFromGateway(clients: { walletClient: WalletClient }, params: FundBoxParams): Promise<{ transferId?: string }>;
function awaitBoxFunded(
  publicClient: PublicClient,
  account: Address,
  amount: bigint,
  usdc: Address,
  opts?: { timeoutMs?: number; pollMs?: number; now?: () => number },
): Promise<boolean>;
function isBoxFunding(recipient: string | undefined, boxes: ReadonlySet<string>): boolean;

interface FundBoxParams {
  account: Address;
  amount: bigint;
  from: GatewayChain;  // which chain's Gateway balance pays
  to: GatewayChain;    // which chain the box is on, and where Circle mints
  onTransferId?: (id: string) => void;
  timeoutMs?: number;
}
```

* `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

```ts theme={null}
class AnnouncementIndex { /* stealth announcements, cached and scanned in chunks */ }
class AccountOwnerIndex { /* boxes by ownerHash, across current and prior factories */ }
```

`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

```ts theme={null}
function explorerAnnouncements(/* ... */): Promise<IndexedAnnouncement[]>;
function explorerAccountsCreated(/* ... */): Promise<CreatedAccount[]>;
function explorerLogs(/* ... */): Promise<unknown[]>;
function explorerUsable(): ExplorerHealth;
function clearExplorerState(): void;

const EXPLORER_MAX_LAG_BLOCKS: bigint;
```

`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

```ts theme={null}
function spendTypedData(p: SpendDigestParams): {
  domain: TypedDataDomain;
  types: typeof SPEND_TYPES;
  primaryType: 'Spend';
  message: { target: Address; amount: bigint; nonce: bigint; action: SpendAction };
};
function spendDigest(p: SpendDigestParams): Hex; // the 32-byte digest; equals account.spendDigest(amount, action)
function ownerHash(owner: Address): Hex;          // keccak256(abi.encode(owner)) - the CREATE2 salt binding
function vaultHash(vault: Address): Hex;          // keccak256(abi.encode(vault)) - the return-address commitment

interface SpendDigestParams {
  account: Address;   // becomes the EIP-712 verifyingContract
  chainId: number;
  target: Address;
  amount: bigint;
  nonce: bigint;
  action: SpendAction;
}

const ACTION_PAY = 0;   // SpendAction
const ACTION_PULL = 1;  // SpendAction
const SPEND_DOMAIN_NAME = 'Ctrl+ArcZ SpendPolicy';
const SPEND_DOMAIN_VERSION = '1';
// SPEND_TYPES - the EIP-712 `Spend(address target,uint256 amount,uint256 nonce,uint8 action)` type
```

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](/guides/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](/guides/stealth-boxes).

```ts theme={null}
const STEALTH_SCHEME_ID = 1;
const STEALTH_KEY_MESSAGE: string;  // signed once; never retype it

function deriveStealthKeys(signature: Hex): StealthKeys;
function generateStealthAddress(meta: StealthMetaAddress, ephemeralKey?: Hex): StealthAnnouncement;
function checkStealthAddress(params: {
  viewingKey: Hex;
  spendingPub: Hex;
  ephemeralPubKey: Hex;
  viewTag?: number;
}): Address | null;
function computeStealthPrivateKey(params: {
  spendingKey: Hex;
  viewingKey: Hex;
  ephemeralPubKey: Hex;
}): Hex;

interface StealthMetaAddress {
  spendingPub: Hex;   // compressed secp256k1 point; funds derive against this
  viewingPub: Hex;    // compressed secp256k1 point; only this key scans
}
interface StealthKeys extends StealthMetaAddress {
  spendingKey: Hex;
  viewingKey: Hex;
}
interface StealthAnnouncement {
  stealthAddress: Address;
  ephemeralPubKey: Hex;
  viewTag: number;    // first byte of the shared secret; skips non-matches cheaply
}
```

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

```ts theme={null}
function newStealthOwner(meta: StealthMetaAddress, ephemeralKey?: Hex): {
  stealthAddress: Address;
  ephemeralPubKey: Hex;
  viewTag: number;
};
function announceArgsFor(
  stealth: { stealthAddress: Address; ephemeralPubKey: Hex },
  box: Address,
  label?: string,
): readonly [bigint, Address, Hex, Hex];
function announceStealthBox(
  clients: { publicClient: PublicClient; walletClient: WalletClient },
  announcer: Address,
  announceArgs: readonly [bigint, Address, Hex, Hex],
): Promise<Hex>;
function discoverStealthBoxes(
  publicClient: PublicClient,
  announcer: Address,
  keys: Pick<StealthKeys, 'viewingKey' | 'spendingPub'>,
  opts?: { fromBlock?: bigint },
): Promise<StealthBox[]>;
function recognizeAnnouncements(
  keys: Pick<StealthKeys, 'viewingKey' | 'spendingPub'>,
  announcements: RawAnnouncement[],
): StealthBox[];

function encodeStealthMetadata(box: Address, label?: string): Hex;
function decodeStealthMetadata(metadata: Hex): StealthMetadata;

interface StealthBox {
  stealthAddress: Address;
  box: Address;       // the SpendPolicyAccount this announcement points at
  ephemeralPubKey: Hex;
  label: string;      // the announced name, or empty
}
```

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.

<Note>
  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.
</Note>

## Layer 5: Bridging USDC

Non-custodial, wallet-side, no server. Full guide: [Bridge USDC](/guides/bridge).

### CCTP

```ts theme={null}
function quoteBridge(params: {
  from: CctpChainName;
  to: CctpChainName;
  amount: bigint;
  fetchImpl?: typeof fetch;
}): Promise<BridgeQuote>;

function bridgeFromWallet(
  clients: { publicClient: PublicClient; walletClient: WalletClient },
  params: {
    from: CctpChainName;
    to: CctpChainName;
    amount: bigint;
    recipient?: Address;               // defaults to the sending wallet
    onQuote?: (quote: BridgeQuote) => void;
    onStep?: (step: CctpStep, txHash?: Hex) => void;
    timeoutMs?: number;
  },
): Promise<BridgeResult>;

function findForwardedMint(params: { sourceDomain: number; burnTxHash: Hex }): Promise<Hex | undefined>;
function waitForForwardedMint(params: {
  sourceDomain: number;
  burnTxHash: Hex;
  timeoutMs?: number;
}): Promise<Hex | undefined>;

function chainLabel(name: CctpChainName): string;
function chainExplorerTxUrl(chain: CctpChainName, txHash: string): string | undefined;
function cctpChainByChainId(chainId: number | undefined): CctpChainName | undefined;

const CCTP_CHAINS: Record<CctpChainName, CctpChain>;  // 20 testnets
const CCTP_TOKEN_MESSENGER: Address;
const IRIS_TESTNET: string;
const FORWARDING_HOOK: Hex;   // ascii 'cctp-forward', padded to 32 bytes

type CctpStep = 'quote' | 'approve' | 'burn' | 'attest' | 'forward';

interface BridgeQuote { amount: bigint; maxFee: bigint; total: bigint }
interface BridgeResult {          // exported as CctpBridgeResult
  approveTxHash?: Hex;
  burnTxHash: Hex;
  forwardTxHash?: Hex;
  quote: BridgeQuote;
}
interface CctpChain {
  domain: number;      // a CCTP domain, NOT a chain id
  chainId: number;
  usdc: Address;
  gasToken?: 'usdc';   // set only where gas is the same USDC being bridged (Arc)
}
```

`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

```ts theme={null}
function depositToGateway(
  clients: { publicClient: PublicClient; walletClient: WalletClient },
  params: { chain: GatewayChain; amount: bigint; onStep?: (s: GatewayStep, h?: Hex) => void },
): Promise<{ approveTxHash?: Hex; depositTxHash: Hex }>;

function gatewayBalance(params: {
  depositor: Address;
  chains?: readonly GatewayChain[];
}): Promise<GatewayBalance>;

function quoteGatewaySpend(params: {
  from: GatewayChain;
  to: GatewayChain;
  amount: bigint;
  depositor: Address;
  recipient?: Address;
}): Promise<GatewayQuote>;

function spendFromGateway(
  clients: { walletClient: WalletClient },
  params: {
    from: GatewayChain;
    to: GatewayChain;
    amount: bigint;
    recipient?: Address;
    onTransferId?: (transferId: string) => void;
    onStep?: (step: GatewayStep, txHash?: Hex) => void;
    timeoutMs?: number;
  },
): Promise<GatewaySpendResult>;

function findGatewayMint(params: { transferId: string }): Promise<GatewayTransferStatus>;
function waitForGatewayMint(params: {
  transferId: string;
  timeoutMs?: number;
}): Promise<GatewayTransferStatus>;
function isGatewayChain(name: string): name is GatewayChain;
function isGatewayWithdrawal(params: { from: GatewayChain; to: GatewayChain }): boolean;

const GATEWAY_CHAIN_NAMES: readonly GatewayChain[];  // 11 of the CCTP chains
const GATEWAY_WALLET: Address;
const GATEWAY_MINTER: Address;
const GATEWAY_API_TESTNET: string;
const DEPOSIT_CONFIRMATION_SECONDS: Record<GatewayChain, number>;

type GatewayStep = 'approve' | 'deposit' | 'quote' | 'sign' | 'transfer' | 'mint';

interface GatewayBalance {
  total: bigint;
  byChain: Partial<Record<GatewayChain, bigint>>;  // spend against THIS, not total
}
interface GatewayQuote {
  maxFee: bigint;      // what gets signed: Circle's quote plus headroom
  quotedFee: bigint;   // Circle's unpadded figure, for showing a real fee
  maxBlockHeight: bigint;
  total: bigint;
}
interface GatewaySpendResult { transferId: string; mintTxHash?: Hex; quote: GatewayQuote }
interface GatewayTransferStatus {
  /** `pending` covers every non-terminal state, including a poll that failed. */
  state: 'pending' | 'done' | 'failed';
  mintTxHash?: Hex;
  reason?: string;
}
```

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

```ts theme={null}
function usdc(subunits: bigint, decimals?: number): string;
function percentOf(maxSpendable: bigint, fraction: number, decimals?: number): string;
function maxGatewaySpendable(here: bigint, feeCeiling: bigint): bigint;
function maxDepositable(walletBalance: bigint, gasReserve: bigint): bigint;
function gatewayShortfall(params: {
  here: bigint;              // the balance ON THE SOURCE CHAIN
  byChain: Record<string, bigint>;
  from: string;
  fromLabel: string;
  committed: bigint;         // amount + fee ceiling
  labelOf: (chain: string) => string;
}): Refusal | null;
function cctpShortfall(params: {
  usdcBalance: bigint;
  total: bigint;
  maxFee: bigint;
  gasCost: bigint;
  nativeBalance: bigint;
  chainLabel: string;
  gasInUsdc: boolean;        // true on Arc: transfer and gas share one balance
  nativePerUsdc?: bigint;
}): Refusal | null;

interface Refusal {
  code: 'gwNoBalanceHere' | 'gwEnoughOn' | 'gwShort' | 'shortWithFee' | 'shortWithGas' | 'noGas';
  params: Record<string, string>;  // already formatted for reading
  fix?: RefusalFix;
}
type RefusalFix =
  | { kind: 'switchSource'; chain: string; label: string }
  | { kind: 'deposit'; amount: bigint; display: string }
  | { kind: 'useMax'; amount: bigint; display: string };
```

`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.

| Class | Raised by | Carries |
| - | - | - |
| `CtrlArcZError` | base class | - |
| `RiskBlockedError` | `sendProtected*` when the firewall blocks | `report: RiskReport`, `reasons: string[]` |
| `TransferUnavailableError` | `claim` / `cancel` / `reclaimExpired` on bad state | `reason: TransferUnavailableReason` |
| `WrongClaimCodeError` | `claim` with a wrong code | `transferId`, `attemptsRemaining`, `txHash` |
| `TransferLockedError` | `claim` after 5 wrong guesses | `transferId`, `txHash` |
| `ClaimOutcomeUnknownError` | `claim` mined but no outcome event | `txHash` |

`TransferUnavailableReason` is `'not_pending' | 'expired' | 'unknown' |
'not_sender'`. Full handling patterns are in
[Errors and results](/guides/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.

```ts theme={null}
import { spendPolicyAccountAbi, readAccount } from '@ctrl-arcz/sdk';
// use the ABI directly with viem, or prefer the typed wrappers like readAccount above
```

## Next

<CardGroup cols={2}>
  <Card title="Private Pay" icon="user-secret" href="/guides/private-pay">
    Pay a merchant from a disposable address under the co-signer's veto.
  </Card>

  <Card title="Protected transfer" icon="shield-check" href="/guides/protected-transfer">
    Code-gated claim, sender cancel, automatic refund.
  </Card>

  <Card title="Contracts" icon="file-contract" href="/reference/contracts">
    The on-chain surface behind the SDK.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.