Skip to content

fix(tempo): KeyAuthorization encoder never enforced the TIP-1049 admin/account pairing - #437

Closed
gomesalexandre wants to merge 1 commit into
wevm:mainfrom
gomesalexandre:fix_keyauthorization_admin_binding_encode_guard
Closed

fix(tempo): KeyAuthorization encoder never enforced the TIP-1049 admin/account pairing#437
gomesalexandre wants to merge 1 commit into
wevm:mainfrom
gomesalexandre:fix_keyauthorization_admin_binding_encode_guard

Conversation

@gomesalexandre

Copy link
Copy Markdown
Contributor

what

KeyAuthorization.toTuple/toRpc never enforced the TIP-1049 invariant that account and isAdmin are paired (documented right above the type: "either both are specified or neither"). A caller bypassing the OneOf type — raw wire input, a non-TypeScript caller, or any as any — could construct isAdmin: true with no account, and the encoder would happily put it on the wire.

fromTuple/fromRpc already tolerate this shape on decode and silently drop the orphan isAdmin marker. That's intentional, not a bug — a deleted comment still visible in git history says so explicitly:

// TIP-1049 admin fields are paired: only emit both when both are present on
// the wire. Wire shapes carrying only one are tolerated for forward-compat
// but the orphan field is dropped (since the public API requires both).

So decode is left untouched. The actual gap is one-sided: ox itself should never produce wire bytes it wouldn't accept as valid input. A decode → re-encode round trip on an orphan-shaped authorization silently drops isAdmin, which changes hash()/getSignPayload() before vs after — since hash() is exactly keccak256(rlp(toTuple(auth))).

repro (before this fix)

const orphan = { address, chainId: 4217n, type: 'secp256k1', isAdmin: true } // as any — no `account`

hash(orphan)                              // 0x5c2d5693...
const decoded = fromTuple(toTuple(orphan))
decoded.isAdmin                           // undefined -- dropped
hash(decoded)                             // 0xc4fdaab1...  -- DIFFERENT

fix

Adds assertAdminBinding(), called from from(), toTuple(), and toRpc(). It throws MissingAdminAccountError only for the unstable direction — isAdmin: true with no account. The reverse (account set, isAdmin: false or omitted) is a normal account-scoped non-admin key, already round-trips stably today, and stays permitted:

isAdmin: true,  account: undefined  -> throws (this fix)
isAdmin: true,  account: 0x...      -> fine, unaffected
isAdmin: false, account: 0x...      -> fine, unaffected
isAdmin: undefined, account: 0x...  -> fine, unaffected (isAdmin defaults false)

Verified every real (non-doc-comment) call site that serializes a KeyAuthorization goes through toRpc/toTupleTransaction.ts:366, TransactionRequest.ts:263, TxEnvelopeTempo.ts:730 — so this closes the gap at every reachable production path, not just the module's own entry points.

why the existing tests didn't catch it

The admin keys (TIP-1049) describe block has 12 tests, but every one of them tests only one side in isolation:

  • Every encoder fixture passes isAdmin and account together (from({ ..., isAdmin: true, account })).
  • The decoder's orphan-shape tests (fromTuple: drops orphan isAdmin without account, fromRpc: drops orphan isAdmin without account) build the orphan tuple/RPC object as a hand-written literal — never from the real encoder's own output.

So the two sides were pinned independently and never composed: nothing ran toTuple(x)fromTuple(...) on an object the encoder itself produced from an orphan input. The new tests do exactly that composition, plus direct toTuple/toRpc/from rejection tests, plus a control confirming a legitimate paired shape and an isAdmin: false account-only shape both still round-trip stably.

severity, honestly

  • The encoder-originated path is TS-blocked under normal usage (tsc --strict rejects the orphan shape via OneOf) — reachable only via as any, a union-collapsing spread, or a non-TypeScript caller.
  • No real node was observed emitting this shape in production.
  • This is a signing-payload-correctness fix on a pre-release surface (src/tempo/**), not a demonstrated live incident — shipping it now closes a real gap before it can bite anyone once this ships and gets more traffic.

receipts

$ pnpm test src/tempo/KeyAuthorization.test.ts
 Test Files  1 passed (1)
      Tests  103 passed (103)

$ pnpm exec tsc -b        # the actual check:types command
(clean, no output)

$ pnpm check               # vp check --fix
Found 0 errors and 2 warnings in 783 files   # both warnings pre-existing, unrelated (site/src/components/Landing.tsx)

Genuine red-before/green-after: stashed just src/tempo/KeyAuthorization.ts (kept the new tests), reran — exactly the 3 new "throws" tests failed with snapshot function didn't throw / the actual round-trip instability, all 100 others passed. Restored the fix, all 103 pass.

Full src/tempo/ suite (pnpm test src/tempo/): 687 passed, 37 skipped — the 2 failing files (e2e.test.ts, multisig.e2e.test.ts) fail identically on unmodified main too (an afterAll trying to fetch a stop endpoint on a localnet node that isn't running in this sandbox — ECONNRESET, not a code regression). Confirmed by stashing and re-running against clean main.

review note

Ran Codex adversarially against this diff. It made real partial progress before I had to stop it — most usefully, it caught that my test file didn't type-check under the actual tsc -b/check:types command CI runs (I'd initially only run tsc --noEmit against the main tsconfig.json, which excludes test files; test/tsconfig.json is stricter and correctly rejected my first draft of the account-only test, which omitted isAdmin — the OneOf type requires both keys together on that branch, so isAdmin: false is the only type-legal way to express "account-scoped, not admin"). Fixed and reverified against the real tsc -b. It didn't reach a final verdict before I stopped it, so I finished the remaining checks myself (the call-site sweep above).

…n/account pairing

toTuple/toRpc happily encoded isAdmin: true with no account onto the
wire, even though the OneOf-typed shape requires both or neither. A
caller bypassing the TS type (raw wire input, a non-TS caller, or any
`as any`) could produce that orphan shape.

fromTuple/fromRpc deliberately tolerate and drop an orphan admin marker
on decode -- a deleted code comment in git history confirms this is
intentional forward-compat, not a bug, so decode is left untouched.
The gap is one-sided: ox itself should never *produce* wire bytes it
wouldn't accept as valid input, since a decode/re-encode round trip on
such a shape silently drops isAdmin, changing hash()/getSignPayload()
before vs after.

Adds assertAdminBinding(), called from from()/toTuple()/toRpc(), which
throws MissingAdminAccountError only for the unstable direction
(isAdmin: true, no account). The reverse -- account set, isAdmin
omitted or false -- is a normal account-scoped non-admin key and
already round-trips stably, so it's left permitted.

Every real (non-doc-comment) call site that serializes a
KeyAuthorization (Transaction.ts, TransactionRequest.ts,
TxEnvelopeTempo.ts) goes through toRpc/toTuple, so this closes the gap
at every reachable production path.

New tests compose the encoder and decoder on the same object (unlike
the existing suite, where every encoder fixture already pairs the two
fields correctly, and the decoder's orphan-shape tests use hand-built
tuple/RPC literals that never came out of the real encoder) -- so they
would have caught this before it shipped.
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

@gomesalexandre is attempting to deploy a commit to the Wevm Team on Vercel.

A member of the Team first needs to authorize it.

@gomesalexandre
gomesalexandre marked this pull request as ready for review September 1, 2026 19:39
@pkg-pr-new

pkg-pr-new Bot commented Sep 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/ox@437

commit: 8f6668c

@jxom jxom closed this Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants