Skip to main content

EscrowManager Authorization Fuzz Coverage

Vertical entrypoint: test/fuzzing/FuzzEscrowAuthIntegrity.sol Invariants: test/fuzzing/properties/Properties_ESAU.sol Descriptions: test/fuzzing/properties/PropertiesDescriptions.sol

Scope

Authorization-flow coverage for EscrowManager — the second half of the "AssetManager and escrow authorization flow" task, complementing AssetManager.md. This vertical targets the module authorization matrix, the admin gate on mutators, and the authorization checks on the module-only entrypoints.

Positive-path escrow lifecycle (createEscrow, withdraw, claim with real token transfers) is exercised by the Marketplace vertical through ESCR-10..15; this vertical focuses on the authorization surface around those entrypoints.

The harness operates on a dedicated pool of four synthetic module addresses (0xE551..0xE554) and four module-type hashes ({bytes32(0), keccak256("FUZZ_MODULE_TYPE_A/B/C")}). The fuzz types are distinct from the real MARKETPLACE_MODULE / ORDERBOOK_MARKETPLACE_MODULE hashes, so a fuzz-registered module can never be authorized to touch a real escrow. The admin calls themselves come from address(this) (harness holds ADMIN); the non-admin rejection handlers call as one of the three engine sender addresses (0x10000, 0x20000, 0x30000).

Handlers

EntrypointCallerTargetPurpose
fuzz_registerModuleharness (admin)escrowManager.registerModuleDrive the module-registration state machine over the fuzz pool
fuzz_registerModuleUnauthorizedcurrentActorescrowManager.registerModuleAssert the ADMIN gate rejects non-admins
fuzz_deactivateModuleharness (admin)escrowManager.deactivateModuleDrive the deactivation state machine
fuzz_deactivateModuleUnauthorizedcurrentActorescrowManager.deactivateModuleADMIN gate
fuzz_setAssetManagerharness (admin)escrowManager.setAssetManagerRotate the wired AssetManager; restores the real wiring before returning
fuzz_setAssetManagerUnauthorizedcurrentActorescrowManager.setAssetManagerADMIN gate
fuzz_createEscrowAsActorcurrentActor (EOA)escrowManager.createEscrowConfirm _requireAuthorizedCallerModule rejects non-module callers
fuzz_withdrawAsActorunauthorized pool addressescrowManager.withdrawConfirm the EscrowNotFound vs ModuleNotAuthorized selector mapping
fuzz_claimAsActorunauthorized pool addressescrowManager.claimSame as above for claim
fuzz_sweepharness (admin)escrowManager.sweepExercise sweep's full revert surface on a documented asset pool
fuzz_sweepUnauthorizedcurrentActorescrowManager.sweepADMIN gate

The admin handlers call through address(this) via fl.doFunctionCall(target, data, admin). The setAssetManager handler is the only one that must carry a manual restore step: if it successfully rotated the AssetManager away from the real one, it immediately re-applies the real address before returning so subsequent fuzz_registerOffer calls keep working (otherwise Marketplace.registerOffer would start reverting with AssetNotSupported and break OFER-13). fuzz_registerModule / fuzz_deactivateModule operate entirely on the fuzz pool and never touch real registrations.

Invariants

Cross-cutting invariants

Re-evaluated after every successful or reverted handler through _runEscrowAuthGlobals. These iterate the full module pool so a storage collision in any mutator would surface regardless of which pair was targeted.

IDCondition
ESAU-01For every module in the pool, moduleTypeOf[m] != 0 iff isAuthorizedModule[moduleTypeOf[m]][m] == true — the two mappings stay in perfect sync
ESAU-02Each module in the pool is authorized under at most one type simultaneously

registerModule — group 10

Checked in registerModulePostconditions for fuzz_registerModule.

IDConditionChecked
ESAU-10moduleTypeOf[m] == type and isAuthorizedModule[type][m] == true after the callon success
ESAU-11No other (type, addr) pair in the pool is mutatedon success & on revert
ESAU-12Revert selector matches the first failing predicate evaluated against pre-state: InvalidModuleType (type==0) → InvalidModuleAddress (addr==0) → ModuleTypeMismatch (different type already registered) → ModuleAlreadyRegistered (same type already registered)on revert
ESAU-13Success implies non-zero type, non-zero module address, and no pre-existing registration for that addresson success

deactivateModule — group 20

Checked in deactivateModulePostconditions for fuzz_deactivateModule.

IDConditionChecked
ESAU-20moduleTypeOf[m] == 0 and isAuthorizedModule[type][m] == false after the callon success
ESAU-21No other (type, addr) pair in the pool is mutatedon success & on revert
ESAU-22Revert selector matches the first failing predicate: InvalidModuleTypeInvalidModuleAddressModuleNotRegistered (pre-state type was 0) → ModuleTypeMismatch (pre-state type differed) → ModuleHasActiveEscrowson revert
ESAU-23Success implies non-zero type, non-zero module address, and matching pre-state registration for that address/type pairon success

