B20 Issuer Operations
Launching a B20 token gets a balance onchain. This page covers what an issuer does after that: taking a balance back under a legal hold, cancelling units, deciding who may move tokens, and telling holders about changes before they land. The call-level reference is in B20 precompiles. The rules behind each check are in the B20 specification.
Each control below is tied to a fork. Beryl and Cobalt controls are live on Mainnet and Sepolia. Denim controls are not live yet.
| Control | Available from | Variants |
|---|---|---|
seizeWithMemo | Cobalt (live: Sepolia September 23, Mainnet September 30, 2026) | Asset and Stablecoin |
| Executor policy on every transfer path | Denim (planning, October 2026) | Asset and Stablecoin |
announce | Beryl | Asset only |
Scheduled multiplier (updateUIMultiplier) | Cobalt | Asset only |
Seize a blocked balance
Section titled “Seize a blocked balance”Upstream’s issuer guides now route every “take it back” case through seizeWithMemo: a lost key, a court order, a sanctions hold, or a holder who is no longer eligible. The call moves tokens from the holder to a safekeeping account in one transaction. It does not burn or mint, so totalSupply stays the same. The memo carries your case reference, and the token emits a dedicated Seized event that auditors can follow.
The older route, burnBlocked followed by a fresh mint, is deprecated. It makes supply dip and recover, emits no Seized event, and leaves reconciliation with two unrelated transactions. Since Cobalt reached Mainnet on September 30, 2026, there is no network where it is still the only option.
Three gates
Section titled “Three gates”A seize runs only if all three of these pass:
| Gate | Passes when | If unset |
|---|---|---|
SEIZE_ROLE | The caller holds the role. | No one can seize. |
SEIZE_EXEMPT_POLICY | The holder (from) is not authorized under the attached policy. | Everyone is authorized, so no one is seizable. |
SEIZE_RECEIVER_POLICY | The destination (to) is authorized. | Any destination works. |
SEIZE must also not be paused. Pausing TRANSFER, MINT, or BURN does not stop a seize.
Freezing does not make an account seizable
Section titled “Freezing does not make an account seizable”Seize reads its own scope and ignores the transfer scopes. Two common setups leave an account frozen but still out of reach:
- Stablecoin blocklist. A blocklist bound to
TRANSFER_SENDER_POLICYstops the holder’s outgoing transfers. To make the same account seizable, also bind that blocklist toSEIZE_EXEMPT_POLICY. One list then both freezes and exposes the account. - Asset allowlist. Taking a holder off an eligibility allowlist freezes their transfers. It does not make them seizable. You need a separate blocklist on
SEIZE_EXEMPT_POLICYwith that holder on it.
The call
Section titled “The call”Grant the role once, attach the blocklist, then seize to your treasury:
token.grantRole(token.SEIZE_ROLE(), seizer);token.updatePolicy(B20Constants.SEIZE_EXEMPT_POLICY, blocklistId);token.seizeWithMemo(blocked, treasury, 50e6, bytes32("legal-hold-2026-118"));Events fire in this order: Transfer(from, to, amount), Memo(caller, memo), then Seized(caller, from, to, amount). After that the treasury holds an ordinary balance. You can transfer it to the holder’s new address, keep it while the case is open, or cancel it (next section).
To restrict where seized funds can go, put the treasury on an allowlist and bind it to SEIZE_RECEIVER_POLICY.
to cannot be zero, the token’s own address, or the same as from. Seizing from the token’s own address is allowed, which is how you recover tokens someone sent to the contract by mistake.
Errors, in check order
Section titled “Errors, in check order”| Error | Meaning |
|---|---|
ContractPaused(SEIZE) | Seize is paused. |
AccessControlUnauthorizedAccount(caller, SEIZE_ROLE) | The caller lacks the role. |
InvalidReceiver(to) | to is zero, the token, or from. |
InvalidSender(from) | from is zero. |
AccountNotSeizable(from) | from is still authorized under SEIZE_EXEMPT_POLICY: the slot is unset, or the holder is not on the blocklist. |
PolicyForbids(SEIZE_RECEIVER_POLICY, policyId) | to fails the receiver policy. |
InsufficientBalance(from, balance, amount) | from holds less than amount. |
Cancel units
Section titled “Cancel units”For an Asset whose units must be destroyed, seize first, then burn from the treasury with burnWithMemo. burnWithMemo burns the caller’s own balance, so either seize to the burning account or transfer to it first. The burner needs BURN_ROLE, and BURN must not be paused.
IB20(token).seizeWithMemo(holder, address(this), 100e6, bytes32("cancel-2026-07"));IB20(token).burnWithMemo(100e6, bytes32("cancel-2026-07"));totalSupply stays level after the seize and drops after the burn. Reuse the same memo on both calls so reconciliation can link them to one case. To disclose the cancellation, wrap the burn in announce (see below).
Limit who may initiate transfers
Section titled “Limit who may initiate transfers”Some issuers want every transfer to go through a designated account, such as a transfer agent, even when both sides are eligible holders. TRANSFER_EXECUTOR_POLICY checks the initiator (msg.sender), separately from the sender and receiver scopes.
From Denim, the rules are:
- The executor check runs first on
transfer,transferFrom, and their memo variants. - A holder calling
transfer, ortransferFromon their own balance, must be on the executor allowlist. Otherwise the call revertsPolicyForbids(TRANSFER_EXECUTOR_POLICY, policyId)before any balance check. - When the caller is
fromand the executor and sender scopes point at the same policy, the node skips the now-redundant sender check. - An allowance is still required for
transferFrom. The allowance limits how much a caller may spend. The executor policy decides whether the caller may start a transfer at all. A transfer agent needs both.
Setup is two calls: create an allowlist seeded with the agent, then bind it.
id = StdPrecompiles.POLICY_REGISTRY.createPolicyWithAccounts( admin, IPolicyRegistry.PolicyType.ALLOWLIST, initiators);IB20(token).updatePolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, id);Creating the policy has no effect until updatePolicy binds it. Add every intended initiator before you bind, including the issuer’s own accounts. Anyone left off loses the ability to start transfers. Batches cap at 64 accounts, and only the policy admin can change membership.
Announce a change to holders
Section titled “Announce a change to holders”announce(internalCalls, id, description, uri) wraps a holder-impacting change in an onchain disclosure. It is Asset-only and needs OPERATOR_ROLE. The wrapped calls keep their own role checks and run with the operator as msg.sender, so the operator also needs MINT_ROLE for batchMint or mintWithMemo and BURN_ROLE for burnWithMemo.
The sequence is: emit Announcement(caller, id, description, uri), run every inner call atomically, emit EndAnnouncement(id). Each inner entry is raw calldata against this same asset, at least 4 bytes long. It is not a target and calldata pair. If any inner call fails, the whole transaction reverts and id stays unused. A successful call burns id for the life of the asset.
Typical uses:
- Extra units (a stock dividend paid in shares): wrap
batchMint. The events areAnnouncement, oneTransferper recipient, thenEndAnnouncement. - Split or reverse split: wrap
updateUIMultiplier. - Replacing a pending multiplier: wrap
cancelUIMultiplierUpdateand a newupdateUIMultiplierin the same announcement. - Treasury burn: wrap
burnWithMemo. - Notice only: pass an empty
internalCallsarray.
For indexers: match Announcement to EndAnnouncement by id, not just by position. Everything between the two logs belongs to the announcement. A state change outside any bracket was made directly. A direct updateUIMultiplier still works, so flag it as undisclosed rather than assume every change was announced.
Schedule a multiplier update
Section titled “Schedule a multiplier update”The multiplier changes what holders see, not what they hold. Raw balanceOf, totalSupply, and transfer amounts never move, so DeFi that reads raw units keeps working. Only the UI views apply it: balanceOfUI(account) equals balanceOf(account) * uiMultiplier() / 1e18.
An asset holds at most one pending update:
updateUIMultiplier(newMultiplier, effectiveAt)records it.effectiveAtmust be later than the current block time, andnewMultipliermust be above zero and at mostMAX_UI_MULTIPLIER. A 2-for-1 split is2e18.- While pending,
uiMultiplier()still returns the old value,newUIMultiplier()returns the scheduled one, and a second schedule revertsUIMultiplierUpdateExists. - At
effectiveAtthe new value takes over. Nothing is written and no event fires at that moment.UIMultiplierUpdatedfired earlier, when the schedule was recorded. - After it matures,
effectiveAt()keeps the old timestamp. Test for a pending update witheffectiveAt() > block.timestamp, never witheffectiveAt() == 0.
cancelUIMultiplierUpdate() drops a pending update before it matures. After maturity it reverts UIMultiplierUpdateDoesNotExist.
The deprecated updateMultiplier(newMultiplier) applies a value at once and clears anything pending. Keep it for fixing a mistake you cannot wait out. It emits UIMultiplierUpdateCancelled only if an update was still live, then MultiplierUpdated, then UIMultiplierUpdated with the current block time as effectiveAtTimestamp, not the old scheduled time.
The setters are not pause-gated. Pausing TRANSFER around a split is possible, but it freezes every holder and needs the pause roles, so upstream advises against it for routine updates.