Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 28 additions & 25 deletions DESIGN_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ All B-20 tokens delegate transfer authorization to the policy engine
via `transferPolicyId`. There is no internal blocklist on the token
itself. Sanctions lists, KYC allowlists, jurisdiction restrictions, and
similar compliance rules all live in the policy registry as
whitelist/blacklist/compound policies.
allowlist/blocklist policies.

**Why.** Composability across tokens (one Coinbase-managed sanctions
blacklist policy serves every stablecoin AND every security AND every
blocklist policy serves every stablecoin AND every security AND every
default token that opts in), single auditable source for compliance
state, and no duplication of mechanism. CCS uses an internal blocklist;
we deliberately diverge to centralize this.
Expand Down Expand Up @@ -280,11 +280,11 @@ commitment from the issuer.

Gated on a separate `redeemPolicyId` (see below).

#### Policy engine scope: TIP-403 + TIP-1015 parity, no callback or richer guards in v1
#### Policy engine scope: TIP-403 only, no compound or richer guards in v1

We considered four levels of policy sophistication for v1:

1. **Pure set membership** (TIP-403): WHITELIST, BLACKLIST.
1. **Pure set membership** (TIP-403): ALLOWLIST, BLOCKLIST.
2. **+ Compound policies** (TIP-1015): asymmetric sender / recipient /
mint-recipient slots referencing simple policies.
3. **+ Callback policies**: a fourth policy type that defers the
Expand All @@ -294,20 +294,20 @@ We considered four levels of policy sophistication for v1:
4. **+ Modular guards / hooks**: the Modular ERC20 vision. Per-operation
guard arrays, custom storage per guard, etc.

We ship **Levels 1 + 2 only** in v1.
We ship **Level 1 only** in v1.

Compound policies (Level 2) were removed: the asymmetric sender /
recipient / mint-recipient slots added complexity for a use case that
can be approximated by pointing the token at a blocklist that covers
all roles uniformly, or by wrapping the precompile in a periphery
contract. The forward-compat story is the same as callback — enum
extensions are backward-compatible, so compound can be added in a
future hardfork without breaking existing consumers.

The case for adding callback (Level 3) was real (richer rules without
chain bloat, small interface delta). But the forward-compat argument is
weak: even if we reserved the `CALLBACK` enum value now, the actual
implementation requires a hardfork — same as just adding it later.
Enum extensions are backward-compatible (existing values keep their
meanings), so consumers don't break when callback is added in a future
hardfork. Conclusion: defer to a future hardfork if real demand
emerges.

The user-stories doc explicitly lists three types (allowlist, blocklist,
compound). Conner has consistently steered toward "fork Tempo cleanly."
Our v1 matches that exactly.
chain bloat, small interface delta). But it similarly requires a
hardfork and has no confirmed demand. Defer to a future hardfork if
real demand emerges.

**Rules that v1 DOES NOT support and would need future work:**
- Per-tx amount limits (callback signature lacks the amount)
Expand All @@ -321,18 +321,21 @@ pattern; no chain change needed.
#### Brokerage allowlist via separate `redeemPolicyId`

Each security token holds two policy IDs:
- `transferPolicyId`: gates transfers and mints. Typically a compound
policy (e.g. KYC'd recipients, sanctions-blacklisted senders).
- `redeemPolicyId`: gates `redeem` callers. Typically a simple
whitelist of brokerage-verified accounts. Coinbase manages this list
by being the policy admin in the registry.
- `transferPolicyId` (inherited from `IB20`): gates transfers and mints.
Typically a blocklist of sanctioned addresses or an allowlist of KYC'd
accounts, depending on the issuer's compliance regime.
- `redeemPolicyId` (security-specific): gates `redeem` / `redeemWithMemo`
callers. Typically an allowlist of brokerage-verified accounts. Set to
built-in ID `0` to disable redemption entirely.

Coinbase manages the relevant policies as the policy admin in the registry.

**Why separate IDs?** Transfer-eligibility and redeem-eligibility are
different sets in practice. Retail can hold and trade a tokenized
security without being able to redeem to brokerage; redemption requires
KYC + brokerage account connection that not all holders have. Putting
both behind the same policy would force every holder to be brokerage-
verified.
KYC + brokerage account connection that not all holders have. A single
shared policy would force every holder to be brokerage-verified before
they could receive a transfer.

#### Announcement coupling for metadata changes

Expand Down Expand Up @@ -534,7 +537,7 @@ just promises determinism + variant-recoverability.
For Default and Stablecoin, `initialSupply` is minted to
`initialSupplyRecipient` atomically at creation. This bypasses BOTH
the policy check (the recipient does not need to satisfy
`isAuthorizedMintRecipient` on `transferPolicyId`) AND the `MINTABLE`
`isAuthorized` on `transferPolicyId`) AND the `MINTABLE`
capability check (the bootstrap mint works even on a token where
`MINTABLE = false`).

