Skip to content
BaseHub by wbnns Updated

B20 Invariants and Conformance Tests

The B20 precompile reference tells you what each call looks like on the wire. This page covers the other half: the properties the implementation promises to hold, whatever order you make those calls in, plus the scenarios used to check them.

Treat the list below as normative. An implementation that breaks any line here is wrong, not merely surprising. Read it alongside the B20 Native Token Standard writeup for the reasoning behind each rule.

  1. No pairing of policyId and account can make isAuthorized revert.
  2. A BLOCKLIST policy that was never created clears every account. The same query against an uncreated ALLOWLIST denies every account.
  3. Once renounceAdmin(policyId) runs, every call that would alter that policy’s membership reverts, permanently.
  4. ALWAYS_ALLOW, at ID 0, passes every account, and ALWAYS_BLOCK rejects every account. Both are fixed points — neither can be created, edited, nor renounced.
  5. Policy IDs never repeat, and within a single PolicyType prefix they only climb.
  1. Neither renounceRole nor revokeRole will strip the final DEFAULT_ADMIN_ROLE holder. renounceLastAdmin() is the only exit.
  2. After renounceLastAdmin() lands, DEFAULT_ADMIN_ROLE is unreachable — no address can ever hold it again.
  3. Roles granted before the admin stepped down keep working after.
  4. A custom role means nothing on its own. No B20 operation consults one.
  1. Policy gating never applies to approve.
  2. TRANSFER_EXECUTOR_POLICY is consulted on transferFrom only. A direct transfer never reaches it.
  3. MINT_RECEIVER_POLICY holds at all times, factory initCalls included.
  4. All three transfer-side scopes stand down while initCalls execute.
  5. Every scope points at ALWAYS_ALLOW the moment a token is created.
  1. totalSupply cannot climb past the supply cap.
  2. A cap cannot be lowered under the prevailing totalSupply.
  3. Burning pulls totalSupply down, which opens headroom beneath the cap.
  1. Each PausableFeature stands alone. Pausing one leaves the others running.
  2. Pause state never reaches approve or permit.
  3. A pause survives factory initCalls rather than being bypassed by them.
  1. The Memo event sits at exactly logIndex + 1 relative to the Transfer it annotates.
  2. A memo method matches its plain counterpart in every respect but the extra event.
  1. permit accepts ECDSA signatures alone. An ERC-1271 contract signature always fails.
  2. A single nonce step follows every permit that verifies — the owner’s counter moves up by one, never more.
  3. A permit signed ahead of updateName stops verifying once the name changes.
  1. An Asset fixes its decimals at creation and cannot revisit them. Anything outside 6 through 18 is rejected.
  2. Every Stablecoin reports 6 decimals; the value is not configurable.
  3. OPERATOR_ROLE exists on Asset tokens and nowhere else.
  4. An announcement ID is spent once across the token’s entire life.
  5. A Stablecoin’s currency code is immutable, and only AZ characters are legal in it.
  6. A multiplier update reaches every holder at the same moment.
  7. batchMint runs MINT_RECEIVER_POLICY against each recipient on its own.
  1. Deployment is deterministic — identical inputs always land on an identical address.
  2. Read position 10 of any B20 address and the variant byte there will name the variant actually deployed.
  3. A (deployer, variant, salt) tuple has exactly one address it can ever produce.
  4. Array order governs initCalls execution, and a single revert anywhere in the sequence rolls back the deployment entire.
#ScenarioExpected
1Create a BLOCKLIST policy, query an account that is not on itisAuthorized returns true
2Add that account to the blocklist and query againisAuthorized returns false
3Take it back off the blocklist and query againisAuthorized returns true
4Create an ALLOWLIST policy, query an account that is not listedisAuthorized returns false
5Query a blocklist-prefixed ID that was never createdReturns true
6Query an allowlist-prefixed ID that was never createdReturns false
7Call renounceAdmin, then attempt updateBlocklistReverts
8Call finalizeUpdateAdmin from an address that is not pendingReverts
#ScenarioExpected
9Grant MINT_ROLE, then call mintSucceeds
10Call mint with no MINT_ROLE heldReverts
11With a single admin left, call revokeRole(DEFAULT_ADMIN_ROLE)Reverts with LastAdminCannotRenounce
12Call renounceLastAdmin()Succeeds — the token is left admin-less
13A MINT_ROLE holder calls mint after renounceLastAdminSucceeds — non-admin roles survive
14Deploy with initialAdmin == address(0), then call grantRoleReverts
#ScenarioExpected
15A blocklisted sender calls transferReverts with PolicyForbids
16That same blocklisted sender calls approveSucceeds
17transferFrom where the executor sits on the executor blocklistReverts
18Direct transfer by a sender on the executor blocklist but not the sender blocklistSucceeds
19Transfer from a blocklisted sender during initCallsSucceeds — the scope is relaxed
20Mint to a receiver on the mint-receiver blocklist during initCallsReverts — this scope is never relaxed
#ScenarioExpected
21Mint an amount that would carry totalSupply over the capReverts with SupplyCapExceeded
22Mint exactly up to the capSucceeds
23Call updateSupplyCap with a value under the current totalSupplyReverts with InvalidSupplyCap
24Burn some supply, then mint back up to the capSucceeds
25Mint while MINT is pausedReverts
#ScenarioExpected
26burnBlocked against a frozen accountSucceeds
27burnBlocked against an account that is not frozenReverts
28burnBlocked called by a BURN_ROLE holder lacking BURN_BLOCKED_ROLEReverts
29Freeze an account, seize the full balance, re-mint to a recovery addressSucceeds
30Burn while BURN is pausedReverts
#ScenarioExpected
31Pause TRANSFER, then call transferReverts
32Pause TRANSFER, then call mintSucceeds
33Pause TRANSFER, then call approveSucceeds
34A pauser calls unpause without holding UNPAUSE_ROLEReverts
#ScenarioExpected
35Call transferWithMemoEmits Transfer, then Memo at consecutive log indices
36Call transferFromWithMemoMemo.caller carries msg.sender, not from
37Call plain transferNo Memo event appears
#ScenarioExpected
38Submit a permit with a sound signature, nonce, and deadlineSucceeds
39Submit a permit whose deadline has passedReverts
40Replay a permit signature that was already spentReverts
41Sign a permit before updateName, submit it afterReverts
42Submit a contract wallet signature (ERC-1271)Reverts
43Submit a permit while TRANSFER is pausedSucceeds
#ScenarioExpected
44Call getB20Address, then deploy with the same parametersThe two addresses match
45Read byte 10 of a deployed Asset addressReturns 0x00
46Deploy the same (deployer, variant, salt) a second timeThe second attempt reverts
47Deploy a variant whose feature is not yet activatedReverts
48An initCall pauses TRANSFER, a later initCall transfersThe transfer reverts
#ScenarioExpected
49Deploy an Asset with decimals = 5Reverts
50Set the multiplier to 2e18, read balanceOf for a raw balance of 100Returns 200
51Reuse an announcement IDReverts with DuplicateAnnouncementId
52Call batchMint where one recipient is off the allowlistReverts
53Deploy a Stablecoin with currency = "usd"Reverts — the code must be AZ