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 tokenMintable twin
Minted by BTRnoyes (TestnetERC20)
Faucet-claimablenoyes, subject to an on-chain cap
Seeded tounmintableSeedUsdPerLegseedUsdPerLeg, 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:

  1. 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.
  2. 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.
  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 misprices them badly when a live feed read fails.

3. 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 deployment’s chain id regardless of the wallet’s current network.

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

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

ParameterValueSource
FAUCET_CAP_DIVISOR50deploy script
FAUCET_PREFUND_CLAIMS200deploy script
seedUsdPerLeg50,000 USDthe instance’s risk-params file
unmintableSeedUsdPerLeg400 USDthe instance’s risk-params file
Enable-gate minimum5 USD across chains 1 / 56 / 8453 / 42161front 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’s setWhitelisted(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 reverts NoCap. Read dailyCap directly 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 safeTransfer with no dedicated error. Only fund() refills it.