Assets and the hub

This page explains what it means for a token to be listed on a BTR pool, why every core designates one base asset (its unit of account, and the root its anchor tree resolves to) and why on Arc Testnet that asset is the mintable twin USDC.b rather than native Circle USDC. It then covers the practical half: how the /faucet page decides what you can claim, what register() and claim() actually do, and the decimal and gas surprises that catch people on their first trade. Routing itself, how a swap is planned across pools, is in Swapping; per-asset addresses, feeds and ERC-20 quirks are in the Asset Registry.


1. What a listing is

On chain, a listing is one assets[token] slot written by PoolConfig.initAsset. Two properties are worth knowing:

  • Decimals are read from the token, never passed in. initAsset calls the ERC-20 and stores what it returns. A token reporting 0 or more than 18 decimals cannot be listed.
  • decimals != 0 is the is-configured sentinel. Every reader gates on it, which is why 0 is unlistable: it collides with “not configured”.

Listing is admin-only, and one-way-latched. Before sealBootstrap(pool) the deployer lists through Admin.addAsset. After it, addAsset reverts permanently and the only path is the timelocked ADD_ASSET queue: Admin.requestOp(..., ADD_ASSET, ...) then Admin.executeAddAsset(pool, token). See Admin for the queue and Access Control for who may drive it.

Every listed asset, the base included, must carry kappaCovBps > 0 (Invariants §I-9).

2. Why every core has a base leg

Each pool stores one baseToken, fixed at initialize. A new listing defaults its anchor to the base (initAsset sets anchor = (token == baseToken) ? 0 : baseToken) and adminSetAnchor can afterwards re-parent it to any correlated listed asset, up to MAX_DEPTH = 4 edges from the root. A core whose whole roster still carries that default is a depth-1 hub-and-spoke star, and any cross there is at most spoke → base → spoke in a single Pool.swap; that is the shape on Arc today, not the protocol rule. Deeper trees are configuration, not a different mechanism, and a swap between two assets sharing a parent prices through that parent without touching the base at all (Anchor Path Pricing).

The base is the numeraire, so it carries constraints no spoke does:

Requirement on the baseWhy
EXTERNAL-quotedits own depeg halt must be able to bite (Depeg Halt)
QUOTE_UNIT_ANCHORit is the unit other legs are attested in
spokes carry an independent ref banda spoke’s mark is checked against a second attestation, not against the base alone

Changing the base is not a pointer swap. PoolConfig.setBaseToken(newBase, spokes), reachable only through Admin.executeBaseMigration, reverts unless every one of these holds:

  • the incoming base is already listed;
  • it is EXTERNAL-quoted;
  • it is QUOTE_UNIT_UOA;
  • it is trading within BASE_DEPEG_HALT_BPS of parity;
  • the demoted base gains a ref band in the same call.

Off chain the same leg is the router’s hub: a pair with no shared pool is fillable only as one hop through the hub symbol (Swapping §2). pool.baseToken() is the authority for which asset that is; no deployment artifact records it.

3. Native Circle tokens vs the faucet twins

On Arc Testnet two assets are the official Circle deployments, declared in the deploy artifact’s nativeTokens and never minted by BTR:

Native USDCNative EURCTwins
Address0x3600…00000x89B5…D72aUSDCB 0x9A8E…061CC, EURCB 0xd901…dE5A1
Decimals6618
Mintablenonoyes (TestnetERC20)
Faucet-claimablenonosubject to an on-chain cap

Every non-native pool leg is a TestnetERC20 deployed as new TestnetERC20(sym + ".b", sym + ".b", 18). The deploy ceremony classifies a token by staticcalling minter(): native USDC reverts, so it is treated as un-mintable float and seeded to unmintableSeedUsdPerLeg = 400 USD, while a mintable mock is seeded to seedUsdPerLeg = 50,000 USD and can be topped back up by the liquidity keeper.

That is the whole reason the hub is the twin. A cross-core route takes the hub out of the source pool and into the destination, so the hub has to be replenishable; native USDC is not. The app therefore sets its base and hub symbol to USDCB and defaults the swap form’s input to it.

3.1. .b is display metadata, never a key

The .b lives in the ERC-20 name()/symbol() and nowhere else. The roster key is dot-free (USDCB), with an explicit display: 'USDC.b' override for rendering. The SDK’s canonicalTokenSymbol strips a trailing .b, so getToken('USDC.b') resolves to the official 6-decimal Circle USDC and getToken('USDCB') to the 18-decimal twin. Feeding a rendered symbol back into a lookup silently swaps one asset for the other; carry the key, not the label.

3.2. The twins own no feed of their own

USDCB and EURCB are deliberately absent from the feed roster and quote off feeds the keeper already services: USDCB off USDT-USDC at a 150 bp ref band, EURCB off EURC-USDC at 250 bp. A dedicated feed would have nothing pushing it and would fail closed at TTL (Oracles).

