Skip to content

Mock implementations and unit tests: MockB20, MockB20Stablecoin, MockTokenFactory`#26

Merged
amiecorso merged 3 commits into
mainfrom
amie/mock-impls
May 20, 2026
Merged

Mock implementations and unit tests: MockB20, MockB20Stablecoin, MockTokenFactory`#26
amiecorso merged 3 commits into
mainfrom
amie/mock-impls

Conversation

@amiecorso
Copy link
Copy Markdown
Collaborator

@amiecorso amiecorso commented May 19, 2026

Summary

Reference implementations for the IB20 default-token surface, the
IB20Stablecoin variant, and the full ITokenFactory.createToken
flow. Lives under test/lib/mocks/ and is etched via vm.etch per
Conner'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 for
    now; deferred to follow-ups).

amiecorso added 3 commits May 19, 2026 12:41
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).
@amiecorso amiecorso changed the title Mock implementations: MockB20, MockB20Stablecoin, full MockTokenFactory Mock implementations and unit tests: MockB20, MockB20Stablecoin, MockTokenFactory` May 19, 2026
@amiecorso amiecorso marked this pull request as ready for review May 20, 2026 00:38
@amiecorso amiecorso merged commit 9262f0e into main May 20, 2026
1 check passed
@amiecorso amiecorso deleted the amie/mock-impls branch May 20, 2026 00:38
Copy link
Copy Markdown
Collaborator

@ilikesymmetry ilikesymmetry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread test/lib/B20Test.sol
// 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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these be defined on the mock itself vs the test?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might be redundant will look into

// revoke / renounce.
uint256 adminCount;
// ---------- Policy slots ----------
mapping(bytes32 policyType => uint64 policyId) policyIds;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

amiecorso added a commit that referenced this pull request May 20, 2026
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.
amiecorso added a commit that referenced this pull request May 20, 2026
…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.
amiecorso added a commit that referenced this pull request May 20, 2026
…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.
amiecorso added a commit that referenced this pull request May 20, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants