---
title: "Assets and the hub"
description: "What a listed asset is on a BTR pool, why every core has a base/hub leg, how native Circle tokens differ from the faucet twins, and how the testnet faucet hands out tokens"
audience: both
type: guide
status: live
lang: en
publish: true
---
# 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](/docs/4-1-swapping); per-asset addresses, feeds and ERC-20 quirks are in the [Asset Registry](/docs/2-2-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](/docs/1-2-3-admin) for the queue and [Access Control](/docs/3-1-access-control-roles-emergency-powers) for who may drive it.

Every listed asset, the base included, must carry `kappaCovBps > 0` ([Invariants §I-9](/docs/1-1-8-invariants#i-9-coverage-toll-is-charge-only-and-terminal-only)).

## 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](/docs/1-1-3-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](/docs/3-5-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_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](/docs/4-1-swapping#2-route-enumeration)). `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](/docs/3-4-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](/docs/2-2-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.

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

---

## 6. Related documentation

- [Swapping](/docs/4-1-swapping): how a swap is routed, within a pool and across pools
- [Asset Registry](/docs/2-2-asset-registry): per-asset addresses, feeds, ERC-20 oddities
- [Pool Composition](/docs/2-3-pool-composition): which assets sit on which core
- [Anchor Path Pricing](/docs/1-1-3-anchor-path-pricing): the anchor column and leg pricing
- [Admin](/docs/1-2-3-admin): the timelocked listing and base-migration queues
- [Spread & Fees](/docs/1-1-4-spread-fees): what a listed asset's fee floor and coverage wall cost
