@ctrl-arcz/sdk
v0.2.1
Published
Ctrl+ArcZ SDK — protected USDC transfers on Arc: pre-send risk firewall, code-gated claim, sender cancel, automatic refund
Maintainers
Readme
@ctrl-arcz/sdk
Protected USDC transfers on Arc: a pre-send risk firewall, code-gated claim, sender cancel, and automatic refund. Kills the "send one dollar first and wait" ritual and blocks address poisoning before the transaction is signed.
npm install @ctrl-arcz/sdk viem30-second quickstart
import { createPublicClient, createWalletClient, http, parseUnits } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { arcTestnet, RPC_URL } from '@ctrl-arcz/sdk';
import {
defineConfig,
registerConfig,
approveUsdc,
generateClaimCode,
fromSecret,
sendProtected,
claim,
RiskBlockedError,
} from '@ctrl-arcz/sdk';
const account = privateKeyToAccount(process.env.SENDER_PRIVATE_KEY as `0x${string}`);
const publicClient = createPublicClient({ chain: arcTestnet, transport: http(RPC_URL) });
const walletClient = createWalletClient({ account, chain: arcTestnet, transport: http(RPC_URL) });
const clients = { publicClient, walletClient };
const recipient = '0x…';
// 1. Register your integrator behaviour once (idempotent).
const config = defineConfig({ recallWindow: 3600 });
const { configId } = await registerConfig(clients, config);
// 2. Approve, then lock the funds with a claim commitment.
const amount = parseUnits('100', 6); // USDC has 6 decimals on Arc's ERC-20 interface
await approveUsdc(clients, amount);
const secret = generateClaimCode(); // { secret, code, salt, claimHash }
try {
// The firewall runs inside sendProtected. A lookalike or a zero-value baiter
// throws RiskBlockedError before a single unit of USDC moves.
const { transferId } = await sendProtected(
clients,
{
configId,
to: recipient,
amount,
claimHash: secret.claimHash,
},
{ config },
);
// Hand `secret.secret` to the recipient yourself. It is the whole proof, so it
// must reach a person, not an address: any delivery keyed to the recipient
// address also reaches a poisoning attacker, who owns that address.
// 3. The recipient releases it, or you relay for them. Funds always go to `to`.
// Whatever the recipient typed back; fromSecret rebuilds what claim needs.
const typedByRecipient = secret.secret;
const { code, salt } = fromSecret(typedByRecipient);
await claim(clients, transferId, code, salt);
} catch (e) {
if (e instanceof RiskBlockedError) {
// e.report is the full RiskReport: level, rule codes, lookalikeOf, complete.
// Hand it to your own risk card. Nothing reached the chain.
console.error(e.report);
} else throw e;
}Cancel any time before a claim lands, and unclaimed transfers refund themselves:
import { cancel, reclaimExpired } from '@ctrl-arcz/sdk';
await cancel(clients, transferId); // sender only, before a claim
await reclaimExpired(clients, transferId); // anyone, after the window. Money returns to the senderThe firewall is on by default
sendProtected and sendProtectedWithPermit run the address-poisoning check() themselves, before any funds move. Installing the SDK is enough to be protected; there is no separate call to remember, and forgetting one cannot quietly disable the defense.
What stops a send:
| Verdict | Default | Rules |
| --------- | ------------------- | ------------------------------------------------------------------- |
| block | Throws | LOOKALIKE_ADDRESS, ZERO_VALUE_BAIT, an unrulable-out lookalike |
| warning | Proceeds (advisory) | NEW_ADDRESS, FRESH_ADDRESS, an incomplete but non-critical scan |
| safe | Proceeds | VERIFIED_RECIPIENT, KNOWN_COUNTERPARTY |
Warnings are advisory on purpose. Paying a brand-new address is the most common legitimate payment there is, and a default that hard-failed it would only teach integrators to switch the guard off. Set onWarning: 'block' on your config if your users must be hard-stopped on any doubt.
const strict = defineConfig({ recallWindow: 3600, onWarning: 'block' });
await registerConfig(clients, strict);
await sendProtected(clients, params, { config: strict }); // warnings now throw tooThe policy lives in one place. sendProtected runs your config through the same shouldBlockSend your UI uses, so a config that says onWarning: 'block' cannot mean one thing in your pre-send screen and another inside the SDK.
Options
| Option | Purpose |
| --------------- | -------------------------------------------------------------------------------------------------- |
| config | The IntegratorConfig whose onWarning decides what a warning does. Defaults to defineConfig() |
| report | A RiskReport you already have for this exact pair, to avoid scanning twice |
| checkOptions | Forwarded to check (custom provider, contractAddress, now) |
| onReport | Called with the report when the scan does not block, so you can surface warnings |
| skipRiskCheck | Turns the guard off entirely. Prefer report if you only want to avoid a redundant scan |
If your UI already ran check(), hand the report over rather than skipping the guard. The report is reused only when it is about the same sender and target and is younger than MAX_REPORT_AGE_MS (two minutes); otherwise the guard silently re-scans. A stale report proves nothing, because a bait transfer could have landed since it was taken.
const report = await check(sender, recipient, { client: publicClient }); // for your UI
// ... user reviews the risk card, then confirms
await sendProtected(clients, params, { config, report }); // no second scan, guard still runsThe failure mode you need to know
The guard calls check(), which reads ArcScan. If the indexer cannot be reached, a send to an address you have not paid before will throw, because a lookalike cannot be ruled out and the firewall fails closed rather than waving the send through. That is the intended behaviour of a firewall, but it means sendProtected now depends on an indexer being up. Your options, in order of preference: retry, verify the address out of band and pass a report you built with evaluateRisk and your own data, or supply a different IDataProvider through checkOptions.
Security notes
- One secret, 80 bits, carried by a human.
claimpays the recipient recorded on-chain, and in a poisoning attack that recipient is the attacker: they holdclaimHashand can grind it offline for as long as they like. So the secret is 16 Crockford base32 characters, not a six-digit code (twenty bits, milliseconds of work). The salt is derived from it, so there is no second half to deliver, and nothing to deliver BY ADDRESS. UsenormaliseSecreton whatever the recipient types, thenfromSecret. The chain only ever stores the hash. - A wrong code does not revert on-chain (the attempt counter has to survive so the five-guess lockout can bind).
claiminspects the receipt and throwsWrongClaimCodeErrororTransferLockedError, so you never mistake a mined transaction for a successful claim. - The firewall never degrades to "safe". If a data source is unavailable, the report is
warningat best,complete: false, and a lookalike that cannot be ruled out is ablock.
API
| Function | Purpose |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| check(sender, target, opts) | Layer 1 firewall, returns a RiskReport (safe, warning, block) |
| evaluateRisk(input, now?) | The pure rule engine, provider-free (for custom data sources) |
| craftLookalike(target) | Mint a real lookalike address (demos and tests) |
| defineConfig(input) | Build an integrator config (window, claim mode, fee, thresholds, onWarning) |
| registerConfig(clients, config) | Register it on-chain, returns an idempotent configId |
| shouldBlockSend(config, level) | The single warning policy, shared by your UI and the SDK guard |
| recommendTransferMode(config, amount) | plain below minProtectedAmount, else protected |
| generateClaimCode() | { secret, code, salt, claimHash } |
| normaliseSecret(typed) | Normalised secret, or null |
| fromSecret(secret) | { secret, code, salt, claimHash } rebuilt from the string |
| approveUsdc(clients, amount) | ERC-20 approval to CtrlArcZ |
| sendProtected(clients, params, opts?) | Firewall, then lock funds. Returns { transferId, txHash, deadline } |
| approvePermit2 / sendProtectedWithPermit(clients, params, opts?) | One-signature send via Permit2, no per-send approve tx |
| claim(clients, id, code, salt) | Release to the recorded recipient |
| cancel(clients, id) | Sender takes the money back |
| reclaimExpired(clients, id) | Anyone refunds an expired transfer to the sender |
| getTransfer(clients, id) | On-chain transfer state |
| watchTransfer(client, id, opts) | Subscribe to state changes |
| getCleanHistory(address, opts) | Layer 3, a spam-free history |
Every chain, from one registry
The SDK started on Arc alone, and one export used to carry every address. It no longer does: the same contracts are deployed on several testnets, and the same ticker is a different contract on each of them. Ask the registry for the chain you are on rather than reaching for a constant.
| Function | Purpose |
| ----------------------------- | ---------------------------------------------------------------------------------- |
| deploymentFor(chainId) | Everything deployed on that chain: contracts, USDC, RPCs, explorer, or undefined |
| deployedChainIds() | The chains this build knows about |
| DEPLOYMENTS | The whole registry, keyed by chain id |
| tokensFor(chainId) | The tokens that exist on it, with addresses, decimals and search names |
| spendableTokensFor(chainId) | The same list without the ones an allowlist would refuse |
| cctpChainByChainId(id) | Circle's name for a chain id, for the bridge functions |
ADDRESSES, arcTestnet and CTRL_ARCZ_ADDRESS are still exported and still
Arc's. They are the right thing to use only when you know you are on Arc; on
anything else deploymentFor is the one that answers.
Spend boxes
Anything that repeats, a subscription or an agent's budget, runs from a SpendPolicyAccount whose policy is on chain rather than from a token allowance. The recipient is locked at deploy time, so whoever submits the pull, the funds can only ever reach it.
| Function | Purpose |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| createEphemeral(clients, factory, salt, policy) | Deploy a box for one payee. policy: target, per-pull cap, min interval, total budget, expiry |
| predictEphemeral(publicClient, factory, salt, policy) | The CREATE2 address a policy would get, before spending anything on it |
CCTP and Gateway
Both are signed by the wallet that owns the money. No server key appears in either path, so no operator balance stands behind a user's transfer.
| Function | Purpose |
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
| bridgeFromWallet(clients, params) | CCTP v2 burn-and-mint between chains. Circle's Forwarding Service submits the mint |
| depositToGateway(clients, params) | Deposit into GatewayWallet; the balance stays credited to the depositor |
| spendFromGateway(clients, params) | Spend the unified balance to any supported chain with an EIP-712 intent, no source-chain tx |
bridgeFromWallet takes onStep for progress and onQuote for the price. The
quote is in the result as well, but the result arrives when the transfer does, and
the fee is knowable long before that: onQuote fires the moment Circle has priced
it and before anything is signed, so a caller recording the transfer can record
what it costs at the same time.
await bridgeFromWallet(clients, {
from: 'Arc_Testnet',
to: 'Base_Sepolia',
amount: 1_000_000n,
onQuote: ({ maxFee }) => showFee(maxFee), // before the first signature
onStep: (step, txHash) => showStep(step, txHash),
});Custom data source
The rule engine is a pure function. Point it at any indexer by implementing IDataProvider, or call evaluateRisk with data you already have:
import { evaluateRisk } from '@ctrl-arcz/sdk';
const report = evaluateRisk({
sender, target,
counterparties: [...], // addresses this sender has paid
targetActivity: { transactionCount, firstSeenAt },
zeroValueBait: { count },
isVerifiedRecipient: false,
});The report it returns can be handed straight to sendProtected as report, so the guard runs on your data without touching ArcScan.
Live reference example
apps/sender in the repo is the reference integration: a React and Vite UI on top of this SDK, with real EIP-1193 wallet connection, the risk firewall, protected send (classic and Permit2), code or gasless claim, cancel, and the poisoning scenario. Sending and receiving are two modes of the one app. Run pnpm dev:sender. It is the "grab and adapt" starting point for an integrator.
Testnet only. Not audited. See the repo for the contract, tests, and live demos.
