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.
Invariants
Section titled “Invariants”Policy registry
Section titled “Policy registry”- No pairing of
policyIdandaccountcan makeisAuthorizedrevert. - A
BLOCKLISTpolicy that was never created clears every account. The same query against an uncreatedALLOWLISTdenies every account. - Once
renounceAdmin(policyId)runs, every call that would alter that policy’s membership reverts, permanently. ALWAYS_ALLOW, at ID0, passes every account, andALWAYS_BLOCKrejects every account. Both are fixed points — neither can be created, edited, nor renounced.- Policy IDs never repeat, and within a single
PolicyTypeprefix they only climb.
- Neither
renounceRolenorrevokeRolewill strip the finalDEFAULT_ADMIN_ROLEholder.renounceLastAdmin()is the only exit. - After
renounceLastAdmin()lands,DEFAULT_ADMIN_ROLEis unreachable — no address can ever hold it again. - Roles granted before the admin stepped down keep working after.
- A custom role means nothing on its own. No B20 operation consults one.
Transfer policies
Section titled “Transfer policies”- Policy gating never applies to
approve. TRANSFER_EXECUTOR_POLICYis consulted ontransferFromonly. A directtransfernever reaches it.MINT_RECEIVER_POLICYholds at all times, factoryinitCallsincluded.- All three transfer-side scopes stand down while
initCallsexecute. - Every scope points at
ALWAYS_ALLOWthe moment a token is created.
Supply
Section titled “Supply”totalSupplycannot climb past the supply cap.- A cap cannot be lowered under the prevailing
totalSupply. - Burning pulls
totalSupplydown, which opens headroom beneath the cap.
- Each
PausableFeaturestands alone. Pausing one leaves the others running. - Pause state never reaches
approveorpermit. - A pause survives factory
initCallsrather than being bypassed by them.
- The
Memoevent sits at exactlylogIndex + 1relative to theTransferit annotates. - A memo method matches its plain counterpart in every respect but the extra event.
Permit
Section titled “Permit”permitaccepts ECDSA signatures alone. An ERC-1271 contract signature always fails.- A single nonce step follows every
permitthat verifies — the owner’s counter moves up by one, never more. - A permit signed ahead of
updateNamestops verifying once the name changes.
Variants
Section titled “Variants”- An Asset fixes its decimals at creation and cannot revisit them. Anything outside 6 through 18 is rejected.
- Every Stablecoin reports
6decimals; the value is not configurable. OPERATOR_ROLEexists on Asset tokens and nowhere else.- An announcement ID is spent once across the token’s entire life.
- A Stablecoin’s currency code is immutable, and only
A–Zcharacters are legal in it. - A multiplier update reaches every holder at the same moment.
batchMintrunsMINT_RECEIVER_POLICYagainst each recipient on its own.
Factory
Section titled “Factory”- Deployment is deterministic — identical inputs always land on an identical address.
- Read position 10 of any B20 address and the variant byte there will name the variant actually deployed.
- A
(deployer, variant, salt)tuple has exactly one address it can ever produce. - Array order governs
initCallsexecution, and a single revert anywhere in the sequence rolls back the deployment entire.
Conformance test cases
Section titled “Conformance test cases”Policy registry
Section titled “Policy registry”| # | Scenario | Expected |
|---|---|---|
| 1 | Create a BLOCKLIST policy, query an account that is not on it | isAuthorized returns true |
| 2 | Add that account to the blocklist and query again | isAuthorized returns false |
| 3 | Take it back off the blocklist and query again | isAuthorized returns true |
| 4 | Create an ALLOWLIST policy, query an account that is not listed | isAuthorized returns false |
| 5 | Query a blocklist-prefixed ID that was never created | Returns true |
| 6 | Query an allowlist-prefixed ID that was never created | Returns false |
| 7 | Call renounceAdmin, then attempt updateBlocklist | Reverts |
| 8 | Call finalizeUpdateAdmin from an address that is not pending | Reverts |
| # | Scenario | Expected |
|---|---|---|
| 9 | Grant MINT_ROLE, then call mint | Succeeds |
| 10 | Call mint with no MINT_ROLE held | Reverts |
| 11 | With a single admin left, call revokeRole(DEFAULT_ADMIN_ROLE) | Reverts with LastAdminCannotRenounce |
| 12 | Call renounceLastAdmin() | Succeeds — the token is left admin-less |
| 13 | A MINT_ROLE holder calls mint after renounceLastAdmin | Succeeds — non-admin roles survive |
| 14 | Deploy with initialAdmin == address(0), then call grantRole | Reverts |
Transfer policies
Section titled “Transfer policies”| # | Scenario | Expected |
|---|---|---|
| 15 | A blocklisted sender calls transfer | Reverts with PolicyForbids |
| 16 | That same blocklisted sender calls approve | Succeeds |
| 17 | transferFrom where the executor sits on the executor blocklist | Reverts |
| 18 | Direct transfer by a sender on the executor blocklist but not the sender blocklist | Succeeds |
| 19 | Transfer from a blocklisted sender during initCalls | Succeeds — the scope is relaxed |
| 20 | Mint to a receiver on the mint-receiver blocklist during initCalls | Reverts — this scope is never relaxed |
Mint and supply cap
Section titled “Mint and supply cap”| # | Scenario | Expected |
|---|---|---|
| 21 | Mint an amount that would carry totalSupply over the cap | Reverts with SupplyCapExceeded |
| 22 | Mint exactly up to the cap | Succeeds |
| 23 | Call updateSupplyCap with a value under the current totalSupply | Reverts with InvalidSupplyCap |
| 24 | Burn some supply, then mint back up to the cap | Succeeds |
| 25 | Mint while MINT is paused | Reverts |
Burn and seize
Section titled “Burn and seize”| # | Scenario | Expected |
|---|---|---|
| 26 | burnBlocked against a frozen account | Succeeds |
| 27 | burnBlocked against an account that is not frozen | Reverts |
| 28 | burnBlocked called by a BURN_ROLE holder lacking BURN_BLOCKED_ROLE | Reverts |
| 29 | Freeze an account, seize the full balance, re-mint to a recovery address | Succeeds |
| 30 | Burn while BURN is paused | Reverts |
| # | Scenario | Expected |
|---|---|---|
| 31 | Pause TRANSFER, then call transfer | Reverts |
| 32 | Pause TRANSFER, then call mint | Succeeds |
| 33 | Pause TRANSFER, then call approve | Succeeds |
| 34 | A pauser calls unpause without holding UNPAUSE_ROLE | Reverts |
| # | Scenario | Expected |
|---|---|---|
| 35 | Call transferWithMemo | Emits Transfer, then Memo at consecutive log indices |
| 36 | Call transferFromWithMemo | Memo.caller carries msg.sender, not from |
| 37 | Call plain transfer | No Memo event appears |
Permit
Section titled “Permit”| # | Scenario | Expected |
|---|---|---|
| 38 | Submit a permit with a sound signature, nonce, and deadline | Succeeds |
| 39 | Submit a permit whose deadline has passed | Reverts |
| 40 | Replay a permit signature that was already spent | Reverts |
| 41 | Sign a permit before updateName, submit it after | Reverts |
| 42 | Submit a contract wallet signature (ERC-1271) | Reverts |
| 43 | Submit a permit while TRANSFER is paused | Succeeds |
Factory
Section titled “Factory”| # | Scenario | Expected |
|---|---|---|
| 44 | Call getB20Address, then deploy with the same parameters | The two addresses match |
| 45 | Read byte 10 of a deployed Asset address | Returns 0x00 |
| 46 | Deploy the same (deployer, variant, salt) a second time | The second attempt reverts |
| 47 | Deploy a variant whose feature is not yet activated | Reverts |
| 48 | An initCall pauses TRANSFER, a later initCall transfers | The transfer reverts |
Variants
Section titled “Variants”| # | Scenario | Expected |
|---|---|---|
| 49 | Deploy an Asset with decimals = 5 | Reverts |
| 50 | Set the multiplier to 2e18, read balanceOf for a raw balance of 100 | Returns 200 |
| 51 | Reuse an announcement ID | Reverts with DuplicateAnnouncementId |
| 52 | Call batchMint where one recipient is off the allowlist | Reverts |
| 53 | Deploy a Stablecoin with currency = "usd" | Reverts — the code must be A–Z |
Related pages
Section titled “Related pages”- B20 Precompile Reference — selectors,
topic0values, and revert codes. - B20 Native Token Standard — the behavioral writeup these invariants formalize.
- Tokenized Stocks — one applied configuration of the standard.