setAssetManager — group 30

Checked in setAssetManagerPostconditions for fuzz_setAssetManager.

IDConditionChecked
ESAU-30assetManager == newAddress after a successful callon success
ESAU-31Revert selector is ZeroAddress (the only documented failure mode)on revert
ESAU-32The module authorization matrix is not touched by the callon success & on revert
ESAU-33A failed setAssetManager attempt implies the requested address was zeroon revert
ESAU-34The directed self-call that restores the real AssetManager succeedsafter successful rotation

createEscrow authorization — group 40

Checked in createEscrowAsActorPostconditions for fuzz_createEscrowAsActor. The caller is always a USER (EOA, never a module), so _requireAuthorizedCallerModule hits the first guard.

IDConditionChecked
ESAU-40Revert selector is ModuleNotRegisteredevery call
ESAU-41Authorization matrix, nextEscrowId, and AssetManager wiring are all unchangedevery call
ESAU-42The call itself must fail; success would mean an unregistered caller created an escrowevery call

withdraw / claim authorization — group 50

Checked in withdrawOrClaimAsActorPostconditions for fuzz_withdrawAsActor and fuzz_claimAsActor. The caller is a pool address that is never authorized for any real escrow's moduleType.

IDConditionChecked
ESAU-50Revert selector is EscrowNotFound when the targeted escrow slot is empty, otherwise ModuleNotAuthorizedevery call
ESAU-51Authorization matrix and nextEscrowId are unchangedevery call
ESAU-52The call itself must fail; success would mean an unauthorized caller moved escrowed fundsevery call

sweep — group 60

Checked in sweepPostconditions for fuzz_sweep. Inputs are restricted (see Preconditions below) so only documented revert branches are reachable.

IDConditionChecked
ESAU-60Revert selector matches the first failing predicate: InvalidAssetType (NONE) → InvalidTokenId (ERC20 + tid≠0) → InvalidSweepAmount (amount==0) → InvalidBeneficiary (beneficiary==0) → InvalidERC721Amount (ERC721 + amt≠1) → SweepExceedsSurplus (amount > surplus)on revert
ESAU-61Authorization matrix is not touched regardless of outcomeevery call

Admin gate (Unauthorized) — group 70

Checked in adminUnauthorizedPostconditions for every *Unauthorized handler.

IDConditionChecked
ESAU-70Revert selector is solady's Ownable.Unauthorized.selectorevery call
ESAU-71Authorization matrix, AssetManager wiring, and nextEscrowId are all unchangedevery call
ESAU-72The admin-guarded call itself must fail for a non-admin callerevery call

Preconditions

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

HandlerClamp rules
registerModule / deactivateModule (both authorized and unauthorized variants)moduleType picked from {bytes32(0), FUZZ_MODULE_TYPE_A, B, C} — index 0 is zero so InvalidModuleType is reachable from the same modulo-based seed picker. moduleAddr picked from address(0) or the four-address fuzz pool so InvalidModuleAddress remains reachable.
setAssetManager (both variants)Candidate selected evenly across {real AssetManager (no-op), synthetic non-zero (rotate+restore), address(0) (ZeroAddress revert)}, one third of the time each.
createEscrowAsActorCaller is always currentActor, one of the tracked engine users, which are EOAs and are never registered as modules, guaranteeing the authorization gate fires first. amount, depositor (picked from tracked users), and tokenId are passed through raw.
withdrawAsActor / claimAsActorescrowId clamped to [0, nextEscrowId + 2] so both the empty-slot branch (EscrowNotFound) and the existing-but-unauthorized branch (ModuleNotAuthorized) are reachable. Caller is the dedicated FUZZ_ESCROW_UNAUTH_CALLER, which is kept out of the mutable module pools and validated as unregistered during setup.
sweep (both variants)assetType ∈ {NONE, ERC20, ERC721, ERC6909} — ERC1155 is excluded because its balanceOf(address, uint256) selector aliases ERC6909's on the real bond token, which would let the ERC1155 branch reach _transferAsset and revert with empty data on a missing safeTransferFrom. For ERC20, tokenId is forced non-zero so InvalidTokenId fires before the ERC20 balance probe (our ERC6909 token has no balanceOf(address)). amount ∈ {0, 1, 2, 3} so both the ERC721 amount == 1 and general InvalidSweepAmount branches are common. Beneficiary is selected from zero address or the tracked users.

All clamp failures raise ClampFail(string), which the integrity layer accepts as a skip. In practice the module and type pools are non-empty after setup, so clamp failures do not occur under normal fuzzing.