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.
initAssetcalls the ERC-20 and stores what it returns. A token reporting 0 or more than 18 decimals cannot be listed. decimals != 0is 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 base | Why |
|---|---|
| EXTERNAL-quoted | its own depeg halt must be able to bite (Depeg Halt) |
QUOTE_UNIT_ANCHOR | it is the unit other legs are attested in |
| spokes carry an independent ref band | a 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_BPSof 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 USDC | Native EURC | Twins | |
|---|---|---|---|
| Address | 0x3600…0000 | 0x89B5…D72a | USDCB 0x9A8E…061CC, EURCB 0xd901…dE5A1 |
| Decimals | 6 | 6 | 18 |
| Mintable | no | no | yes (TestnetERC20) |
| Faucet-claimable | no | no | subject 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:
USDC.bsits roughly 7 bp off native USDC permanently, andUSDT/USDC.bquotes at exactly 1.0 forever because the two oracle configs are byte-identical. That is a shared-feed artifact, not a depeg.- The 150 bp ref band on
USDC.bmeasures against a mirror of the same feed, so aUSDTdepeg is inherited rather than caught by that band. - 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.babout 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
- Open
/faucet.?token=USDCdeep-links one asset to the top of the list. - 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 setswhitelisted[msg.sender] = true. - 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:
NotWhitelistedif you never registered;NoCapifdailyCap[token] == 0;CapExhaustedif 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 = caprather 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.
| Parameter | Value | Site |
|---|---|---|
FAUCET_CAP_DIVISOR | 50 | deploy script |
FAUCET_PREFUND_CLAIMS | 200 | deploy script |
seedUsdPerLeg | 50,000 USD | arc-risk-params.json |
unmintableSeedUsdPerLeg | 400 USD | arc-risk-params.json |
| Enable-gate minimum | 5 USD across chains 1 / 56 / 8453 / 42161 | front 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’ssetWhitelisted(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 revertsNoCap. ReaddailyCapdirectly 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
safeTransferwith no dedicated error. Onlyfund()refills it.
6. Related documentation
- Swapping: how a swap is routed, within a pool and across pools
- Asset Registry: per-asset addresses, feeds, ERC-20 oddities
- Pool Composition: which assets sit on which core
- Anchor Path Pricing: the anchor column and leg pricing
- Admin: the timelocked listing and base-migration queues
- Spread & Fees: what a listed asset’s fee floor and coverage wall cost