Three consequences follow from the shared feed, and none of them is a bug in the pool:

  1. USDC.b sits roughly 7 bp off native USDC permanently, and USDT/USDC.b quotes at exactly 1.0 forever because the two oracle configs are byte-identical. That is a shared-feed artifact, not a depeg.
  2. The 150 bp ref band on USDC.b measures against a mirror of the same feed, so a USDT depeg is inherited rather than caught by that band.
  3. The liquidity keeper refuses two assets sharing one feed id, and the SDK’s offline mark table has no row for the twins: a front end falling back to offline marks renders EURC.b about 12% low when a live feed read fails.

4. Decimals, and gas on Arc

Every mock is 18 decimals regardless of what its mainnet reference uses: WBTC.b is 18 where mainnet WBTC is 8; USDT.b, XAUT.b and PYUSD.b are 18 where their references are 6. The only 6-decimal listings on Arc are the two native Circle tokens. The “decimals” column of the Asset Registry describes the mainnet reference, not the token in your wallet. The pool rescales per hop with a pure power-of-ten shift between the two legs’ stored decimals (Pricing.sol), so this is invisible to pricing and visible only in raw amounts.

Arc’s native currency is USDC at 18 decimals, while the ERC-20 view of the same balance at 0x3600…0000 is 6. There is no wrapped native token; USDC is instead flagged as the ERC-20 whose balance also pays the transaction fee. A MAX that spends the entire balance therefore cannot afford its own gas (four deposits reverted TransferFromFailed exactly that way), so the app reserves a gas buffer when the input token is the native ERC-20.

5. The testnet faucet

TestnetFaucet is a testnet-only helper. It does not mint: the deploy ceremony mints to the deployer, approves the faucet, calls fund(token, amount) with 200 days of cap, then setCap(token, cap). claim is a safeTransfer out of that pre-funded balance. Minting itself is minter-only on TestnetERC20, and there is no setter for the minter.

5.1. Claiming

  1. Open /faucet. ?token=USDC deep-links one asset to the top of the list.
  2. First visit only: press Enable faucet. The app checks your mainnet balances on Ethereum, BSC, Base and Arbitrum against a 5 USD minimum (marked at fixed rates, ETH 3000 and BNB 600), then sends register(), which sets whitelisted[msg.sender] = true.
  3. Press Claim on a row. The app sends claim(address token) and re-reads whitelist and remaining allowance from the receipt.

The button is live only when the faucet is deployed, you are whitelisted, and remaining(user, token) > 0. When you are disconnected or on another network the same click connects or switches chain instead. Reads are pinned to the Arc chain id regardless of the wallet’s current network.

5.2. What claim enforces

claim reverts:

  • NotWhitelisted if you never registered;
  • NoCap if dailyCap[token] == 0;
  • CapExhausted if the day’s allowance is spent.

Otherwise it resets the day bucket if the day rolled, transfers the full remaining amount, and writes claimedToday = cap.

Two behaviours follow directly:

  • One claim per token per day, all-or-nothing. Because the write is claimedToday = cap rather than an increment, there is no partial second claim.
  • The day is block.timestamp / 1 days, a UTC-midnight roll rather than 24 hours since your last claim. Claim at 23:55 UTC and you can claim again five minutes later.

5.3. Caps

The cap is derived, not typed: it is a leg’s USD depth target divided by FAUCET_CAP_DIVISOR = 50 (2% of the target per day), converted at the ceremony mark into the token’s own decimals. At seedUsdPerLeg = 50,000 that is roughly 1,000 USD per mintable leg per day. FAUCET_PREFUND_CLAIMS = 200 days of cap are placed in the faucet at deploy.

ParameterValueSite
FAUCET_CAP_DIVISOR50deploy script
FAUCET_PREFUND_CLAIMS200deploy script
seedUsdPerLeg50,000 USDarc-risk-params.json
unmintableSeedUsdPerLeg400 USDarc-risk-params.json
Enable-gate minimum5 USD across chains 1 / 56 / 8453 / 42161front end

dailyCap(token) read on chain is the enforced limit. The per-token daily figures in the app’s config are a display fallback used only when that read is missing or zero, and several of them are stale relative to the derived cap.

5.4. Limits worth knowing before you rely on it

  • The 5 USD check is client-side only. register() is permissionless on chain. The real backstops are the daily caps and the owner’s setWhitelisted(user, false) revoke.
  • Native USDC and EURC are never claimable. They are excluded from the list by construction and skipped by the deploy’s funding step. Since Arc’s gas is USDC, a brand-new wallet cannot bootstrap gas here: it must arrive already funded.
  • An uncapped token looks identical to an exhausted one. With dailyCap == 0, remaining() returns 0 and the row reads Done today; a claim in that state reverts NoCap. Read dailyCap directly if you need to tell the two apart; the twins in particular were listed out of band and their caps should be verified on chain rather than assumed.
  • An empty faucet reverts inside safeTransfer with no dedicated error. Only fund() refills it.