Faucets
A faucet exists only on a testnet deployment, and only for that instance’s mintable mock tokens. Which instances are testnets: deployed instances. Testnet assets have no value, and a faucet hands out pool assets, never gas (Pool Legs §5).
1. What the faucet is
TestnetFaucet is a rate-limited dispenser, not a minter. The deploy ceremony mints to the
deployer, approves the faucet, calls fund(token, amount) with FAUCET_PREFUND_CLAIMS 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, so an empty faucet is
refilled by fund() and by nothing else.
2. Issuer tokens versus mintable twins
A testnet roster mixes two kinds of token. Some are the official issuer deployments, declared in the
deploy artifact’s nativeTokens and never minted by BTR; the rest are BTR mocks,
new TestnetERC20(sym + ".b", sym + ".b", 18).
| Official issuer token | Mintable twin | |
|---|---|---|
| Minted by BTR | no | yes (TestnetERC20) |
| Faucet-claimable | no | yes, subject to an on-chain cap |
| Seeded to | unmintableSeedUsdPerLeg | seedUsdPerLeg, and topped up by the liquidity keeper |
The ceremony classifies a token by staticcalling minter(): an issuer token reverts and is treated
as un-mintable float. That is also why the hub leg is a twin rather than the
issuer token (Pool Legs §2). Per-instance
addresses and decimals for both kinds: Asset Registry.
2.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 issuer token 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.
2.2. A twin owns no feed of its own
Twins are absent from the feed roster and quote off a feed the keeper already services, because a dedicated feed would have nothing pushing it and would fail closed at TTL (Oracle Keeper). Which feed each twin rides, and the ref band it carries: Asset Registry §6.
Consequences of the shared feed:
- A twin sits a few basis points off its issuer token permanently, and quotes exactly 1.0 forever against the asset whose feed it borrows, because the two oracle configs are byte-identical. That is a shared-feed artifact, not a depeg.
- The twin’s ref band measures against a mirror of the same feed, so a depeg of the borrowed feed’s asset 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 misprices them badly when a live feed read fails.
3. 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 deployment’s chain id regardless of the wallet’s
current network.
4. 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.
- One claim per token per day, all-or-nothing: the write is
claimedToday = cap, not an increment, so 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. Caps
The cap is derived, not typed: a leg’s USD depth target divided by FAUCET_CAP_DIVISOR (2% of the
target per day), converted at the ceremony mark into the token’s own decimals.
| Parameter | Value | Source |
|---|---|---|
FAUCET_CAP_DIVISOR | 50 | deploy script |
FAUCET_PREFUND_CLAIMS | 200 | deploy script |
seedUsdPerLeg | 50,000 USD | the instance’s risk-params file |
unmintableSeedUsdPerLeg | 400 USD | the instance’s risk-params file |
| Enable-gate minimum | 5 USD across chains 1 / 56 / 8453 / 42161 | front end |
At a 50,000 USD seed that is roughly 1,000 USD per mintable leg per day, and 200 days of cap are placed in the faucet at deploy.
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.
6. Limits
- 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. - Issuer tokens are never claimable: excluded from the list and skipped by the deploy’s funding step. Where the chain’s gas token is one of them, a brand-new wallet cannot bootstrap gas from the faucet and 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 to tell the two apart; a token listed out of band should have its cap verified on chain rather than assumed. - An empty faucet reverts inside
safeTransferwith no dedicated error. Onlyfund()refills it.
7. Related documentation
- Pool Legs: what a listing is, the base leg, and why the hub is a twin
- Asset Registry: per-asset addresses, feeds, ERC-20 oddities
- Contract Addresses: the
TestnetFaucetaddress per chain