Skip to content

[simplex]: MPCVault TYPE_SIGN_TYPED_DATA signatures don't verify against the submitted payload #1132

Closed

Description

@seunlanlege

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 to SolverAccount at 0xfCd233b937D7622AAc63ced3C9A1A12F4a6B64E3 on Base)
  • Filler substrate account: 13Vq4AEmUdFe61GB2Vrx58j3d9S4bjscUB3hDFDvfSj5YsBf
  • Client: MpcVaultService (sdk/packages/simplex/src/services/wallet/mpcvault.ts), gRPC stubs generated from mpcvault/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.

  1. 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).
  2. 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 scheme EIP-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).
  3. 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.
  4. 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 PackedUserOperation typed data, all 5 configured chain IDs)
    • EIP-191 wraps (of the digest, of the struct hash, double-wraps)
    • domain-separator malformations: no EIP712Domain fields, 5-field with zero salt, dropped chainId, chainId ∈ {0, 1}, chainId-only, dropped/zeroed verifyingContract, chainId hashed as a string type
    • struct-hash malformations: bytes32 fields re-hashed as dynamic bytes, dynamic fields ABI-encoded inline, uint256 values hashed as decimal strings
    • hashes of the JSON content itself: keccak / EIP-191-wrapped keccak / personal_sign over 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)
  5. 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).
  6. 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 ExecuteSigningRequests is 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; needs x-mtoken + whitelisted IP + running client-signer.)
  • GetSigningRequestDetails on one of the failed requests — compare recorded content vs returned signature; check whether signatures contains one entry or several.
  • docker logs mpcvault-signer during 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 require types.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

  1. seunlanlege commented on Aug 14, 2026

    @seunlanlege
    MemberAuthor

    Root cause pinned — live A/B against a real vault (2026-08-14)

    Test rig: fresh vault + client signer + auto-approving callback, MpcVaultService unchanged (typed data via TYPE_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:

    Payload Result
    EIP712Domain listed in types, numeric chainId ✅ signs the canonical digest
    no EIP712Domain, numeric chainId ❌ signature verifies against nothing
    no EIP712Domain, string chainId (the exact pre-#1134 filler shape) ❌ signature verifies against nothing
    EIP712Domain listed in types, string chainId ✅ signs the canonical digest

    Conclusions:

    1. The trigger is exactly the missing types.EIP712Domain. The chainId encoding (string vs number) has no effect.
    2. With the domain type absent, MPCVault does not mis-hash deterministically — the signature matches no digest constructible from the payload (canonical, EIP-191 wraps, empty/zero/string-typed domain-separator variants, bare struct hash), consistent with the production forensics on the failed filler bids (60 bids, ~100k candidate digests, per-bid-varying recovery). Likely the two MPC parties hash inconsistently and the ceremony emits shares over mismatched digests.
    3. [sdk]: emit standard eth_signTypedData_v4 payload for userop signing #1134's canonical-payload fix is empirically validated: the recovery-asserting integration test (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_DATA should reject payloads lacking types.EIP712Domain instead of returning a signature that verifies against nothing.

    Can close once #1134 merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions