diff --git a/README.md b/README.md index 9205ad76..7e3bcf94 100644 --- a/README.md +++ b/README.md @@ -12,26 +12,49 @@ A collection of Solidity interfaces, libraries, and mock implementations for Base precompiles. -## Installation +## Products + +- [**ActivationRegistry**](docs/ActivationRegistry/README.md) — Feature flags controlled by Base team to activate/deactivate features. +- [**PolicyRegistry**](docs/PolicyRegistry/README.md) — Membership sets controlled by custom admins, initially providing allow and block lists for B20 token operations. +- [**B20**](docs/B20/README.md) — Standard ERC-20 implementation with extensions for roles, policies, memos, pausing, ERC-2612 permits, and a variant system. + +## Source Integration + +These source files are imported by production contracts to interact with Base precompiles. ```bash forge install base/base-std ``` -## Standard Precompiles -
 src
-├── StdPrecompiles.sol: Collection of precompiles and their interfaces
-└── interfaces
-    ├── IB20.sol: Core Token Standard
-    ├── IB20Stablecoin.sol: Stablecoin variant of B20
-    ├── IB20Security.sol: Security variant of B20
-    ├── IPolicyRegistry.sol: Policy registry shared across B20s
-    └── ITokenFactory.sol: B20 factory contract
+├── StdPrecompiles.sol: Precompile addresses with interface wrapper handles
+├── interfaces
+│   ├── IB20.sol: Core token standard
+│   ├── IB20Stablecoin.sol: Stablecoin variant of B20
+│   ├── IB20Security.sol: Security variant of B20
+│   ├── IB20Factory.sol: B20 factory precompile
+│   ├── IPolicyRegistry.sol: Policy registry precompile
+│   └── IActivationRegistry.sol: Activation registry precompile
+└── lib
+    ├── B20Constants.sol: B20 role and policy-type identifier constants
+    └── B20FactoryLib.sol: Pure encoders for B20 factory params and initCalls
+
+ +## Test Integration + +These mock contracts replace the live precompiles in unit tests, allowing tests to run without a fork. + +
+test/lib/mocks
+├── MockActivationRegistry.sol: Mock implementation of the activation registry precompile
+├── MockPolicyRegistry.sol: Mock implementation of the policy registry precompile
+└── MockB20Factory.sol: Mock implementation of the B20 factory precompile
 
