Chain definitions, EIP-712/EIP-3009 helpers, and the decimal conversion Circle Arc actually requires.
Stack
Rails and standards
Guarantees
npm install @furlpay/arc-kitArc is an EVM L1 where USDC is the native gas asset. That removes the ETH float from a payments stack — the fee payer holds the same asset the payment is denominated in — and it introduces one hazard that has no equivalent on any other chain:
Arc exposes ONE USDC balance through TWO interfaces at different precision.
- native — 18 decimals. Gas, native sends,
msg.value. Canonical.- ERC-20 — 6 decimals.
transfer/approve/balanceOf. A truncating view.
They are not two holdings. There is no wrapper contract. addr.balance and USDC.balanceOf(addr) describe the same money at different resolutions, and Circle's guidance is explicit: do not model native and ERC-20 USDC as separate balances.
Here is what that looks like on mainnet right now — a real account, both interfaces:
eth_getBalance -> 57628035984085784575 (18dp, canonical)
balanceOf -> 57628035 ( 6dp, truncated)
57628035984085784575 / 10^12 == 57628035 exactly
984,085,784,575 base units of that account's real money are invisible to the ERC-20 view. Treat the two interfaces as separate assets and you double-count every Arc balance. Store at 6 decimals and you silently discard dust the chain is still tracking. Mix the two in one variable and you are a single multiply away from a 10¹² error in a money path.
Plain objects in viem's Chain shape, so this package doesn't depend on viem:
import { createPublicClient, http } from "viem";
import { arcMainnet, arcTestnet } from "@furlpay/arc-kit";
const client = createPublicClient({ chain: arcMainnet, transport: http() });nativeCurrency.decimals is 18 deliberately — fee estimation works in native units. It does not mean an Arc USDC token transfer is 18 decimals.
import { toErc20, toNative, erc20Remainder, isExactInErc20 } from "@furlpay/arc-kit";
const native = 57628035984085784575n;
toErc20(native); // 57628035n — truncates, like the chain does
erc20Remainder(native); // 984085784575n — what truncation dropped
isExactInErc20(native); // false — this amount loses dust
toNative(57628035n); // 57628035000000000000n — always exacttoErc20 truncates and never rounds up. Rounding up would invent money the ERC-20 interface cannot move, so a balance check would pass for a transfer that then reverts.
Two consequences worth internalising:
toErc20(999_999_999_999n); // 0n — a zero ERC-20 balance does NOT prove an empty account// Catches the loud failure: a native figure handed to an ERC-20 sink.
assertErc20Scale(57628035984085784575n, "transfer.amount");
// ArcPrecisionError: transfer.amount: ... is implausible as an ERC-20 (6dp)
// USDC amount — that is >= 1e12 USDC.assertErc20Scale is a smoke alarm, not a type system — 1_000_000n is legal in both interfaces, so it cannot catch the quiet case. The real fix is to convert at exactly one boundary and never carry both precisions in the same variable.
import { formatNativeUsdc, parseNativeUsdc } from "@furlpay/arc-kit";
formatNativeUsdc(57628035984085784575n); // "57.628035"
formatNativeUsdc(57628035984085784575n, 18); // "57.628035984085784575"
parseNativeUsdc("57.628035984085784575"); // 57628035984085784575n
parseNativeUsdc("1.0000000000000000001"); // throws — refuses to truncate your inputThe useful finding here is a negative one: Arc needs no special signing handling. Its USDC is Circle's FiatTokenV2 with the standard EIP-712 domain, so an existing EIP-3009 signer or ecrecover verifier works as soon as it knows the chain id.
import { arcUsdcDomain, TRANSFER_WITH_AUTHORIZATION_TYPES, arcAuthorizationValue } from "@furlpay/arc-kit";
const signature = await walletClient.signTypedData({
domain: arcUsdcDomain(), // name "USDC", version "2", chain 5042
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
primaryType: "TransferWithAuthorization",
message: {
from, to,
value: arcAuthorizationValue(10n ** 18n, "native"), // -> 1_000_000n (1 USDC, 6dp)
validAfter: 0n, validBefore, nonce,
},
});transferWithAuthorization is an ERC-20 function, so the authorization's value is in 6-decimal units — not the 18-decimal native figure. arcAuthorizationValue makes you name which side your amount came from, because a default there would make the wrong answer silent.
arcUsdcDomain() throws on a non-Arc chain id rather than returning a domain that produces signatures no Arc contract will accept.
Every constant in this package was read from the live networks, not copied from documentation. You can reproduce all of it:
node scripts/verify-onchain.mjs # Arc mainnet
node scripts/verify-onchain.mjs --testnet # Arc testnetIt checks eth_chainId, the USDC interface's decimals/symbol/name/version, that EIP-3009 is present, that DOMAIN_SEPARATOR() is exposed — and then samples a real account from a recent block and proves native / 10^12 == balanceOf. Exit code is non-zero on any mismatch, so it works as a CI gate.
Or by hand:
curl -s -X POST https://rpc.mainnet.arc.io -H 'content-type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}'
# {"jsonrpc":"2.0","id":1,"result":"0x13b2"} -> 5042| Mainnet | Testnet | |
|---|---|---|
| Chain id | 5042 (0x13b2) |
5042002 (0x4cef52) |
| RPC | https://rpc.mainnet.arc.io |
https://rpc.testnet.arc.io |
| Explorer | https://explorer.arc.io |
https://explorer.testnet.arc.io |
| USDC (ERC-20 interface) | 0x3600…0000 |
0x3600…0000 |
| Native / ERC-20 decimals | 18 / 6 | 18 / 6 |
| Faucet | — | https://faucet.circle.com |
CCTP domain: 26. Circle's supported-domains table lists Arc at 26. Community documentation written before mainnet launch described 26 as testnet only — if you are wiring a mainnet burn, confirm against Circle's own table rather than trusting any secondary source, this README included.
This package is deliberately small: constants, conversion, and typed-data helpers. It does not wrap RPC calls, sign anything, or bundle a client — bring your own viem, ethers, or fetch.
See CONTRIBUTING.md. Security issues: SECURITY.md — please do not open a public issue.
MIT