npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 viem

30-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 sender

The 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 too

The 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 runs

The 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. claim pays the recipient recorded on-chain, and in a poisoning attack that recipient is the attacker: they hold claimHash and 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. Use normaliseSecret on whatever the recipient types, then fromSecret. 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). claim inspects the receipt and throws WrongClaimCodeError or TransferLockedError, 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 warning at best, complete: false, and a lookalike that cannot be ruled out is a block.

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.