Repository navigation
[simplex]: MPCVault TYPE_SIGN_TYPED_DATA signatures don't verify against the submitted payload #1132
Description
Summary
Signatures returned by MPCVault's CreateSigningRequest with evm_message.type = TYPE_SIGN_TYPED_DATA do not verify against the EIP-712 digest of the submitted payload — or against any digest derivable from it that we could find. Every bid UserOp and delegation-via-bundler UserOp signed through this path since the MPC-backed filler upgraded to typed-data signing (post #1030 builds) fails on-chain validation with AA24 / "Invalid account signature". Signatures from the same vault through the raw-message path (raw_message + ECDSA_HASH_FUNCTION_USE_MESSAGE_DIRECTLY) validate and execute on-chain.
A hotfix routes all MPC typed-data signing through local hashing + the raw-message path. This issue tracks the underlying investigation.
Affected setup
- Solver EOA / MPCVault wallet:
0xb52c73CB71428a2a727044BCC82ed48E545AE468(EIP-7702-delegated toSolverAccountat0xfCd233b937D7622AAc63ced3C9A1A12F4a6B64E3on Base) - Filler substrate account:
13Vq4AEmUdFe61GB2Vrx58j3d9S4bjscUB3hDFDvfSj5YsBf - Client:
MpcVaultService(sdk/packages/simplex/src/services/wallet/mpcvault.ts), gRPC stubs generated frommpcvault/mpcvaultapis@6ea3272(verified current: the two upstream commits since are additive only)
Evidence (2026-08-14)
Data sources: fillerBids / iOrderV3s / iOrderV3Fills on the nexus indexer, intents_getBidsForOrder on the nexus RPC, Base RPC.
- The vault and key are fine. The EIP-7702 delegation direct tx and its authorization tuple — both signed via the raw-message path — landed on Base (the EOA carries the
0xef0100…delegation designator; nonce 30). - Era split across all 60 of this filler's indexed bids. Old filler build (pre-[evm]: transparent solver bid signing #1030 scheme, blocks ≤ 11363691, ≈Aug 1–12): bids were signed via
signMessage→signPersonalMessage, which hashes locally and signs through raw-message. 54/54 recover the solver EOA under the then-deployed schemeEIP-191(keccak(userOpHash ‖ commitment ‖ sessionKey))— hence this filler's 94 successful fills. Current build (typed-data signing, blocks ≥ 11474495): 0 recover under any scheme (the remaining 3 bids are phantom orders with no session to test the legacy digest against). - Control. 20/20 bids from other fillers (private-key signers) recover correctly with identical decoding/digest machinery — the machinery and the on-chain scheme are not the problem.
- The signature is not any plausible transformation of the payload. Tested per failing bid, requiring recovery to the solver EOA (all missed, v-flip included):
- canonical EIP-712 digest (EntryPoint v0.8
PackedUserOperationtyped data, all 5 configured chain IDs) - EIP-191 wraps (of the digest, of the struct hash, double-wraps)
- domain-separator malformations: no
EIP712Domainfields, 5-field with zerosalt, droppedchainId,chainId∈ {0, 1},chainId-only, dropped/zeroedverifyingContract,chainIdhashed as a string type - struct-hash malformations:
bytes32fields re-hashed as dynamicbytes, dynamic fields ABI-encoded inline, uint256 values hashed as decimal strings - hashes of the JSON content itself: keccak / EIP-191-wrapped keccak /
personal_signover 4 serializations (member order as-sent and eth_signTypedData_v4 order × chainId as string/number × sender checksummed/lowercase, with and without trailing newline) - the pre-[evm]: transparent solver bid signing #1030 legacy digest, r/s swapped, r/s byte-reversed
- all 40,320 permutations of the message field order × 2 typehash variants (Go-map-iteration hypothesis)
- canonical EIP-712 digest (EntryPoint v0.8
- Not a stable wrong key. Recovering each failing signature over its correct digest yields a different unknown address per bid (e.g.
0x5a0ac3b7…,0xe9dc423e…,0x44f66b02…— all with zero on-chain history). - Not crossed responses between concurrent requests. No signature from any bid recovers the EOA over any other bid's digest (full cross-pair over the indexed set). MPCVault docs confirm
ExecuteSigningRequestsis scoped to the single request uuid.
Working hypothesis
The only variable separating working from broken signatures is who computes the digest: raw-message (client hashes, MPCVault signs 32 bytes) works; typed-data (MPCVault parses and hashes the JSON) does not. The per-request-varying, payload-unrelated output is consistent with the two MPC parties — MPCVault's server and the operator's client-signer container — hashing the typed data inconsistently, so the threshold shares combine over mismatched digests and the result verifies under neither. Prime suspect trigger: our payload omits types.EIP712Domain (viem-style; CryptoUtils.packedUserOpTypedData doesn't emit it), which EIP-712 implementations handle divergently (geth apitypes errors, ethers infers, hand-rolled code varies). Notably the March integration test payload included EIP712Domain and only asserted signature length — recovery was never checked, so a broken typed-data path would have passed.
Also plausible: the returned SignatureContainer entry for typed-data requests is a per-share partial rather than the combined signature (upstream proto comments call the container "per-share signature results"), while raw-message requests return the combined one.
How to pin it
- Live A/B with operator credentials: sign one fixed payload three ways — typed-data without
types.EIP712Domain, typed-data with it, and the raw pre-hashed digest — and recover each against the wallet address. (Probe script pattern exists from this investigation; needsx-mtoken+ whitelisted IP + running client-signer.) -
GetSigningRequestDetailson one of the failed requests — compare recorded content vs returned signature; check whethersignaturescontains one entry or several. -
docker logs mpcvault-signerduring a typed-data signing — does the client-signer parse/hash the typed data itself? - Binary analysis of
ghcr.io/mpcvault/client-signer(Go symbol table: which EIP-712 implementation, does it requiretypes.EIP712Domain). - Report to MPCVault support with the A/B results.
Interim state
The MPC adapter now hashes typed data locally (viem hashTypedData) and signs through the raw-message path, so bids, delegation-via-bundler UserOps, and Circle permits all ride the code path with on-chain-proven signatures. Trade-off: the MPCVault console shows an opaque 32-byte digest instead of the structured message. If MPCVault's typed-data endpoint is fixed (or the trigger is confirmed as the missing EIP712Domain entry and a compliant payload verifies), the structured path can be restored behind a recovery-asserting test.
Activity
[simplex]: route MPCVault typed-data signing through raw message hashes
[simplex]: route MPCVault typed-data signing through raw message hashes
[simplex]: assert signature recovery in MPCVault integration tests
seunlanlege commentedon Aug 14, 2026
Root cause pinned — live A/B against a real vault (2026-08-14)
Test rig: fresh vault + client signer + auto-approving callback,
MpcVaultServiceunchanged (typed data viaTYPE_SIGN_TYPED_DATA). Same typed-data content signed in four payload shapes; every returned signature checked by ECDSA recovery against the canonical EIP-712 digest and a battery of malformed-digest candidates:EIP712Domainlisted intypes, numericchainIdEIP712Domain, numericchainIdEIP712Domain, stringchainId(the exact pre-#1134 filler shape)EIP712Domainlisted intypes, stringchainIdConclusions:
types.EIP712Domain. ThechainIdencoding (string vs number) has no effect.test:mpcvault) passes against the live endpoint, and the raw-message path (signPersonalMessage,signRawHash,signRawHashComponents,signTransaction) verifies correct on the same vault — matching the on-chain history (54/54 valid raw-era bid signatures, 94 fills).Worth reporting upstream to MPCVault:
TYPE_SIGN_TYPED_DATAshould reject payloads lackingtypes.EIP712Domaininstead of returning a signature that verifies against nothing.Can close once #1134 merges.