Mock implementations and unit tests: MockB20, MockB20Stablecoin, MockTokenFactory`#26
Conversation
Implements the spec-as-Solidity reference for the IB20 default-token
surface, the IB20Stablecoin variant, and the full ITokenFactory
createToken flow. Mocks live under test/lib/mocks/, etched at the
canonical precompile / token addresses via vm.etch from
BaseTest.setUp and the factory's create flow respectively.
Out of scope:
- IB20Security: interface still in flux.
- MockPolicyRegistry / MockActivationRegistry: skeleton-only for now
(built-in sentinels + admin getter). End-to-end test coverage that
exercises custom policies will need MockPolicyRegistry fleshed out;
deferred to follow up with Eric's impl branch.
New files:
- test/lib/mocks/MockB20Storage.sol
Slot-layout library. Single ERC-7201 namespaced struct at
base.b20 holding every piece of mutable token state. The struct
field ORDER is the slot layout the Rust precompile mirrors;
there is no separate flat list of slot constants that can drift
out of sync. A sibling library MockB20StablecoinStorage at
base.b20.stablecoin holds the variant-specific currency string.
Both STORAGE_LOCATION constants are hardcoded and verified
against a derivedLocation() pure helper that recomputes the
ERC-7201 formula at runtime so tests can assert equality.
- test/lib/mocks/MockB20.sol
Full IB20 reference impl. Written as Solidity-as-if-Rust:
spec-correspondence over Solidity idiom. Key shape choices:
* decimals() decodes address byte [11] — no storage slot.
* Authorization bypass for the factory bootstrap window is an
explicit _isPrivileged() check (msg.sender == FACTORY &&
!initialized) consulted at every gate. Token invariants
(supply-cap math, balance accounting) are not bypassed.
* Roles use OZ AccessControl semantics with an explicit
adminCount field for O(1) LastAdminCannotRenounce checks on
renounceRole. The dedicated renounceLastAdmin path requires
adminCount == 1 and zeroes the count.
* Policy slots are a single bytes32 -> uint64 mapping; the four
standard policy-type constants on IB20 are exposed as the
keccak256 of their string names.
* Pause is stored as a uint256 bitmask internally; the
PausableFeature[] surface is translated at the boundary.
* EIP-712 domain has no name/version per IB20 spec (only chainId
+ verifyingContract). DOMAIN_SEPARATOR is computed dynamically.
* Variant extension goes through internal virtual
_bootstrapVariant(bytes) so the base contract never enumerates
variants.
- test/lib/mocks/MockB20Stablecoin.sol
Variant extension. Stores currency in its own ERC-7201 namespace
(no slot conflict with base). Overrides _bootstrapVariant to
decode abi.encode(string) and write the currency.
Modified:
- test/lib/mocks/MockTokenFactory.sol
Full createToken implementation. Decodes variant params (revert
on unsupported version, invalid decimals, missing required
fields), refuses to overwrite existing tokens (TokenAlreadyExists),
etches the variant-appropriate runtime bytecode, calls
bootstrap(name, symbol, admin, variantData), dispatches initCalls
via low-level .call() so msg.sender stays as factory (triggering
the token's auth bypass), calls closeBootstrap() to seal the
privileged window, and emits TokenCreated. Init-call reverts
abort the whole creation. Security variant reverts with
UnsupportedVersion(0) pending interface stabilization.
- test/lib/BaseTest.sol
Refreshed the 'Mock status' natspec to reflect the new state:
factory and B20 mocks now fully live; policy registry and
activation registry remain skeletons.
- test/lib/B20Test.sol
Refreshed the _deployToken natspec — the old warning that 'no
token code is deployed there yet, so any call against token will
revert' is now stale; the factory plants live mock bytecode
during createToken.
- test/unit/PolicyRegistry/nextPolicyId.t.sol
Changed test_nextPolicyId_success_advancesPerCreate's first
parameter from IPolicyRegistry.PolicyType to uint8 (with a
natspec note about vm.assume-bounding inside the body). Solidity
reverts at function entry on fuzz inputs that don't decode to a
valid enum value, which caused the stub to fail under fuzzing
despite having an empty body; uint8 + later cast works around it.
Build clean. forge test: 284 passed, 0 failed (stubs are all no-op
bodies; this confirms nothing regresses and the new mocks integrate
cleanly with the existing helpers).
…0 test fillout ** Rustier MockB20 / MockTokenFactory ** - Factory writes token initial state directly via vm.store at the ERC-7201 slot offsets declared on MockB20Storage (NAME_OFFSET = 0, etc.), mirroring how the Rust precompile factory will work. No bootstrap() / closeBootstrap() / _bootstrapVariant() functions on the token any more; public surface is exactly IB20. - Added _writeString helper handling Solidity's short (<32) vs long (>=32) string storage encoding so the factory can write name/symbol/ currency without going through Solidity getters. - TokenCreated event gained \`address admin\` -- canonical signal for the initial admin grant since there's no RoleGranted at bootstrap. ** Factory address: 0xb20F...000f ** - Moved from 0xB200...000f to 0xb20F...000f. The 0xB20F prefix is disjoint from the B-20 token prefix (which has 0x00 at byte [1]), so isB20(factory) now returns false unambiguously. Trailing 0x0F echoes the prefix for visual symmetry. ** Zero-admin allowed ** - Removed ZeroAddress error from ITokenFactory; B20CreateParams natspec updated to document the "demonstrate no owner" path (memecoins / credibly-neutral tokens). Use renounceLastAdmin to evolve to admin- less after creation. ** Test fillout: 33 Factory + 39 ERC-20, 0 failing ** - TokenFactory (createToken, getTokenAddress, getTokenVariant, isB20): 17 + 8 + 4 + 4. Includes 3 new tests beyond Conner's scaffold: _initCallFailed_revertsWholeCreation (no partial state), _emitsTokenCreatedBeforeInitCallEvents (log ordering), _factoryHasNoPersistentPrivilege (bootstrap window actually closes). Repurposed the zero-admin revert stubs as success cases. - IB20 ERC-20 surface (name, symbol, decimals, totalSupply, balanceOf, allowance, approve, transfer, transferFrom): 2+2+2+2+3+4+5+9+11. 1 intentional skip: allowance-via-permit is deferred to permit.t.sol where the EIP-712 sign setup lives. ** Test infra ** - BaseTest._assumeValidCaller filters zero / VM cheatcode / precompile addresses out of fuzzed prank addresses. - B20Test._assumeValidActor extends that with the token address. Plus role / policy-type / sentinel constants and _grantRole / _mint / _setPolicy / _pause helpers. forge test: 283 passed, 0 failed, 1 skipped (284 total).
…permit, currency Fills in 137 unit tests across the remaining B20 surface, bringing the suite from "scaffolds-only" to "every IB20 / IB20Stablecoin surface function exercised by both revert and success paths." ** Per-surface fillout ** - metadata: 11 tests (contractURI, setName, setSymbol, setContractURI) - roles: 33 tests (hasRole, grantRole, revokeRole, renounceRole, renounceLastAdmin, setRoleAdmin, getRoleAdmin, 6 role-constant readers) - pause: 17 tests (pause, unpause, isPaused, pausedFeatures) - policy: 13 tests (policyId, updatePolicy, 4 policy-type-constant readers) - supply: 28 tests (mint, burn, burnBlocked, setSupplyCap, supplyCap) - memo: 14 tests (transferWithMemo, transferFromWithMemo, mintWithMemo, burnWithMemo) -- exercises Transfer-then-Memo log ordering - permit: 14 tests (DOMAIN_SEPARATOR, eip712Domain, nonces, permit) - B20Stablecoin: 1 test (currency) ** Test infra additions ** - B20Test.PERMIT_TYPEHASH constant + _signPermit / _signPermitAs helpers for EIP-712 permit signing. _signPermitAs lets wrong-owner revert tests sign a digest under one identity using a different private key. ** Two stubs dropped (not testable behavior) ** - permit.test_permit_revert_zeroSpender: stub natspec asked for InvalidSpender(0) revert, but neither IB20 natspec nor MockB20 impl has a zero-spender guard. A permit with spender=0 either succeeds (valid sig) or fails via the existing InvalidSigner path; no special case warranted. - allowance.test_allowance_success_reflectsPermit: literally permit_success_setsAllowance reframed; the canonical test lives in permit.t.sol. forge test: 282 passed, 0 failed, 0 skipped (282 total).
MockB20, MockB20Stablecoin, MockTokenFactory`
ilikesymmetry
left a comment
There was a problem hiding this comment.
Great progress with testing infra setup. I think we need to talk more about preferred layout for policies so that we can get 1 SLOAD for hot path of token transfer
| string symbol, | ||
| uint8 decimals | ||
| uint8 decimals, | ||
| address admin |
There was a problem hiding this comment.
@stevieraykatz and I thought it'd be best to omit admin because we'll get a RoleGranted event for it from the token itself already.
There was a problem hiding this comment.
so this was a point of debate with the AI... I think it depends how the rust implementation sets this -- whether it calls grantRole or just writes the slot. This might just be an artifact of what it meant to try to write the factory in the most rustlike way in solidity, but seems that calling the token grantRole might not happen
| // setup paths where the token may not yet exist (e.g. createToken | ||
| // tests). Verified against MockB20's constants by direct equality | ||
| // in the role-constants test suite. | ||
| bytes32 internal constant DEFAULT_ADMIN_ROLE = bytes32(0); |
There was a problem hiding this comment.
Should these be defined on the mock itself vs the test?
There was a problem hiding this comment.
this might be redundant will look into
| // revoke / renounce. | ||
| uint256 adminCount; | ||
| // ---------- Policy slots ---------- | ||
| mapping(bytes32 policyType => uint64 policyId) policyIds; |
There was a problem hiding this comment.
Am realizing that we don't get clean packing into one slot for logical groups of policies (eg transfer sender/receiver/executor) if we use conventional mapping. Might need to be more custom than this
There was a problem hiding this comment.
this is a good callout, will look closer tomorrow
| // -- 6. Emit creation event BEFORE initCalls dispatch (per | ||
| // ITokenFactory natspec) so init-call effects appear | ||
| // strictly after the creation event in the log order. | ||
| // Includes admin since there's no separate RoleGranted |
There was a problem hiding this comment.
I guess it is net simpler to have one event to track like this. Alternative perspective is that there's only ever one event to look at for role changes if we force-emit RoleGranted from token on creation.
There was a problem hiding this comment.
yeah I think this might be the way: write the slot directly and force emit rolegranted from the token. there's probably a way to do this using foundry
TRANSFER_SENDER, TRANSFER_RECEIVER, TRANSFER_EXECUTOR, MINT_RECEIVER now occupy one 256-bit slot (packedPolicyIds) instead of four mapping slots. Rare/variant policy types overflow to extraPolicyIds mapping. Cuts the transfer policy check to a single SLOAD reading both sender and receiver policy IDs. Mint/burn/executor paths similarly bench against the packed slot via inline shift+mask extraction. Addresses Conner's PR #26 review on hot-path policy lookups.
…okenCreated The Rust precompile atomically writes the initial admin role slot AND pushes the RoleGranted log in the same call frame. The mock now mirrors that: after vm.etch'ing the token and vm.store'ing name/symbol/cap, the factory calls token.grantRole(DEFAULT_ADMIN_ROLE, admin) during the privileged window so RoleGranted fires from the token's own context — no bootstrap surface added to MockB20 itself. Order: vm.etch -> vm.store metadata -> emit TokenCreated -> grantRole (canonical RoleGranted) -> initCalls -> set initialized. Drops admin field from TokenCreated since RoleGranted is now the canonical signal for the initial admin assignment. Addresses Conner's PR #26 review on bootstrap event canonicality.
…ants libraries Tests previously redeclared MINT_ROLE, BURN_ROLE, etc. inside B20Test. Now they import the canonical values from libraries co-located with the mocks, so MockB20 and tests share a single source of truth. Solidity contract types don't expose 'public constant' members at compile time (you can only call the runtime getter via an instance), so the constants live in libraries (B20Constants in MockB20.sol, PolicyRegistryConstants in MockPolicyRegistry.sol). The mock contracts re-expose them as 'public constant' delegating to the library values for the IB20 / IPolicyRegistry ABI. Sweep updates 30+ test files to reference B20Constants.MINT_ROLE, PolicyRegistryConstants.ALWAYS_ALLOW_ID, etc. Addresses Conner's PR #26 review on role constant duplication.
…constants (#33) * Pack 4 hot-path policy IDs into single slot TRANSFER_SENDER, TRANSFER_RECEIVER, TRANSFER_EXECUTOR, MINT_RECEIVER now occupy one 256-bit slot (packedPolicyIds) instead of four mapping slots. Rare/variant policy types overflow to extraPolicyIds mapping. Cuts the transfer policy check to a single SLOAD reading both sender and receiver policy IDs. Mint/burn/executor paths similarly bench against the packed slot via inline shift+mask extraction. Addresses Conner's PR #26 review on hot-path policy lookups. * Emit canonical RoleGranted from token on bootstrap; drop admin from TokenCreated The Rust precompile atomically writes the initial admin role slot AND pushes the RoleGranted log in the same call frame. The mock now mirrors that: after vm.etch'ing the token and vm.store'ing name/symbol/cap, the factory calls token.grantRole(DEFAULT_ADMIN_ROLE, admin) during the privileged window so RoleGranted fires from the token's own context — no bootstrap surface added to MockB20 itself. Order: vm.etch -> vm.store metadata -> emit TokenCreated -> grantRole (canonical RoleGranted) -> initCalls -> set initialized. Drops admin field from TokenCreated since RoleGranted is now the canonical signal for the initial admin assignment. Addresses Conner's PR #26 review on bootstrap event canonicality. * Dedup role + policy constants into B20Constants / PolicyRegistryConstants libraries Tests previously redeclared MINT_ROLE, BURN_ROLE, etc. inside B20Test. Now they import the canonical values from libraries co-located with the mocks, so MockB20 and tests share a single source of truth. Solidity contract types don't expose 'public constant' members at compile time (you can only call the runtime getter via an instance), so the constants live in libraries (B20Constants in MockB20.sol, PolicyRegistryConstants in MockPolicyRegistry.sol). The mock contracts re-expose them as 'public constant' delegating to the library values for the IB20 / IPolicyRegistry ABI. Sweep updates 30+ test files to reference B20Constants.MINT_ROLE, PolicyRegistryConstants.ALWAYS_ALLOW_ID, etc. Addresses Conner's PR #26 review on role constant duplication.
Summary
Reference implementations for the
IB20default-token surface, theIB20Stablecoinvariant, and the fullITokenFactory.createTokenflow. Lives under
test/lib/mocks/and is etched viavm.etchperConner's mock-vs-src proposal. Written as Solidity-as-if-Rust:
spec-correspondence over Solidity idiom.
Out of scope:
IB20Security(interface still in flux).MockPolicyRegistry/MockActivationRegistry(skeletons only fornow; deferred to follow-ups).