Skip to content

Repository files navigation

@furlpay/arc-kit

Chain definitions, EIP-712/EIP-3009 helpers, and the decimal conversion Circle Arc actually requires.

Circle Arc Chain ID Gas MIT

Stack

TypeScript Node.js npm ESM

Rails and standards

USDC EIP-3009 EIP-712 CCTP x402

Guarantees

Zero dependencies Tests Verified on-chain viem compatible

npm install @furlpay/arc-kit

The problem this exists for

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


Usage

Chain definitions

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.

Converting between the interfaces

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 exact

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

Display and input

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 input

EIP-712 / EIP-3009

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


Verify it yourself

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 testnet

It 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

Reference

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.

Scope

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.

Contributing

See CONTRIBUTING.md. Security issues: SECURITY.md — please do not open a public issue.

License

MIT

About

Circle Arc (chain 5042) toolkit: chain definitions, EIP-712/EIP-3009 helpers, and the native-vs-ERC-20 decimal conversion Arc requires. Zero runtime dependencies.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages