Finding 0001: Wallet-side policy is easy to misconfigure into uselessness
A wallet policy engine can constrain an x402 payment on its own, but three silent integration traps push teams toward deleting the very clauses that cap spending.
- Type: an observation about a control, not an attack. No S1 or S2 outcome.
- Components: Turnkey policy engine,
viem2.56,@turnkey/viem0.14.41
What we set out to check
Whether a wallet provider’s policy engine can constrain an x402 payment on its own: the right token, the right chain, an allowlisted payee and an amount under a cap. An x402 payment is an EIP-3009 TransferWithAuthorization, signed as EIP-712 typed data.
It can. Our final policy passes all five checks in our spike: two legitimate payments are signed, and wrong-payee, over-cap and wrong-token requests are refused by Turnkey with no help from our code.
What went wrong on the way
Using the vendor’s own viem adapter, the obvious policy denied everything. There were three separate causes, and none was reported as such. Turnkey only ever said “insufficient permissions”.
- Field names. The policy language uses
chain_idandverifying_contract. Turnkey’s own Ethereum examples page showsverifyingContract. At least wrong names are rejected when the policy is created. - Empty domain.
viem.serializeTypedDataemitsdomain: {}unlesstypesincludes an explicitEIP712Domainentry. Normal viem accounts derive that entry, so callers never pass it, and@turnkey/viemdoes not add it. Every clause about chain, token contract or token name then silently fails to match. - Amounts are strings. viem serializes
uint256as a JSON string. The policy language compares integers only and has no cast, somessage['value'] <= 100000can never match. Counting, slicing and string comparison don’t help. It works only if the payload carries the amount as a JSON number, which means bypassing the vendor adapter and serializing the payload yourself.
We also saw that a freshly created policy is not always in force immediately. Two clauses were denied, then accepted unchanged a minute later.
Why this matters
All three failures are fail-closed. They block legitimate payments; they don’t let bad ones through. So this is not a vulnerability.
The risk is what a team does next. Faced with a policy that denies everything and an error that doesn’t say why, the path of least resistance is to delete clauses until payments flow. The clauses that “don’t work” out of the box are exactly the chain, token-contract and amount-cap clauses. The clause that works first time is the payee allowlist. A team that stops there ships a wallet policy with no spending limit, and believes it has one.
Hypothesis (not yet verified): deployed wallet-side policies for x402 are likely to be weaker than their authors think, and the amount cap is the most likely casualty. If that is true, the realistic baseline many teams run is closer to “payee allowlist only” than to a full wallet policy. Our lab therefore runs attacks against both, as separate deployment profiles (baseline and as-documented), and reports them separately.
Caveats
- One vendor, one SDK version, one afternoon. Privy, CDP and Crossmint may behave differently. CDP’s typed criteria, with numeric operators on typed-data fields, suggest it handles amounts natively, but we could not test it.
- Points 2 and 3 may already be known to Turnkey. We intend to share them.