Skip to main content

AssetManager Fuzz Coverage

Vertical entrypoint: test/fuzzing/FuzzAssetManagerIntegrity.sol Invariants: test/fuzzing/properties/Properties_ASSET.sol Descriptions: test/fuzzing/properties/PropertiesDescriptions.sol Target contract: src/marketplace/AssetManager.sol

Scope

End-to-end exercise of AssetManager focused on the three concerns from the task brief:

  • Asset listing eligibilitysetAsset, setAssetTokenId, and the view getters (isAssetSupported, isTokenIdAllowed, getAssetConfig, getAssetType) must remain consistent under arbitrary reconfiguration.
  • Supported asset-type constraintsvalidateAsset must enforce the shape rules for each AssetType (ERC20 requires tokenId == 0, ERC721 requires amount == 1, NONE is rejected via the disabled gate).
  • Module authorization assumptions used by marketplace flows — only ADMIN may mutate configuration, while view functions are callable by any address (marketplace modules rely on this through flows such as EscrowManager.createEscrow and Marketplace.registerOffer).

The harness operates on a dedicated pool of four synthetic token addresses (0xA551..0xA554) disjoint from address(token) wired into Marketplace. This keeps the Marketplace vertical stable under arbitrary AssetManager reconfiguration — fuzzing AssetManager cannot accidentally disable the real bond token and break OFER/ESCR invariants.

A bounded set of four tokenIds ({0, 1, 2, 3}) is used as the per-(token, tokenId) allowlist probe. tokenId = 0 naturally covers the ERC20 tokenId == 0 success branch; {1, 2, 3} covers the rejection branches.

Handlers

EntrypointCallerTargetPurpose
fuzz_setAssetaddress(this) (admin)assetManager.setAssetConfigure token-level asset support (assetType, enabled, enforceTokenId)
fuzz_setAssetUnauthorizedcurrentActor (non-admin)assetManager.setAssetConfirm the ADMIN gate rejects a non-admin caller
fuzz_setAssetTokenIdaddress(this) (admin)assetManager.setAssetTokenIdToggle a tokenId-level allowlist entry
fuzz_setAssetTokenIdUnauthorizedcurrentActor (non-admin)assetManager.setAssetTokenIdConfirm the ADMIN gate rejects a non-admin caller
fuzz_validateAssetaddress(this)assetManager.validateAssetExercise the shape-validation path and compare against the state-derived oracle
fuzz_validateAssetAsActorcurrentActorassetManager.validateAssetConfirm the view has no authorization gate — marketplace modules call it as themselves

The six entrypoints cover every mutator and the full authorization surface. fuzz_validateAsset and fuzz_validateAssetAsActor share preconditions and postconditions but differ in caller, so a regression that introduced an auth check on the view would be caught by the AsActor variant failing while the admin variant keeps passing.

Invariants

Global consistency

Re-evaluated on every successful mutation through the per-handler postconditions.

IDConditionChecked
ASET-01isAssetSupported(token, tokenId) equals `enabled && (!enforceTokenId
ASET-02getAssetType(token) reverts iff the token's config is disabled, and otherwise returns config.assetTypeafter setAsset success

setAsset — group 10

Checked in setAssetPostconditions for fuzz_setAsset.

IDConditionChecked
ASET-10After success, getAssetConfig(token) returns exactly the (assetType, enabled, enforceTokenId) passed inon success
ASET-11After success, every isTokenIdAllowed(token, *) entry is unchanged — the _allowedTokenIds mapping is sticky across setAsset callson success
ASET-12Revert selector is one of {ZeroAddress, AssetManager__InvalidAssetType}on revert
ASET-13On revert, the failure matches the specific guard: ZeroAddress iff token == 0, otherwise InvalidAssetType and enabled && assetType == NONE must holdon revert
ASET-14No other token in the fuzz pool has its config or allowlist mutatedon success & on revert
ASET-15Success implies token != 0 and not (enabled && assetType == NONE), so guard weakening is caught even if the call no longer revertson success

setAssetTokenId — group 20

Checked in setAssetTokenIdPostconditions for fuzz_setAssetTokenId.

IDConditionChecked
ASET-20After success, isTokenIdAllowed(token, tokenId) equals the enabled argumenton success
ASET-21After success, the token-level getAssetConfig(token) is unchanged (only the allowlist entry moved)on success
ASET-22Revert selector is one of {AssetManager__AssetNotSupported, AssetManager__TokenIdAllowlistDisabled}on revert
ASET-23Precise selector mapping: AssetNotSupported iff pre-state !enabled; otherwise TokenIdAllowlistDisabled and pre-state !enforceTokenId must holdon revert
ASET-14No other token in the fuzz pool has its config or allowlist mutatedon success & on revert
ASET-24Success implies the token was enabled and tokenId allowlisting was enforced in pre-stateon success

validateAsset — group 30

Checked in validateAssetPostconditions for both fuzz_validateAsset and fuzz_validateAssetAsActor.

IDConditionChecked
ASET-30Success iff `enabled && (!enforceTokenId
ASET-31On revert, the selector matches the first failing predicate in the contract's evaluation order: AssetNotSupportedTokenIdNotSupportedInvalidTokenIdInvalidAmountForERC721on revert (within ASET-30 body)
ASET-32validateAsset does not mutate any token's configuration or allowlist entry — view-function purityevery call
ASET-33On success, the returned AssetType equals the stored config.assetType and canonical nominal metadata is zero without a configured BondRegistry sourceon success

Module authorization — group 40

Checked in setAssetUnauthorizedPostconditions / setAssetTokenIdUnauthorizedPostconditions for the corresponding _Unauthorized handlers.

IDConditionChecked
ASET-40setAsset called by a non-admin always reverts with Unauthorized() (solady onlyRoles gate)every call
ASET-41setAssetTokenId called by a non-admin always reverts with Unauthorized()every call
ASET-42The entire fuzz pool is unchanged after any non-admin attempt (no partial mutation)every call

fuzz_validateAssetAsActor is the positive counterpart: the view must succeed for any caller on the same state the admin would observe — no separate invariant ID is needed because it reuses ASET-30..33.

Preconditions

Summary of the clamping and state-selection logic. Full implementation in helper/preconditions/PreconditionsAssetManager.sol.

HandlerClamp rules
setAssettoken picked from address(0) or knownAssetTokens, so the ZeroAddress guard remains reachable; assetType = AssetType(seed % 5) so NONE is reachable and the InvalidAssetType branch is exercised; enabled and enforceTokenId passed through raw.
setAssetTokenIdtoken picked from address(0) or knownAssetTokens; tokenId picked from the bounded tokenId pool; enabled passed through raw. Preconditions do not gate on the current config so the success path, disabled-token revert, and allowlist-disabled revert are reachable.
validateAsset / validateAssetAsActortoken picked from address(0) or knownAssetTokens, and tokenId picked from the bounded pool (so tokenId == 0 is common and the ERC20 success path is reachable); amount clamped to amount % 8 so amount == 1 is common and the ERC721 success branch is exercised.
Unauthorized variantsIdentical input selection to their authorized counterparts; only the caller changes (currentActor instead of the harness).

All clamp failures raise ClampFail(string), which the integrity layer accepts as a skip rather than a bug — in practice the asset pools are never empty post-setup, so clamp failures do not occur under normal fuzzing.

Why this coverage is sufficient

  • Every state transition has both a positive and negative oracle. Every mutator checks both success-state correctness (ASET-10/20) AND revert-reason correctness (ASET-12/13/22/23) based on pre-state snapshots, not just "does not revert unexpectedly".
  • Cross-function consistency is pinned. ASET-01 and ASET-02 tie the cheap view getters to the internal config, so a bug that de-synchronises them (e.g. isAssetSupported forgetting to honour enabled) fails immediately after any mutation.
  • validateAsset is a state-derived oracle, not just a reverts-check. ASET-30 computes the expected outcome from the pre-state snapshot and asserts exact selector mapping — a bug that swapped the order of the shape checks would be caught.
  • Cross-token isolation (ASET-14) guards against storage collisions. Mutators provably touch only the target token.
  • Sticky allowlist (ASET-11). setAsset is verified to preserve _allowedTokenIds entries — this is the subtle property marketplace flows rely on when an admin briefly disables and re-enables a token.
  • Module authorization is checked from both sides. ADMIN-only mutators reject non-admins (ASET-40/41/42); the auth-free view (validateAsset) is explicitly exercised by a non-admin caller (fuzz_validateAssetAsActor) so a regression adding an auth gate there would fail immediately.