-## Development +## Contributing + +### Development ```bash forge build @@ -42,7 +65,7 @@ Solidity version: `0.8.30` for reference implementations. Interfaces are written for broader compatibility (`>=0.8.20 <0.9.0`) so consumers can import them without forcing a specific compiler. -## Fork testing against live precompiles +### Fork testing against live precompiles The unit suite can run against a chain hosting the live Rust precompile implementations to verify the Rust impls match the Solidity reference's diff --git a/docs/ActivationRegistry/README.md b/docs/ActivationRegistry/README.md new file mode 100644 index 00000000..dfa05d43 --- /dev/null +++ b/docs/ActivationRegistry/README.md @@ -0,0 +1,51 @@ +# ActivationRegistry + +The ActivationRegistry tracks which Base features are live. This is managed exclusive by the Base and integrators don't typically need to query it. See [`IActivationRegistry`](../../src/interfaces/IActivationRegistry.sol) for the full Solidity interface. + +## Feature IDs + +Feature IDs are opaque `bytes32` identifiers. By convention each is the keccak256 digest of a human-readable feature name (e.g., `keccak256("base.b20_token")`); a feature ID is permanently bound to its semantic and is never recycled. + +The canonical IDs in use today, defined in [`ActivationRegistryFeatureList`](../../test/lib/mocks/ActivationRegistryFeatureList.sol): + +| Constant | Preimage | Value | +|---|---|---| +| `B20_TOKEN` | `"base.b20_token"` | `0x47a1afe8d3d691b87e090ee972d223a11f4da971ff5416c04985bb2393aca752` | +| `B20_FACTORY` | `"base.b20_factory"` | `0x78751e29c8bcc0d609ab18e9fbc4158e73f7db25ae2ee095dad42e2578b1e800` | +| `B20_SECURITY` | `"base.b20_security"` | `0x83d32fab502ae0e8bc4352a117767262cb5e47cc8d67a744008ed4ff03fcf5e6` | +| `B20_STABLECOIN` | `"base.b20_stablecoin"` | `0xecfa0def2c10020caaf65e6155aa69c84b24892aaef76eeac52e0e2b3a0b8601` | +| `POLICY_REGISTRY` | `"base.policy_registry"` | `0xb582ebae03f16fee49a6763f78df482fb11ae73f103ed0d330bbe556aa90a43f` | + +## User Flows + +### Activate Feature + +The admin marks a feature as live; downstream consumers can immediately observe the change. + +```mermaid +sequenceDiagram + participant Admin + participant ActivationRegistry + + Admin->>ActivationRegistry: activate(featureId) + Note over ActivationRegistry: features[featureId] = true + ActivationRegistry-->>Admin: emit FeatureActivated(featureId) +``` + +Reverts: `Unauthorized` (non-admin caller), `AlreadyActivated`, `DelegateCallNotAllowed` / `StaticCallNotAllowed`. + +### Deactivate Feature + +The admin marks a previously-active feature as inactive. + +```mermaid +sequenceDiagram + participant Admin + participant ActivationRegistry + + Admin->>ActivationRegistry: deactivate(featureId) + Note over ActivationRegistry: features[featureId] = false + ActivationRegistry-->>Admin: emit FeatureDeactivated(featureId) +``` + +Reverts: `Unauthorized`, `AlreadyDeactivated`, `DelegateCallNotAllowed` / `StaticCallNotAllowed`. diff --git a/docs/B20/Factory.md b/docs/B20/Factory.md new file mode 100644 index 00000000..7b288492 --- /dev/null +++ b/docs/B20/Factory.md @@ -0,0 +1,42 @@ +# B20 Factory + +The B20 Factory is the singleton precompile that creates B20 tokens of every variant. Anyone can call its single entry point, `createB20`. See [`IB20Factory`](../../src/interfaces/IB20Factory.sol) for the full Solidity interface. + +## `createB20` parameters + +`createB20` takes four arguments: + +### `variant` + +Selects which variant of B20 to deploy — currently `DEFAULT`, `STABLECOIN`, or `SECURITY`. See the [variant overview](README.md#variant-overview) for what each one bundles. + +### `params` + +Variant-specific creation arguments, ABI-encoded as a versioned struct (one struct per variant; the leading byte selects the encoding version). Required and optional fields differ per variant — see [`IB20Factory`](../../src/interfaces/IB20Factory.sol) for each variant's struct spec. + +### `initCalls` + +An optional array of ABI-encoded calls dispatched on the new token immediately after creation. These let you configure anything beyond the variant's defined `params` — role grants, mint operations, policy slot wiring, contract URI, and so on. They execute on the new token as if the factory were the admin, so admin-gated operations are permitted within this window. The factory itself receives no official roles and has no persisted access to the token. + +Build the array with [`B20FactoryLib`](../../src/lib/B20FactoryLib.sol) helpers (or encode manually): + +```solidity +// Configure the new token: cap supply and gate minting on an allowlist. +bytes[] memory initCalls = new bytes[](2); +initCalls[0] = B20FactoryLib.encodeUpdateSupplyCap(1_000_000e18); +initCalls[1] = B20FactoryLib.encodeUpdatePolicy(B20Constants.MINT_RECEIVER_POLICY, mintPolicyId); +``` + +### `salt` + +Caller-chosen entropy that influences the deployed token's address — see [B20 Address Derivation](#b20-address-derivation). + +## B20 Address Derivation + +B20 addresses are deterministic: `[B20 prefix (10 bytes)][variant byte (1 byte)][bytes9(keccak256(deployer, salt))]`. The variant byte being recoverable from the address means off-chain tooling can identify the variant without an RPC call. + +`getB20Address(variant, deployer, salt)` predicts the address before deployment. `isB20(address)` matches against the prefix pattern (recovered from the address with no storage read), and `isB20Initialized(address)` flips true exactly once when `createB20` completes at that address. + +## Composing with the factory + +The factory is callable from any account, including from your own contract. Wrapping the factory is the standard path for layering access control on top of permissionless creation, bundling defaults into a higher-level builder, or defining a custom salting scheme. diff --git a/docs/B20/README.md b/docs/B20/README.md new file mode 100644 index 00000000..83629905 --- /dev/null +++ b/docs/B20/README.md @@ -0,0 +1,119 @@ +# B20 + +B20 is an ERC-20 superset designed for Base. All B20s are deployed via the singleton `IB20Factory` precompile (see [Factory](Factory.md)). + +B20 supports several variants: + +- **Default** — covered on this page +- **[Stablecoin](Stablecoin.md)** — fixed-decimals fiat-backed variant +- **[Security](Security.md)** — corporate-actions / share-class variant + +This document covers the Default B20 variant and everything shared across the variant family. + +## ERC-20 + +Implements the [ERC-20](https://eips.ethereum.org/EIPS/eip-20) standard surface with full selector parity — drop-in for existing tooling. + +## Roles model + +B20 role-based access control follows from [OZ AccessControl](https://docs.openzeppelin.com/contracts/5.x/access-control) with a fixed set of custom roles and one behavior override on admin renunciation. + +Standard role taxonomy: + +| Role | Gates | +|---|---| +| `DEFAULT_ADMIN_ROLE` | All admin operations: role grants, policy updates, supply-cap changes | +| `MINT_ROLE` | `mint`, `mintWithMemo` | +| `BURN_ROLE` | Caller-side burns (`burn`, `burnWithMemo`) | +| `BURN_BLOCKED_ROLE` | Sanctions-burns against policy-blocked accounts (`burnBlocked`) | +| `PAUSE_ROLE` | `pause` | +| `UNPAUSE_ROLE` | `unpause` | +| `METADATA_ROLE` | `updateName`, `updateSymbol`, `updateContractURI` | + +User-defined roles are supported via `setRoleAdmin` and `grantRole`. They have no built-in effect; B20 only enforces gates against the seven roles above. + +Roles are granted, revoked, and renounced through the standard OZ AccessControl methods. The one departure: the last `DEFAULT_ADMIN_ROLE` holder cannot be removed via `renounceRole` or `revokeRole` (both revert with `LastAdminCannotRenounce`); the dedicated `renounceLastAdmin()` is the only path that permanently transitions the token to admin-less. Tokens that intend to launch admin-less from the start pass `initialAdmin == address(0)` at creation, which never grants the role and skips the `renounceLastAdmin` step entirely. + +After `renounceLastAdmin()` (or for tokens deployed with `initialAdmin == address(0)`), operations gated by `DEFAULT_ADMIN_ROLE` become permanently uncallable. Roles that were already granted to other addresses (`MINT_ROLE`, `BURN_ROLE`, `PAUSE_ROLE`, `UNPAUSE_ROLE`, `METADATA_ROLE`, etc.) continue to function independently. Admin-resurrection is blocked: `grantRole`, `revokeRole`, and `setRoleAdmin` all revert with `AccessControlUnauthorizedAccount` on an admin-less token, even if the caller holds a custom role that would normally satisfy the meta-role gate. A custom-admin chain such as `setRoleAdmin(MINT_ROLE, BURN_ROLE) → grantRole(BURN_ROLE, X)` cannot restore admin power. + +## Policy integration + +B20 declares a fixed set of *policy scopes*. Each scope stores a `uint64` policy ID that points into the [PolicyRegistry](../PolicyRegistry/README.md); on every gated operation, B20 calls `isAuthorized` against the relevant scope and reverts (`PolicyForbids`) if the account isn't authorized. + +Scope identifiers follow the `{ACTION}_{ACTOR}_POLICY` convention: + +| Scope | Gates | +|---|---| +| `TRANSFER_SENDER_POLICY` | The `from` of `transfer` / `transferFrom` | +| `TRANSFER_RECEIVER_POLICY` | The `to` of `transfer` / `transferFrom` | +| `TRANSFER_EXECUTOR_POLICY` | The `msg.sender` of `transferFrom` (not consulted on `transfer`) | +| `MINT_RECEIVER_POLICY` | The `to` of `mint` | + +`approve` itself is not policy-gated — only the actual movement of balance via `transfer` / `transferFrom` is checked. A blocked address can hold or receive allowances; the gate fires when balance moves. + +Because scopes are per-actor, send-side and receive-side rules can be configured independently. Common patterns include allowlisting receivers while leaving sends open (e.g. KYC-only deposits) and restricting `MINT_RECEIVER_POLICY` to a custodian set while leaving everyday transfers unrestricted. + +> ⚠️ **Every scope defaults to `ALWAYS_ALLOW` at token creation** unless overridden in the bootstrap `initCalls`. Token behavior must be intentionally constrained — an unattended deployment of B20 is fully open. + +Scopes are read via `policyId(scope)` and written via `updatePolicy(scope, policyId)`. `updatePolicy` is admin-gated and reverts if the scope identifier isn't recognized — typo'd scopes hard-revert rather than silently no-op'ing. + +See [PolicyRegistry](../PolicyRegistry/README.md) for registry mechanics (built-in policy IDs, encoding, admin lifecycle). + +## Mint + +New supply is created via `mint` / `mintWithMemo`, gated by `MINT_ROLE`. The recipient is policy-checked against `MINT_RECEIVER_POLICY`, and the operation reverts with `SupplyCapExceeded` if it would push `totalSupply` past the cap. + +## Burn + +Two burn paths serve two operational needs: + +- **`burn` / `burnWithMemo`** — caller burns from their own balance. Gated by `BURN_ROLE`. Permissioned so asset issuers can maintain equivalent units for wrapped assets without exposing supply to arbitrary holders. +- **`burnBlocked`** — burns from a third party's balance. Gated by `BURN_BLOCKED_ROLE`. The target account MUST be denied by `TRANSFER_SENDER_POLICY` — this is the freeze-and-seize path required by regulated issuers, deliberately impossible against accounts that aren't policy-blocked. + +## Supply cap + +The supply cap is optional; the sentinel `type(uint256).max` indicates no cap and is the default at creation. `updateSupplyCap(newCap)` is admin-gated and emits `SupplyCapUpdated` — the cap may be raised or lowered freely, but lowering below current `totalSupply` reverts with `InvalidSupplyCap` because already-issued supply is never invalidated. + +## Memos + +A memo is an optional `bytes32` payload that callers attach to a token operation for off-chain reference — payment IDs, compliance tagging, settlement correlation, etc. + +Every memo'd operation emits a `Memo(address indexed caller, bytes32 indexed memo)` event immediately after the operation's primary event, with a `bytes32(0)` memo permitted as a "no memo content" signal. Indexers join the `Memo` log to its parent via `(transactionHash, logIndex − 1)` — the memo always sits immediately after its primary event in log order. + +Memo-emitting entrypoints: + +- `transferWithMemo`, `transferFromWithMemo` — same semantics as their non-memo counterparts plus the `Memo` event. +- `mintWithMemo`, `burnWithMemo` — same pattern on issuance and self-burn. + +## Pause + +B20 pauses are granular: the `PausableFeature` enum partitions the gated surface into independently pausable operations, currently `TRANSFER`, `MINT`, `BURN`, and `REDEEM` (Security variant only). The enum is append-only across protocol versions, so existing positions are stable forever. `isPaused(feature)` is `O(1)`; `pausedFeatures()` returns the full set as an array. + +`pause(features)` and `unpause(features)` are gated by *separate* roles (`PAUSE_ROLE` and `UNPAUSE_ROLE`) by design — an incident-response operator can pause without holding the authority to re-enable. + +## ERC-2612 Permit / EIP-712 + +B20 implements [ERC-2612](https://eips.ethereum.org/EIPS/eip-2612) (signed approvals) using an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) domain shaped as `(name, version, chainId, verifyingContract)`, with `version` fixed at `"1"` and `salt` unused. Because `name` is re-hashed into the domain on every signed call, `updateName` automatically rotates the domain separator; each successful `updateName` emits one `EIP712DomainChanged` event ([ERC-5267](https://eips.ethereum.org/EIPS/eip-5267)). + +`DOMAIN_SEPARATOR()` and `eip712Domain()` are exposed for callers that want to read the domain dynamically rather than reconstruct it. `nonces(owner)` is the per-account replay counter incremented on every `permit`. + +ERC-1271 contract signatures are deliberately NOT accepted — permit recovers via ECDSA from 65-byte signatures only. Smart-contract accounts should use call-batching or gasless flows. [Permit2](https://github.com/Uniswap/permit2) is usable as a periphery alternative. + +## Contract URI (ERC-7572) + +`contractURI()` returns a string pointing to off-chain metadata about the token (typically a JSON document) per [ERC-7572](https://eips.ethereum.org/EIPS/eip-7572). `updateContractURI(newUri)` is gated by `METADATA_ROLE`. + +## Metadata updates + +`METADATA_ROLE` gates two metadata setters: + +- `updateName(newName)` updates the token name AND rotates the EIP-712 domain separator (see [ERC-2612 Permit / EIP-712](#erc-2612-permit--eip-712)). Emits `NameUpdated` and `EIP712DomainChanged`. +- `updateSymbol(newSymbol)` updates the symbol with no other side effects. Emits `SymbolUpdated`. + +## Variant overview + +| Variant | Decimals | What it adds | +|---|---|---| +| Default | 18 (fixed) | — | +| [Stablecoin](Stablecoin.md) | 6 (fixed) | currency ISO code | +| [Security](Security.md) | 6 (fixed) | share-ratio, announcements, identifiers, redemptions | diff --git a/docs/B20/Security.md b/docs/B20/Security.md new file mode 100644 index 00000000..ae743f5b --- /dev/null +++ b/docs/B20/Security.md @@ -0,0 +1,82 @@ +# B20 Security + +The Security variant of B20 — designed for tokenized securities. Everything in [B20/README.md](README.md) applies; this page covers the deltas only. See [`IB20Security`](../../src/interfaces/IB20Security.sol) for the full Solidity interface. + +## Share Ratio + +Shares are a virtualized representation of an asset amount, computed by applying a uniform ratio on top of the underlying token balance stored on every account. The ratio applies to all accounts equally, which lets issuers model corporate actions like splits or reverse-splits without rewriting individual balances. + +Read the current ratio with `sharesToTokensRatio()`; the value is in WAD precision (`1e18`, exposed as `WAD_PRECISION()`). `toShares(tokenAmount)` converts a token amount to shares at the current ratio, and `sharesOf(account)` is a convenience over ERC-20's `balanceOf` that returns the same account's balance expressed in shares. + +`updateShareRatio(newRatio)` updates the ratio and should be wrapped in an announcement (see [Announcements](#announcements)). + +## Announcements + +Announcements are publicly viewable notifications posted by a token operator. They can represent anything the operator wants to create a record of and can be coupled with actual state changes on the token (updating share ratio, batched mints/burns, updating identifiers, and so on). + +### Event Topology + +An announcement is delimited by a paired `Announcement(msg.sender, id, description, uri)` event (opens the bracket) and `EndAnnouncement(id)` event (closes it). Every state-changing call dispatched inside the bracket belongs to that announcement. A recursion guard prevents nesting, and each `id` is enforced unique forever (`AnnouncementIdAlreadyUsed`) so indexers can correlate brackets across transactions. + +Indexers should treat every `Announcement` log as the start of exactly one bracket; effects between `Announcement` and `EndAnnouncement` belong to the announced action; effects emitted *without* a surrounding bracket are direct invocations and should be flagged as emergency overrides. + +### Wrapping calls in announcements + +Wrap a set of operations in a single announcement by calling `announce(internalCalls, id, description, uri)`. The function (gated by `SECURITY_OPERATOR_ROLE`) emits `Announcement`, dispatches each internal call via self-`delegatecall` (which preserves `msg.sender` so the inner role checks see the operator), then emits `EndAnnouncement`. Inner reverts are wrapped in `InternalCallFailed` rather than bubbled — replay the call directly to debug. Nested calls to `announce` revert with `AnnouncementInProgress`; calls shorter than 4 bytes revert with `InternalCallMalformed`. + +```solidity +// Disclose and execute a 2-for-1 forward split atomically. +bytes[] memory internalCalls = new bytes[](1); +internalCalls[0] = abi.encodeCall(IB20Security.updateShareRatio, (newRatio)); + +IB20Security(token).announce({ + internalCalls: internalCalls, + id: "2026-Q3-split", + description: "2-for-1 forward stock split", + uri: "https://disclosures.example.com/..." +}); +``` + +The four corporate-actions setters should be wrapped in `announce()`: + +- `updateShareRatio(...)` +- `batchMint(...)` +- `batchBurn(...)` +- `updateSecurityIdentifier(...)` + +Direct invocation by a role holder is permitted as an **emergency override** — it succeeds but produces no bracket events. Suitable only for break-glass scenarios where the inability to emit an announcement is itself part of the response. + +## Batch Mint/Burn + +`batchMint(recipients, amounts)` mints to many accounts in one call, gated by `MINT_ROLE`. `batchBurn(holders, amounts)` burns from many accounts in one call, gated by `BURN_FROM_ROLE`. Both should be wrapped in `announce()`, which additionally requires the operator to hold `SECURITY_OPERATOR_ROLE` (typically granted as a single bundle). + +## Redemptions + +Redemptions let token holders initiate their own burn — typically the on-chain leg of a flow where the issuer post-processes the redemption off-chain (delivering underlying assets, crediting fiat, etc.). The Security variant exposes `redeem(amount)` and `redeemWithMemo(amount, memo)`, both of which burn the caller's token balance and emit `Redeemed(redeemer, tokenAmount, shareAmount)`. `redeemWithMemo` additionally emits `Memo` per the indexer-join convention (see [B20 README → Memos](README.md#memos)). + +Two gates apply on every call: + +- `REDEEM_SENDER_POLICY` — the caller must be authorized. +- `minimumRedeemable` — admin-set floor (in shares); redemptions below this floor revert with `BelowMinimumRedeemable`. Any redemption that resolves to zero shares (e.g. token dust against a large share ratio) is always rejected, even when `minimumRedeemable == 0`. Read with `minimumRedeemable()`; update with `updateMinimumRedeemable(newMin)` (admin-gated; emits `MinimumRedeemableUpdated`). + +> ⚠️ **`REDEEM_SENDER_POLICY` defaults to `ALWAYS_BLOCK` at token creation** — redemptions are closed until the admin explicitly opens them via `updatePolicy`. The conservative default reflects that redemption is irreversible and regulator-sensitive. + +## Security Identifiers + +Each Security token can carry one or more standardized identifiers (ISIN, CUSIP, FIGI, SEDOL, etc.). Read with `securityIdentifier(type)`; the value is a `string`. ISIN is required at creation via `B20SecurityCreateParams.isin`; other identifiers are optional and added post-creation. + +`updateSecurityIdentifier(type, value)` adds or updates an identifier and should be wrapped in `announce()`. Passing an empty `value` removes the entry. Unknown identifier types revert with `InvalidIdentifierType`. + +## Additional roles + +### `SECURITY_OPERATOR_ROLE` + +Gates the four corporate-actions setters (`updateShareRatio`, `batchMint`, `batchBurn`, `updateSecurityIdentifier`) and the `announce` wrapper itself. Held separately from `DEFAULT_ADMIN_ROLE` so corporate-actions operators don't need full admin authority. Operationally paired with `METADATA_ROLE` — when granting one, you typically grant the other to the same address. + +### `BURN_FROM_ROLE` + +Gates `batchBurn`, which burns balances held by other accounts as part of an announced corporate action. Distinct from `BURN_ROLE` (caller burns their own balance) and `BURN_BLOCKED_ROLE` (sanctions-seizure against policy-blocked addresses) — three burn primitives serve three different operational scenarios. + +## Fixed Decimals (6) + +`decimals()` is hard-wired to `6`. The choice matches the precision used by popular real-world securities-platform integrations. diff --git a/docs/B20/Stablecoin.md b/docs/B20/Stablecoin.md new file mode 100644 index 00000000..fa745757 --- /dev/null +++ b/docs/B20/Stablecoin.md @@ -0,0 +1,13 @@ +# B20 Stablecoin + +The Stablecoin variant of B20. Everything in [B20/README.md](README.md) applies; this page covers the deltas only. See [`IB20Stablecoin`](../../src/interfaces/IB20Stablecoin.sol) for the Solidity interface. + +## Fixed Decimals (6) + +`decimals()` is hard-wired to `6`. The choice matches existing popular stablecoins. + +## Currency Codes + +`currency()` returns the ISO-style currency code as a `string` (e.g., `"USD"`, `"EUR"`). It is set once via `B20StablecoinCreateParams.currency` at creation, immutable thereafter, and restricted to `A`–`Z` bytes (no lowercase, no digits, no separators). + +The value is **self-declared** — the contract does not verify it against any registry or allowlist. Wallets and indexers can use it to group stablecoins by underlying fiat without an external lookup, but it is not a proof of fiat backing. diff --git a/docs/PolicyRegistry/README.md b/docs/PolicyRegistry/README.md new file mode 100644 index 00000000..02e3776a --- /dev/null +++ b/docs/PolicyRegistry/README.md @@ -0,0 +1,108 @@ +# PolicyRegistry + +The PolicyRegistry is a singleton precompile for list-based access policies — allowlists and blocklists. Any caller can create a policy and nominate its admin; B20 tokens and other consumers reference policies by `uint64` ID for authorization checks. See [`IPolicyRegistry`](../../src/interfaces/IPolicyRegistry.sol) for the full Solidity interface. + +## Policy Types + +Two policy types are supported today: + +- **`BLOCKLIST`** — accounts are authorized by default; the admin maintains a list of accounts to explicitly deny. +- **`ALLOWLIST`** — accounts are denied by default; the admin maintains a list of accounts to explicitly authorize. + +Additional types (union / intersect composition of existing policies) are planned for a future hardfork via additive `PolicyType` enum values and sibling creator functions. + +## Policy IDs + +Each policy is identified by a `uint64` ID. The top byte (`[63:56]`) encodes the `PolicyType`; the low 56 bits (`[55:0]`) are a global counter. Type is recoverable from any ID via pure bit extraction, with no storage read. + +Custom policy IDs are assigned from a single global counter starting at `2`. The values `0` and `1` are reserved for two **built-in policies** that consumers can reference on a slot without creating a policy: + +| Policy | Value | Semantics | +|---|---|---| +| `ALWAYS_ALLOW` | `0` | `isAuthorized(ALWAYS_ALLOW, *) → true` | +| `ALWAYS_BLOCK` | `(uint64(ALLOWLIST) << 56) \| 1` | `isAuthorized(ALWAYS_BLOCK, *) → false` | + +`ALWAYS_ALLOW` is also the default state of every unassigned policy slot on a B20 token. + +> **Precondition for consumers.** `isAuthorized` never reverts on a non-existent or malformed `policyId` — it collapses to empty-member-set semantics (ALLOWLIST → `false`, BLOCKLIST → `true`). Consumers that store policy IDs (notably `IB20.updatePolicy`) MUST validate `policyExists(policyId)` at write time, since a typo'd BLOCKLIST ID would silently behave as `ALWAYS_ALLOW`. + +## User Flows + +### Create Policy + +A caller deploys a new policy, nominates its admin (often themselves or a multisig), and optionally seeds an initial member set in the same call. + +```mermaid +sequenceDiagram + participant Creator + participant PolicyRegistry + + Creator->>PolicyRegistry: createPolicy(admin, policyType) + Note over PolicyRegistry: allocate new policyId
store type and admin + PolicyRegistry-->>Creator: emit PolicyCreated(policyId, creator, policyType) + PolicyRegistry-->>Creator: emit PolicyAdminUpdated(policyId, 0, admin) +``` + +Use `createPolicyWithAccounts(admin, policyType, accounts)` for the seeded variant — same shape, plus a membership seeding step that emits `AllowlistUpdated` or `BlocklistUpdated` (depending on `policyType`) carrying the full batch. + +Reverts: `ZeroAddress` (if `admin` is `address(0)`), `BatchSizeTooLarge` (seeded variant only). + +### Update Membership + +The policy admin sets `accounts` to a uniform membership state — all included or all excluded — in a single batch. + +```mermaid +sequenceDiagram + participant PolicyAdmin + participant PolicyRegistry + + PolicyAdmin->>PolicyRegistry: updateAllowlist(policyId, allowed, accounts) + Note over PolicyRegistry: set each account's
membership to `allowed` + PolicyRegistry-->>PolicyAdmin: emit AllowlistUpdated(policyId, updater, allowed, accounts) +``` + +`updateBlocklist(policyId, blocked, accounts)` has the same shape for `BLOCKLIST` policies; it emits `BlocklistUpdated` instead. Use the matching call for the policy's type — mixing them reverts. + +Reverts: `PolicyNotFound` (unknown `policyId`), `IncompatiblePolicyType` (wrong call for the policy's type), `Unauthorized` (caller isn't current admin), `BatchSizeTooLarge`. + +### Transfer Admin + +A two-step transfer: the current admin proposes a successor, then the proposed admin accepts. The active admin doesn't change until the second step. + +```mermaid +sequenceDiagram + participant CurrentAdmin + participant PolicyRegistry + participant NewAdmin + + CurrentAdmin->>PolicyRegistry: stageUpdateAdmin(policyId, newAdmin) + Note over PolicyRegistry: pendingAdmin = newAdmin + PolicyRegistry-->>CurrentAdmin: emit PolicyAdminStaged(policyId, currentAdmin, newAdmin) + + NewAdmin->>PolicyRegistry: finalizeUpdateAdmin(policyId) + Note over PolicyRegistry: admin = newAdmin
clear pendingAdmin + PolicyRegistry-->>NewAdmin: emit PolicyAdminUpdated(policyId, currentAdmin, newAdmin) +``` + +`stageUpdateAdmin(policyId, address(0))` cancels an in-flight transfer. Re-staging while a pending admin already exists overwrites the prior nomination — the previous candidate loses their ability to finalize. + +Reverts (Step 1): `PolicyNotFound`, `Unauthorized` (caller isn't current admin). +Reverts (Step 2): `PolicyNotFound`, `NoPendingAdmin` (no transfer in flight), `Unauthorized` (caller isn't the staged pending admin). + +### Renounce Admin + +The current admin permanently relinquishes administration of the policy. The membership set is frozen forever; the policy can never be re-administered. + +```mermaid +sequenceDiagram + participant PolicyAdmin + participant PolicyRegistry + + PolicyAdmin->>PolicyRegistry: renounceAdmin(policyId) + Note over PolicyRegistry: admin = address(0)
clear pendingAdmin + PolicyRegistry-->>PolicyAdmin: emit PolicyAdminUpdated(policyId, oldAdmin, 0) +``` + +The policy continues to exist and remains a valid target of `isAuthorized` queries forever — only mutation is disabled. + +Reverts: `PolicyNotFound`, `Unauthorized` (caller isn't current admin). diff --git a/src/StdPrecompiles.sol b/src/StdPrecompiles.sol index 43f05e2a..04d41348 100644 --- a/src/StdPrecompiles.sol +++ b/src/StdPrecompiles.sol @@ -5,15 +5,10 @@ import {IActivationRegistry} from "./interfaces/IActivationRegistry.sol"; import {IPolicyRegistry} from "./interfaces/IPolicyRegistry.sol"; import {IB20Factory} from "./interfaces/IB20Factory.sol"; -/// @title Standard Precompiles Library for Base -/// @notice Address constants for Base's singleton precompiles, paired -/// with interface-typed handles so callers can interact with -/// them directly (e.g. `StdPrecompiles.B20_FACTORY.createB20(...)`). +/// @title StdPrecompiles +/// @notice Address constants for Base's singleton precompiles, each paired with an interface-typed +/// handle (e.g. `StdPrecompiles.B20_FACTORY.createB20(...)`). library StdPrecompiles { - /// @dev The `0xB20F` prefix mirrors the `0xB2` B-20 token prefix while staying - /// disjoint (B-20 tokens have `0x00` at byte [1]; the factory has `0x0F`), - /// so `isB20(B20_FACTORY_ADDRESS)` returns false unambiguously. The - /// trailing `0x0F` byte echoes the prefix for visual symmetry. address internal constant B20_FACTORY_ADDRESS = 0xB20f000000000000000000000000000000000000; address internal constant POLICY_REGISTRY_ADDRESS = 0x8453000000000000000000000000000000000002; address internal constant ACTIVATION_REGISTRY_ADDRESS = 0x8453000000000000000000000000000000000001; diff --git a/src/interfaces/IActivationRegistry.sol b/src/interfaces/IActivationRegistry.sol index 00014fd7..64a994d5 100644 --- a/src/interfaces/IActivationRegistry.sol +++ b/src/interfaces/IActivationRegistry.sol @@ -2,102 +2,77 @@ pragma solidity >=0.8.20 <0.9.0; /// @title IActivationRegistry -/// @notice Singleton precompile that gates Base-native features behind -/// an activation admin. Each feature is identified by an opaque -/// `bytes32` feature id and is either active or inactive; -/// consumers (other precompiles, system configuration, or -/// downstream contracts) consult `isActivated` to gate behavior. -/// -/// The activation admin is the only address authorized to call -/// `activate` or `deactivate`; all other callers revert with -/// `Unauthorized`. -/// -/// @dev The precompile enforces two call-context invariants that are -/// surfaced as reverts but cannot originate from normal Solidity -/// consumers: -/// - `DelegateCallNotAllowed`: the precompile MUST be invoked -/// via `CALL` (not `DELEGATECALL` or `CALLCODE`), so the admin -/// identity is bound to `msg.sender` rather than the calling -/// contract's storage context. -/// - `StaticCallNotAllowed`: activation control mutates state and -/// cannot be invoked from a `STATICCALL` frame. -/// -/// Feature ids are opaque to the registry: it does not interpret -/// them, and any `bytes32` is a valid id. By convention the -/// producing component picks a stable id derived from a -/// human-readable feature name (the chain-node source uses -/// 32-byte digests for this purpose). +/// @notice Singleton precompile that gates Base-native features behind an activation admin. Each feature +/// is identified by an opaque `bytes32` id and is either active or inactive. interface IActivationRegistry { /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ - /// @notice `caller` is not the activation admin and is therefore - /// not authorized to call `activate` or `deactivate`. + /// @notice Caller is not the activation admin. error Unauthorized(address caller); - /// @notice `activate` was called on a feature that is already - /// activated. + /// @notice Feature is already activated. error AlreadyActivated(bytes32 feature); - /// @notice `deactivate` was called on a feature that is already - /// deactivated. + /// @notice Feature is already deactivated. error AlreadyDeactivated(bytes32 feature); - /// @notice `feature` is not activated. Returned by `deactivate` and - /// by precompiles that consult the registry as a hard gate - /// (the chain node uses an `assertActivated`-style flow for - /// this); not raised by `isActivated`, which returns `false` - /// instead. + /// @notice Feature is not activated. error FeatureNotActivated(bytes32 feature); - /// @notice The precompile was invoked via `DELEGATECALL` or - /// `CALLCODE`. All entry points require a direct `CALL`. + /// @notice The precompile was invoked via `DELEGATECALL` or `CALLCODE`. error DelegateCallNotAllowed(); - /// @notice A state-mutating entry point (`activate` or `deactivate`) - /// was invoked from a `STATICCALL` frame. + /// @notice A state-mutating entry point was invoked from a `STATICCALL` frame. error StaticCallNotAllowed(); /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ - /// @notice Emitted when `feature` transitions from inactive to - /// activated. `caller` is the activation admin. + /// @notice Emitted when `feature` is activated. event FeatureActivated(bytes32 indexed feature, address indexed caller); - /// @notice Emitted when `feature` transitions from activated to - /// inactive. `caller` is the activation admin. + /// @notice Emitted when `feature` is deactivated. event FeatureDeactivated(bytes32 indexed feature, address indexed caller); /*////////////////////////////////////////////////////////////// ACTIVATION QUERIES //////////////////////////////////////////////////////////////*/ - /// @notice Whether `feature` is currently activated. Returns - /// `false` for any feature id that has never been activated - /// or has since been deactivated. + /// @notice Whether `feature` is currently activated. Never reverts. + /// + /// @param feature Feature to query. + /// + /// @return Whether `feature` is activated. function isActivated(bytes32 feature) external view returns (bool); /// @notice The address authorized to call `activate` and `deactivate`. + /// @return Current activation admin. function admin() external view returns (address); /*////////////////////////////////////////////////////////////// ACTIVATION CONTROL //////////////////////////////////////////////////////////////*/ - /// @notice Activates `feature`. Caller MUST equal `admin()` (else - /// `Unauthorized`). Reverts with `AlreadyActivated` if the - /// feature is already activated; reverts with - /// `StaticCallNotAllowed` if invoked under `STATICCALL`. - /// Emits `FeatureActivated` on success. + /// @notice Activates `feature`. Emits `FeatureActivated`. + /// + /// @dev Reverts with `DelegateCallNotAllowed` when invoked via `DELEGATECALL` or `CALLCODE`. + /// @dev Reverts with `StaticCallNotAllowed` when invoked under `STATICCALL`. + /// @dev Reverts with `Unauthorized` when the caller is not the activation admin. + /// @dev Reverts with `AlreadyActivated` when `feature` is already activated. + /// + /// @param feature Feature to activate. function activate(bytes32 feature) external; - /// @notice Deactivates `feature`. Caller MUST equal `admin()` (else - /// `Unauthorized`). Reverts with `AlreadyDeactivated` if - /// the feature is already deactivated; reverts with - /// `StaticCallNotAllowed` if invoked under `STATICCALL`. - /// Emits `FeatureDeactivated` on success. + /// @notice Deactivates `feature`. Emits `FeatureDeactivated`. + /// + /// @dev Reverts with `DelegateCallNotAllowed` when invoked via `DELEGATECALL` or `CALLCODE`. + /// @dev Reverts with `StaticCallNotAllowed` when invoked under `STATICCALL`. + /// @dev Reverts with `Unauthorized` when the caller is not the activation admin. + /// @dev Reverts with `AlreadyDeactivated` when `feature` is already deactivated. + /// + /// @param feature Feature to deactivate. function deactivate(bytes32 feature) external; } diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 9de40a05..40041213 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -2,108 +2,21 @@ pragma solidity >=0.8.20 <0.9.0; /// @title IB20 -/// @notice The base Solidity surface every Base-native token (B-20) implements. -/// Variants (Stablecoin, Security, ...) extend this interface; nothing -/// on this surface is variant-specific. /// -/// @dev Backward-compatible with ERC-20 at the function-selector level: -/// `transfer`, `transferFrom`, `approve`, `balanceOf`, `allowance`, -/// `totalSupply`, `name`, `symbol`, `decimals` all match ERC-20 -/// selectors and event signatures. Memo'd siblings live alongside, -/// and their existence does not change the ERC-20 selectors any -/// wallet or contract already expects. -/// -/// **Role model.** Standard OpenZeppelin AccessControl semantics: -/// seven named roles (`DEFAULT_ADMIN_ROLE`, `MINT_ROLE`, `BURN_ROLE`, -/// `BURN_BLOCKED_ROLE`, `PAUSE_ROLE`, `UNPAUSE_ROLE`, `METADATA_ROLE`) -/// plus arbitrary user-defined roles. `grantRole`, `revokeRole`, `renounceRole`, and -/// `setRoleAdmin` work uniformly across all roles, with one -/// protocol-level constraint: the LAST holder of -/// `DEFAULT_ADMIN_ROLE` cannot renounce via `renounceRole` (this -/// guards against accidentally bricking the token's admin -/// surface). Tokens that DO want to permanently shed admin -/// control (e.g. memecoins finalizing a fair launch, immutable -/// tokens locking in their configuration) use the dedicated -/// `renounceLastAdmin()` function, whose distinct name and -/// existence is the explicit intent signal. After -/// `renounceLastAdmin()` the token has zero admins forever: no -/// further `grantRole(DEFAULT_ADMIN_ROLE, ...)`, no policy -/// updates, no supply-cap changes, no name/symbol changes, no -/// further admin operations of any kind. -/// -/// **Pause model.** Pause is granular: `pause(PausableFeature[])` -/// halts a set of operation classes (transfer, mint, burn, ...) -/// and `unpause(PausableFeature[])` resumes a (possibly -/// different) subset. Both are additive against the -/// currently-paused set, so callers can pause incrementally and -/// resume a single class without disturbing the others. The -/// on-chain storage layout (a bitmask) is an implementation -/// detail; the public surface speaks only in `PausableFeature` -/// values. -/// -/// **Policy model.** Each supported `bytes32 policyScope` resolves -/// to a dedicated storage slot on the token. The policy-type -/// identifier is the `keccak256` hash of its name. Four standard -/// types are exposed as constants on this base surface: -/// - `TRANSFER_SENDER_POLICY` — checked against `from` on every transfer -/// - `TRANSFER_RECEIVER_POLICY` — checked against `to` on every transfer -/// - `TRANSFER_EXECUTOR_POLICY` — checked against `msg.sender` on `transferFrom` -/// (when distinct from `from`) -/// - `MINT_RECEIVER_POLICY` — checked against `to` on every mint -/// Variants extend this set by adding their own dedicated slots -/// (e.g. `IB20Security` adds `REDEEM_SENDER_POLICY` for its `redeem` -/// path, stored in the variant's own namespaced storage). There -/// is no generic catch-all mapping: every `policyScope` either -/// resolves to a real slot or doesn't exist at all. Both reads -/// (`policyId`) and writes (`updatePolicy`) for a `policyScope` -/// not supported by the token (or its variant) revert -/// `UnsupportedPolicyType` — an unsupported `policyScope` is -/// nonsense the token can't parse, so the registry never gets -/// consulted with it. (Reads stay strict, not silent-zero, -/// because a typo'd query returning `0` would masquerade as -/// "no restriction" instead of surfacing the bug.) -/// -/// Each policy slot defaults to built-in ID `0` (always-allow) so -/// newly created tokens are unrestricted until the admin -/// configures their compliance regime. ID `type(uint64).max` -/// (always-reject) is the explicit hard-deny for a given role -/// (e.g. disabling redemption on a non-redeemable token). -/// -/// Asymmetric per-role configuration is expressed by pointing -/// different slots at different policies — for example, a -/// sanctions BLOCKLIST on `TRANSFER_SENDER_POLICY` and an unrestricted -/// always-allow on `TRANSFER_RECEIVER_POLICY`. The registry stays flat; -/// all composition happens at the token layer. `approve` is NOT -/// gated by any 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 -/// (smart-contract accounts use call-batching or paymaster flows -/// instead to set allowances). The EIP-712 domain binds -/// `(name, version, chainId, verifyingContract)` — the -/// canonical EIP-2612 shape, so off-the-shelf permit helpers -/// (viem, ethers, wagmi, OpenZeppelin) and wallet UIs -/// (MetaMask in particular, which renders `domain.name` -/// prominently in the signing prompt) work without -/// per-token customization. `name` is the live token `name()`, -/// re-hashed into the domain on each call so a successful -/// `updateName(...)` invalidates outstanding signatures and -/// emits ERC-5267 `EIP712DomainChanged()` to signal the -/// change. `version` is the constant string `"1"` and never -/// changes; bumping it would require a new contract anyway. -/// `salt` is unused (empty). ERC-5267 `eip712Domain()` is -/// exposed for domain introspection by integrators that -/// prefer to read the domain dynamically rather than -/// reconstruct it from `name()` + the conventional shape. +/// @notice The base Solidity surface every Base-native token (B-20) implements. Variants +/// (Stablecoin, Security, ...) extend this interface; nothing on this surface is +/// variant-specific. interface IB20 { /*////////////////////////////////////////////////////////////// TYPES //////////////////////////////////////////////////////////////*/ - /// @notice Pausable operation classes. Passed in arrays to `pause` - /// and `unpause`, returned by `pausedFeatures`, and used by - /// `isPaused` and the `ContractPaused` revert. Append-only - /// across protocol versions; existing values are stable. + /// @notice Pausable operation classes. Append-only across protocol versions; existing values are stable. + /// + /// @param TRANSFER `transfer`, `transferFrom`, and memo'd variants. + /// @param MINT `mint` and `mintWithMemo`. + /// @param BURN `burn`, `burnWithMemo`, and `burnBlocked`. + /// @param REDEEM Security variant's `redeem` path. enum PausableFeature { TRANSFER, MINT, @@ -115,334 +28,207 @@ interface IB20 { ERRORS //////////////////////////////////////////////////////////////*/ - /// @notice `account` does not hold `neededRole`. Used for ALL - /// role-based access checks: function-level role gates - /// (`MINT_ROLE`, `BURN_ROLE`, etc.), `grantRole` / - /// `revokeRole` when the caller does not hold the admin role - /// for the target role, and `setRoleAdmin` when the caller - /// does not hold the current admin role for the target role. - /// @dev Matches OZ AccessControl's `AccessControlUnauthorizedAccount` - /// error exactly. + /// @notice `account` does not hold `neededRole`. + /// + /// @param account Account that failed the role check. + /// @param neededRole Role the account was required to hold. error AccessControlUnauthorizedAccount(address account, bytes32 neededRole); - /// @notice Caller failed a positional authorization check that is NOT - /// expressible as "missing role X" (e.g. caller must be the - /// specific account whose state is being mutated, in - /// contexts where no role applies). Used sparingly; most - /// authorization failures revert with - /// `AccessControlUnauthorizedAccount`. + /// @notice Caller failed a positional authorization check that is not expressible as "missing role X". error Unauthorized(); - /// @notice The `PausableFeature` covering this operation is - /// currently paused. + /// @notice The `PausableFeature` covering this operation is currently paused. + /// + /// @param feature Paused feature that gated the operation. error ContractPaused(PausableFeature feature); - /// @notice `spender`'s allowance from the relevant token owner is - /// less than `needed` for the requested `transferFrom`. - /// @dev Matches OZ ERC20 / ERC-6093 exactly. + /// @notice `spender`'s allowance is less than `needed` for the requested `transferFrom`. + /// + /// @param spender Spender whose allowance was insufficient. + /// @param allowance Current allowance. + /// @param needed Allowance required. error InsufficientAllowance(address spender, uint256 allowance, uint256 needed); - /// @notice `sender`'s balance is less than `needed` for the requested - /// transfer or burn. - /// @dev Matches OZ ERC20 / ERC-6093 exactly. + /// @notice `sender`'s balance is less than `needed` for the requested transfer or burn. + /// + /// @param sender Account whose balance was insufficient. + /// @param balance Current balance. + /// @param needed Balance required. error InsufficientBalance(address sender, uint256 balance, uint256 needed); - /// @notice The transfer's source address is invalid (typically - /// `address(0)`, which cannot hold balance to send from). - /// @dev Matches OZ ERC20 / ERC-6093 exactly. + /// @notice The transfer's source address is invalid (typically `address(0)`). error InvalidSender(address sender); - /// @notice The transfer's destination address is invalid (typically - /// `address(0)`; mints and burns use the literal zero - /// address as the from/to and are not subject to this check). - /// @dev Matches OZ ERC20 / ERC-6093 exactly. + /// @notice The transfer's destination address is invalid (typically `address(0)`). error InvalidReceiver(address receiver); - /// @notice The approval's `owner` address is invalid (typically - /// `address(0)`). - /// @dev Matches OZ ERC20 / ERC-6093 exactly. + /// @notice The approval's `owner` address is invalid (typically `address(0)`). error InvalidApprover(address approver); - /// @notice The approval's `spender` address is invalid (typically - /// `address(0)`). - /// @dev Matches OZ ERC20 / ERC-6093 exactly. + /// @notice The approval's `spender` address is invalid (typically `address(0)`). error InvalidSpender(address spender); - /// @notice An amount argument was zero where a non-zero value is - /// required. NOT used for ERC-20 amount arguments: per OZ / - /// ERC-6093, ERC-20 functions do not validate `amount > 0`. + /// @notice An amount argument was zero where a non-zero value is required. Not used for ERC-20 amount arguments. error InvalidAmount(); - /// @notice An empty array was passed to a function that requires at - /// least one element (e.g. `pause([])`, `unpause([])`). + /// @notice An empty array was passed to a function that requires at least one element. error EmptyFeatureSet(); - /// @notice The proposed supply cap is below the current `totalSupply`, - /// which would invalidate already-issued supply. + /// @notice The proposed supply cap is below the current `totalSupply`. + /// + /// @param currentSupply Current `totalSupply`. + /// @param proposedCap Rejected proposed cap. error InvalidSupplyCap(uint256 currentSupply, uint256 proposedCap); /// @notice The mint would push `totalSupply` past the configured cap. + /// + /// @param cap Configured supply cap. + /// @param attempted Resulting supply that would exceed the cap. error SupplyCapExceeded(uint256 cap, uint256 attempted); - /// @notice A policy slot denied the operation. `policyScope` identifies - /// which slot (e.g. `TRANSFER_SENDER_POLICY`, `MINT_RECEIVER_POLICY`) and - /// `policyId` is the ID currently set in that slot. + /// @notice A policy slot denied the operation. + /// + /// @param policyScope Identifier of the slot that denied (e.g. `TRANSFER_SENDER_POLICY`). + /// @param policyId Policy ID currently configured in that slot. error PolicyForbids(bytes32 policyScope, uint64 policyId); - /// @notice The provided policy ID does not exist in the policy - /// registry. + /// @notice The provided policy ID does not exist in the policy registry. error PolicyNotFound(uint64 policyId); - /// @notice `policyId` or `updatePolicy` was called with a - /// `policyScope` that this token (and its variant, if any) - /// does not recognize. Each token implementation defines a - /// fixed set of supported policy types; both reads and - /// writes for anything outside that set revert here so a - /// typo'd query can never be silently interpreted as - /// "no restriction", and an admin can never assign a policy - /// to a slot that doesn't exist. + /// @notice `policyScope` is not a slot this token (or its variant) supports. error UnsupportedPolicyType(bytes32 policyScope); - /// @notice `burnBlocked` was called against a `from` address that is - /// currently authorized under the active `TRANSFER_SENDER_POLICY` - /// policy. `burnBlocked` exists specifically to seize supply - /// from policy-blocked addresses; calling it against a - /// non-blocked address is rejected by design. + /// @notice `burnBlocked` was called against a `from` that is currently authorized under `TRANSFER_SENDER_POLICY`. error AccountNotBlocked(address account); - /// @notice An EIP-2612 `permit` was submitted with a `deadline` - /// strictly less than the current block timestamp. - /// @dev Matches OZ ERC20Permit's `ExpiredSignature` error - /// exactly. + /// @notice An EIP-2612 `permit` was submitted with a `deadline` strictly less than `block.timestamp`. error ExpiredSignature(uint256 deadline); - /// @notice ECDSA recovery on an EIP-2612 `permit` returned `signer`, - /// which does not match the claimed `owner`. - /// @dev Matches OZ ERC20Permit's `InvalidSigner` error - /// exactly. + /// @notice ECDSA recovery on an EIP-2612 `permit` returned `signer`, which does not match the claimed `owner`. + /// + /// @param signer Recovered signer. + /// @param owner Claimed owner. error InvalidSigner(address signer, address owner); - /// @notice `renounceRole(DEFAULT_ADMIN_ROLE, ...)` was called when the - /// caller is the last admin. `renounceRole` is the routine - /// "give up MY hold on this role" path and guards against - /// accidentally leaving the token with zero admins; callers - /// that intentionally want a permanently adminless token must - /// use the dedicated `renounceLastAdmin()` function instead. + /// @notice `renounceRole(DEFAULT_ADMIN_ROLE, ...)` was called by the sole remaining admin. error LastAdminCannotRenounce(); - /// @notice `renounceLastAdmin()` was called by an account that is not - /// the sole remaining holder of `DEFAULT_ADMIN_ROLE`. The - /// function exists exclusively to transition the token from - /// single-admin to zero-admin atomically; revoking other - /// admins to reach single-admin state first is the caller's - /// responsibility. + /// @notice `renounceLastAdmin()` was called when other accounts also hold `DEFAULT_ADMIN_ROLE`. error NotSoleAdmin(); - /// @notice The `callerConfirmation` argument to `renounceRole` was not - /// `msg.sender`. This guard prevents accidental renunciation - /// caused by a fat-fingered call to a different account's - /// role. - /// @dev Matches OZ AccessControl's `AccessControlBadConfirmation` - /// error exactly. + /// @notice The `callerConfirmation` argument to `renounceRole` was not `msg.sender`. error AccessControlBadConfirmation(); /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ - /// @notice ERC-20 standard transfer event. Emitted on every successful - /// transfer (including memo'd variants), mint - /// (`from = address(0)`), and burn (`to = address(0)`, - /// including `burnBlocked` and `redeem`). + /// @notice ERC-20 transfer event. Emitted on every successful transfer (including memo'd variants), + /// mint (`from = address(0)`), and burn (`to = address(0)`, including `burnBlocked` and `redeem`). event Transfer(address indexed from, address indexed to, uint256 amount); - /// @notice ERC-20 standard approval event. + /// @notice ERC-20 approval event. Emitted by `approve` and `permit`. event Approval(address indexed owner, address indexed spender, uint256 amount); - /// @notice Emitted by `transferWithMemo`, `transferFromWithMemo`, - /// `mintWithMemo`, and `burnWithMemo` immediately AFTER the - /// underlying ERC-20 `Transfer` event. `caller` is the - /// `msg.sender` of the memo'd call (the spender on - /// `transferFromWithMemo`, the holder on the others). - /// The memo carries no from/to/amount fields; indexers join - /// it to the preceding `Transfer` log via - /// `(transactionHash, logIndex - 1)`, and may additionally - /// filter on `caller` for per-account memo streams. - /// @dev Variants may emit this event from additional functions - /// (e.g. `redeem` on a Security token); the event signature - /// is shared. + /// @notice Emitted by `transferWithMemo`, `transferFromWithMemo`, `mintWithMemo`, and `burnWithMemo` + /// immediately after the underlying `Transfer` event. `caller` is the `msg.sender` of the memo'd call. event Memo(address indexed caller, bytes32 indexed memo); - /// @notice Emitted by `burnBlocked` in addition to the standard - /// `Transfer(from, address(0), amount)`. Distinguishes - /// compliance-driven seizure (which destroys balance belonging - /// to a third party) from `burn` (which destroys the caller's - /// own balance). + /// @notice Emitted by `burnBlocked` in addition to `Transfer(from, address(0), amount)`. event BurnedBlocked(address indexed caller, address indexed from, uint256 amount); - /// @notice Emitted when `account` is granted `role`. `sender` is the - /// account that originated the call (the admin for `role`, - /// or the same as `account` if the grant happens via factory - /// init or other internal path). - /// @dev Matches OZ AccessControl's `RoleGranted` event exactly. + /// @notice Emitted when `account` is granted `role`. `sender` is the originating caller. event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); - /// @notice Emitted when `role` is revoked from `account`. `sender` - /// is the account that originated the call: - /// - if via `revokeRole`, it is the admin role bearer - /// - if via `renounceRole`, it is the role bearer (i.e. - /// `account`) - /// @dev Matches OZ AccessControl's `RoleRevoked` event exactly. + /// @notice Emitted when `role` is revoked from `account`. `sender` is the originating caller + /// (the admin role bearer via `revokeRole`, or `account` itself via `renounceRole`). event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); - /// @notice Emitted when the admin role for `role` is changed via - /// `setRoleAdmin`. Both the function and event adopt OZ - /// AccessControl naming (`setRoleAdmin` + `RoleAdminChanged`) - /// rather than this interface's `update*` / `*Updated` - /// convention, so OZ-aware tooling (Etherscan, Safe, - /// indexers, audit checklists) continues to recognize both - /// the role-admin mutator and its log without a separate - /// handler. - /// @dev Matches OZ AccessControl's `RoleAdminChanged` event exactly. + /// @notice Emitted by `setRoleAdmin` when the admin role for `role` changes. event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); - /// @notice Emitted by `renounceLastAdmin` in addition to the - /// standard `RoleRevoked(DEFAULT_ADMIN_ROLE, previousAdmin, - /// previousAdmin)` event. Signals the irreversible - /// transition of the token to a permanently adminless - /// state: no `grantRole`, `revokeRole`, `setRoleAdmin`, - /// `updatePolicy`, or `updateSupplyCap` call can ever - /// succeed again. Existing role holders (`MINT_ROLE`, - /// `BURN_ROLE`, `METADATA_ROLE`, etc.) retain their - /// abilities, but no new grants are possible. - /// Indexers should treat this event as a one-way state - /// transition. + /// @notice Emitted by `renounceLastAdmin` in addition to the standard + /// `RoleRevoked(DEFAULT_ADMIN_ROLE, previousAdmin, previousAdmin)` event. event LastAdminRenounced(address indexed previousAdmin); - /// @notice Emitted by `pause`. `features` is the argument to the - /// call (not the resulting paused state). `updater` is the - /// caller. + /// @notice Emitted by `pause`. `features` is the argument to the call (not the resulting paused state). event Paused(address indexed updater, PausableFeature[] features); - /// @notice Emitted by `unpause`. `features` is the argument to the - /// call (not the resulting paused state). `updater` is the - /// caller. + /// @notice Emitted by `unpause`. `features` is the argument to the call (not the resulting paused state). event Unpaused(address indexed updater, PausableFeature[] features); - /// @notice Emitted by `updatePolicy` when a token's policy slot is - /// changed. `policyScope` is one of the standard policy-type - /// identifiers (e.g. `TRANSFER_SENDER_POLICY()`); `oldPolicyId` and - /// `newPolicyId` are the prior and current registry IDs for - /// that slot. Initial slot assignment at creation is also - /// emitted via `PolicyUpdated` with `oldPolicyId == 0`. + /// @notice Emitted by `updatePolicy` when a token's policy slot is changed. Initial slot assignment at + /// creation is also emitted via `PolicyUpdated` with `oldPolicyId == 0`. event PolicyUpdated(bytes32 indexed policyScope, uint64 oldPolicyId, uint64 newPolicyId); - /// @notice Emitted by `updateSupplyCap`. Includes the prior cap for - /// indexer convenience. + /// @notice Emitted by `updateSupplyCap`. event SupplyCapUpdated(address indexed updater, uint256 oldSupplyCap, uint256 newSupplyCap); - /// @notice Emitted by `updateContractURI`. Per ERC-7572, this event is - /// intentionally parameterless: integrators re-fetch - /// `contractURI()` after seeing it. + /// @notice Emitted by `updateContractURI`. Per ERC-7572, parameterless: integrators re-fetch `contractURI()`. event ContractURIUpdated(); - /// @notice Emitted by `updateName`. Carries the new name string for - /// indexer consumption. + /// @notice Emitted by `updateName`. Carries the new name string. event NameUpdated(address indexed updater, string newName); - /// @notice Emitted by `updateSymbol`. Carries the new symbol string for - /// indexer consumption. + /// @notice Emitted by `updateSymbol`. Carries the new symbol string. event SymbolUpdated(address indexed updater, string newSymbol); - /// @notice ERC-5267 domain-change signal. Emitted whenever a - /// field that participates in this token's EIP-712 - /// domain changes value. On this surface the only such - /// field is `name`, so this event is emitted exactly - /// once per successful `updateName(...)` call, - /// immediately after the inherited `NameUpdated` event. - /// The event signature is parameterless per ERC-5267: - /// off-chain integrators that cache `DOMAIN_SEPARATOR()` - /// or `eip712Domain()` re-fetch after observing it. - /// `updateSymbol(...)` does NOT emit this event; `symbol` - /// is not in the EIP-712 domain. + /// @notice ERC-5267 domain-change signal. Emitted exactly once per successful `updateName` call, + /// immediately after `NameUpdated`. `updateSymbol` does NOT emit this event. event EIP712DomainChanged(); /*////////////////////////////////////////////////////////////// ROLE IDENTIFIERS //////////////////////////////////////////////////////////////*/ - /// @notice The default top-level admin role, equal to `bytes32(0)` per - /// the OpenZeppelin AccessControl convention. The admin - /// manages all other roles via `grantRole`, `revokeRole`, and - /// `setRoleAdmin`. The admin can also `updatePolicy` and - /// `updateSupplyCap`. Name, symbol, and `contractURI` updates - /// are gated by `METADATA_ROLE`, not by this role. - /// @dev There is NO two-step delay-protected transfer for this - /// role. `grantRole(DEFAULT_ADMIN_ROLE, ...)` and - /// `revokeRole(DEFAULT_ADMIN_ROLE, ...)` work uniformly. - /// The only constraint is that the last admin cannot renounce - /// (see `LastAdminCannotRenounce`). + /// @notice The default top-level admin role (`bytes32(0)`). Required to call `grantRole`, `revokeRole`, + /// `setRoleAdmin`, `updatePolicy`, and `updateSupplyCap`. + /// @return Role identifier. function DEFAULT_ADMIN_ROLE() external view returns (bytes32); - /// @notice Required to call `mint` and `mintWithMemo`. Held separately - /// from `BURN_ROLE` and `BURN_BLOCKED_ROLE` so issuance and - /// destruction authority can be split across teams. + /// @notice Required to call `mint` and `mintWithMemo`. + /// @return Role identifier. function MINT_ROLE() external view returns (bytes32); - /// @notice Required to call `burn` and `burnWithMemo`. Note that - /// `burn` operates on the caller's own balance only; to - /// destroy supply held by a third party (e.g. for sanctions - /// seizure), see `BURN_BLOCKED_ROLE` and `burnBlocked`. + /// @notice Required to call `burn` and `burnWithMemo`. + /// @return Role identifier. function BURN_ROLE() external view returns (bytes32); - /// @notice Required to call `burnBlocked`. Held separately from - /// `BURN_ROLE` so the authority to destroy a third party's - /// balance (gated on that party being unauthorized under the - /// active `TRANSFER_SENDER_POLICY` policy) can be granted only to a - /// compliance role, not to general burn operators. + /// @notice Required to call `burnBlocked`. + /// @return Role identifier. function BURN_BLOCKED_ROLE() external view returns (bytes32); - /// @notice Required to call `pause`. Held separately from - /// `UNPAUSE_ROLE` so emergency-stop authority can be delegated - /// to a 24/7 ops team without also granting unpause authority. + /// @notice Required to call `pause`. + /// @return Role identifier. function PAUSE_ROLE() external view returns (bytes32); - /// @notice Required to call `unpause`. Distinct from `PAUSE_ROLE` so - /// resumption requires a deliberate, typically more senior - /// action than the pause itself. + /// @notice Required to call `unpause`. + /// @return Role identifier. function UNPAUSE_ROLE() external view returns (bytes32); - /// @notice Required to call `updateName`, `updateSymbol`, and - /// `updateContractURI`. Held separately from - /// `DEFAULT_ADMIN_ROLE` so the authority to re-brand or - /// legally-restructure the token can be delegated to a - /// metadata operator (e.g. corporate-actions desk for - /// security tokens) without granting the broader admin - /// powers (role grants, policy changes, supply-cap changes, - /// etc.). + /// @notice Required to call `updateName`, `updateSymbol`, and `updateContractURI`. + /// @return Role identifier. function METADATA_ROLE() external view returns (bytes32); /*////////////////////////////////////////////////////////////// POLICY TYPE IDENTIFIERS //////////////////////////////////////////////////////////////*/ - /// @notice The policy slot consulted against `from` on every transfer - /// (including the `from` side of `transferFrom`). Identifier - /// is `keccak256("TRANSFER_SENDER_POLICY")`. + /// @notice Policy slot consulted against `from` on every transfer (including `transferFrom`). + /// @return Policy scope identifier. function TRANSFER_SENDER_POLICY() external view returns (bytes32); - /// @notice The policy slot consulted against `to` on every transfer. - /// Identifier is `keccak256("TRANSFER_RECEIVER_POLICY")`. + /// @notice Policy slot consulted against `to` on every transfer. + /// @return Policy scope identifier. function TRANSFER_RECEIVER_POLICY() external view returns (bytes32); - /// @notice The policy slot consulted against `msg.sender` on - /// `transferFrom` (the spender, when distinct from `from`). - /// Not consulted on `transfer` (where `msg.sender == from`). - /// Identifier is `keccak256("TRANSFER_EXECUTOR_POLICY")`. + /// @notice Policy slot consulted against `msg.sender` on `transferFrom` when distinct from `from`. + /// Not consulted on `transfer`. + /// @return Policy scope identifier. function TRANSFER_EXECUTOR_POLICY() external view returns (bytes32); - /// @notice The policy slot consulted against `to` on every mint. - /// Identifier is `keccak256("MINT_RECEIVER_POLICY")`. + /// @notice Policy slot consulted against `to` on every mint. + /// @return Policy scope identifier. function MINT_RECEIVER_POLICY() external view returns (bytes32); /*////////////////////////////////////////////////////////////// @@ -450,392 +236,361 @@ interface IB20 { //////////////////////////////////////////////////////////////*/ /// @notice Token name. Set at creation, mutable via `updateName`. + /// @return Current token name. function name() external view returns (string memory); /// @notice Token symbol. Set at creation, mutable via `updateSymbol`. + /// @return Current token symbol. function symbol() external view returns (string memory); /// @notice Number of decimal places. Immutable per token variant. - /// @dev Current variant defaults are fixed by the factory: - /// Default = 18, Stablecoin = 6, Security = 6. + /// @return Number of decimal places. function decimals() external view returns (uint8); /// @notice Total token supply currently in circulation. + /// @return Current total supply. function totalSupply() external view returns (uint256); /// @notice Balance of `account`. + /// + /// @param account Account whose balance is being queried. + /// + /// @return Current balance. function balanceOf(address account) external view returns (uint256); /// @notice Allowance granted by `owner` to `spender`. + /// + /// @param owner Allowance owner. + /// @param spender Allowance spender. + /// + /// @return Current allowance. function allowance(address owner, address spender) external view returns (uint256); - /// @notice Transfers `amount` from `msg.sender` to `to`. Preconditions - /// evaluate in the order listed; when multiple are violated, - /// the earliest-listed revert is the one that surfaces: - /// 1. `ContractPaused(TRANSFER)` if `TRANSFER` is paused. - /// 2. `InvalidReceiver(to)` if `to == address(0)`. - /// 3. `InvalidSender(msg.sender)` if `msg.sender == address(0)`. - /// 4. `PolicyForbids(TRANSFER_SENDER_POLICY, policyId)` if `msg.sender` - /// is not authorized under the active `TRANSFER_SENDER_POLICY`. - /// 5. `PolicyForbids(TRANSFER_RECEIVER_POLICY, policyId)` if `to` - /// is not authorized under the active `TRANSFER_RECEIVER_POLICY`. - /// 6. `InsufficientBalance(msg.sender, balance, amount)` if the - /// caller does not have enough balance. - /// @dev Does NOT consult the `TRANSFER_EXECUTOR_POLICY` policy: on direct - /// `transfer` the executor IS the sender, and the sender - /// check already covers that address. When the token is - /// configured as a gas asset, fee debits go through this - /// same path. + /// @notice Transfers `amount` from `msg.sender` to `to`. Emits `Transfer`. + /// + /// @dev Reverts with `ContractPaused(TRANSFER)` when `TRANSFER` is paused. + /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. + /// @dev Reverts with `InvalidSender` when `msg.sender == address(0)`. + /// @dev Reverts with `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `msg.sender` is not authorized. + /// @dev Reverts with `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized. + /// @dev Reverts with `InsufficientBalance` when `msg.sender`'s balance is below `amount`. + /// + /// @param to Destination address. + /// @param amount Amount to transfer. + /// + /// @return Always `true` on success. function transfer(address to, uint256 amount) external returns (bool); - /// @notice Transfers `amount` from `from` to `to` using `msg.sender`'s - /// allowance. Preconditions evaluate in the order listed; - /// when multiple are violated, the earliest-listed revert is - /// the one that surfaces: - /// 1. `ContractPaused(TRANSFER)` if `TRANSFER` is paused. - /// 2. `InvalidReceiver(to)` if `to == address(0)`. - /// 3. `InvalidSender(from)` if `from == address(0)`. - /// 4. `InsufficientAllowance(msg.sender, allowance, amount)` - /// if the caller does not have enough allowance from `from`. - /// 5. `PolicyForbids(TRANSFER_EXECUTOR_POLICY, policyId)` if - /// `msg.sender != from` and `msg.sender` is not authorized - /// under the active `TRANSFER_EXECUTOR_POLICY`. - /// 6. `PolicyForbids(TRANSFER_SENDER_POLICY, policyId)` if `from` - /// is not authorized under the active `TRANSFER_SENDER_POLICY`. - /// 7. `PolicyForbids(TRANSFER_RECEIVER_POLICY, policyId)` if `to` - /// is not authorized under the active `TRANSFER_RECEIVER_POLICY`. - /// 8. `InsufficientBalance(from, balance, amount)` if `from` - /// does not have enough balance. - /// @dev The sender-side check is performed against `from` (the - /// party whose balance moves), the receiver check against - /// `to`, and the executor check against `msg.sender` only - /// when distinct from `from`. A sanctioned spender cannot - /// move tokens for a non-sanctioned holder. + /// @notice Transfers `amount` from `from` to `to` using `msg.sender`'s allowance. Emits `Transfer`. + /// + /// @dev Reverts with `ContractPaused(TRANSFER)` when `TRANSFER` is paused. + /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. + /// @dev Reverts with `InvalidSender` when `from == address(0)`. + /// @dev Reverts with `InsufficientAllowance` when the caller's allowance from `from` is below `amount`. + /// @dev Reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender != from` and `msg.sender` is not authorized. + /// @dev Reverts with `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `from` is not authorized. + /// @dev Reverts with `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized. + /// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`. + /// + /// @param from Source address. + /// @param to Destination address. + /// @param amount Amount to transfer. + /// + /// @return Always `true` on success. function transferFrom(address from, address to, uint256 amount) external returns (bool); - /// @notice Sets `spender`'s allowance to `amount`. NOT gated by any - /// policy or by pause; only the act of MOVING balance is gated. - /// @dev Reverts in the following canonical order (first violated - /// check wins): - /// 1. `InvalidApprover(msg.sender)` if the caller is - /// `address(0)` (theoretically unreachable for normal - /// callers but enforced for parity with OZ ERC20). - /// 2. `InvalidSpender(spender)` if `spender == address(0)`. + /// @notice Sets `spender`'s allowance to `amount`. Not gated by any policy or by pause. Emits `Approval`. + /// + /// @dev Reverts with `InvalidApprover` when `msg.sender == address(0)`. + /// @dev Reverts with `InvalidSpender` when `spender == address(0)`. + /// + /// @param spender Account being granted the allowance. + /// @param amount Allowance amount. + /// + /// @return Always `true` on success. function approve(address spender, uint256 amount) external returns (bool); /*////////////////////////////////////////////////////////////// METADATA UPDATES //////////////////////////////////////////////////////////////*/ - /// @notice Updates the token's `name`. Requires `METADATA_ROLE`. - /// No length restrictions. Emits `NameUpdated` followed by - /// the ERC-5267 `EIP712DomainChanged()` event (in that - /// order). - /// @dev Several customers (Coinbase Tokenized Equities, Coinbase - /// Wrapped Assets) need the ability to update name and symbol - /// post-deployment for re-branding or legal-restructuring - /// events. Tokens that do not want to update their name - /// simply never grant `METADATA_ROLE`. - /// - /// Because `name` is bound into the EIP-712 domain (see - /// the contract-level "Permit" notes), a successful - /// `updateName(...)` invalidates outstanding off-chain - /// `permit` signatures issued under the previous name — - /// the recovered signer no longer matches the new - /// domain separator. + /// @notice Updates the token's `name`. Emits `NameUpdated` followed by `EIP712DomainChanged`. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `METADATA_ROLE`. + /// + /// @param newName New token name. function updateName(string calldata newName) external; - /// @notice Updates the token's `symbol`. Requires `METADATA_ROLE`. - /// No length restrictions. Emits `SymbolUpdated`. + /// @notice Updates the token's `symbol`. Emits `SymbolUpdated`. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `METADATA_ROLE`. + /// + /// @param newSymbol New token symbol. function updateSymbol(string calldata newSymbol) external; /*////////////////////////////////////////////////////////////// MEMO TRANSFER VARIANTS //////////////////////////////////////////////////////////////*/ - /// @notice Same as `transfer`, but additionally emits `Memo(memo)` - /// immediately after the standard `Transfer` event. The - /// standard `Transfer` event is also emitted for ERC-20 - /// indexer compatibility. - /// @dev The memo event carries only the memo. Indexers join it to - /// the preceding `Transfer` log via - /// `(transactionHash, logIndex - 1)`. Same access control and - /// policy checks as `transfer`. A memo of `bytes32(0)` is - /// permitted; it indicates "no memo" while still emitting the - /// memo event. + /// @notice Same as `transfer`, plus emits `Memo` immediately after the standard `Transfer` event. + /// A memo of `bytes32(0)` is permitted. + /// + /// @param to Destination address. + /// @param amount Amount to transfer. + /// @param memo Off-chain memo payload. + /// + /// @return Always `true` on success. function transferWithMemo(address to, uint256 amount, bytes32 memo) external returns (bool); - /// @notice Same as `transferFrom`, with a memo. See `transferWithMemo`. + /// @notice Same as `transferFrom`, plus emits `Memo` immediately after the standard `Transfer` event. + /// A memo of `bytes32(0)` is permitted. + /// + /// @param from Source address. + /// @param to Destination address. + /// @param amount Amount to transfer. + /// @param memo Off-chain memo payload. + /// + /// @return Always `true` on success. function transferFromWithMemo(address from, address to, uint256 amount, bytes32 memo) external returns (bool); /*////////////////////////////////////////////////////////////// MINT / BURN //////////////////////////////////////////////////////////////*/ - /// @notice Mints `amount` to `to`. Preconditions evaluate in the - /// order listed; when multiple are violated, the - /// earliest-listed revert is the one that surfaces: - /// 1. `ContractPaused(MINT)` if `MINT` is paused. - /// 2. `AccessControlUnauthorizedAccount(msg.sender, MINT_ROLE)` - /// if the caller does not hold `MINT_ROLE`. - /// 3. `InvalidReceiver(to)` if `to == address(0)`. - /// 4. `PolicyForbids(MINT_RECEIVER_POLICY, policyId)` if `to` - /// is not authorized under the active `MINT_RECEIVER_POLICY`. - /// 5. `SupplyCapExceeded(cap, totalSupply + amount)` if the - /// mint would push `totalSupply` past `supplyCap`. - /// @dev Per-minter rate limiting is NOT enshrined at any level - /// (Default or variant). Minter quotas live in EVM - /// periphery contracts: a controller / wrapper that holds - /// `MINT_ROLE` and enforces per-caller quotas before - /// invoking `mint` on the precompile. Bridge's - /// `TIP20Controller` and CDP Custom Stablecoin's mint flow - /// are both expressible this way. - /// Emits `Transfer(address(0), to, amount)`. + /// @notice Mints `amount` to `to`. Emits `Transfer(address(0), to, amount)`. + /// + /// @dev Reverts with `ContractPaused(MINT)` when `MINT` is paused. + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `MINT_ROLE`. + /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. + /// @dev Reverts with `PolicyForbids(MINT_RECEIVER_POLICY, ...)` when `to` is not authorized. + /// @dev Reverts with `SupplyCapExceeded` when `totalSupply + amount > supplyCap`. + /// + /// @param to Mint recipient. + /// @param amount Amount to mint. function mint(address to, uint256 amount) external; - /// @notice Same as `mint`, with a memo. Emits `Memo(memo)` immediately - /// after the standard `Transfer` event. + /// @notice Same as `mint`, plus emits `Memo` immediately after the standard `Transfer` event. + /// A memo of `bytes32(0)` is permitted. + /// + /// @param to Mint recipient. + /// @param amount Amount to mint. + /// @param memo Off-chain memo payload. function mintWithMemo(address to, uint256 amount, bytes32 memo) external; - /// @notice Burns `amount` from the caller's own balance. Preconditions - /// evaluate in the order listed; when multiple are violated, - /// the earliest-listed revert is the one that surfaces: - /// 1. `ContractPaused(BURN)` if `BURN` is paused. - /// 2. `AccessControlUnauthorizedAccount(msg.sender, BURN_ROLE)` - /// if the caller does not hold `BURN_ROLE`. - /// 3. `InsufficientBalance(caller, balance, amount)` if the - /// caller does not have enough balance. - /// NOT subject to any policy: burn destroys the caller's own - /// supply with no recipient. - /// @dev To destroy balance held by a third party (compliance - /// seizure from a policy-blocked address), use `burnBlocked`. + /// @notice Burns `amount` from the caller's own balance. Not subject to any policy. /// Emits `Transfer(caller, address(0), amount)`. + /// + /// @dev Reverts with `ContractPaused(BURN)` when `BURN` is paused. + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `BURN_ROLE`. + /// @dev Reverts with `InsufficientBalance` when the caller's balance is below `amount`. + /// + /// @param amount Amount to burn. function burn(uint256 amount) external; - /// @notice Same as `burn`, with a memo. Emits `Memo(memo)` immediately - /// after the standard `Transfer` event. + /// @notice Same as `burn`, plus emits `Memo` immediately after the standard `Transfer` event. + /// A memo of `bytes32(0)` is permitted. + /// + /// @param amount Amount to burn. + /// @param memo Off-chain memo payload. function burnWithMemo(uint256 amount, bytes32 memo) external; - /// @notice Destroys `amount` of `from`'s balance. Preconditions - /// evaluate in the order listed; when multiple are violated, - /// the earliest-listed revert is the one that surfaces: - /// 1. `ContractPaused(BURN)` if `BURN` is paused. - /// 2. `AccessControlUnauthorizedAccount(msg.sender, BURN_BLOCKED_ROLE)` - /// if the caller does not hold `BURN_BLOCKED_ROLE`. - /// 3. `AccountNotBlocked(from)` if `from` IS authorized under - /// the active `TRANSFER_SENDER_POLICY` policy. `burnBlocked` - /// exists for seizure of policy-blocked balance; calling - /// it against a non-blocked address is rejected by design. - /// 4. `InsufficientBalance(from, balance, amount)` if - /// `amount > balanceOf(from)`. - /// @dev Designed for sanctions-seizure flows where compliance - /// requires destruction of balance held by a blocked - /// address. Tokens that follow a "freeze, never seize" - /// philosophy (e.g. CDP Custom Stablecoin) simply never - /// grant `BURN_BLOCKED_ROLE`. - /// Emits `Transfer(from, address(0), amount)` and + /// @notice Destroys `amount` of `from`'s balance. Emits `Transfer(from, address(0), amount)` and /// `BurnedBlocked(caller, from, amount)`. + /// + /// @dev Reverts with `ContractPaused(BURN)` when `BURN` is paused. + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `BURN_BLOCKED_ROLE`. + /// @dev Reverts with `AccountNotBlocked` when `from` is currently authorized under `TRANSFER_SENDER_POLICY`. + /// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`. + /// + /// @param from Account whose balance is being seized. + /// @param amount Amount to burn. function burnBlocked(address from, uint256 amount) external; /*////////////////////////////////////////////////////////////// ROLES //////////////////////////////////////////////////////////////*/ - /// @notice Returns whether `account` is a member of `role`. `role` may - /// be any `bytes32` value; user-defined roles are supported and - /// have no built-in effect on the token's own functions but - /// may be consumed by external contracts. + /// @notice Whether `account` is a member of `role`. User-defined roles are supported and have no + /// built-in effect on the token's own functions. + /// + /// @param role Role identifier. + /// @param account Account to check. + /// + /// @return Whether `account` holds `role`. function hasRole(bytes32 role, address account) external view returns (bool); - /// @notice Returns the role required to grant or revoke `role`. - /// Defaults to `DEFAULT_ADMIN_ROLE` if not explicitly set via - /// `setRoleAdmin`. + /// @notice The role required to grant or revoke `role`. Defaults to `DEFAULT_ADMIN_ROLE` if not + /// explicitly set via `setRoleAdmin`. + /// + /// @param role Role whose admin is being queried. + /// + /// @return Admin role identifier. function getRoleAdmin(bytes32 role) external view returns (bytes32); - /// @notice Grants `role` to `account`. Caller MUST hold the admin - /// role for `role` (see `getRoleAdmin`). + /// @notice Grants `role` to `account`. Emits `RoleGranted`. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold the admin role for `role`, + /// or when the token has been transitioned to admin-less via `renounceLastAdmin` (admin-resurrection guard). + /// + /// @param role Role to grant. + /// @param account Recipient. function grantRole(bytes32 role, address account) external; - /// @notice Revokes `role` from `account`. Caller MUST hold the admin - /// role for `role`. + /// @notice Revokes `role` from `account`. Emits `RoleRevoked`. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold the admin role for `role`, + /// or when the token has been transitioned to admin-less via `renounceLastAdmin`. + /// @dev Reverts with `LastAdminCannotRenounce` when `role == DEFAULT_ADMIN_ROLE` and `account` is the last default admin. + /// Use `renounceLastAdmin` to clear the final admin. + /// + /// @param role Role to revoke. + /// @param account Account to revoke from. function revokeRole(bytes32 role, address account) external; - /// @notice Caller renounces `role` for themselves. Always permitted - /// (no admin authorization needed), EXCEPT that - /// `renounceRole(DEFAULT_ADMIN_ROLE, msg.sender)` reverts - /// with `LastAdminCannotRenounce` when the caller is the - /// sole remaining admin. Callers that intentionally want to - /// transition the token to a permanently adminless state - /// must use `renounceLastAdmin()` instead; the dedicated - /// function name is the explicit intent signal that - /// distinguishes "I'm giving up my hold on this role" from - /// "the token should have zero admins forever." - /// @dev `callerConfirmation` MUST equal `msg.sender`; otherwise - /// reverts with `AccessControlBadConfirmation`. This guard - /// prevents a fat-fingered call from accidentally renouncing - /// for a different account. + /// @notice Caller renounces `role` for themselves. Emits `RoleRevoked`. + /// + /// @dev Reverts with `AccessControlBadConfirmation` when `callerConfirmation != msg.sender`. + /// @dev Reverts with `LastAdminCannotRenounce` when `role == DEFAULT_ADMIN_ROLE` and the caller is the last default admin. + /// + /// @param role Role to renounce. + /// @param callerConfirmation MUST equal `msg.sender`. function renounceRole(bytes32 role, address callerConfirmation) external; - /// @notice Permanently transitions the token to a zero-admin state. - /// Revokes `DEFAULT_ADMIN_ROLE` from `msg.sender` and emits - /// `LastAdminRenounced(msg.sender)` (in addition to the - /// standard `RoleRevoked(DEFAULT_ADMIN_ROLE, msg.sender, - /// msg.sender)`). After this call, the token has no admin - /// and no holder of `DEFAULT_ADMIN_ROLE` can ever be - /// reinstated: `grantRole(DEFAULT_ADMIN_ROLE, ...)` would - /// require an admin caller and there is none. All - /// admin-gated operations (`updatePolicy`, `updateSupplyCap`, - /// and any `grantRole` / `revokeRole` / `setRoleAdmin` for - /// other roles) become permanently uncallable. Operations - /// gated by other roles (`updateName` / `updateSymbol` / - /// `updateContractURI` via `METADATA_ROLE`, `mint` via - /// `MINT_ROLE`, etc.) remain callable by their existing - /// role holders, but no new grants for those roles are - /// possible. - /// @dev Caller MUST be the sole remaining holder of - /// `DEFAULT_ADMIN_ROLE`; otherwise reverts with - /// `NotSoleAdmin` (when there are additional admins) or - /// `AccessControlUnauthorizedAccount` (when `msg.sender` - /// holds no admin role at all). To reach the single-admin - /// state from a multi-admin starting point, the existing - /// admin(s) must revoke or renounce other admins first via - /// `revokeRole` / `renounceRole`, leaving exactly one. - /// - /// No `callerConfirmation` argument: the dedicated function - /// name is the intent signal. This mirrors the - /// `renounceAdmin` pattern on `IPolicyRegistry`, which also - /// has no confirmation argument and relies on its distinct - /// name for accidental-misuse protection. + /// @notice Permanently transitions the token to a zero-admin state. Revokes `DEFAULT_ADMIN_ROLE` + /// from `msg.sender`. Emits `RoleRevoked(DEFAULT_ADMIN_ROLE, msg.sender, msg.sender)` and + /// `LastAdminRenounced(msg.sender)`. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when `msg.sender` does not hold `DEFAULT_ADMIN_ROLE`. + /// @dev Reverts with `NotSoleAdmin` when other accounts also hold `DEFAULT_ADMIN_ROLE`. function renounceLastAdmin() external; - /// @notice Sets the admin role for `role`. Caller MUST hold the - /// current admin role for `role`. Useful for delegating role - /// management to a different role hierarchy. + /// @notice Sets the admin role for `role`. Emits `RoleAdminChanged`. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold the current admin role for `role`, + /// or when the token has been transitioned to admin-less via `renounceLastAdmin`. + /// + /// @param role Role whose admin is being updated. + /// @param newAdminRole New admin role identifier. function setRoleAdmin(bytes32 role, bytes32 newAdminRole) external; /*////////////////////////////////////////////////////////////// PAUSE //////////////////////////////////////////////////////////////*/ - /// @notice The set of `PausableFeature`s currently paused on this - /// token. Returns an empty array when nothing is paused. - /// Order is implementation-defined; callers should treat the - /// result as a set, not a sequence. + /// @notice The `PausableFeature`s currently paused on this token. Order is implementation-defined; + /// callers should treat the result as a set. + /// @return Currently-paused features. function pausedFeatures() external view returns (PausableFeature[] memory); - /// @notice Whether `feature` is currently paused. O(1) regardless of - /// how many features are paused. + /// @notice Whether `feature` is currently paused. O(1). + /// + /// @param feature Feature to query. + /// + /// @return Whether `feature` is paused. function isPaused(PausableFeature feature) external view returns (bool); - /// @notice Pauses the `features` operations. Additive: features - /// already paused remain paused, and the listed features - /// become paused (duplicates within the call are idempotent). - /// Reverts in the following canonical order (first violated - /// check wins): - /// 1. `AccessControlUnauthorizedAccount(msg.sender, PAUSE_ROLE)` - /// if the caller does not hold `PAUSE_ROLE`. - /// 2. `EmptyFeatureSet()` if `features.length == 0`. + /// @notice Pauses each of `features`. Additive: features already paused remain paused; duplicates + /// within the call are idempotent. Emits `Paused`. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `PAUSE_ROLE`. + /// @dev Reverts with `EmptyFeatureSet` when `features.length == 0`. + /// + /// @param features Features to pause. function pause(PausableFeature[] calldata features) external; - /// @notice Unpauses the `features` operations. Listed features - /// become unpaused; features not listed are unaffected - /// (duplicates are idempotent; unpausing a feature that is - /// not currently paused is a no-op for that feature). - /// Reverts in the following canonical order (first violated - /// check wins): - /// 1. `AccessControlUnauthorizedAccount(msg.sender, UNPAUSE_ROLE)` - /// if the caller does not hold `UNPAUSE_ROLE`. - /// 2. `EmptyFeatureSet()` if `features.length == 0`. + /// @notice Unpauses each of `features`. Features not listed are unaffected; duplicates are idempotent. + /// Emits `Unpaused`. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `UNPAUSE_ROLE`. + /// @dev Reverts with `EmptyFeatureSet` when `features.length == 0`. + /// + /// @param features Features to unpause. function unpause(PausableFeature[] calldata features) external; /*////////////////////////////////////////////////////////////// POLICY //////////////////////////////////////////////////////////////*/ - /// @notice The current policy ID configured for `policyScope`. Returns - /// `0` (always-allow built-in) for any policy slot that has - /// never been assigned. Reverts `UnsupportedPolicyType` for - /// a `policyScope` not supported by this token (or its - /// variant). Standard policy types are exposed as the - /// role-identifier constants `TRANSFER_SENDER_POLICY()`, - /// `TRANSFER_RECEIVER_POLICY()`, `TRANSFER_EXECUTOR_POLICY()`, - /// and `MINT_RECEIVER_POLICY()`. Variants add their own - /// constants for variant-specific operations (e.g. - /// `REDEEM_SENDER_POLICY()` on `IB20Security`). - /// @dev All slots default to `0` (always-allow) at token creation: - /// newly created tokens are unrestricted until the admin - /// points each slot at a concrete policy. To explicitly - /// hard-deny a slot (e.g. disabling redemption on a - /// non-redeemable token), point it at the ALWAYS_BLOCK - /// sentinel. + /// @notice The current policy ID configured for `policyScope`. Returns `0` (always-allow built-in) + /// for any slot that has never been assigned. + /// + /// @dev Reverts with `UnsupportedPolicyType` when `policyScope` is not recognized by this token. + /// + /// @param policyScope Policy slot identifier. + /// + /// @return Configured policy ID. function policyId(bytes32 policyScope) external view returns (uint64); - /// @notice Updates the policy ID assigned to `policyScope`. Requires - /// `DEFAULT_ADMIN_ROLE`. The target policy MUST exist in the - /// registry (or be one of the built-in IDs `0` or - /// `type(uint64).max`); otherwise reverts with - /// `PolicyNotFound`. Takes effect immediately for the next + /// @notice Updates the policy ID assigned to `policyScope`. Takes effect immediately for the next /// operation that consults this slot. Emits `PolicyUpdated`. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `DEFAULT_ADMIN_ROLE`. + /// @dev Reverts with `UnsupportedPolicyType` when `policyScope` is not recognized by this token. + /// @dev Reverts with `PolicyNotFound` when `newPolicyId` is not a built-in sentinel and does not exist in the registry. + /// + /// @param policyScope Policy slot identifier. + /// @param newPolicyId Policy ID to assign to the slot. function updatePolicy(bytes32 policyScope, uint64 newPolicyId) external; /*////////////////////////////////////////////////////////////// SUPPLY CAP //////////////////////////////////////////////////////////////*/ - /// @notice The maximum total supply enforced on `mint`. A value of - /// `type(uint256).max` indicates no cap (the default for - /// tokens that do not specify a cap at creation). + /// @notice The maximum total supply enforced on `mint`. `type(uint256).max` indicates no cap. + /// @return Current supply cap. function supplyCap() external view returns (uint256); - /// @notice Sets a new supply cap. Requires `DEFAULT_ADMIN_ROLE`. - /// Reverts with `InvalidSupplyCap` if the new cap is below - /// the current `totalSupply` (we never invalidate - /// already-issued supply). The cap may be raised or lowered - /// freely otherwise. Emits `SupplyCapUpdated`. + /// @notice Sets a new supply cap. May be raised or lowered freely, but never below current + /// `totalSupply`. Emits `SupplyCapUpdated`. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `DEFAULT_ADMIN_ROLE`. + /// @dev Reverts with `InvalidSupplyCap` when `newSupplyCap < totalSupply()`. + /// + /// @param newSupplyCap New supply cap. function updateSupplyCap(uint256 newSupplyCap) external; /*////////////////////////////////////////////////////////////// PERMIT (EIP-2612 + ERC-5267) //////////////////////////////////////////////////////////////*/ - /// @notice The current EIP-712 domain separator for this token. - /// Computed dynamically each call so it remains correct - /// after a chain fork that changes `block.chainid` and - /// after any `updateName(...)` that mutates `name`. - /// @dev Domain content: `(name, version, chainId, verifyingContract)`. - /// `name` is the live `name()` value, re-hashed on each - /// call. `version` is the constant string `"1"`. `salt` - /// is unused (empty). Type hash is - /// `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")`, - /// the canonical EIP-2612 shape. + /// @notice The current EIP-712 domain separator for this token. Recomputed on each call so it + /// remains correct after `updateName` or a chain fork that changes `block.chainid`. + /// @return Current domain separator. function DOMAIN_SEPARATOR() external view returns (bytes32); - /// @notice The current permit nonce for `owner`. Incremented by - /// exactly 1 on each successful `permit`. + /// @notice The current EIP-2612 permit nonce for `owner`. Incremented by 1 on each successful `permit`. + /// + /// @param owner Account whose nonce is being queried. + /// + /// @return Current nonce. function nonces(address owner) external view returns (uint256); - /// @notice EIP-2612 canonical permit. Recovers `owner` via ECDSA from - /// `(v, r, s)`. EOA signatures only; ERC-1271 contract - /// signatures are NOT supported on the Default surface. - /// Reverts with `ExpiredSignature(deadline)` if - /// `block.timestamp > deadline`, or - /// `InvalidSigner(recovered, owner)` if recovery does - /// not yield `owner`. - /// @dev Smart-contract accounts that need permit-style flows should - /// use call-batching (e.g. the EIP-7702 path) or paymaster- - /// based gasless flows; we deliberately do not enshrine - /// ERC-1271 dispatch here. Permit2 remains usable as a - /// periphery alternative. + /// @notice EIP-2612 permit. Recovers `owner` via ECDSA from `(v, r, s)`. EOA signatures only; + /// ERC-1271 contract signatures are NOT supported. Emits `Approval`. + /// + /// @dev Reverts with `ExpiredSignature` when `block.timestamp > deadline`. + /// @dev Reverts with `InvalidSigner` when ECDSA recovery does not yield `owner`. + /// @dev Reverts with `InvalidSpender` when `spender == address(0)`. + /// + /// @param owner Token holder granting the allowance. + /// @param spender Account being granted the allowance. + /// @param value Allowance amount. + /// @param deadline Unix timestamp after which the signature is invalid. + /// @param v ECDSA recovery byte. + /// @param r ECDSA signature r component. + /// @param s ECDSA signature s component. function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; - /// @notice ERC-5267 EIP-712 domain introspection. Returns the parts - /// of the EIP-712 domain populated for this token. - /// @dev `fields` is `0x0f` (bits 0, 1, 2, 3 set — `name`, - /// `version`, `chainId`, `verifyingContract` are - /// populated). `name` is the live `name()` value; - /// `version` is the constant string `"1"`. `salt` is - /// zero and `extensions` is empty. + /// @notice ERC-5267 EIP-712 domain introspection. + /// + /// @return fields Bitmap of populated domain fields (`0x0f`: `name`, `version`, `chainId`, `verifyingContract`). + /// @return name Live `name()` value. + /// @return version Constant `"1"`. + /// @return chainId Current `block.chainid`. + /// @return verifyingContract This token's address. + /// @return salt Unused (zero). + /// @return extensions Empty. function eip712Domain() external view @@ -853,12 +608,15 @@ interface IB20 { CONTRACT URI (ERC-7572) //////////////////////////////////////////////////////////////*/ - /// @notice An off-chain URI pointing at contract-level metadata for - /// this token, per ERC-7572. + /// @notice Off-chain URI pointing at contract-level metadata for this token, per ERC-7572. + /// @return Current contract URI. function contractURI() external view returns (string memory); - /// @notice Updates `contractURI`. Requires `METADATA_ROLE`. Emits - /// the parameterless `ContractURIUpdated` event per ERC-7572; + /// @notice Updates `contractURI`. Emits the parameterless `ContractURIUpdated` event per ERC-7572; /// integrators re-fetch `contractURI()` after observing it. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `METADATA_ROLE`. + /// + /// @param newURI New contract URI. function updateContractURI(string calldata newURI) external; } diff --git a/src/interfaces/IB20Factory.sol b/src/interfaces/IB20Factory.sol index 2e5dad0b..26fb3a8b 100644 --- a/src/interfaces/IB20Factory.sol +++ b/src/interfaces/IB20Factory.sol @@ -2,194 +2,75 @@ pragma solidity >=0.8.20 <0.9.0; /// @title IB20Factory -/// @notice Singleton factory precompile for creating B-20 tokens of any -/// variant. A single entry point `createToken` dispatches on a -/// `B20Variant` discriminator; per-variant creation arguments -/// are ABI-encoded into `params` and prefixed with a `version` -/// byte so the encoding can evolve without breaking the factory's -/// immutable surface. Creation is permissionless; the caller picks -/// the initial admin. /// -/// @dev **Factory address.** The factory lives at the fixed address -/// `0xB20F00000000000000000000000000000000000F`. The `0xB20F` -/// prefix is deliberately distinct from the B-20 token prefix -/// `0xB200` (byte `[1]` differs), so `isB20(factory)` returns -/// false unambiguously. -/// -/// **Token address schema (20 bytes).** -/// - `[0:10]` — `bytes10(0xB200000000000000000000)` shared -/// prefix identifying a factory-created B-20. -/// - `[10]` — `bytes1(variant)` (matches `B20Variant`). -/// - `[11:20]` — `bytes9(keccak256(abi.encode(msg.sender, salt)))`. -/// -/// **Variant evolution.** Adding new required fields to an -/// existing variant after launch is NOT supported: the factory is -/// an immutable precompile, and downstream protocols (e.g. -/// Clanker-style launchers) depend on the surface. Schemas evolve -/// in two ways: -/// 1. Bumping the `version` byte at the head of a variant's -/// params struct and ABI-decoding accordingly. Old versions -/// remain valid forever. -/// 2. Customizing per-token configuration via `initCalls` (see -/// below) instead of growing the params struct. -/// -/// **`initCalls`.** After the token is deployed and the bootstrap -/// state is set, each entry in `initCalls` is invoked on the new -/// token with `msg.sender == factory`. These calls are entirely -/// privileged: every authorization gate the token would normally -/// enforce against the caller (role checks, transfer-policy -/// checks, capability gates) is bypassed for calls originating -/// from the factory during the bootstrap window. The window -/// closes the moment `createToken` returns; from that point -/// forward every call is subject to the standard role and policy -/// checks. The factory is NOT added to any role on the token; -/// "privileged" means authorization is skipped for the bootstrap -/// caller, not that the factory has been granted authority that -/// persists. Invariants intrinsic to token correctness -/// (supply-cap math, balance accounting, etc.) are still enforced. -/// -/// This is the supported path for configuring optional -/// post-creation state (initial mints, supply caps, policy slot -/// assignments, contract URI, initial pause state, etc.) -/// atomically in the same transaction as creation. The factory -/// does not interpret the call data; any revert in an init call -/// reverts the whole creation. -/// -/// **Per-variant validation.** Variant-specific required-field -/// checks (e.g. stablecoins must specify a non-empty `currency`) -/// are applied at the end of the variant decode, after the -/// common version check, so each variant owns its own invariants. +/// @notice Singleton factory precompile for creating B-20 tokens of any variant. A single +/// entry point `createB20` dispatches on a `B20Variant` discriminator; variant-specific +/// arguments are ABI-encoded into `params` with a leading `version` byte. interface IB20Factory { /*////////////////////////////////////////////////////////////// TYPES //////////////////////////////////////////////////////////////*/ - /// @notice Variant of a B-20 token. Encoded in address byte `[10]`, - /// so the variant of a B-20 address is a pure address-prefix - /// read with no storage lookup. Whether an address is a - /// factory-created B-20 in the first place is answered by - /// `isB20` (a prefix check on bytes `[0:10]`); the variant - /// byte itself does not need an "absent" sentinel. + /// @notice Variant of a B-20 token. Encoded in address byte `[10]`. + /// + /// @param DEFAULT Default B-20 variant. + /// @param STABLECOIN Stablecoin variant (fixed `6` decimals, immutable `currency`). + /// @param SECURITY Security variant (share ratio, announcements, redemptions). enum B20Variant { DEFAULT, STABLECOIN, SECURITY } - /// @notice Creation parameters for a Default-variant B-20 token. - /// ABI-encoded into the `params` argument of `createToken`. - /// @param version Encoding version. Currently `1`. Future - /// hardforks may introduce additional versions - /// with different field layouts; the leading - /// byte selects the decoder. - /// @param name ERC-20 token name. - /// @param symbol ERC-20 token symbol. - /// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`. All - /// post-creation admin operations authorize - /// against this account (and any additional - /// admins it later grants). The bootstrap - /// `initCalls` are NOT subject to this check: - /// they execute with full privilege regardless - /// of role state, and the privileged window - /// closes the moment `createToken` returns. - /// May be `address(0)` for the "demonstrate no - /// owner" case (memecoins, credibly-neutral - /// tokens). When zero, no role is granted at - /// creation and the token has no admin: no - /// role grants, policy changes, or pauses are - /// ever possible post-creation. The - /// `renounceLastAdmin` path is the alternative - /// for tokens that need an admin during setup - /// and then want to evolve to admin-less. + /// @notice Creation parameters for a Default-variant B-20 token. ABI-encoded into `params`. struct B20CreateParams { + /// @dev Encoding version. Currently `1`. uint8 version; + /// @dev ERC-20 token name. string name; + /// @dev ERC-20 token symbol. string symbol; + /// @dev Initial holder of `DEFAULT_ADMIN_ROLE`, or `address(0)` to deploy admin-less. address initialAdmin; } - /// @notice Creation parameters for a Stablecoin-variant B-20 token. - /// @param version Encoding version. Currently `1`. - /// @param name ERC-20 token name. - /// @param symbol ERC-20 token symbol. - /// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`. - /// @param currency Immutable self-declared currency - /// identifier — uppercase ASCII letters - /// (`A`–`Z`). - /// @dev Decimals are fixed at `6`. There is no decimals field - /// and no setter for `currency` — both are fixed for the - /// token's lifetime at creation. + /// @notice Creation parameters for a Stablecoin-variant B-20 token. ABI-encoded into `params`. struct B20StablecoinCreateParams { + /// @dev Encoding version. Currently `1`. uint8 version; + /// @dev ERC-20 token name. string name; + /// @dev ERC-20 token symbol. string symbol; + /// @dev Initial holder of `DEFAULT_ADMIN_ROLE`, or `address(0)` to deploy admin-less. address initialAdmin; + /// @dev Immutable self-declared currency identifier; uppercase ASCII `A`-`Z` only. string currency; } - /// @notice Creation parameters for a Security-variant B-20 token. - /// @param version Encoding version. Currently `1`. - /// @param name ERC-20 token name. - /// @param symbol ERC-20 token symbol. - /// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`. - /// @param isin International Securities Identification - /// Number. Required: empty string reverts. - /// Additional identifiers (CUSIP, FIGI, - /// SEDOL) can be attached post-creation - /// via `IB20Security.updateSecurityIdentifier`. - /// @param minimumRedeemable Initial value of `minimumRedeemable`. - /// Use `0` to allow any non-zero redemption. - /// @dev Decimals are fixed at `6`. Security tokens have no `initialSupply` - /// parameter: all issuance flows through `create` - /// (rate-limited compliant path) or `adminMint` (cold-path - /// batch with announcement coupling) after deployment. - /// - /// For the Security variant, the `REDEEM_SENDER_POLICY` - /// slot defaults to the always-block built-in (policy ID - /// `1`) rather than always-allow, so redemption is closed by - /// default and an admin must opt-in by pointing the slot at - /// an allowlist (or another policy) before any holder can - /// call `redeem`. To open redemption at creation, override - /// the slot atomically by including an - /// `updatePolicy(REDEEM_SENDER_POLICY, )` entry - /// in `initCalls`. + /// @notice Creation parameters for a Security-variant B-20 token. ABI-encoded into `params`. struct B20SecurityCreateParams { + /// @dev Encoding version. Currently `1`. uint8 version; + /// @dev ERC-20 token name. string name; + /// @dev ERC-20 token symbol. string symbol; + /// @dev Initial holder of `DEFAULT_ADMIN_ROLE`, or `address(0)` to deploy admin-less. address initialAdmin; + /// @dev International Securities Identification Number. Required; empty string reverts. string isin; + /// @dev Initial value of `minimumRedeemable`. Use `0` to allow any non-zero redemption. uint256 minimumRedeemable; } - /// @notice Event payload carried in the `variantEventParams` field of - /// `B20Created` for STABLECOIN-variant tokens. ABI-encoded - /// and emitted with a leading `version` byte so stream-based - /// indexers can decode by `(variant, version)` without an - /// RPC call to read storage. - /// @param version Event-encoding version. Currently `1`. - /// Independent of `B20StablecoinCreateParams.version`; - /// the event payload schema can evolve separately - /// from the create-call payload schema. - /// @param currency The immutable stablecoin currency identifier, same - /// value that was passed in - /// `B20StablecoinCreateParams.currency`. Surfaced here - /// because `currency` has no setter and no other - /// event ever emits it; indexers that cannot make - /// mid-handler view calls would - /// otherwise have no way to recover it from the - /// event stream. - /// @dev DEFAULT and SECURITY variants currently emit an empty - /// `variantEventParams` byte string: DEFAULT has no extra - /// immutable identity fields beyond what's already in - /// `B20Created`, and SECURITY's `isin` and - /// `minimumRedeemable` are mutable and surfaced via their - /// own `SecurityIdentifierUpdated` / - /// `MinimumRedeemableUpdated` events. Either variant can - /// promote from empty to a non-empty payload in a future - /// version without re-issuing a new event type. + /// @notice Event payload carried in the `variantEventParams` field of `B20Created` for + /// STABLECOIN-variant tokens. ABI-encoded and prefixed with a version byte. struct B20StablecoinEventParams { + /// @dev Event-encoding version. Currently `1`. Independent of `B20StablecoinCreateParams.version`. uint8 version; + /// @dev Stablecoin currency identifier, identical to the value passed in + /// `B20StablecoinCreateParams.currency`. string currency; } @@ -197,64 +78,38 @@ interface IB20Factory { ERRORS //////////////////////////////////////////////////////////////*/ - /// @notice A token already exists at the deterministic address - /// derived from `(variant, msg.sender, salt)`. - /// Caller must use a different salt. + /// @notice A token already exists at the deterministic address derived from + /// `(variant, msg.sender, salt)`. Caller must use a different salt. error TokenAlreadyExists(address token); - /// @notice `variant` is not a recognized `B20Variant`. Reached - /// only when the factory is invoked with a raw variant byte - /// outside the enum range (typed `createToken` callers are - /// rejected by ABI decoding before this check fires). + /// @notice `variant` is not a recognized `B20Variant`. error InvalidVariant(); - /// @notice The leading `version` byte in `params` does not match - /// any known encoding for the requested variant. + /// @notice The leading `version` byte in `params` does not match any known encoding for the requested variant. error UnsupportedVersion(uint8 version, B20Variant variant); /// @notice A required string argument was the empty string. - /// @param field Name of the missing field (e.g. `"isin"`). Empty - /// `currency` is rejected separately via `InvalidCurrency("")`. + /// + /// @param field Name of the missing field (e.g. `"isin"`). error MissingRequiredField(string field); - /// @notice The stablecoin `currency` contained a non-`A`–`Z` byte. + /// @notice The stablecoin `currency` contained a non-`A`-`Z` byte. error InvalidCurrency(string code); - /// @notice One of the `initCalls` reverted. The factory bubbles the - /// underlying revert reason where the call returns one; - /// this error wraps empty reverts. + /// @notice One of the `initCalls` reverted. The factory bubbles the underlying revert reason + /// where the call returns one; this error wraps empty reverts. error InitCallFailed(uint256 index); /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ - /// @notice Emitted once per `createToken` invocation. The fields - /// cover the universal token-identity surface only; - /// variant-specific state changes (e.g. `currency`, `isin`, - /// supply cap, policy slots) are observable via the - /// token's own events as they're applied during `initCalls`. - /// @dev The initial admin grant (when `initialAdmin != address(0)`) - /// is announced via the standard `RoleGranted(DEFAULT_ADMIN_ROLE, - /// admin, factory)` event emitted from the token's own - /// context during the same transaction — NOT as a field on - /// this event. Role state is always observable via the - /// `RoleGranted` / `RoleRevoked` event stream from the token; - /// `B20Created` is the token-identity signal only. The - /// "demonstrate no owner" path (`initialAdmin == address(0)`) - /// skips the grant and emits no `RoleGranted` at bootstrap. - /// @param token Address of the newly-created token. - /// @param variant Which `B20Variant` was created. - /// @param name ERC-20 token name. - /// @param symbol ERC-20 token symbol. - /// @param decimals ERC-20 decimals (fixed per variant). - /// @param variantEventParams ABI-encoded variant-specific immutable identity - /// fields, leading with a version byte. Empty - /// (`""`) for DEFAULT and SECURITY (no extra - /// immutable fields not already covered); for - /// STABLECOIN, carries `abi.encode(B20StablecoinEventParams)` - /// with `currency`. Indexers decode by - /// `(variant, leading version byte)`. + /// @notice Emitted once per `createB20` invocation, after the token's identity is sealed + /// and before any `initCalls` are dispatched. + /// + /// @dev `variantEventParams` carries variant-specific immutable identity data, ABI-encoded + /// and prefixed with a version byte. Empty for DEFAULT and SECURITY; for STABLECOIN, + /// `abi.encode(B20StablecoinEventParams)` with `currency`. event B20Created( address indexed token, B20Variant indexed variant, @@ -268,47 +123,23 @@ interface IB20Factory { CREATE //////////////////////////////////////////////////////////////*/ - /// @notice Creates a B-20 token of the given `variant` at the - /// deterministic address derived from `(variant, msg.sender, salt)`. - /// `params` MUST be the ABI-encoded - /// variant-specific struct (`B20CreateParams`, - /// `B20StablecoinCreateParams`, or `B20SecurityCreateParams`), - /// leading with a `version` byte the factory uses to select - /// the decoder. + /// @notice Creates a B-20 token of the given `variant` at the deterministic address derived + /// from `(variant, msg.sender, salt)`, then dispatches each entry in `initCalls` on + /// the new token. Emits `B20Created`. /// - /// After the token is constructed and its identity state - /// (name, symbol, decimals, admin, variant-specific fields) - /// is sealed, the factory invokes each entry in `initCalls` - /// on the new token. These calls are entirely privileged: - /// all authorization gates the token would normally enforce - /// against the caller (role checks, transfer-policy checks, - /// capability gates) are bypassed for calls from the factory - /// during this bootstrap window. The factory is not added to - /// any role; the bypass is bound to the call site, not to - /// RBAC state. Once `createToken` returns, every subsequent - /// call on the token is subject to the standard checks. + /// @dev Reverts with `InvalidVariant` when `variant` is outside the `B20Variant` range. + /// @dev Reverts with `UnsupportedVersion` when the leading `version` byte in `params` is unrecognized for `variant`. + /// @dev Reverts with `MissingRequiredField` when a required string field is empty. + /// @dev Reverts with `InvalidCurrency` when a stablecoin `currency` contains a non-`A`-`Z` byte. + /// @dev Reverts with `TokenAlreadyExists` when a token already exists at the derived address. + /// @dev Reverts with `InitCallFailed` (or the bubbled inner reason) when any entry in `initCalls` reverts. /// - /// This is the supported path for configuring all optional - /// post-creation state atomically: initial mints, supply - /// caps, policy slot assignments, initial pause state, - /// contract URI, etc. Any init-call revert reverts the - /// entire creation. + /// @param variant Which variant struct `params` decodes as. + /// @param salt Caller-chosen salt for deterministic address derivation. + /// @param params ABI-encoded variant-specific creation struct, leading with the version byte. + /// @param initCalls Bootstrap calls invoked on the new token after identity is sealed. /// - /// Emits `B20Created` once the token's identity is sealed - /// and before any `initCalls` are dispatched, so init-call - /// effects appear strictly after the creation event in the - /// log order. - /// @param variant Which variant struct `params` decodes as. - /// @param salt Caller-chosen salt for deterministic address - /// derivation. - /// @param params ABI-encoded variant-specific creation struct, - /// leading with the version byte. - /// @param initCalls Optional bootstrap calls invoked on the new - /// token after identity is sealed. Executed as - /// fully privileged calls from the factory: all - /// token-side authorization checks are bypassed - /// for this window only. - /// @return token The address of the newly created token. + /// @return token The address of the newly created token. function createB20(B20Variant variant, bytes32 salt, bytes calldata params, bytes[] calldata initCalls) external returns (address token); @@ -317,25 +148,27 @@ interface IB20Factory { ADDRESS QUERIES //////////////////////////////////////////////////////////////*/ - /// @notice Returns the deterministic address `createToken` would - /// assign for `(variant, sender, salt)`. Address derivation - /// depends only on these inputs; the remaining `params` - /// fields (including decimals, which are fixed by variant) - /// do not affect the address. + /// @notice Returns the deterministic address `createB20` would assign for `(variant, sender, salt)`. Never reverts. + /// + /// @param variant Variant of the token whose address is being predicted. + /// @param sender Account that would call `createB20`. + /// @param salt Caller-chosen salt. + /// + /// @return The deterministic token address. function getB20Address(B20Variant variant, address sender, bytes32 salt) external view returns (address); - /// @notice Whether `token` was created by this factory. Recovered - /// from the address prefix (bytes `[0:10]`); no storage read. + /// @notice Returns whether `token` was created by this factory, recovered from the address prefix. Never reverts. + /// + /// @param token Address to check. + /// + /// @return Whether `token` matches the B-20 address prefix. function isB20(address token) external view returns (bool); - /// @notice Whether the token at `token` has been initialized by this - /// factory (i.e. `createToken` ran to completion at that - /// address). Returns `false` for B-20-prefixed addresses - /// whose deterministic slot has not yet been claimed by a - /// `createToken` call, and for any address that is not a - /// B-20 at all. The bootstrap window (the `initCalls` loop - /// inside `createToken`) is fully privileged but not yet - /// initialized; this flag flips exactly once, at the moment - /// `createToken` returns. + /// @notice Returns whether `createB20` has run to completion at `token`. Flips exactly once, + /// the moment the creating `createB20` call returns. Never reverts. + /// + /// @param token Address to check. + /// + /// @return Whether `token` is an initialized B-20. function isB20Initialized(address token) external view returns (bool); } diff --git a/src/interfaces/IB20Security.sol b/src/interfaces/IB20Security.sol index 29cb6d9f..31327f6d 100644 --- a/src/interfaces/IB20Security.sol +++ b/src/interfaces/IB20Security.sol @@ -5,319 +5,120 @@ import {IB20} from "./IB20.sol"; /// @title IB20Security /// @author Coinbase -/// @notice A B-20 token variant for tokenized securities (equities, ETFs, -/// commodities, etc.). Extends `IB20` with primitives specific to -/// securities: holder-impacting announcements, split-safe -/// share-ratio accounting, security-identifier metadata, batched -/// mint/burn for cold-path corporate actions, and a -/// holder-initiated redemption path for off-chain settlement. /// -/// @dev **Inherited surface.** `IB20` already provides the pieces -/// shared across all B-20 variants: ERC-20 surface, -/// single-recipient `mint(address,uint256)` and `burn(uint256)` -/// (gated by `MINT_ROLE` and `BURN_ROLE`), memo'd siblings, -/// pause vectors (including `REDEEM`), permit, contract URI, -/// and OZ-style role management. Security tokens use all of -/// these as-is and do not redeclare them here. -/// -/// **Security-specific additions.** This interface adds: -/// 1. `announce(bytes[],string,string,string)` plus -/// `SECURITY_OPERATOR_ROLE`. The single canonical wrapper -/// for posting a holder-impacting disclosure AND atomically -/// executing the on-chain calls it discloses; see -/// "Announcement pairing" below for the topology. -/// 2. `sharesToTokensRatio()` / `toShares(...)` / `sharesOf(...)` -/// plus `updateShareRatio(...)` for split-safe -/// DeFi-compatible share accounting. -/// 3. `batchMint(address[],uint256[])` and -/// `batchBurn(address[],uint256[])` for the cold-path -/// corporate-actions issuance and clawback flows. These are -/// scoped to the security-token surface (not the base -/// `IB20`) because batched destruction of third-party -/// balances is a compliance-sensitive operation and the -/// batched issuance path is the natural target of the -/// announcement bracket above. See the per-function -/// natspec for role gating; `batchBurn` is held tighter -/// than `batchMint`. -/// 4. `redeem(...)` / `redeemWithMemo(...)` plus -/// `updateMinimumRedeemable(...)` and `minimumRedeemable()` -/// for the holder-initiated off-chain settlement path. -/// 5. `securityIdentifier(...)` / `updateSecurityIdentifier(...)` -/// for ISIN, CUSIP, FIGI, and similar off-chain registry IDs. -/// -/// **EIP-712 domain.** Inherited unchanged from `IB20`: the -/// domain binds `(name, version, chainId, verifyingContract)`, -/// the inherited `updateName(...)` emits `NameUpdated` followed -/// by `EIP712DomainChanged()` (in that order), and outstanding -/// off-chain `permit` signatures issued under a previous `name` -/// cease to be valid as soon as `updateName` lands. See the -/// base `IB20` "Permit" notes for the full rationale; this -/// variant does not override any part of the domain. -/// -/// **Metadata updates.** The inherited `updateName(...)` and -/// `updateSymbol(...)` continue to be gated by `METADATA_ROLE` -/// from `IB20`; this interface does NOT re-gate them. Security -/// tokens that want the corporate-actions desk to be the sole -/// caller of these functions grant `METADATA_ROLE` only to -/// addresses that also hold `SECURITY_OPERATOR_ROLE`. That -/// pairing is operational, not contract-enforced. The standard -/// way to issue a name or symbol change is to wrap the -/// `updateName` / `updateSymbol` call as an entry in -/// `announce(...)`'s `internalCalls`, so the rebrand lands in -/// the same `Announcement` ↔ `EndAnnouncement` bracket as the -/// disclosure that explains it. -/// -/// **Announcement pairing.** Every state-changing operator -/// call that affects holder-visible token semantics -/// (`updateShareRatio`, `updateSecurityIdentifier`, `updateName`, -/// `updateSymbol`, `updateContractURI`, `batchMint`, `batchBurn`, -/// and admin-level changes such as `updatePolicy` / -/// `updateSupplyCap` / `pause` / `unpause`) SHOULD be issued by -/// encoding the call into the `internalCalls` parameter of -/// `announce(...)`. The token then: -/// 1. emits `Announcement(caller, id, description, uri)`, -/// 2. `delegatecall`s each entry in `internalCalls` with -/// `msg.sender` preserved (so the operator's own roles -/// apply to the inner calls), reverting the entire -/// announce if any inner call reverts, and -/// 3. emits `EndAnnouncement(id)`. -/// This binds every change to its off-chain disclosure -/// atomically: indexers never see a `Transfer`, a -/// `ShareRatioUpdated`, or any other state-mutation event -/// from a wrapped call without the surrounding bracket, and -/// they never see a half-applied bracket because any inner -/// revert unwinds the entire transaction including the -/// `Announcement` event. -/// -/// The bare functions remain individually callable by their -/// role holders for emergency operator override, but -/// unwrapped invocations produce no bracket events and so are -/// indistinguishable from any other state mutation in the log -/// stream. Production corporate-actions flows are expected to -/// go through `announce(...)`; standalone calls are an escape -/// hatch, not the standard path. Recursion (an inner call -/// re-invoking `announce`) reverts with -/// `AnnouncementInProgress` so the bracket is always exactly -/// one level deep. +/// @notice A B-20 token variant for tokenized securities. Extends `IB20` with announcements, +/// share-ratio accounting, batched mint/burn for corporate actions, holder-initiated +/// redemption, and security-identifier metadata. interface IB20Security is IB20 { /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ - /// @notice The supplied `id` has previously been consumed by - /// `announce`. Each announcement id may be used at most - /// once over the lifetime of the token. + /// @notice `announce` was called with an `id` that has already been consumed. error AnnouncementIdAlreadyUsed(string id); - /// @notice `updateSecurityIdentifier` was called with an empty - /// `identifierType` string. The category name is always - /// required; pass the empty string in `value` to remove an - /// entry instead. + /// @notice `updateSecurityIdentifier` was called with an empty `identifierType`. error InvalidIdentifierType(); - /// @notice A batched function (`batchMint`, `batchBurn`) was called - /// with parallel arrays of differing lengths. The two - /// lengths are reported verbatim in the order the function - /// declares them (`recipients`/`amounts` for `batchMint`; - /// `accounts`/`amounts` for `batchBurn`). + /// @notice A batched function was called with parallel arrays of differing lengths. + /// + /// @param leftLen Length of the first array argument. + /// @param rightLen Length of the second array argument. error LengthMismatch(uint256 leftLen, uint256 rightLen); - /// @notice A batched function (`batchMint`, `batchBurn`) was called - /// with empty arrays. Empty batches are rejected so the - /// caller cannot accidentally emit a no-op corp-actions - /// transaction. + /// @notice A batched function was called with empty arrays. error EmptyBatch(); - /// @notice `redeem` / `redeemWithMemo` was called with an `amount` - /// that resolves to a share count below the active redemption - /// floor. `shares` is the computed share count - /// (`amount * sharesToTokensRatio / WAD_PRECISION`); - /// `minimum` is the configured `minimumRedeemable`. Also - /// emitted when the resulting share count is zero (which is - /// always rejected, regardless of `minimumRedeemable`). + /// @notice `redeem` / `redeemWithMemo` resolved to a share count of zero or below the active floor. + /// + /// @param shares Computed share count (`amount * sharesToTokensRatio / WAD_PRECISION`). + /// @param minimum Configured `minimumRedeemable`. error BelowMinimumRedeemable(uint256 shares, uint256 minimum); - /// @notice Reverted by `announce` when one of its `internalCalls` - /// tries to invoke `announce` itself. The recursion guard - /// keeps the bracketing topology one level deep: - /// exactly one `Announcement` and one matching - /// `EndAnnouncement` per outer call, with no nesting. - /// Indexers can therefore treat every `Announcement` log - /// as the unambiguous start of a single bracket pairing. + /// @notice An inner call dispatched by `announce` tried to re-invoke `announce`. error AnnouncementInProgress(); - /// @notice Reverted by `announce` when one of its `internalCalls` - /// is malformed (shorter than four bytes, no function - /// selector to validate). Carries the offending raw - /// calldata blob. + /// @notice An inner call dispatched by `announce` was shorter than four bytes. + /// + /// @param call Offending raw calldata blob. error InternalCallMalformed(bytes call); - /// @notice Reverted by `announce` when one of its `internalCalls` - /// reverts during the inner `delegatecall`. Carries the - /// offending raw calldata blob; the inner revert reason - /// is intentionally not bubbled through so this error - /// identifies the wrapped call deterministically. To debug - /// a failing inner call, replay it as a direct invocation - /// and read its native revert. + /// @notice An inner call dispatched by `announce` reverted. The inner revert reason is not bubbled. + /// + /// @param call Offending raw calldata blob. error InternalCallFailed(bytes call); /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ - /// @notice Emitted by `redeem` and `redeemWithMemo` when a holder - /// redeems tokens. `amt` is in tokens; the corresponding - /// share amount is `amt * sharesToTokensRatio / - /// WAD_PRECISION`. + /// @notice Emitted by `redeem` and `redeemWithMemo` immediately after the `Transfer` burn event. + /// `amt` is in tokens; the corresponding share count is `amt * sharesToTokensRatio / WAD_PRECISION`. event Redeemed(address indexed from, uint256 amt, uint256 sharesToTokensRatio); - /// @notice Emitted by `updateMinimumRedeemable` when the redemption - /// floor is changed. + /// @notice Emitted by `updateMinimumRedeemable`. event MinimumRedeemableUpdated(address indexed caller, uint256 newMinimumRedeemable); - /// @notice Emitted by `updateShareRatio` when the share-to-tokens - /// ratio is changed. + /// @notice Emitted by `updateShareRatio`. event ShareRatioUpdated(uint256 sharesToTokensRatio); - /// @notice Emitted by `updateSecurityIdentifier` when an identifier - /// entry is set, updated, or removed. An empty `value` - /// indicates removal. + /// @notice Emitted by `updateSecurityIdentifier`. An empty `value` indicates removal. event SecurityIdentifierUpdated(string identifierType, string value); - /// @notice Emitted by `announce` when a holder-impacting disclosure - /// is posted. Indexers join this with subsequent - /// security-token state changes via `id`. + /// @notice Emitted by `announce` to open an announcement bracket. event Announcement(address indexed caller, string id, string description, string uri); - /// @notice Emitted by `announce` immediately after every entry in - /// `internalCalls` has executed successfully (or - /// immediately after `Announcement` itself for a pure - /// announcement with `internalCalls.length == 0`). - /// Carries the same `id` as the paired `Announcement` so - /// indexers can join start ↔ end on the id even when - /// scanning logs in isolation. The recursion guard - /// (`AnnouncementInProgress`) makes the pairing - /// within-tx unambiguous; the `id` field hardens cross-tx - /// indexing as well. + /// @notice Emitted by `announce` to close the bracket opened by the paired `Announcement` with the same `id`. event EndAnnouncement(string id); /*////////////////////////////////////////////////////////////// ROLE IDENTIFIERS //////////////////////////////////////////////////////////////*/ - /// @notice Required to call `announce`, `updateShareRatio`, and - /// `updateSecurityIdentifier`. Held separately from - /// `DEFAULT_ADMIN_ROLE` so corporate-actions operators can - /// be delegated without the broader admin powers (role - /// grants, policy changes, supply-cap changes, etc.). - /// `updateName` / `updateSymbol` are NOT gated by this role; they - /// are gated by the inherited `METADATA_ROLE` from `IB20`. - /// See the contract-level notes for the recommended - /// operational pairing. + /// @notice Required to call `announce`, `updateShareRatio`, and `updateSecurityIdentifier`. + /// `updateName` / `updateSymbol` remain gated by the inherited `METADATA_ROLE`. + /// @return Role identifier. function SECURITY_OPERATOR_ROLE() external view returns (bytes32); - /// @notice Required to call `batchBurn`. Held separately from - /// `BURN_ROLE` (which gates burn-of-self) and from - /// `BURN_BLOCKED_ROLE` (which gates seizure of - /// policy-blocked accounts) so the authority to destroy - /// third-party balances WITHOUT a blocked-status precondition - /// can be delegated narrowly to the corporate-actions desk - /// for clawbacks, consolidations, and similar batched - /// destructions. Tokens that do not need a batched-clawback - /// path simply never grant this role. + /// @notice Required to call `batchBurn`. + /// @return Role identifier. function BURN_FROM_ROLE() external view returns (bytes32); /*////////////////////////////////////////////////////////////// PRECISION //////////////////////////////////////////////////////////////*/ - /// @notice Fixed-point precision used to scale `sharesToTokensRatio`. - /// Equal to `1e18` (one WAD). Exposed on the ABI so callers - /// that read `sharesToTokensRatio()` directly can interpret - /// the value without hardcoding the constant; typical - /// callers should prefer `toShares(...)` / `sharesOf(...)`, - /// which apply the precision internally. + /// @notice Fixed-point precision used to scale `sharesToTokensRatio`. Equal to `1e18`. + /// @return Precision constant. function WAD_PRECISION() external view returns (uint256); /*////////////////////////////////////////////////////////////// POLICY TYPE IDENTIFIERS //////////////////////////////////////////////////////////////*/ - /// @notice The policy slot consulted against `msg.sender` on - /// `redeem` and `redeemWithMemo`. Identifier is - /// `keccak256("REDEEM_SENDER_POLICY")`. + /// @notice Policy slot consulted against `msg.sender` on `redeem` and `redeemWithMemo`. + /// @return Policy scope identifier. function REDEEM_SENDER_POLICY() external view returns (bytes32); /*////////////////////////////////////////////////////////////// ANNOUNCEMENTS //////////////////////////////////////////////////////////////*/ - /// @notice Posts a holder-impacting announcement and, in the same - /// transaction, atomically executes the on-chain calls - /// that the announcement describes. Each `id` may be - /// consumed at most once over the lifetime of the token; - /// subsequent calls that reuse `id` revert with - /// `AnnouncementIdAlreadyUsed`. - /// - /// Pass `internalCalls` as an empty array for a pure - /// disclosure (no on-chain change to bracket); pass one - /// or more ABI-encoded calldata blobs to bracket the - /// corresponding state changes inside the announcement. - /// For example, to disclose and execute a 2-for-1 split - /// in one transaction: - /// - /// announce({ - /// internalCalls: [ - /// abi.encodeCall(this.updateShareRatio, (newRatio)) - /// ], - /// id: "2026-Q3-split", - /// description: "2-for-1 forward stock split", - /// uri: "https://disclosures.example.com/...", - /// }); - /// - /// @dev Requires `SECURITY_OPERATOR_ROLE`. Topology: - /// 1. Marks `id` consumed and emits - /// `Announcement(msg.sender, id, description, uri)`. - /// 2. For each `internalCalls[i]`: - /// a. Validates the embedded function selector. Calls - /// shorter than four bytes revert with - /// `InternalCallMalformed(internalCalls[i])`. Calls - /// whose selector is `announce` itself revert with - /// `AnnouncementInProgress` so the bracket cannot - /// nest. - /// b. Issues `address(this).delegatecall(internalCalls[i])`, - /// which preserves `msg.sender` so role checks on - /// the inner function see the operator (not the - /// token contract). On failure, reverts with - /// `InternalCallFailed(internalCalls[i])`; the - /// inner revert reason is intentionally not bubbled - /// (replay the call directly to debug). - /// 3. Emits `EndAnnouncement(id)`. - /// - /// Atomicity: any inner-call revert (including - /// `InternalCallFailed`, `InternalCallMalformed`, or - /// `AnnouncementInProgress`) unwinds the entire - /// transaction, so no `Announcement` event is observable - /// without its matching `EndAnnouncement`. - /// - /// The inner functions invoked through `internalCalls` - /// are subject to their normal authorization gates (role - /// checks, policy checks, pause vectors); the announcement - /// wrapper does not add or relax any of them. The - /// operator therefore needs both `SECURITY_OPERATOR_ROLE` - /// (to call `announce`) and whatever role each inner - /// function requires (e.g. `MINT_ROLE` for `batchMint`, - /// `METADATA_ROLE` for `updateName`). - /// - /// @param internalCalls ABI-encoded calldata blobs executed - /// in-order via self-`delegatecall`. May - /// be empty (pure disclosure). - /// @param id Caller-chosen announcement identifier; - /// single-use over the token's lifetime. - /// @param description Human-readable summary of the - /// announcement. - /// @param uri Off-chain URI containing the full - /// announcement contents. + /// @notice Posts a holder-impacting announcement and atomically dispatches each entry in + /// `internalCalls` via self-`delegatecall` (preserving `msg.sender`). Emits + /// `Announcement` then `EndAnnouncement` with the same `id`. Pass an empty + /// `internalCalls` for a pure disclosure. + /// + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `SECURITY_OPERATOR_ROLE`. + /// @dev Reverts with `AnnouncementIdAlreadyUsed` when `id` has previously been consumed. + /// @dev Reverts with `InternalCallMalformed` when an entry in `internalCalls` is shorter than four bytes. + /// @dev Reverts with `AnnouncementInProgress` when an entry in `internalCalls` targets `announce` itself. + /// @dev Reverts with `InternalCallFailed` when an entry in `internalCalls` reverts during the inner `delegatecall`. + /// + /// @param internalCalls ABI-encoded calldata blobs executed in order via self-`delegatecall`; may be empty. + /// @param id Caller-chosen announcement identifier; single-use over the token's lifetime. + /// @param description Human-readable summary of the announcement. + /// @param uri Off-chain URI containing the full announcement contents. function announce( bytes[] calldata internalCalls, string calldata id, @@ -325,204 +126,129 @@ interface IB20Security is IB20 { string calldata uri ) external; - /// @notice Returns true if `id` has previously been consumed by - /// `announce`. + /// @notice Whether `id` has previously been consumed by `announce`. + /// + /// @param id Announcement identifier to query. + /// + /// @return Whether `id` is used. function isAnnouncementIdUsed(string calldata id) external view returns (bool); /*////////////////////////////////////////////////////////////// SHARE RATIO //////////////////////////////////////////////////////////////*/ - /// @notice The current share-to-tokens ratio, scaled to the - /// implementation's `WAD_PRECISION`. + /// @notice The current share-to-tokens ratio, scaled to `WAD_PRECISION`. + /// @return Current ratio. function sharesToTokensRatio() external view returns (uint256); - /// @notice Converts a raw token balance to its current share count - /// via the active share ratio: - /// `balance * sharesToTokensRatio / WAD_PRECISION`. + /// @notice Converts `balance` to its current share count: `balance * sharesToTokensRatio / WAD_PRECISION`. + /// + /// @param balance Token amount to convert. + /// + /// @return Share count at the current ratio. function toShares(uint256 balance) external view returns (uint256); - /// @notice Convenience: `toShares(balanceOf(account))`. + /// @notice Convenience for `toShares(balanceOf(account))`. + /// + /// @param account Account whose share count is being queried. + /// + /// @return Share count. function sharesOf(address account) external view returns (uint256); - /// @notice Sets a new share ratio (typically following an off-chain - /// stock split or reverse split). Holder balances are NOT - /// rewritten; the displayed share count derives from the - /// new ratio at read time, preserving DeFi composability. + /// @notice Sets a new share ratio. Holder balances are not rewritten; displayed share counts + /// derive from the new ratio at read time. Emits `ShareRatioUpdated`. /// - /// @dev Requires `SECURITY_OPERATOR_ROLE`. Emits - /// `ShareRatioUpdated`. Standard usage is to invoke this - /// through `announce(...)`'s `internalCalls`, which - /// brackets the ratio change with a matching disclosure - /// atomically (see the contract-level "Announcement - /// pairing" notes). Direct invocation by a role holder - /// remains permitted for emergency override but produces - /// no `Announcement` / `EndAnnouncement` bracket. + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `SECURITY_OPERATOR_ROLE`. /// - /// @param newSharesToTokensRatio The new ratio scaled to - /// `WAD_PRECISION`. + /// @param newSharesToTokensRatio New ratio scaled to `WAD_PRECISION`. function updateShareRatio(uint256 newSharesToTokensRatio) external; /*////////////////////////////////////////////////////////////// BATCHED ISSUANCE AND CORP-ACTION CLAWBACK //////////////////////////////////////////////////////////////*/ - /// @notice Mints `amounts[i]` tokens to `recipients[i]`. The - /// batched sibling of the inherited single-recipient - /// `IB20.mint(address,uint256)`; supports cold-path - /// issuance flows for corporate-actions events (initial - /// allocations, secondary issuances, etc.) that need to - /// land many recipients in one transaction. + /// @notice Mints `amounts[i]` to `recipients[i]` in one call. All-or-nothing: any element + /// revert unwinds the whole transaction. Emits `Transfer(address(0), recipients[i], amounts[i])` + /// per element. /// - /// @dev Reverts in the following canonical order (first violated - /// check wins): - /// 1. `ContractPaused(MINT)` if `MINT` is paused. - /// 2. `AccessControlUnauthorizedAccount(msg.sender, MINT_ROLE)` - /// if the caller does not hold `MINT_ROLE`. - /// 3. `LengthMismatch(recipients.length, amounts.length)` if - /// the parallel arrays disagree. - /// 4. `EmptyBatch()` if either array is empty. - /// 5..N. Per-element checks inside the loop: `InvalidReceiver`, - /// `PolicyForbids(MINT_RECEIVER_POLICY, ...)`, - /// `SupplyCapExceeded` — see `mint`'s natspec for the - /// per-element precedence. - /// The pause and role gates are evaluated ONCE for the - /// whole batch; per-element gates fire per recipient inside - /// the loop. - /// All-or-nothing: if any element reverts, the entire - /// transaction reverts and no partial state is committed. - /// Emits `Transfer(address(0), recipients[i], amounts[i])` - /// per element. Standard usage is to invoke this through - /// `announce(...)`'s `internalCalls`, which brackets the - /// issuance with a matching disclosure atomically (see - /// the contract-level "Announcement pairing" notes). - /// Direct invocation by a role holder remains permitted - /// for emergency override but produces no `Announcement` / - /// `EndAnnouncement` bracket. + /// @dev Reverts with `ContractPaused(MINT)` when `MINT` is paused. + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `MINT_ROLE`. + /// @dev Reverts with `LengthMismatch` when `recipients.length != amounts.length`. + /// @dev Reverts with `EmptyBatch` when either array is empty. + /// @dev Reverts with `InvalidReceiver` when any `recipients[i] == address(0)`. + /// @dev Reverts with `PolicyForbids(MINT_RECEIVER_POLICY, ...)` when any recipient is not authorized. + /// @dev Reverts with `SupplyCapExceeded` when the cumulative mint would exceed the cap. /// - /// @param recipients Accounts receiving the minted tokens. - /// @param amounts Per-recipient amounts, parallel to - /// `recipients`. + /// @param recipients Accounts receiving the minted tokens. + /// @param amounts Per-recipient amounts, parallel to `recipients`. function batchMint(address[] calldata recipients, uint256[] calldata amounts) external; - /// @notice Burns `amounts[i]` tokens from `accounts[i]`. Distinct - /// from the inherited `IB20.burnBlocked(address,uint256)`: - /// where `burnBlocked` exists for sanctions-style seizure - /// and refuses to operate against accounts that are still - /// authorized under `TRANSFER_SENDER_POLICY`, `batchBurn` is the - /// general corporate-actions clawback path and operates - /// unconditionally on the supplied accounts. Supports - /// cold-path consolidations, redemptions-in-kind, and - /// court-ordered destructions that need to land many - /// debits in one transaction without first arranging for - /// each account to be policy-blocked. + /// @notice Burns `amounts[i]` from `accounts[i]` in one call, unconditionally on the supplied + /// accounts (no policy gate). All-or-nothing: any element revert unwinds the whole + /// transaction. Emits `Transfer(accounts[i], address(0), amounts[i])` per element; + /// does NOT emit `BurnedBlocked`. /// - /// @dev `BURN_FROM_ROLE` is enforced WITHOUT the factory-bootstrap - /// bypass — clawback against existing balances has no - /// init-time use case, so the role check is unconditional. - /// NOT gated by any policy: the corporate-actions desk is - /// trusted to pick the right set of accounts off-chain, and - /// the role grant is the on-chain authorization. Reverts in - /// the following canonical order (first violated check wins): - /// 1. `ContractPaused(BURN)` if `BURN` is paused. - /// 2. `AccessControlUnauthorizedAccount(msg.sender, BURN_FROM_ROLE)` - /// if the caller does not hold `BURN_FROM_ROLE`. - /// 3. `LengthMismatch(accounts.length, amounts.length)` if - /// the parallel arrays disagree. - /// 4. `EmptyBatch()` if either array is empty. - /// 5. Per-element `InsufficientBalance(accounts[k], balance, - /// amounts[k])` from `_burnRaw`. - /// All-or-nothing: if any element reverts, the entire - /// transaction reverts and no partial state is committed. - /// Emits `Transfer(accounts[i], address(0), amounts[i])` - /// per element; does NOT emit `BurnedBlocked` (that event - /// is reserved for `burnBlocked`'s sanctions semantics). - /// Standard usage is to invoke this through `announce(...)`'s - /// `internalCalls`, which brackets the clawback with a - /// matching disclosure atomically (see the contract-level - /// "Announcement pairing" notes). Direct invocation by a - /// role holder remains permitted for emergency override but - /// produces no `Announcement` / `EndAnnouncement` bracket. + /// @dev Reverts with `ContractPaused(BURN)` when `BURN` is paused. + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `BURN_FROM_ROLE`. Strict — no factory-bootstrap bypass. + /// @dev Reverts with `LengthMismatch` when `accounts.length != amounts.length`. + /// @dev Reverts with `EmptyBatch` when either array is empty. + /// @dev Reverts with `InsufficientBalance` when any `accounts[i]`'s balance is below `amounts[i]`. /// - /// @param accounts Accounts whose balances will be debited. - /// @param amounts Per-account amounts, parallel to `accounts`. + /// @param accounts Accounts whose balances will be debited. + /// @param amounts Per-account amounts, parallel to `accounts`. function batchBurn(address[] calldata accounts, uint256[] calldata amounts) external; /*////////////////////////////////////////////////////////////// REDEMPTION //////////////////////////////////////////////////////////////*/ - /// @notice Burns `amount` tokens from the caller, recording intent - /// to settle off-chain. + /// @notice Burns `amount` from the caller, recording intent to settle off-chain. Emits + /// `Transfer(caller, address(0), amount)` followed by `Redeemed`. /// - /// @dev Reverts in the following canonical order (first violated - /// check wins): - /// 1. `ContractPaused(REDEEM)` if `REDEEM` is paused. - /// Enforced WITHOUT the factory-bootstrap bypass — redeem - /// is a holder-initiated path with no legitimate init-time - /// use case. - /// 2. `PolicyForbids(REDEEM_SENDER_POLICY, policyId)` if - /// `msg.sender` is not authorized under the active - /// `REDEEM_SENDER_POLICY` policy. - /// 3. `BelowMinimumRedeemable(shares, minimumRedeemable)` if - /// the corresponding share amount (`amount * - /// sharesToTokensRatio / WAD_PRECISION`) is zero OR is - /// strictly less than `minimumRedeemable`. Zero-share - /// redemptions are always rejected, regardless of - /// `minimumRedeemable`'s configured value, so a holder - /// cannot burn token dust that resolves to no shares. - /// 4. `InsufficientBalance(caller, balance, amount)` if the - /// caller does not have enough balance. - /// Emits `Transfer(caller, address(0), amount)` followed by - /// `Redeemed(caller, amount, sharesToTokensRatio)`. + /// @dev Reverts with `ContractPaused(REDEEM)` when `REDEEM` is paused. Strict — no factory-bootstrap bypass. + /// @dev Reverts with `PolicyForbids(REDEEM_SENDER_POLICY, ...)` when the caller is not authorized. + /// @dev Reverts with `BelowMinimumRedeemable` when the resolved share count is zero or below `minimumRedeemable`. + /// @dev Reverts with `InsufficientBalance` when the caller's balance is below `amount`. /// - /// @param amount Token amount to redeem from the caller's balance. + /// @param amount Token amount to redeem from the caller's balance. function redeem(uint256 amount) external; - /// @notice Same as `redeem`, with a memo. Emits `Memo(memo)` - /// immediately after `Transfer()` and before `Redeemed`. - /// See `IB20.transferWithMemo` for the memo convention; a memo - /// of `bytes32(0)` is permitted. + /// @notice Same as `redeem`, plus emits `Memo` immediately after the `Transfer` event and + /// before `Redeemed`. A memo of `bytes32(0)` is permitted. + /// + /// @param amount Token amount to redeem from the caller's balance. + /// @param memo Off-chain memo payload. function redeemWithMemo(uint256 amount, bytes32 memo) external; - /// @notice Sets a new minimum-redeemable threshold in shares. - /// `redeemShares` reverts if the resulting share amount would be - /// below this value. + /// @notice Sets a new minimum-redeemable threshold in shares. Emits `MinimumRedeemableUpdated`. /// - /// @dev Requires `DEFAULT_ADMIN_ROLE`. Emits - /// `MinimumRedeemableUpdated`. + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `DEFAULT_ADMIN_ROLE`. /// - /// @param newMinimumRedeemable New minimum redeemable amount, in - /// shares. + /// @param newMinimumRedeemable New minimum redeemable amount, in shares. function updateMinimumRedeemable(uint256 newMinimumRedeemable) external; /// @notice The current minimum-redeemable threshold, in shares. + /// @return Current floor. function minimumRedeemable() external view returns (uint256); /*////////////////////////////////////////////////////////////// SECURITY IDENTIFIERS //////////////////////////////////////////////////////////////*/ - /// @notice Returns the value of the named identifier (e.g. ISIN, - /// CUSIP, FIGI). Returns the empty string if not set. + /// @notice The value of the named identifier (e.g. ISIN, CUSIP, FIGI), or the empty string if not set. + /// + /// @param identifierType Identifier category. + /// + /// @return Current value, or the empty string. function securityIdentifier(string calldata identifierType) external view returns (string memory); - /// @notice Sets, updates, or removes a security identifier. Passing - /// an empty `value` removes the entry; passing a non-empty - /// `value` sets or overwrites it. + /// @notice Sets, updates, or removes a security identifier. An empty `value` removes the entry. + /// Emits `SecurityIdentifierUpdated`. /// - /// @dev Requires `SECURITY_OPERATOR_ROLE`. Emits - /// `SecurityIdentifierUpdated`. Reverts with `InvalidIdentifierType` - /// if `identifierType` is the empty string. Standard - /// usage is to invoke this through `announce(...)`'s - /// `internalCalls`, which brackets the identifier change - /// with a matching disclosure atomically (see the - /// contract-level "Announcement pairing" notes). Direct - /// invocation by a role holder remains permitted for - /// emergency override but produces no `Announcement` / - /// `EndAnnouncement` bracket. + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `SECURITY_OPERATOR_ROLE`. + /// @dev Reverts with `InvalidIdentifierType` when `identifierType` is the empty string. /// - /// @param identifierType Identifier category (e.g. "ISIN"). - /// @param value New value, or empty string to remove. + /// @param identifierType Identifier category (e.g. "ISIN"). + /// @param value New value, or empty string to remove. function updateSecurityIdentifier(string calldata identifierType, string calldata value) external; } diff --git a/src/interfaces/IB20Stablecoin.sol b/src/interfaces/IB20Stablecoin.sol index ff88fe8a..5d1c765f 100644 --- a/src/interfaces/IB20Stablecoin.sol +++ b/src/interfaces/IB20Stablecoin.sol @@ -4,12 +4,9 @@ pragma solidity >=0.8.20 <0.9.0; import {IB20} from "./IB20.sol"; /// @title IB20Stablecoin -/// @notice B-20 variant for fiat-pegged stablecoins. Inherits the full -/// `IB20` surface and adds one immutable `currency()` identifier. +/// @notice B-20 variant for fiat-pegged stablecoins. Extends `IB20` with an immutable `currency()` identifier. interface IB20Stablecoin is IB20 { - /// @notice The currency identifier this stablecoin tracks - /// (e.g. `"USD"`, `"EUR"`, `"JPY"`). Set at creation, - /// immutable thereafter. Uppercase ASCII letters (`A`–`Z`); - /// self-declared and not verified by the contract. + /// @notice The currency identifier this stablecoin tracks (e.g. `"USD"`, `"EUR"`, `"JPY"`). + /// @return Currency identifier. function currency() external view returns (string memory); } diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 9c75eba5..9300ea43 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -2,70 +2,18 @@ pragma solidity >=0.8.20 <0.9.0; /// @title IPolicyRegistry -/// @notice Singleton registry of address-membership policies. B-20 tokens -/// reference policies in this registry by `uint64 policyId` to -/// enforce authorization at the protocol level: every transfer, -/// mint, and redeem on a B-20 token resolves to one or more -/// `isAuthorized(policyId, account)` calls into this registry. /// -/// Two policy types are supported in v1: -/// - **ALLOWLIST**: an account is authorized only if it is on the -/// policy's member set. -/// - **BLOCKLIST**: an account is authorized unless it is on the -/// policy's member set. -/// -/// The registry deliberately stops at flat membership checks. -/// There is no on-registry composition (no AND/OR/COMPOUND), -/// no callback or richer guard policies, no amount conditioning. -/// Asymmetric per-role rules on a token are expressed by storing -/// multiple policy IDs on the token itself (one per role slot), -/// not by composing inside the registry. See `IB20`'s policy -/// model for how this is wired on the token side. -/// -/// @dev The registry is a singleton precompile at a fixed address. -/// All B-20 tokens on the chain share the same `policyId` -/// namespace. Anyone may create a policy; the creator nominates -/// the policy admin (typically themselves or a multisig). -/// -/// **Built-in policy IDs** (always present): -/// - `0` — ALWAYS_ALLOW. Also the default state of every -/// unassigned policy slot. Reads naturally as an empty -/// BLOCKLIST (no one blocked → allow all). -/// - `(uint64(ALLOWLIST) << 56) | 1` — ALWAYS_BLOCK. Reads -/// naturally as an empty ALLOWLIST (no one allowed → -/// block all). Useful as an explicit hard-deny on a slot. -/// -/// **Policy ID encoding.** The top byte is the `PolicyType` -/// discriminator; the low 56 bits are a global counter. Type is -/// recoverable from the ID via pure bit extraction — no SLOAD -/// per call on the B-20 hot path. -/// ``` -/// [63:56] uint8(PolicyType) discriminator -/// [55:0] counter (built-ins use 0/1; custom IDs start at 2) -/// ``` -/// Custom policy IDs are assigned from a single global counter -/// starting at `2` (reserving `0`/`1` for the built-in -/// sentinels). Admin is stored in policy-record storage keyed -/// by `policyId`, NOT in the ID. -/// -/// **Future extensions** (not in v1 scope, intended path): -/// - Union / intersect policies: compose two same-typed policies -/// into a derived membership check. Would be added as new -/// `PolicyType` enum values with sibling `createUnionPolicy` / -/// `createIntersectPolicy` creators. Enum extension is -/// backward-compatible; existing policies and consumers stay -/// valid. Defer to a future hardfork. +/// @notice Singleton registry of address-membership policies. Policies are referenced by +/// `uint64 policyId` and queried via `isAuthorized(policyId, account)`. interface IPolicyRegistry { /*////////////////////////////////////////////////////////////// TYPES //////////////////////////////////////////////////////////////*/ - /// @notice Policy type discriminator. Order is chosen so each - /// built-in sentinel ID reads as a degenerate form of its - /// list type: `0` = empty BLOCKLIST = ALWAYS_ALLOW; - /// `(ALLOWLIST << 56) | 1` = empty ALLOWLIST = ALWAYS_BLOCK. - /// @param BLOCKLIST Authorized unless in the policy's set. - /// @param ALLOWLIST Authorized only if in the policy's set. + /// @notice Policy type discriminator. + /// + /// @param BLOCKLIST Authorized unless in the policy's set. + /// @param ALLOWLIST Authorized only if in the policy's set. enum PolicyType { BLOCKLIST, ALLOWLIST @@ -75,83 +23,69 @@ interface IPolicyRegistry { ERRORS //////////////////////////////////////////////////////////////*/ - /// @notice Caller is not the policy admin (where current admin is - /// required) or is not the pending admin (where pending admin - /// is required by `finalizeUpdateAdmin`). + /// @notice Caller is not the admin required by the attempted operation. error Unauthorized(); - /// @notice The referenced policy ID does not exist (and is not - /// built-in). Reverted by mutating entry points; view - /// queries return the "absent" value instead. + /// @notice The referenced policy ID does not exist. error PolicyNotFound(); - /// @notice The operation is incompatible with the policy's type. For - /// example, calling `updateAllowlist` on a BLOCKLIST policy. + /// @notice The operation is incompatible with the policy's type. error IncompatiblePolicyType(); /// @notice A required address argument was the zero address. error ZeroAddress(); /// @notice A membership batch exceeded the registry limit. - /// @param maxBatchSize The maximum number of accounts permitted per - /// `createPolicyWithAccounts`, `updateAllowlist`, or - /// `updateBlocklist` call. + /// @param maxBatchSize Maximum number of accounts permitted per call. error BatchSizeTooLarge(uint256 maxBatchSize); - /// @notice `finalizeUpdateAdmin` was called for a policy with no - /// currently-staged pending admin. + /// @notice `finalizeUpdateAdmin` was called with no pending admin staged. error NoPendingAdmin(); /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ - /// @notice A new policy was created. The creator may or may not be the - /// policy admin (the admin is set explicitly at creation). + /// @notice A new policy was created. event PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType); - /// @notice A new admin was staged via `stageUpdateAdmin`. The active - /// admin does not change until `finalizeUpdateAdmin` is called - /// by `pendingAdmin`. `pendingAdmin == address(0)` indicates - /// a previously-staged transfer was cleared. + /// @notice A new admin was staged. `pendingAdmin == address(0)` clears a prior nomination. event PolicyAdminStaged(uint64 indexed policyId, address indexed currentAdmin, address indexed pendingAdmin); - /// @notice The active admin actually changed: either via - /// `finalizeUpdateAdmin` (where `newAdmin` is the previously - /// pending admin) or via `renounceAdmin` (where - /// `newAdmin == address(0)`). Initial admin assignment at - /// policy creation is also emitted as a `PolicyAdminUpdated` - /// with `previousAdmin == address(0)`. + /// @notice The active admin changed. `newAdmin == address(0)` indicates renunciation; + /// `previousAdmin == address(0)` indicates initial assignment at creation. event PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin); - /// @notice One or more accounts had their ALLOWLIST membership set to - /// `allowed`. Emitted once per `updateAllowlist` call, carrying - /// the full batch. + /// @notice One or more accounts had their ALLOWLIST membership set to `allowed` in a single batch. event AllowlistUpdated(uint64 indexed policyId, address indexed updater, bool allowed, address[] accounts); - /// @notice One or more accounts had their BLOCKLIST membership set to - /// `blocked`. Emitted once per `updateBlocklist` call, carrying - /// the full batch. + /// @notice One or more accounts had their BLOCKLIST membership set to `blocked` in a single batch. event BlocklistUpdated(uint64 indexed policyId, address indexed updater, bool blocked, address[] accounts); /*////////////////////////////////////////////////////////////// POLICY CREATION //////////////////////////////////////////////////////////////*/ - /// @notice Creates a new policy with no initial members. - /// @dev Permissionless. Reverts with `ZeroAddress` if `admin` is - /// `address(0)`. Out-of-range `policyType` values are - /// rejected by ABI decoding before this function body runs. - /// @param admin The address authorized to modify membership on - /// this policy and to transfer or renounce - /// administration. - /// @param policyType BLOCKLIST or ALLOWLIST. + /// @notice Creates a new policy with no initial members. Permissionless. + /// + /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. + /// + /// @param admin Initial admin authorized to modify membership and transfer or renounce administration. + /// @param policyType BLOCKLIST or ALLOWLIST. + /// /// @return newPolicyId The newly assigned policy ID. function createPolicy(address admin, PolicyType policyType) external returns (uint64 newPolicyId); - /// @notice Same as `createPolicy`, but seeds the policy's member set - /// with `accounts` in a single call. Useful for one-shot - /// creation flows that ship with a non-empty initial state. + /// @notice Creates a new policy seeded with `accounts` as initial members. Permissionless. + /// + /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. Takes precedence over `BatchSizeTooLarge`. + /// @dev Reverts with `BatchSizeTooLarge` when `accounts.length` exceeds the registry limit. + /// + /// @param admin Initial admin authorized to modify membership and transfer or renounce administration. + /// @param policyType BLOCKLIST or ALLOWLIST. + /// @param accounts Initial member set. + /// + /// @return newPolicyId The newly assigned policy ID. function createPolicyWithAccounts(address admin, PolicyType policyType, address[] calldata accounts) external returns (uint64 newPolicyId); @@ -160,95 +94,99 @@ interface IPolicyRegistry { POLICY ADMINISTRATION //////////////////////////////////////////////////////////////*/ - /// @notice Stages a proposed new admin for `policyId`. Caller MUST be - /// the current admin. The active admin does NOT change until - /// `pendingAdmin` calls `finalizeUpdateAdmin(policyId)`. - /// @dev Calling `stageUpdateAdmin` while a pending admin already - /// exists overwrites the prior nomination (the previously - /// pending admin loses their ability to finalize). Pass - /// `address(0)` to clear a previously-staged transfer - /// without nominating a new candidate. - /// - /// Two-step transfer guards against typos and key compromise: - /// the candidate must actively claim the role, and the - /// current admin retains control until they do. - /// @param policyId The policy whose admin is being staged. - /// @param newAdmin The proposed new admin, or `address(0)` to clear. + /// @notice Stages a proposed new admin for `policyId`. The active admin does not change + /// until `pendingAdmin` calls `finalizeUpdateAdmin`. + /// + /// @dev Reverts with `PolicyNotFound` when `policyId` does not exist. + /// @dev Reverts with `Unauthorized` when the caller is not the current admin. + /// + /// @param policyId Policy whose admin is being staged. + /// @param newAdmin Proposed new admin, or `address(0)` to clear any pending nomination. function stageUpdateAdmin(uint64 policyId, address newAdmin) external; - /// @notice Completes a two-step admin transfer. Caller MUST be the - /// address most recently staged via `stageUpdateAdmin`. - /// Promotes the caller to active admin and clears the pending - /// slot. Reverts with `NoPendingAdmin` if no transfer is in - /// flight. + /// @notice Completes a two-step admin transfer. Promotes the caller to active admin and clears the pending slot. + /// + /// @dev Reverts with `PolicyNotFound` when `policyId` does not exist. + /// @dev Reverts with `NoPendingAdmin` when no transfer is in flight. + /// @dev Reverts with `Unauthorized` when the caller is not the staged pending admin. + /// + /// @param policyId Policy whose admin transfer is being finalized. function finalizeUpdateAdmin(uint64 policyId) external; - /// @notice Single-step: the current admin permanently relinquishes - /// administration of `policyId`. Caller MUST be the current - /// admin. After this call, `policyAdmin(policyId)` returns - /// `address(0)` and no further admin-gated operations on this - /// policy can succeed: the policy's member set is frozen - /// forever, and the policy can never be re-administered. - /// @dev Any in-flight pending admin (set via `stageUpdateAdmin`) - /// is cleared as a side effect of renunciation. The policy - /// continues to exist and remains a valid target of - /// `isAuthorized` queries; only mutation is disabled. + /// @notice Permanently relinquishes administration of `policyId`. The member set is frozen + /// and the policy can never be re-administered; `isAuthorized` queries continue to work. + /// + /// @dev Reverts with `PolicyNotFound` when `policyId` does not exist. + /// @dev Reverts with `Unauthorized` when the caller is not the current admin. + /// + /// @param policyId Policy whose administration is being renounced. function renounceAdmin(uint64 policyId) external; - /// @notice Adds or removes `accounts` from an ALLOWLIST policy. All - /// accounts receive the same `allowed` setting in one batch. - /// Caller MUST be the current policy admin. - /// @dev Reverts with `IncompatiblePolicyType` if the policy is not - /// ALLOWLIST. Emits a single `AllowlistUpdated` event - /// carrying the full batch. + /// @notice Sets `accounts` membership in an ALLOWLIST policy to `allowed` in one batch. + /// + /// @dev Reverts with `PolicyNotFound` when `policyId` does not exist. + /// @dev Reverts with `IncompatiblePolicyType` when the policy is not ALLOWLIST. + /// @dev Reverts with `Unauthorized` when the caller is not the current admin. + /// @dev Reverts with `BatchSizeTooLarge` when `accounts.length` exceeds the registry limit. + /// + /// @param policyId Policy to update. + /// @param allowed Membership state to apply to every account in the batch. + /// @param accounts Accounts to update. function updateAllowlist(uint64 policyId, bool allowed, address[] calldata accounts) external; - /// @notice Adds or removes `accounts` from a BLOCKLIST policy. All - /// accounts receive the same `blocked` setting in one batch. - /// Caller MUST be the current policy admin. - /// @dev Reverts with `IncompatiblePolicyType` if the policy is not - /// BLOCKLIST. Emits a single `BlocklistUpdated` event - /// carrying the full batch. + /// @notice Sets `accounts` membership in a BLOCKLIST policy to `blocked` in one batch. + /// + /// @dev Reverts with `PolicyNotFound` when `policyId` does not exist. + /// @dev Reverts with `IncompatiblePolicyType` when the policy is not BLOCKLIST. + /// @dev Reverts with `Unauthorized` when the caller is not the current admin. + /// @dev Reverts with `BatchSizeTooLarge` when `accounts.length` exceeds the registry limit. + /// + /// @param policyId Policy to update. + /// @param blocked Membership state to apply to every account in the batch. + /// @param accounts Accounts to update. function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; /*////////////////////////////////////////////////////////////// AUTHORIZATION QUERIES //////////////////////////////////////////////////////////////*/ - /// @notice Whether `account` is authorized under `policyId`. - /// - For ALLOWLIST: returns true iff `account` is on the - /// policy's member set. - /// - For BLOCKLIST: returns true iff `account` is NOT on the - /// policy's member set. - /// - For built-in ID `0` (always-allow): always returns true. - /// - For the built-in ALWAYS_BLOCK ID - /// (`(uint64(ALLOWLIST) << 56) | 1`): always returns false. - /// - For malformed IDs (top byte outside the `PolicyType` - /// enum range): returns false. The function never reverts. - /// @dev **Precondition: `policyId` must exist.** No existence-check - /// SLOAD on the hot path; non-existent IDs collapse to empty- - /// member-set semantics (ALLOWLIST → false, BLOCKLIST → true). - /// Callers that store policy IDs (notably `IB20.updatePolicy`) - /// MUST validate `policyExists` at write time. + /// @notice Returns whether `account` is authorized under `policyId`. Never reverts; unknown + /// or malformed IDs collapse to empty-member-set semantics (ALLOWLIST -> false, + /// BLOCKLIST -> true). + /// + /// @dev Callers that store policy IDs MUST validate `policyExists(policyId)` at write time. + /// + /// @param policyId Policy to query. + /// @param account Account to check. + /// + /// @return Whether `account` is authorized. function isAuthorized(uint64 policyId, address account) external view returns (bool); /*////////////////////////////////////////////////////////////// POLICY QUERIES //////////////////////////////////////////////////////////////*/ - /// @notice Whether `policyId` exists. True for the two built-in - /// sentinel IDs and for any custom ID previously assigned. - /// False for unknown and malformed IDs. Never reverts. + /// @notice Returns whether `policyId` is a built-in sentinel or a previously-assigned custom ID. Never reverts. + /// + /// @param policyId Policy to query. + /// + /// @return Whether the policy exists. function policyExists(uint64 policyId) external view returns (bool); - /// @notice Current admin of `policyId`. Returns `address(0)` for - /// built-in sentinels, renounced policies, unknown IDs, and - /// malformed IDs. Never reverts. + /// @notice Returns the current admin of `policyId`, or `address(0)` for built-in sentinels, + /// renounced policies, unknown IDs, and malformed IDs. Never reverts. + /// + /// @param policyId Policy to query. + /// + /// @return Current admin, or `address(0)`. function policyAdmin(uint64 policyId) external view returns (address); - /// @notice Currently-staged pending admin for `policyId`. Returns - /// `address(0)` when no transfer is in flight, and for - /// built-in sentinels, unknown IDs, and malformed IDs. + /// @notice Returns the currently-staged pending admin for `policyId`, or `address(0)` when + /// no transfer is in flight or for built-in sentinels, unknown IDs, and malformed IDs. /// Never reverts. + /// + /// @param policyId Policy to query. + /// + /// @return Pending admin, or `address(0)`. function pendingPolicyAdmin(uint64 policyId) external view returns (address); } diff --git a/src/lib/B20Constants.sol b/src/lib/B20Constants.sol index 26197a84..f52cbdd1 100644 --- a/src/lib/B20Constants.sol +++ b/src/lib/B20Constants.sol @@ -1,17 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -/// @notice Canonical B-20 role and policy-type identifier constants. -/// Declared as a library (not on the contract) so tests and -/// downstream tooling can reference them at compile time via -/// `MINT_ROLE` etc. — Solidity's `public constant` -/// on a contract is only accessible via instance (e.g. -/// `token.MINT_ROLE()`), which doesn't work where the token -/// doesn't yet exist (factory creation tests, initCall -/// construction). -/// @dev `MockB20` re-exposes each value as `bytes32 public constant` -/// to satisfy `IB20`'s view-function ABI; the library is the -/// single source of truth, the public constants delegate. +/// @title B20Constants +/// @notice Canonical B-20 role and policy-type identifier constants, exposed for compile-time use. library B20Constants { bytes32 internal constant DEFAULT_ADMIN_ROLE = bytes32(0); bytes32 internal constant MINT_ROLE = keccak256("MINT_ROLE"); @@ -28,5 +19,6 @@ library B20Constants { bytes32 internal constant TRANSFER_EXECUTOR_POLICY = keccak256("TRANSFER_EXECUTOR_POLICY"); bytes32 internal constant MINT_RECEIVER_POLICY = keccak256("MINT_RECEIVER_POLICY"); + /// @notice Bitmask with all `PausableFeature` bits set (TRANSFER | MINT | BURN | REDEEM). uint8 internal constant ALL_FEATURES_PAUSED = 15; } diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index d2e6e7ab..5e2be29c 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -8,68 +8,22 @@ import {IB20Security} from "../interfaces/IB20Security.sol"; /// @title B20FactoryLib /// @author Coinbase -/// @notice Helpers for constructing the calldata an integrator passes -/// to `StdPrecompiles.B20_FACTORY.createB20(...)`. Two layers: -/// -/// 1. `encode*CreateParams` helpers that produce the per-variant -/// `params` blob (a leading version byte plus the -/// variant-specific struct fields, ABI-encoded). The factory -/// decodes the blob via -/// `abi.decode(params, (CreateParams))`; the library -/// and the factory share the struct layouts from -/// `IB20Factory`. -/// -/// 2. `encode*` helpers that produce a single bootstrap -/// `initCall` (an ABI-encoded call against `IB20` or -/// `IB20Security`), plus `build*` helpers that assemble -/// common multi-entry init-call arrays. Role-grant bundles -/// are typed per variant — `B20RoleHolders` for default and -/// stablecoin, `B20SecurityRoleHolders` for security — so -/// the compiler rejects wrong-shape inputs at the call site; -/// a parallel-arrays `buildRoleGrants(bytes32[], address[])` -/// overload is the escape hatch for custom role sets. The -/// `bytes[] initCalls` argument to `createB20` is the -/// concatenation of however many of these the integrator -/// needs; `concat` stitches typed bundles together with -/// caller-supplied extras. -/// -/// @dev Pure encoder library: no precompile dispatch, no storage -/// reads, no role/policy checks. Authorization happens inside -/// the factory's bootstrap window (see `IB20Factory`) when the -/// resulting calldata is actually invoked. The library deals -/// in `bytes` and `bytes[]` only. -/// -/// Init-call shape: each entry in `initCalls` is the -/// ABI-encoded function call (selector + args) that the factory -/// invokes on the freshly-deployed token during the privileged -/// bootstrap window. Every `encode*` helper here produces -/// exactly one such entry; `build*` helpers produce zero or -/// more, in struct-field order. +/// @notice Pure encoder helpers for the `params` blob and `initCalls` array consumed by +/// `IB20Factory.createB20`. No precompile dispatch, no storage reads, no auth checks. library B20FactoryLib { - /// @notice Current encoding version for `B20CreateParams`. Carried - /// as the leading `version` field so the factory can route - /// between encodings as this variant's schema evolves - /// independently of the others. + /// @notice Encoding version carried as the leading byte of a `B20CreateParams` blob. uint8 internal constant B20_CREATE_PARAMS_VERSION = 1; - /// @notice Current encoding version for `B20StablecoinCreateParams`. - /// Independent of the other variants' versions. + /// @notice Encoding version carried as the leading byte of a `B20StablecoinCreateParams` blob. uint8 internal constant B20_STABLECOIN_CREATE_PARAMS_VERSION = 1; - /// @notice Current encoding version for `B20SecurityCreateParams`. - /// Independent of the other variants' versions. + /// @notice Encoding version carried as the leading byte of a `B20SecurityCreateParams` blob. uint8 internal constant B20_SECURITY_CREATE_PARAMS_VERSION = 1; - /// @notice Current encoding version for `B20StablecoinEventParams`, - /// the payload carried in the `variantEventParams` field of the - /// `B20Created` event for STABLECOIN-variant tokens. - /// Independent of `B20_STABLECOIN_CREATE_PARAMS_VERSION` — - /// the event payload schema can evolve separately from the - /// create-call payload schema. + /// @notice Encoding version carried as the leading byte of a `B20StablecoinEventParams` blob. uint8 internal constant B20_STABLECOIN_EVENT_PARAMS_VERSION = 1; - /// @notice Two parallel arrays passed to a `build*` helper had - /// different lengths. + /// @notice Two parallel arrays passed to a `build*` helper had different lengths. /// /// @param leftLen Length of the first array argument. /// @param rightLen Length of the second array argument. @@ -79,16 +33,10 @@ library B20FactoryLib { ROLE-HOLDER BUNDLES //////////////////////////////////////////////////////////////*/ - /// @notice Bootstrap role-grant bundle for `B20Variant.DEFAULT` and - /// `B20Variant.STABLECOIN` tokens. Each field maps to one - /// named role on `IB20`; `address(0)` skips that role at - /// bootstrap so callers can leave it unassigned and grant - /// it later. + /// @notice Bootstrap role-grant bundle for `B20Variant.DEFAULT` and `B20Variant.STABLECOIN`. + /// `address(0)` fields are skipped at bootstrap. /// - /// @dev `DEFAULT_ADMIN_ROLE` is intentionally omitted: it is set - /// via the `*CreateParams.initialAdmin` field, NOT through - /// this struct. Security-only roles (`BURN_FROM_ROLE`, - /// `SECURITY_OPERATOR_ROLE`) are in `B20SecurityRoleHolders`. + /// @dev `DEFAULT_ADMIN_ROLE` is assigned via `*CreateParams.initialAdmin`, not this struct. struct B20RoleHolders { /// @dev Account granted `MINT_ROLE`. address minter; @@ -104,14 +52,10 @@ library B20FactoryLib { address metadataAdmin; } - /// @notice Bootstrap role-grant bundle for `B20Variant.SECURITY` - /// tokens. Superset of `B20RoleHolders`: adds the - /// `BURN_FROM_ROLE` and `SECURITY_OPERATOR_ROLE` slots that - /// only exist on `IB20Security`. + /// @notice Bootstrap role-grant bundle for `B20Variant.SECURITY`. Superset of `B20RoleHolders` + /// with `BURN_FROM_ROLE` and `SECURITY_OPERATOR_ROLE` slots. /// - /// @dev `DEFAULT_ADMIN_ROLE` is intentionally omitted: it is set - /// via `B20SecurityCreateParams.initialAdmin`, NOT through - /// this struct. + /// @dev `DEFAULT_ADMIN_ROLE` is assigned via `B20SecurityCreateParams.initialAdmin`, not this struct. struct B20SecurityRoleHolders { /// @dev Account granted `MINT_ROLE`. address minter; @@ -135,18 +79,11 @@ library B20FactoryLib { CREATE-PARAMS ENCODERS //////////////////////////////////////////////////////////////*/ - /// @notice Encodes a `B20CreateParams` (default variant) as the - /// `params` blob expected by `IB20Factory.createB20` when - /// `variant == B20Variant.DEFAULT`. The leading byte is - /// `B20_CREATE_PARAMS_VERSION`. + /// @notice Encodes a `B20CreateParams` blob (default variant) tagged with `B20_CREATE_PARAMS_VERSION`. /// - /// @param name ERC-20 token name. - /// @param symbol ERC-20 token symbol. - /// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`, or - /// `address(0)` for the demonstrate-no-owner - /// path. - /// - /// @return The ABI-encoded `B20CreateParams` blob. + /// @param name ERC-20 token name. + /// @param symbol ERC-20 token symbol. + /// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`, or `address(0)` to deploy admin-less. function encodeDefaultCreateParams(string memory name, string memory symbol, address initialAdmin) internal pure @@ -159,20 +96,13 @@ library B20FactoryLib { ); } - /// @notice Encodes a `B20StablecoinCreateParams` as the `params` - /// blob expected by `IB20Factory.createB20` when - /// `variant == B20Variant.STABLECOIN`. The leading byte is + /// @notice Same as `encodeDefaultCreateParams`, plus the stablecoin `currency`. Tagged with /// `B20_STABLECOIN_CREATE_PARAMS_VERSION`. /// - /// @param name ERC-20 token name. - /// @param symbol ERC-20 token symbol. - /// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`, or - /// `address(0)`. - /// @param currency ISO 4217 fiat code this stablecoin tracks. - /// Validated against `ISO4217.sol` by the - /// factory. - /// - /// @return The ABI-encoded `B20StablecoinCreateParams` blob. + /// @param name ERC-20 token name. + /// @param symbol ERC-20 token symbol. + /// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`, or `address(0)`. + /// @param currency Self-declared currency identifier (uppercase ASCII). function encodeStablecoinCreateParams( string memory name, string memory symbol, @@ -190,22 +120,14 @@ library B20FactoryLib { ); } - /// @notice Encodes a `B20SecurityCreateParams` as the `params` blob - /// expected by `IB20Factory.createB20` when - /// `variant == B20Variant.SECURITY`. The leading byte is - /// `B20_SECURITY_CREATE_PARAMS_VERSION`. - /// - /// @param name ERC-20 token name. - /// @param symbol ERC-20 token symbol. - /// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`, - /// or `address(0)`. - /// @param isin International Securities Identification - /// Number; required. Empty string is - /// rejected by the factory with - /// `MissingRequiredField`. - /// @param minimumRedeemable Initial `minimumRedeemable` (shares). + /// @notice Same as `encodeDefaultCreateParams`, plus security `isin` and `minimumRedeemable`. + /// Tagged with `B20_SECURITY_CREATE_PARAMS_VERSION`. /// - /// @return The ABI-encoded `B20SecurityCreateParams` blob. + /// @param name ERC-20 token name. + /// @param symbol ERC-20 token symbol. + /// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`, or `address(0)`. + /// @param isin International Securities Identification Number. Required; empty string reverts at the factory. + /// @param minimumRedeemable Initial `minimumRedeemable` (shares). function encodeSecurityCreateParams( string memory name, string memory symbol, @@ -225,18 +147,10 @@ library B20FactoryLib { ); } - /// @notice Encodes a `B20StablecoinEventParams` as the `variantEventParams` - /// blob the factory emits in the `B20Created` event when - /// `variant == B20Variant.STABLECOIN`. The leading byte is - /// `B20_STABLECOIN_EVENT_PARAMS_VERSION`. Indexers decode - /// this blob by `(variant, leading version byte)` to recover - /// the immutable `currency` without an RPC call. + /// @notice Encodes a `B20StablecoinEventParams` as the `variantEventParams` blob the factory + /// emits in the `B20Created` event for `B20Variant.STABLECOIN`. /// - /// @param currency ISO 4217 fiat code this stablecoin tracks; same - /// value passed to `encodeStablecoinCreateParams` - /// at creation time. - /// - /// @return The ABI-encoded `B20StablecoinEventParams` blob. + /// @param currency ISO 4217 fiat code this stablecoin tracks. function encodeStablecoinEventParams(string memory currency) internal pure returns (bytes memory) { return abi.encode( IB20Factory.B20StablecoinEventParams({version: B20_STABLECOIN_EVENT_PARAMS_VERSION, currency: currency}) @@ -247,103 +161,60 @@ library B20FactoryLib { INIT-CALL SETTER ENCODERS //////////////////////////////////////////////////////////////*/ - /// @notice Encodes a call to `IB20.updateSupplyCap(newSupplyCap)` - /// as a bootstrap initCall. - /// - /// @param newSupplyCap New supply cap (use `type(uint256).max` for - /// no cap). - /// - /// @return The ABI-encoded initCall blob. + /// @notice Encodes a bootstrap initCall to `IB20.updateSupplyCap`. + /// @param newSupplyCap New supply cap (`type(uint256).max` for no cap). function encodeUpdateSupplyCap(uint256 newSupplyCap) internal pure returns (bytes memory) { return abi.encodeCall(IB20.updateSupplyCap, (newSupplyCap)); } - /// @notice Encodes a call to `IB20.updateContractURI(newURI)` as a - /// bootstrap initCall. - /// - /// @param newURI New contract URI (ERC-7572). - /// - /// @return The ABI-encoded initCall blob. + /// @notice Encodes a bootstrap initCall to `IB20.updateContractURI`. + /// @param newURI New contract URI (ERC-7572). function encodeUpdateContractURI(string memory newURI) internal pure returns (bytes memory) { return abi.encodeCall(IB20.updateContractURI, (newURI)); } - /// @notice Encodes a call to `IB20.updateName(newName)` as a - /// bootstrap initCall. - /// - /// @param newName New ERC-20 token name. Also updates the EIP-712 - /// domain separator's `name` field. - /// - /// @return The ABI-encoded initCall blob. + /// @notice Encodes a bootstrap initCall to `IB20.updateName`. + /// @param newName New ERC-20 token name. function encodeUpdateName(string memory newName) internal pure returns (bytes memory) { return abi.encodeCall(IB20.updateName, (newName)); } - /// @notice Encodes a call to - /// `IB20.updatePolicy(policyScope, newPolicyId)` as a - /// bootstrap initCall. Use the policy-scope constants from - /// `B20Constants` (`TRANSFER_SENDER_POLICY`, - /// `TRANSFER_RECEIVER_POLICY`, `TRANSFER_EXECUTOR_POLICY`, - /// `MINT_RECEIVER_POLICY`) and from `IB20Security` - /// (`REDEEM_SENDER_POLICY()`). - /// - /// @param policyScope The policy-slot identifier. - /// @param newPolicyId The new policy registry ID (`0` for - /// always-allow, `1` for always-reject). + /// @notice Encodes a bootstrap initCall to `IB20.updatePolicy`. /// - /// @return The ABI-encoded initCall blob. + /// @param policyScope The policy-slot identifier. + /// @param newPolicyId The new policy registry ID. function encodeUpdatePolicy(bytes32 policyScope, uint64 newPolicyId) internal pure returns (bytes memory) { return abi.encodeCall(IB20.updatePolicy, (policyScope, newPolicyId)); } - /// @notice Encodes a call to `IB20.grantRole(role, account)` as a - /// bootstrap initCall. Use the role constants from - /// `B20Constants` (`MINT_ROLE`, `BURN_ROLE`, - /// `BURN_BLOCKED_ROLE`, `BURN_FROM_ROLE`, `PAUSE_ROLE`, - /// `UNPAUSE_ROLE`, `METADATA_ROLE`, - /// `SECURITY_OPERATOR_ROLE`). - /// - /// @param role The role identifier. - /// @param account The account to grant the role to. + /// @notice Encodes a bootstrap initCall to `IB20.grantRole`. /// - /// @return The ABI-encoded initCall blob. + /// @param role Role to grant. + /// @param account Account to grant the role to. function encodeGrantRole(bytes32 role, address account) internal pure returns (bytes memory) { return abi.encodeCall(IB20.grantRole, (role, account)); } - /// @notice Encodes a call to `IB20.revokeRole(role, account)` as a - /// bootstrap initCall. Rare during bootstrap; useful when - /// pairing with `setRoleAdmin` to lock down a role. + /// @notice Encodes a bootstrap initCall to `IB20.revokeRole`. /// - /// @param role The role identifier. - /// @param account The account to revoke the role from. - /// - /// @return The ABI-encoded initCall blob. + /// @param role Role to revoke. + /// @param account Account to revoke the role from. function encodeRevokeRole(bytes32 role, address account) internal pure returns (bytes memory) { return abi.encodeCall(IB20.revokeRole, (role, account)); } - /// @notice Encodes a call to `IB20.setRoleAdmin(role, newAdminRole)` - /// as a bootstrap initCall. - /// - /// @param role The role whose admin is being changed. - /// @param newAdminRole The new admin role for `role`. + /// @notice Encodes a bootstrap initCall to `IB20.setRoleAdmin`. /// - /// @return The ABI-encoded initCall blob. + /// @param role Role whose admin is being changed. + /// @param newAdminRole New admin role for `role`. function encodeSetRoleAdmin(bytes32 role, bytes32 newAdminRole) internal pure returns (bytes memory) { return abi.encodeCall(IB20.setRoleAdmin, (role, newAdminRole)); } - /// @notice Encodes a call to - /// `IB20Security.batchMint(recipients, amounts)` as a - /// bootstrap initCall. Parallel-array semantics; the - /// token reverts on malformed inputs. - /// - /// @param recipients Accounts receiving the minted tokens. - /// @param amounts Per-recipient amounts, parallel to - /// `recipients`. + /// @notice Encodes a bootstrap initCall to `IB20Security.batchMint`. /// - /// @return The ABI-encoded initCall blob. + /// @param recipients Accounts receiving the minted tokens. + /// @param amounts Per-recipient amounts, parallel to `recipients`. function encodeBatchMint(address[] memory recipients, uint256[] memory amounts) internal pure @@ -352,17 +223,10 @@ library B20FactoryLib { return abi.encodeCall(IB20Security.batchMint, (recipients, amounts)); } - /// @notice Encodes a call to - /// `IB20Security.updateSecurityIdentifier(identifierType, value)` - /// as a bootstrap initCall. Use to attach additional - /// identifiers (CUSIP, FIGI, SEDOL, etc.) alongside the - /// ISIN set via `B20SecurityCreateParams`. + /// @notice Encodes a bootstrap initCall to `IB20Security.updateSecurityIdentifier`. /// - /// @param identifierType Identifier category (e.g. `"CUSIP"`). - /// Empty string is rejected by the token. - /// @param value New value, or empty string to remove. - /// - /// @return The ABI-encoded initCall blob. + /// @param identifierType Identifier category (e.g. `"CUSIP"`). + /// @param value New value, or empty string to remove. function encodeUpdateSecurityIdentifier(string memory identifierType, string memory value) internal pure @@ -371,12 +235,8 @@ library B20FactoryLib { return abi.encodeCall(IB20Security.updateSecurityIdentifier, (identifierType, value)); } - /// @notice Encodes a call to `IB20Security.updateShareRatio(newShareRatio)` - /// as a bootstrap initCall. - /// - /// @param newShareRatio New share ratio. - /// - /// @return The ABI-encoded initCall blob. + /// @notice Encodes a bootstrap initCall to `IB20Security.updateShareRatio`. + /// @param newShareRatio New shares-to-tokens ratio, scaled to `WAD_PRECISION`. function encodeUpdateShareRatio(uint256 newShareRatio) internal pure returns (bytes memory) { return abi.encodeCall(IB20Security.updateShareRatio, (newShareRatio)); } @@ -385,19 +245,11 @@ library B20FactoryLib { INIT-CALL ARRAY BUILDERS //////////////////////////////////////////////////////////////*/ - /// @notice Builds the `grantRole` initCalls implied by a - /// `B20RoleHolders` bundle. Use for `B20Variant.DEFAULT` - /// and `B20Variant.STABLECOIN` tokens. Each non-zero - /// address in `holders` produces one `encodeGrantRole` - /// entry in struct-field order: mint, burn, burn-blocked, - /// pause, unpause, metadata. Zero-address fields are - /// skipped so callers can leave a role unassigned at - /// bootstrap. The returned array is sized exactly to the - /// number of grants (no trailing empty slots). + /// @notice Builds the `grantRole` initCalls implied by a `B20RoleHolders` bundle, in struct-field + /// order. `address(0)` fields are skipped; the result is sized exactly to the kept entries. /// - /// @param holders The IB20 role-holder bundle. - /// - /// @return initCalls The ABI-encoded `grantRole` initCalls. + /// @param holders Role-holder bundle. + /// @return initCalls ABI-encoded `grantRole` initCalls. function buildRoleGrants(B20RoleHolders memory holders) internal pure returns (bytes[] memory initCalls) { bytes32[] memory roles = new bytes32[](6); roles[0] = B20Constants.MINT_ROLE; @@ -418,20 +270,10 @@ library B20FactoryLib { return buildRoleGrants(roles, accounts); } - /// @notice Builds the `grantRole` initCalls implied by a - /// `B20SecurityRoleHolders` bundle. Use for - /// `B20Variant.SECURITY` tokens. Each non-zero address in - /// `holders` produces one `encodeGrantRole` entry in - /// struct-field order: mint, burn, burn-blocked, burn-from, - /// pause, unpause, metadata, security-operator. - /// Zero-address fields are skipped so callers can leave a - /// role unassigned at bootstrap. The returned array is - /// sized exactly to the number of grants (no trailing - /// empty slots). - /// - /// @param holders The security role-holder bundle. + /// @notice Same as `buildRoleGrants(B20RoleHolders)`, but for the security role set. /// - /// @return initCalls The ABI-encoded `grantRole` initCalls. + /// @param holders Security role-holder bundle. + /// @return initCalls ABI-encoded `grantRole` initCalls. function buildRoleGrants(B20SecurityRoleHolders memory holders) internal pure returns (bytes[] memory initCalls) { bytes32[] memory roles = new bytes32[](8); roles[0] = B20Constants.MINT_ROLE; @@ -456,32 +298,15 @@ library B20FactoryLib { return buildRoleGrants(roles, accounts); } - /// @notice Builds the `grantRole` initCalls implied by parallel - /// `roles` / `accounts` arrays. The general primitive - /// that the typed-bundle overloads above delegate to; use - /// this directly when your factory bundles roles in a - /// shape that doesn't match `B20RoleHolders` / - /// `B20SecurityRoleHolders` (e.g. a custom config struct - /// that already encodes the parallel arrays inline, or a - /// role set that includes integrator-defined roles). - /// - /// Entry `k` produces `encodeGrantRole(roles[k], accounts[k])`; - /// entries whose `accounts[k] == address(0)` are skipped - /// so callers can leave a role unassigned at bootstrap. - /// Output ordering matches input ordering for the kept - /// entries; the returned array is sized exactly to the - /// number of grants (no trailing empty slots). + /// @notice Builds the `grantRole` initCalls implied by parallel `roles` / `accounts` arrays. + /// Entries where `accounts[k] == address(0)` are skipped; the result preserves input order + /// and is sized exactly to the kept entries. /// - /// @dev Reverts with `LengthMismatch` if `roles` and `accounts` - /// differ in length. The typed overloads above bypass - /// this check by construction (their array allocations - /// are paired in this library). + /// @dev Reverts with `LengthMismatch` when `roles.length != accounts.length`. /// - /// @param roles Role identifiers; parallel to `accounts`. - /// @param accounts Role holders; parallel to `roles`. `address(0)` - /// entries are skipped. - /// - /// @return initCalls The ABI-encoded `grantRole` initCalls. + /// @param roles Role identifiers, parallel to `accounts`. + /// @param accounts Role holders, parallel to `roles`. + /// @return initCalls ABI-encoded `grantRole` initCalls. function buildRoleGrants(bytes32[] memory roles, address[] memory accounts) internal pure @@ -503,22 +328,14 @@ library B20FactoryLib { } } - /// @notice Builds the `updateSecurityIdentifier` initCalls implied - /// by parallel `identifierTypes` / `identifierValues` - /// arrays. Entry `k` produces - /// `encodeUpdateSecurityIdentifier(identifierTypes[k], identifierValues[k])` - /// in array order. All entries are emitted; empty types or - /// values are passed through to the token, which validates - /// them at runtime. - /// - /// @dev Reverts with `LengthMismatch` if `identifierTypes` and - /// `identifierValues` differ in length. + /// @notice Builds the `updateSecurityIdentifier` initCalls implied by parallel + /// `identifierTypes` / `identifierValues` arrays. All entries are emitted in input order. /// - /// @param identifierTypes Identifier categories (e.g. `"CUSIP"`). - /// @param identifierValues Values parallel to `identifierTypes`. + /// @dev Reverts with `LengthMismatch` when `identifierTypes.length != identifierValues.length`. /// - /// @return initCalls The ABI-encoded `updateSecurityIdentifier` - /// initCalls. + /// @param identifierTypes Identifier categories (e.g. `"CUSIP"`). + /// @param identifierValues Values parallel to `identifierTypes`. + /// @return initCalls ABI-encoded `updateSecurityIdentifier` initCalls. function buildSecurityIdentifierUpdates(string[] memory identifierTypes, string[] memory identifierValues) internal pure @@ -534,17 +351,11 @@ library B20FactoryLib { } } - /// @notice Concatenates two init-call arrays into a single array, - /// preserving order. Useful for stitching a typed-core - /// initCall bundle (e.g. the output of `buildRoleGrants`) - /// together with a caller-supplied tail of bespoke entries - /// before passing the combined array to `createB20`. - /// - /// @param head Init calls to place at the start of the result. - /// @param tail Init calls to place after `head`. May be empty. + /// @notice Concatenates `head` and `tail` into a single init-call array, preserving order. /// - /// @return initCalls The concatenated array (`head` then `tail`), - /// sized exactly to `head.length + tail.length`. + /// @param head Init calls placed first. + /// @param tail Init calls placed after `head`. May be empty. + /// @return initCalls Concatenated array, sized `head.length + tail.length`. function concat(bytes[] memory head, bytes[] memory tail) internal pure returns (bytes[] memory initCalls) { initCalls = new bytes[](head.length + tail.length); uint256 i;