B20 Precompile Reference
This page is the call-level companion to the B20 Native Token Standard writeup. That page explains how the standard behaves; this one gives you the wire details — every function with its four-byte selector, every event with its topic0, and every revert with the selector you’ll see come back.
Precompile addresses
Section titled “Precompile addresses”The same three addresses apply on every network that has B20 turned on.
| Surface | Address |
|---|---|
| B20Factory | 0xB20f000000000000000000000000000000000000 |
| ActivationRegistry | 0x8453000000000000000000000000000000000001 |
| PolicyRegistry | 0x8453000000000000000000000000000000000002 |
The base token surface. Every B20 token exposes it, whichever variant it was created as.
Role identifiers
Section titled “Role identifiers”| Function | Selector | What it returns |
|---|---|---|
DEFAULT_ADMIN_ROLE | 0xa217fddf | The top-level admin role, which is bytes32(0). Holders can manage the role graph, policy slots, and the supply cap. |
MINT_ROLE | 0xe9a9c850 | The role a caller needs for mint and mintWithMemo. |
BURN_ROLE | 0xb930908f | The role a caller needs for burn and burnWithMemo. |
BURN_BLOCKED_ROLE | 0x32ad9be8 | The role behind the deprecated burnBlocked path, kept for older integrations. |
SEIZE_ROLE | 0x3c7e9ba5 | The role a caller needs for seizeWithMemo. |
PAUSE_ROLE | 0x389ed267 | The role a caller needs for pause. |
UNPAUSE_ROLE | 0x309756fb | The role a caller needs for unpause. |
METADATA_ROLE | 0x38841782 | The role behind updateName, updateSymbol, and updateContractURI. |
Policy scope identifiers
Section titled “Policy scope identifiers”Each of these names one slot on the token. The slot holds a uint64 policy ID, and the token consults it against the account listed below.
| Function | Selector | Account checked |
|---|---|---|
TRANSFER_SENDER_POLICY | 0xd116fc21 | from, on every transfer — transferFrom included. |
TRANSFER_RECEIVER_POLICY | 0x210f521b | to, on every transfer. |
TRANSFER_EXECUTOR_POLICY | 0x724e9c53 | msg.sender on transferFrom, but only where it differs from from. |
MINT_RECEIVER_POLICY | 0x6e5b013d | to, on every mint. |
SEIZE_EXEMPT_POLICY | 0xfeb346ec | from, when seizeWithMemo runs. |
SEIZE_RECEIVER_POLICY | 0xb31da27f | to, when seizeWithMemo runs. |
ERC-20 surface
Section titled “ERC-20 surface”| Function | Selector | Behavior |
|---|---|---|
name | 0x06fdde03 | The token name. Fixed at creation, but updateName can change it later. |
symbol | 0x95d89b41 | The token symbol. Fixed at creation, but updateSymbol can change it later. |
decimals | 0x313ce567 | The decimal count. Each variant fixes this and it never moves. |
totalSupply | 0x18160ddd | How much of the token is in circulation right now. |
balanceOf | 0x70a08231 | The balance held by account. |
allowance | 0xdd62ed3e | How much spender is still cleared to move on behalf of owner. |
transfer | 0xa9059cbb | Moves amount from the caller to to, emitting Transfer. |
transferFrom | 0x23b872dd | Moves amount from from to to, drawing on the caller’s allowance. Emits Transfer. |
approve | 0x095ea7b3 | Rewrites spender’s allowance to amount. Neither policies nor the pause state apply. Emits Approval. |
Memo variants
Section titled “Memo variants”Each of these does exactly what its plain counterpart does, then emits Memo in the log slot directly after the primary event.
| Function | Selector | Wraps |
|---|---|---|
transferWithMemo | 0x95777d59 | transfer |
transferFromWithMemo | 0x929c2539 | transferFrom |
mintWithMemo | 0xe44f0b12 | mint |
burnWithMemo | 0x38f23b0b | burn |
Supply operations
Section titled “Supply operations”| Function | Selector | Behavior |
|---|---|---|
mint | 0x40c10f19 | Issues amount to to and emits Transfer(address(0), to, amount). |
burn | 0x42966c68 | Destroys amount out of the caller’s own balance. No policy applies. |
burnBlocked | 0xec0cf3dc | Deprecated. Destroys amount belonging to a from that TRANSFER_SENDER_POLICY currently denies. |
seizeWithMemo | 0xf916d81b | Takes amount from from and reassigns it to to in one privileged step. |
supplyCap | 0x8f770ad0 | The ceiling mint enforces. It tops out at type(uint128).max, which doubles as the no-cap sentinel. |
updateSupplyCap | 0xe5a97f07 | Moves the cap up or down. It can never sit below the supply already outstanding. |
Access control
Section titled “Access control”| Function | Selector | Behavior |
|---|---|---|
hasRole | 0x91d14854 | Whether account belongs to role. The graph accepts user-defined roles, though the token itself enforces none of them. |
getRoleAdmin | 0x248a9ca3 | Which role can hand out or take away role. Falls back to DEFAULT_ADMIN_ROLE where nothing was set. |
grantRole | 0x2f2ff15d | Adds account to role and emits RoleGranted. |
revokeRole | 0xd547741f | Drops account from role and emits RoleRevoked. |
setRoleAdmin | 0x1e4e0091 | Reassigns which role administers role. Emits RoleAdminChanged. |
renounceRole | 0x36568abe | Lets the caller give up a role they hold. Emits RoleRevoked. |
renounceLastAdmin | 0x6f79e3d7 | The one route to a permanently admin-less token. It strips DEFAULT_ADMIN_ROLE from the last holder for good. |
| Function | Selector | Behavior |
|---|---|---|
pausedFeatures | 0xde9997e3 | The set of PausableFeature values paused on this token. Don’t rely on the ordering. |
isPaused | 0x165c44bc | Whether one specific feature is paused. Constant time. |
pause | 0x9f98bd2d | Pauses everything in features. It adds to the existing set, so repeats are harmless. |
unpause | 0xb61b2ebc | Clears the pause on everything in features, leaving anything unlisted alone. Repeats are harmless. |
Policy slots
Section titled “Policy slots”| Function | Selector | Behavior |
|---|---|---|
policyId | 0xdb3de624 | The policy currently wired into policyScope. An unset slot reads back as 0, the always-allow built-in. |
updatePolicy | 0xadf9c4ea | Points policyScope at a different policy ID. The next gated call already sees it. |
ERC-2612 permit and metadata
Section titled “ERC-2612 permit and metadata”| Function | Selector | Behavior |
|---|---|---|
nonces | 0x7ecebe00 | The permit nonce for owner, which climbs by one after each permit that lands. |
permit | 0xd505accf | The EIP-2612 signed approval. It recovers owner from (v, r, s) by ECDSA, so only EOA signatures work. |
DOMAIN_SEPARATOR | 0x3644e515 | The token’s live EIP-712 domain separator, recomputed per call. |
eip712Domain | 0x84b0196e | ERC-5267 introspection over that domain. |
contractURI | 0xe8a3d485 | Where the token’s contract-level metadata lives offchain, per ERC-7572. |
updateContractURI | 0x7e5b1e24 | Repoints contractURI. Per ERC-7572 the resulting event carries no arguments. |
updateName | 0x84da92a7 | Rewrites name. Emits NameUpdated, then EIP712DomainChanged, because the separator rotates with it. |
updateSymbol | 0x537f5312 | Rewrites symbol and emits SymbolUpdated. |
IB20Asset
Section titled “IB20Asset”Extra surface carried only by tokens created as the ASSET variant.
| Function | Selector | Behavior |
|---|---|---|
OPERATOR_ROLE | 0xf5b541a6 | The role behind announce and the multiplier operations. |
WAD_PRECISION | 0x664808a8 | The fixed-point scale the multiplier uses, equal to 1e18. |
MAX_UI_MULTIPLIER | 0x785c0cf0 | The largest multiplier the setters will take. |
multiplier | 0x1b3ed722 | The multiplier in force, at WAD_PRECISION. Stored balances stay raw underneath it. |
uiMultiplier | 0xa60bf13d | The ERC-8056 UI multiplier as it stands now. |
newUIMultiplier | 0xdc767007 | The UI multiplier waiting to take over, if one is scheduled. |
effectiveAt | 0x97a4064f | When that pending multiplier is due to apply. |
updateUIMultiplier | 0x628e600f | Queues a UI-multiplier change for the effectiveAt timestamp. |
cancelUIMultiplierUpdate | 0x2c97a0f0 | Drops the one pending change and returns the token to having none queued. |
updateMultiplier | 0x5ffe6146 | The emergency override. It sets the multiplier on the spot rather than scheduling it. |
toScaledBalance | 0x04f04c99 | Scales a raw balance up: rawBalance * multiplier / WAD_PRECISION. |
toRawBalance | 0x0ca06c44 | Scales a scaled balance back down to its raw form. |
scaledBalanceOf | 0x1da24f3e | Shorthand for toUIAmount(balanceOf(account)). |
balanceOfUI | 0x437a9958 | An account’s balance with the UI adjustment already applied. |
totalSupplyUI | 0x9bea6429 | Total supply with the UI adjustment already applied. |
toUIAmount | 0x3248d4ff | Converts a raw amount into its UI form. |
fromUIAmount | 0x65cd9b3c | Converts a UI amount back into its raw form. |
announce | 0x595135dd | Publishes an announcement that affects holders and fires off each bundled call atomically. |
isAnnouncementIdUsed | 0xc0da474e | Whether announce has already spent id. IDs never come back around. |
batchMint | 0x68573107 | Issues amounts[i] to recipients[i] across the arrays. It’s all or nothing — one bad element reverts the lot. |
extraMetadata | 0x4ddf9da0 | Reads a named metadata entry, giving back an empty string where none is set. |
updateExtraMetadata | 0xb2851ef5 | Writes a metadata entry, or clears it when you pass an empty value. |
IB20Stablecoin
Section titled “IB20Stablecoin”Tokens created as the STABLECOIN variant add one call.
| Function | Selector | Behavior |
|---|---|---|
currency | 0xe5a6b10f | The currency code the stablecoin says it tracks, such as "USD", "EUR", or "JPY". |
IB20Factory
Section titled “IB20Factory”Every B20 token comes into existence through this one precompile.
| Function | Selector | Behavior |
|---|---|---|
createB20 | 0x62975e6a | Deploys a token of the requested variant at the address derived deterministically for it. |
getB20Address | 0x8c30260f | Tells you which address createB20 would pick for (variant, sender, salt). It never reverts. |
isB20 | 0xfa19b927 | Whether this factory made token, read straight off the address prefix. It never reverts. |
isB20Initialized | 0x6a45ba98 | Whether createB20 finished at token. It flips once and stays there. |
IPolicyRegistry
Section titled “IPolicyRegistry”The singleton registry holding every policy, keyed by uint64 policyId.
| Function | Selector | Behavior |
|---|---|---|
createPolicy | 0xca5d55f6 | Opens an empty simple policy. Anyone can call it. |
createPolicyWithAccounts | 0xa2d3044f | Opens a simple policy with accounts already in it. Anyone can call it. |
createCompositePolicy | 0x6fdd1491 | Opens a composite that joins existing simple policies under a logic rule. |
updateAllowlist | 0x3388fb5b | Sets each of accounts to allowed on an ALLOWLIST policy, in one batch. |
updateBlocklist | 0x5c4e51b8 | Sets each of accounts to blocked on a BLOCKLIST policy, in one batch. |
updateComposite | 0xbfe142c0 | Swaps a composite’s children out wholesale for childPolicyIds. |
stageUpdateAdmin | 0x1d7ae695 | Nominates a replacement admin for policyId. The sitting admin keeps control until the handover completes. |
finalizeUpdateAdmin | 0x33031a9c | Closes out the two-step handover. The caller becomes admin and the pending slot empties. |
renounceAdmin | 0xefdb7fa3 | Gives up the policy permanently, which freezes its membership and children from then on. |
isAuthorized | 0x55a1179e | Whether account clears policyId. It never reverts, and an unknown ID behaves as an empty set. |
policyExists | 0x330f5637 | Whether policyId is a built-in sentinel or an ID that was actually handed out. It never reverts. |
policyAdmin | 0x09dd0a47 | The sitting admin for policyId, or the zero address for built-ins. |
pendingPolicyAdmin | 0x017548b7 | The nominated admin for policyId, or the zero address when nothing is staged. |
compositePolicyChildIds | 0x7c40df74 | The children a composite policyId is built from. |
MIN_COMPOSITE_CHILD_POLICIES | 0xb3ae29f7 | The fewest children a composite can reference, inclusive. It never reverts. |
MAX_COMPOSITE_CHILD_POLICIES | 0x54309870 | The most children a composite can reference, inclusive. It never reverts. |
IActivationRegistry
Section titled “IActivationRegistry”The gate that decides which features are live on the chain.
| Function | Selector | Behavior |
|---|---|---|
isActivated | 0xba87af80 | Whether feature is on right now. It never reverts. |
checkActivated | 0xde5bfd9b | The asserting form — it reverts with FeatureNotActivated(feature) when the feature is off. |
admin | 0xf851a440 | The only address cleared to call activate and deactivate. |
activate | 0x59db6e85 | Switches feature on and emits FeatureActivated. |
deactivate | 0x22eee84c | Switches feature off and emits FeatureDeactivated. |
Events
Section titled “Events”Look these up by topic0 when you’re decoding logs.
| Event | Interface | Topic0 |
|---|---|---|
Transfer | IB20 | 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef |
Approval | IB20 | 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925 |
Memo | IB20 | 0x6989f5818dcfd11f8cd53b27c94cec33dae1589735f03e639cba54553a1825e8 |
Seized | IB20 | 0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c |
BurnedBlocked | IB20 | 0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3 |
RoleGranted | IB20 | 0x2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d |
RoleRevoked | IB20 | 0xf6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b |
RoleAdminChanged | IB20 | 0xbd79b86ffe0ab8e8776151514217cd7cacd52c909f66475c3af44e129f0b00ff |
LastAdminRenounced | IB20 | 0xe8d3a9872e7ca325571ff1e4c51ddd69090a0345240cc605ccde365ec867cc67 |
Paused | IB20 | 0x43e072977b8112813d7c2aa0b63d3c121c1fe3e714e6d2eacb5735fe4027e976 |
Unpaused | IB20 | 0xdc6281474ea3dea2a856e225c7ba3edf427de9164495817c4d49a595e683fed4 |
PolicyUpdated | IB20 | 0x8b4790f7ff717fc8f60f07ae099e47ef318dc04b37ae98056b50a22b79056626 |
SupplyCapUpdated | IB20 | 0x6d14f44808ce024f263432bc38d019a9951fbe674e9898b54844dbc8dc09c23a |
ContractURIUpdated | IB20 | 0xa5d4097edda6d87cb9329af83fb3712ef77eeb13738ffe43cc35a4ce305ad962 |
NameUpdated | IB20 | 0x74321da206c1b9fa34367f7ece59ca49371dcd13820b9a5c3767ae1ecceed51a |
SymbolUpdated | IB20 | 0x64e8b5c6dcea43dd79766bb3b8af7c45968d12b68c960cf2da23856f34d598d4 |
EIP712DomainChanged | IB20 | 0x0a6387c9ea3628b88a633bb4f3b151770f70085117a15f9bf3787cda53f13d31 |
UIMultiplierUpdated | IB20Asset | 0x2205df4534432b2f60654a3fdb48737ffdaf3e9edb1a498bd985bc026b15b055 |
MultiplierUpdated | IB20Asset | 0x4dbe4840d7465bd162f67814cea0b519567a2e0e578bcde61e7f4ced361e5a3d |
UIMultiplierUpdateCancelled | IB20Asset | 0x883856335ba5f60c18b9817c4505d3c7d3f6223dcf39516b30c508c46a5e1cad |
ExtraMetadataUpdated | IB20Asset | 0xd7bb345be29e78d635203d40fe0567e7ef19d5cd5cc5fcd25f768b8063e82aa1 |
Announcement | IB20Asset | 0xccebf8218a62875909564adef86a6f4df81503cb617221e793357d62f8e813f7 |
EndAnnouncement | IB20Asset | 0x96d64dafe2c790596430196b982ad1da3221cb3b0f4e6e2df77f2e4f71a90037 |
B20Created | IB20Factory | 0xfd9bf2730513a1709722ff379a0844dfd8f997d600693c2bcc659e188bbdba0d |
PolicyCreated | IPolicyRegistry | 0xdc870ce85be577234b8548f42b93f84bbff6d0c1f38ee725c809c49932a13885 |
PolicyAdminStaged | IPolicyRegistry | 0xdbf3b34a4c956c56ca05cd4b8f9293a4347ad61994445a4b89817d4a19561136 |
PolicyAdminUpdated | IPolicyRegistry | 0x98925cfb1bc09c5b43dd0dd56d3d95aa04fb3300927580cc588c3f5dd58c15e1 |
AllowlistUpdated | IPolicyRegistry | 0x18c46532f90187ba11e436e21da087b684801d7f0787f2043f26f079c91e9ef0 |
BlocklistUpdated | IPolicyRegistry | 0x2ff63c102b1b9fd7f5d39f83039c5d6aaf50a414a4f2def2704e41be2628f1e3 |
CompositePolicyUpdated | IPolicyRegistry | 0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363 |
FeatureActivated | IActivationRegistry | 0x8c7a0ecdbb8d96e867e43ec1aef80027976ee493c18bef399fa799ed19752451 |
FeatureDeactivated | IActivationRegistry | 0x15bf65a782c3258c63268ba9d7aed710cf9f9315d3687d9a4632ccdad7926c84 |
Errors
Section titled “Errors”Reverse lookup for revert selectors. A few names appear more than once — NonPayable and Unauthorized are shared across surfaces, and Unauthorized does not even carry the same selector everywhere, so read the interface column.
| Error | Selector | Cause |
|---|---|---|
NonPayable | 0x6fb1b0e9 | ETH rode along with a call to a nonpayable token selector. |
AccessControlUnauthorizedAccount | 0xe2517d3f | account is missing neededRole. |
AccessControlBadConfirmation | 0x6697b232 | renounceRole got a callerConfirmation that wasn’t msg.sender. |
Unauthorized | 0x82b42900 | A positional authorization check failed — one that doesn’t reduce to a missing role. |
ContractPaused | 0xf9df5ac9 | The PausableFeature this operation falls under is paused. |
InsufficientAllowance | 0x192b9e4e | The transferFrom asked for more than spender is allowed to move. |
InsufficientBalance | 0xdb42144d | The transfer or burn asked for more than sender holds. |
InvalidSender | 0x4c14f64c | The source address on the transfer is not valid, typically address(0). |
InvalidReceiver | 0x9cfea583 | The destination address on the transfer is not valid — address(0), or from Denim the address of the token being credited. |
InvalidApprover | 0x8bc146c4 | The owner on the approval is not valid, typically address(0). |
InvalidSpender | 0x4e15efda | The spender on the approval is not valid, typically address(0). |
InvalidAmount | 0x2c5211c6 | An amount came through as zero where a nonzero one is required. ERC-20 amount arguments are exempt. |
EmptyFeatureSet | 0x4861ff45 | An empty array reached a function that needs at least one entry. |
InvalidSupplyCap | 0x0a3780ce | The proposed cap sits outside the permitted band — under current supply, or over the maximum. |
SupplyCapExceeded | 0x4b344b11 | The mint would carry totalSupply past the cap. |
PolicyForbids | 0xa43fec12 | One of the policy slots turned the operation down. |
PolicyNotFound | 0xcccad523 | The policy ID given isn’t in the registry. |
UnsupportedPolicyType | 0xcdd98a4a | This token, or this variant of it, has no such policyScope. |
AccountNotSeizable | 0x91dbbc8d | seizeWithMemo targeted a from that SEIZE_EXEMPT_POLICY still authorizes. |
AccountNotBlocked | 0x64a5cb46 | The deprecated burnBlocked targeted a from that is still authorized. |
ExpiredSignature | 0xbd2a913c | The permit arrived with a deadline already behind block.timestamp. |
InvalidSigner | 0x7ba5ffb5 | ECDSA recovery on the permit produced a signer that isn’t the owner claimed. |
LastAdminCannotRenounce | 0x361513e7 | The sole remaining admin tried to leave via renounceRole(DEFAULT_ADMIN_ROLE, ...). |
NotSoleAdmin | 0x2a98e73b | renounceLastAdmin() was called while other accounts still hold DEFAULT_ADMIN_ROLE. |
IB20Asset
Section titled “IB20Asset”| Error | Selector | Cause |
|---|---|---|
AnnouncementIdAlreadyUsed | 0xd10b3c9e | The id handed to announce was already spent. |
AnnouncementInProgress | 0x5c5f0829 | A call dispatched from inside announce tried to call announce again. |
InternalCallMalformed | 0x4e2f143e | A call dispatched by announce came in under four bytes. |
InternalCallFailed | 0xb288a127 | A call dispatched by announce reverted normally, and its reason is wrapped here. |
InvalidMetadataKey | 0x86ea3abb | updateExtraMetadata got an empty key. |
InvalidMultiplier | 0x6f12f3dc | A multiplier setter was handed a value it won’t accept. |
EffectiveAtInPast | 0x14119cf6 | The effectiveAt on updateUIMultiplier isn’t in the future. |
EffectiveAtTooFar | 0x1ce214fa | The effectiveAt on updateUIMultiplier overran type(uint64).max. |
UIMultiplierUpdateExists | 0x4481a68e | updateUIMultiplier was called while another update is still pending. |
UIMultiplierUpdateDoesNotExist | 0xa7d6a5ca | cancelUIMultiplierUpdate found nothing pending to cancel. |
LengthMismatch | 0xab8b67c6 | A batched call got parallel arrays of unequal length. |
EmptyBatch | 0xc2e5347d | A batched call got empty arrays. |
IB20Factory
Section titled “IB20Factory”| Error | Selector | Cause |
|---|---|---|
NonPayable | 0x6fb1b0e9 | ETH rode along with a call to a nonpayable factory selector. |
TokenAlreadyExists | 0x15ef3a57 | Something already occupies the deterministic address this creation derives. |
InvalidVariant | 0xf10e8e43 | The variant given isn’t a recognized B20Variant. |
UnsupportedVersion | 0xc0d8b4e0 | The leading version byte in params matches no known encoding for that variant. |
MissingRequiredField | 0x4a43ae87 | A required string argument came through empty. |
InvalidCurrency | 0x997c1de8 | The stablecoin currency had content, but carried a byte outside A-Z. |
InvalidDecimals | 0xca950391 | The asset decimals fell outside the permitted inclusive range. |
InitCallFailed | 0x4eae0860 | One of the initCalls reverted, and the factory passes the underlying reason back up. |
IPolicyRegistry
Section titled “IPolicyRegistry”| Error | Selector | Cause |
|---|---|---|
NonPayable | 0x6fb1b0e9 | ETH rode along with a call to a nonpayable registry selector. |
Unauthorized | 0x82b42900 | The caller isn’t the admin the operation demands. |
PolicyNotFound | 0x720caa4f | The policy ID referenced doesn’t exist. |
IncompatiblePolicyType | 0xf1011ef5 | The operation doesn’t fit this policy’s type. |
ZeroAddress | 0xd92e233d | An address argument that has to be set came through as the zero address. |
BatchSizeTooLarge | 0x083e2f67 | A membership batch ran past the registry’s limit. |
NoPendingAdmin | 0xb4539afa | finalizeUpdateAdmin ran with nobody staged. |
ChildPoliciesOutsideOfRange | 0x697ec868 | A composite’s child count fell outside the permitted range. |
InvalidChildPolicy | 0x46508ef6 | A composite’s children must be existing simple policies. Composites can’t nest. |
IActivationRegistry
Section titled “IActivationRegistry”| Error | Selector | Cause |
|---|---|---|
Unauthorized | 0x8e4a23d6 | The caller isn’t the activation admin. |
AlreadyActivated | 0x866b0041 | The feature is already on. |
FeatureNotActivated | 0xb9b2a425 | The feature is off. |
DelegateCallNotAllowed | 0x0d89438e | The precompile was reached through DELEGATECALL or CALLCODE. |
StaticCallNotAllowed | 0xbeaba5b7 | A state-changing entry point was reached from inside a STATICCALL frame. |
Constants
Section titled “Constants”| Role | Solidity constant |
|---|---|
| Default admin | bytes32(0) |
| Mint | keccak256("MINT_ROLE") |
| Burn | keccak256("BURN_ROLE") |
| Deprecated blocked burn | keccak256("BURN_BLOCKED_ROLE") |
| Seize | keccak256("SEIZE_ROLE") |
| Pause | keccak256("PAUSE_ROLE") |
| Unpause | keccak256("UNPAUSE_ROLE") |
| Metadata | keccak256("METADATA_ROLE") |
| Asset operator | keccak256("OPERATOR_ROLE") |
Policy scopes
Section titled “Policy scopes”| Scope | Solidity constant |
|---|---|
| Transfer sender | keccak256("TRANSFER_SENDER_POLICY") |
| Transfer receiver | keccak256("TRANSFER_RECEIVER_POLICY") |
| Transfer executor | keccak256("TRANSFER_EXECUTOR_POLICY") |
| Mint receiver | keccak256("MINT_RECEIVER_POLICY") |
| Seize exempt | keccak256("SEIZE_EXEMPT_POLICY") |
| Seize receiver | keccak256("SEIZE_RECEIVER_POLICY") |
Policy IDs
Section titled “Policy IDs”Two counters are reserved for the built-ins; anything you create starts at 2.
| Name | Value |
|---|---|
ALWAYS_ALLOW | 0 |
ALWAYS_BLOCK | (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | 1 |
Custom IDs pack a type byte over a counter:
[8-bit PolicyType][56-bit counter]| PolicyType | Byte |
|---|---|
BLOCKLIST | 0x00 |
ALLOWLIST | 0x01 |
UNION | 0x02 |
INTERSECT | 0x03 |
Variant bytes
Section titled “Variant bytes”The variant is encoded into the address, so you can read it off the prefix.
| Variant | Byte | Address shape |
|---|---|---|
ASSET | 0x00 | 0xB200... |
STABLECOIN | 0x01 | 0xB201... |
Supply and decimals
Section titled “Supply and decimals”| Constant | Value |
|---|---|
| Minimum Asset decimals | 6 |
| Maximum Asset decimals | 18 |
| Maximum supply cap, and the no-cap sentinel | type(uint128).max |
| Bitmask with every feature paused | 15 — TRANSFER, MINT, BURN, SEIZE together |
Availability by hardfork
Section titled “Availability by hardfork”Every selector on this page is final, but not all of them are dialable today. Beryl shipped the standard and is live on Mainnet and Sepolia. A second group arrives with Cobalt, now booked for Sepolia on September 23, 2026 and Mainnet on September 30, 2026 — call any of it before your target network reaches its date and there is nothing at the address to answer.
Cobalt adds no breaking change. Every Beryl function, event topic, and error keeps the exact four-byte selector or topic0 it has now, and keeps its behaviour.
Live since Beryl
Section titled “Live since Beryl”| Network | B20 live since |
|---|---|
| Base Mainnet | 2026-06-25 |
| Base Sepolia | 2026-06-18 |
Beryl carried the roles model, the policy registry with its six per-token scopes, memos, granular pausing, supply caps, permit, the factory, and both the Asset and Stablecoin variants.
Arriving with Cobalt
Section titled “Arriving with Cobalt”Three groups are specified and documented above, but undialable until Cobalt activates.
The seize surface on IB20. seizeWithMemo (0xf916d81b), the SEIZE_ROLE, SEIZE_EXEMPT_POLICY and SEIZE_RECEIVER_POLICY getters, the Seized event, the AccountNotSeizable error, and the SEIZE pause ordinal (3, bit 8) all land together. It is defined on the shared IB20 surface, so Asset and Stablecoin expose it identically.
The ERC-8056 multiplier surface on IB20Asset. uiMultiplier, toUIAmount, fromUIAmount, balanceOfUI and totalSupplyUI become the canonical names, and the scheduled setter updateUIMultiplier joins newUIMultiplier, effectiveAt, cancelUIMultiplierUpdate, MAX_UI_MULTIPLIER and supportsInterface. The older multiplier, toScaledBalance, toRawBalance and scaledBalanceOf are kept as aliases returning the same values.
Composite policies on IPolicyRegistry. createCompositePolicy, updateComposite, compositePolicyChildIds, the two bound getters, the CompositePolicyUpdated event, and the ChildPoliciesOutsideOfRange and InvalidChildPolicy errors. The PolicyType enum gains UNION = 2 and INTERSECT = 3 by appending, so the existing values and the packed-ID top-byte encoding do not move.
Arriving with Denim
Section titled “Arriving with Denim”Two changes are already in the node source, gated behind Denim rather than Cobalt. Denim has a target month but no activation timestamp yet.
A token can no longer be credited to itself. transfer, transferFrom, mint and seizeWithMemo all reject a to equal to the address of the token being credited, reverting InvalidReceiver. Asset and Stablecoin enforce it identically. The zero address was already refused; this extends the same treatment to the token’s own address, which previously accepted the credit and stranded the balance where nothing could move it.
The rule is narrower than it first sounds, and the distinction is the part worth getting right:
- Only the token being credited is refused. Sending B20 token A to the address of a different B20 token B is still a valid transfer. An earlier revision of this change rejected every B20-prefixed recipient; it was narrowed before landing, so do not build on the broader reading.
- On
transferFromthe recipient is validated before the allowance is touched, so a call that trips this rule leaves the allowance unspent rather than consuming it on a revert. - For seizure, this closes a disguised-burn path: because seize leaves
totalSupplyalone, seizing into the token address used to remove a balance from circulation without recording it as a burn.
Inverted policy IDs. Denim moves the policy registry to its third version, which treats bit 63 of a policy ID as a NOT flag. Setting it gives you the complement of an existing policy without building a second member set: bind the inverted ID to a token scope, or hand it to a composite as a child to express “A AND NOT X”. It works on all four types. Membership stays on the base policy, so editing the base changes the inverse too. The one new selector is invertedPolicyId(uint64 policyId) external pure returns (uint64), which flips the bit either way. You can also set it by hand.
On an inverted ID, isAuthorized returns the negation of the base, except when the base does not exist: then it returns false. That fail-closed rule stops a mistyped inverted ID from turning into allow-everyone. policyExists and policyAdmin ignore bit 63 and answer for the base, because an inverted ID has no record of its own.
What changes for integrators at Cobalt
Section titled “What changes for integrators at Cobalt”Nothing you call today stops working, so none of this is a forced migration. The timing has tightened, though: Sepolia activates on September 23, 2026 and Mainnet a week later, so each item below is now a path worth moving to on a schedule rather than eventually.
Seizure replaces burnBlocked
Section titled “Seizure replaces burnBlocked”burnBlocked and BURN_BLOCKED_ROLE are deprecated but untouched — same selector, same events, same behaviour, still callable. seizeWithMemo(from, to, amount, memo) becomes the canonical route for administrative balance removal.
The two are not interchangeable, and swapping the selector alone will not reproduce the old outcome:
- Seize is a transfer, not a burn. It moves
amountfromfromtotoand leavestotalSupplyalone. To get the old result, seize into a treasury address you control and then callburn. Do not seize into the token’s own address: that revertsInvalidReceiverfrom Denim onward, and before Denim it strands the balance instead of burning it. - They read different policies.
seizeWithMemoconsultsSEIZE_EXEMPT_POLICY;burnBlockedconsultsTRANSFER_SENDER_POLICY. A token can define a seizable set that has nothing to do with its transfer-blocked set. Under both, “eligible” means not authorized by the relevant policy. - They sit in different role and pause domains.
SEIZE_ROLEandBURN_BLOCKED_ROLEare separate grants, andSEIZE(ordinal 3) andBURN(ordinal 2) are separate pause bits. Pausing one leaves the other running.
The scope was renamed at Cobalt. It was SEIZE_HOLDER_POLICY with selector 0xb279d311, and it is now SEIZE_EXEMPT_POLICY with selector 0xfeb346ec. Both the selector and the keccak256 scope constant change, so any code holding either value hardcoded needs updating. Behaviour did not move with the name — the inverted check is the same one it always was.
Attach a BLOCKLIST to this scope. Accounts on the blocklist read as unauthorized and are therefore seizable; everyone else stays exempt. An ALLOWLIST or the ALWAYS_BLOCK sentinel inverts that and makes every holder seizable, which is almost never what an issuer wants here. The scope is also independent of TRANSFER_SENDER_POLICY: blocking an account from transferring does not make it seizable.
When more than one guard would fail, the holder gate reports first, then the destination gate, then the balance: AccountNotSeizable before PolicyForbids(SEIZE_RECEIVER_POLICY, ...) before InsufficientBalance.
Multiplier changes get scheduled
Section titled “Multiplier changes get scheduled”updateUIMultiplier(newMultiplier, effectiveAt) is the path meant for corporate actions such as splits and reinvested dividends. Only one pending update exists at a time.
- Schedule with
updateUIMultiplier. It needsOPERATOR_ROLE, andeffectiveAtmust be strictly in the future. - Observe while it waits:
newUIMultiplier()gives the target,effectiveAt()gives the flip time, anduiMultiplier()/multiplier()still report the value in force. - Let it mature. Once
block.timestamp >= effectiveAtthe reads flip on their own. No event marks the moment. - Or drop it with
cancelUIMultiplierUpdate(), which emitsUIMultiplierUpdateCancelled.
To reorder overlapping actions, cancel and reschedule in a single announcement rather than as two calls: announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...).
The instant updateMultiplier(uint256) stays, downgraded to an emergency failsafe. It applies immediately, clears any live pending update, and is meant for reversing a scheduling mistake — usually paired with a pause — rather than for routine use.
Composite policies join existing ones
Section titled “Composite policies join existing ones”A composite authorizes by combining 2 to 4 existing simple policies under UNION (OR) or INTERSECT (AND). Build one with createCompositePolicy(admin, policyType, childPolicyIds); change it with updateComposite(policyId, childPolicyIds), which replaces the child set in full — there is no partial update and no way to clear the list.
The problem this solves is duplication. A B20 token stores one policy ID per scope, so combining a shared KYC allowlist with a token-specific one previously meant copying both member lists into a third, flattened policy and running infrastructure to keep that copy in step with its sources. Every such copy has a window in which it is wrong: an account added upstream cannot transact until the copy catches up, and an account removed upstream keeps its access until then. A composite stores references instead of members, so there is no copy to drift.
Evaluation is live and shallow. isAuthorized calls each child’s own isAuthorized at read time rather than working from a set captured at creation, UNION short-circuits on the first child that authorizes, and INTERSECT short-circuits on the first that does not. Recursion never passes depth 1, because write-time validation rejects any child that is itself a composite or a built-in sentinel with InvalidChildPolicy. Nesting is impossible by construction.
The registry stores the child list exactly as given. It does not sort it and does not reject duplicates, so ordering is yours to choose. That choice cannot change the answer, since both gates are order-independent, but it does change what you pay: each child actually evaluated costs a membership read, and short-circuiting stops the walk early. Put the child most likely to decide the outcome first — the broadest allowlist under UNION, the narrowest under INTERSECT.
Two adjacent behaviours are easy to guess wrong:
- A renounced child keeps working.
renounceAdminon a child policy only clears its admin and freezes its membership; the policy still exists and still answersisAuthorized, so the parent composite evaluates it exactly as before. compositePolicyChildIdsnever reverts. Handed a simple policy or an ID that was never created, it returns an empty array rather than an error, so an empty result does not distinguish “not a composite” from “does not exist”.
Two existing behaviours shift. createPolicy and createPolicyWithAccounts gain a revert path that was previously unreachable: passing UNION or INTERSECT to a simple constructor now reverts IncompatiblePolicyType, checked right after ZeroAddress. And the registry now carries two independent size limits — keep ChildPoliciesOutsideOfRange, which guards the [2, 4] child count, separate from BatchSizeTooLarge, which guards the 64-account membership batch. They are different errors on different call paths, and one tells you nothing about the other.
Selector stability
Section titled “Selector stability”Everything on this page is fixed. B20 method and event signatures sit on the chain’s consensus surface, so a selector that ships never changes meaning and never gets withdrawn. The standard only grows by addition: a later release can introduce new calls, but it will not rewrite the behaviour behind one you already integrated against.
That gives you one practical guarantee. Code compiled against the selectors here keeps working across upgrades, and you only revisit it when you want something new.
Two names are the exception, and they are already marked throughout this page. burnBlocked and BURN_BLOCKED_ROLE are deprecated. They still resolve, because nothing is removed, but seizure flows are expected to use seizeWithMemo once Cobalt is live.