Target: github.com/stackup-wallet/keystore @ 806fc6a (2025-08-29). Cantina $100K. Verdict: WALK CLEAN. Post-Spearbit (July 2025) audit; mediums fixed at HEAD.
ERC-4337 smart accounts whose config (owner/guardian/permission set) is a Merkle tree (UCMT). A permissionless singleton Keystore stores only the root per (refHash, account). Each leaf node = abi.encodePacked(verifier(20B), config). Stateless verifiers attest signatures; keystore enforces Merkle inclusion + a 2D nonce. Cross-chain sync is achieved by replaying signed UpdateActions onto each chain's keystore (deterministic-deploy, same address everywhere).
Cross-chain replay is frequently the FEATURE (config sync), so "can this be replayed?" is the wrong lead. Enumerate the signed-message preimage and check each field:
- account present => blocks cross-account hijack (shared refHash/signer). MISSING = takeover bug.
- nonce present => blocks same-chain replay. (2D nonce here: key|seq, keyed by (refHash,key,account).)
- current-config hash (keccak256(node)) present => binds WHO authorizes.
- next-config hash (keccak256(nextNode)) present => binds WHAT config you move to. MISSING = next-config substitution bug.
- chainid => SHOULD be opt-in (here useChainId flag). Present-always would break sync; absent-always would be fine for sync but the per-field checks above are what actually matter.
Stackup binds all of {account, nonce, node, nextNode} and gates chainid behind useChainId. Clean.
On a root update, if the new config (nextNode) is supplied, its verifier must ALSO validate the SAME message (not just prove Merkle inclusion in nextHash). Otherwise a relayer/attacker could install a config the incoming controller never signed. Stackup's _requiresNextNodeVerifierCall(...) && _isSigValidationFailed(message, nextNode, nextData) enforces this. Audit lens: confirm both the OUTGOING (current node) AND INCOMING (next node) verifier sign off, over a message that binds both node hashes.
A generic upstream layer (the keystore) reduces ERC-4337 packed validationData to a bool by testing only == SIG_VALIDATION_FAILED(1). This silently drops validUntil/validAfter/authorizer. Safe ONLY if (a) that path is not meant to be time-gated AND (b) the downstream ERC-4337 consumer receives the un-truncated value on the value-moving path. In Stackup, handleUpdates is intentionally not time-gated and the account-side validate flow forwards the full value, so both hold. For a future target, a value-moving path that truncates a not-yet-valid window to "valid" would be a real (>=Low) finding.
Audit fix "implement ERC-7739" (shared-signer ERC-1271 replay) lives in the solady ERC1271 base, NOT in the project's KeystoreAccount.sol. The base wraps _erc1271IsValidSignatureNowCalldata with TypedDataSign/PersonalSign rehashing. Before declaring an audited medium unfixed at HEAD, grep the pinned dependency submodule, not just the in-scope diff.
_requireSortedAndUnique (strictly ascending owners). PR47.nonReentrant (ReentrancyGuardTransient).256 owners/sigs bounds; min-threshold>0.
~/bounty/cycles/cycle-223-stackup/repo/test/core/Cycle223.t.sol - T1 (cross-chain replay by-design PASS), T1b (cross-account replay blocked PASS), T3 (time-bounded update applies immediately PASS). All real forge stdout in 00-verdict.md.