Expand Down
109 changes: 29 additions & 80 deletions src/interfaces/IB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,15 @@ pragma solidity >=0.8.20 <0.9.0;
/// Functions whose capability bit is unset revert with
/// `FeatureDisabled`, regardless of role state. See `Capabilities`.
///
/// **Policy model.** Every transfer, mint, and redeem passes
/// through the token's currently-set policy ID, resolved against
/// the singleton policy registry. Transfer checks consult the
/// policy for `from`, `to`, AND `msg.sender` (the spender, when
/// distinct from `from`). Mint checks consult the policy for the
/// recipient via the mint-recipient slot of a compound policy.
/// Redeem checks consult the policy for `msg.sender` via the
/// redeemer slot of a compound policy: tokens without redemption
/// configure that slot as always-reject, making `redeem` revert
/// for every caller. Burn checks consult only the role of the
/// caller; `BURN_ROLE` plus the caller's own balance are
/// sufficient. `approve` is NOT gated by the policy (only the
/// act of MOVING balance is gated).
/// **Policy model.** Every transfer and mint passes through the
/// token's currently-set policy ID, resolved against the singleton
/// policy registry via `isAuthorized(transferPolicyId, address)`.
/// Transfer checks run for `from`, `to`, AND `msg.sender` (the
/// spender, when distinct from `from`). Mint checks run for `to`
/// (the recipient). Burn checks consult only the role of the caller;
/// `BURN_ROLE` plus the caller's own balance are sufficient.
/// `approve` is NOT gated by the policy (only the act of MOVING
/// balance is gated).
///
/// **Permit.** EIP-2612 permit, EOA signatures only. ERC-1271
/// contract signatures are NOT supported on the default surface
Expand Down Expand Up @@ -157,10 +153,6 @@ interface IB20 {
/// permanent.
error FeatureDisabled(uint256 capability);

/// @notice The redemption amount is below the configured
/// `minimumRedeemable` threshold.
error MinimumRedeemableNotMet(uint256 amount, uint256 minimum);

/// @notice `renounceRole(DEFAULT_ADMIN_ROLE, ...)` was called when the
/// caller is the last admin. Tokens MUST always have at least
/// one admin; rotate to a new admin first via `grantRole`.
Expand Down Expand Up @@ -228,10 +220,14 @@ interface IB20 {
/// @notice Emitted by `unpause`. All paused vectors are cleared.
event Unpaused(address indexed updater);

/// @notice Emitted by `changeTransferPolicyId`. Includes the prior ID
/// @notice Emitted by `updateTransferPolicy`. Includes the prior ID
/// for indexer convenience.
event TransferPolicyUpdated(address indexed updater, uint64 oldPolicyId, uint64 newPolicyId);

/// @notice Emitted by `updateMintPolicy`. Includes the prior ID
/// for indexer convenience.
event MintPolicyUpdated(address indexed updater, uint64 oldPolicyId, uint64 newPolicyId);

/// @notice Emitted by `setSupplyCap`. Includes the prior cap for
/// indexer convenience.
event SupplyCapUpdated(address indexed updater, uint256 oldSupplyCap, uint256 newSupplyCap);
Expand All @@ -249,18 +245,6 @@ interface IB20 {
/// indexer consumption.
event SymbolUpdated(address indexed updater, string newSymbol);

/// @notice Emitted by `redeem` and `redeemWithMemo` (in addition to
/// the standard `Transfer(holder, address(0), amount)`).
/// Distinguishes user-initiated redemption (which implies an
/// off-chain settlement obligation) from plain `burn`, which
/// emits the same `Transfer` event but carries no
/// off-chain meaning.
event Redeemed(address indexed holder, uint256 amount);

/// @notice Emitted by `setMinimumRedeemable`. Includes the prior
/// minimum for indexer convenience.
event MinimumRedeemableUpdated(address indexed updater, uint256 oldMinimum, uint256 newMinimum);

/*//////////////////////////////////////////////////////////////
ROLE IDENTIFIERS
//////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -462,54 +446,6 @@ interface IB20 {
/// after the standard `Transfer` event.
function burnWithMemo(uint256 amount, bytes32 memo) external;

/*//////////////////////////////////////////////////////////////
REDEEM
//////////////////////////////////////////////////////////////*/

/// @notice Destroys `amount` of the caller's balance, signaling an
/// off-chain redemption claim against the issuer. Subject to:
/// 1. `amount >= minimumRedeemable()` (else
/// `MinimumRedeemableNotMet(amount, minimum)`).
/// 2. `amount <= balanceOf(msg.sender)` (else
/// `InsufficientBalance(msg.sender, balance, amount)`).
/// 3. The `REDEEM` pause vector is unset (else
/// `ContractPaused(REDEEM)`).
/// 4. The active transfer policy authorizes `msg.sender` as
/// a redeemer (else `PolicyForbids(transferPolicyId)`).
/// @dev No role is required: redemption is a user-initiated
/// operation on the caller's own balance, gated entirely by
/// the policy's redeemer slot.
///
/// Tokens that do not offer redemption configure their
/// transfer policy with the redeemer slot pointed at policy
/// ID `0` (always-reject); calls to `redeem` then revert
/// with `PolicyForbids` for every caller. The function is
/// present on every Default token but its availability is
/// policy-driven.
///
/// Distinct from `burn` (which requires `BURN_ROLE` and
/// carries no off-chain settlement implication). Both emit
/// `Transfer(holder, address(0), amount)`; `redeem`
/// additionally emits `Redeemed(holder, amount)` so indexers
/// can distinguish.
function redeem(uint256 amount) external;

/// @notice Same as `redeem`, with a memo. Emits `Memo(memo)`
/// immediately after the standard `Transfer` event (and
/// after `Redeemed`).
function redeemWithMemo(uint256 amount, bytes32 memo) external;

/// @notice The minimum amount that may be redeemed in a single call
/// to `redeem` / `redeemWithMemo`. Defaults to 0 (no
/// minimum) at creation.
function minimumRedeemable() external view returns (uint256);

/// @notice Sets a new minimum redeemable amount. Requires
/// `DEFAULT_ADMIN_ROLE`. May be set to 0 to disable the
/// minimum entirely. Takes effect immediately for the next
/// redemption.
function setMinimumRedeemable(uint256 newMinimum) external;

/*//////////////////////////////////////////////////////////////
ROLES
//////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -596,14 +532,27 @@ interface IB20 {
/// mints. Resolved against the singleton policy registry
/// precompile. ID `0` always rejects (functional soft-pause
/// via policy); ID `1` always allows.
function transferPolicyId() external view returns (uint64);
function transferPolicyId() external view returns (uint64 policyId);

/// @notice Sets a new transfer policy. Requires `DEFAULT_ADMIN_ROLE`.
/// The policy MUST exist in the registry (or be one of the
/// built-in IDs `0` or `1`); otherwise reverts with
/// `PolicyNotFound`. Takes effect immediately for the next
/// transfer or mint.
function updateTransferPolicy(uint64 newPolicyId) external;

/// @notice The policy ID currently gating this token's transfers and
/// mints. Resolved against the singleton policy registry
/// precompile. ID `0` always rejects (functional soft-pause
/// via policy); ID `1` always allows.
function mintPolicyId() external view returns (uint64 policyId);

/// @notice Sets a new transfer policy. Requires `DEFAULT_ADMIN_ROLE`.
/// The policy MUST exist in the registry (or be one of the
/// built-in IDs `0` or `1`); otherwise reverts with
/// `PolicyNotFound`. Takes effect immediately for the next
/// transfer or mint.
function changeTransferPolicyId(uint64 newPolicyId) external;
function updateMintPolicy(uint64 newPolicyId) external;

/*//////////////////////////////////////////////////////////////
SUPPLY CAP
Expand Down
Loading