Skip to content

Commit 6821e3e

Browse files
committed
fix(eip712): harden typed-data signing correctness
Two correctness fixes in the EIP-712 signing path: - util/eip712.ts: hexToBytes silently truncated an odd-length hex string (dropping the last nibble), producing a wrong hash for `bytes` values. It now throws so signing fails loudly instead of signing the wrong data. - bridges/ethers.ts: when signTypedData is called without an explicit primaryType, the ethers bridge picked the first key in `types`. That signs the wrong struct when the root type is not declared first (a dependency listed above it). It now infers the root type (the struct not referenced by any other), matching ethers.js. Adds regression tests for both.
1 parent 8de4537 commit 6821e3e

4 files changed

Lines changed: 70 additions & 2 deletions

File tree

src/__tests__/bridges.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,28 @@ describe("EthersAdapterSigner", () => {
119119
})
120120
})
121121

122+
it("infers the root primaryType when omitted, not the first types key", async () => {
123+
const adapter = createMockAdapter()
124+
const signer = walletAdapterToEthersSigner(adapter, {})
125+
// `Person` (a dependency) is declared before `Mail` (the root). The primary
126+
// type is the struct not referenced by any other, i.e. `Mail` - not the
127+
// first key.
128+
const types = {
129+
Person: [{ name: "wallet", type: "address" }],
130+
Mail: [
131+
{ name: "from", type: "Person" },
132+
{ name: "contents", type: "string" },
133+
],
134+
}
135+
await signer.signTypedData({ name: "Test" }, types, {
136+
from: { wallet: "0x1234567890abcdef1234567890abcdef12345678" },
137+
contents: "hi",
138+
})
139+
expect(adapter.signTypedData).toHaveBeenCalledWith(
140+
expect.objectContaining({ primaryType: "Mail" }),
141+
)
142+
})
143+
122144
it("connect returns new signer with different provider", () => {
123145
const adapter = createMockAdapter()
124146
const signer = walletAdapterToEthersSigner(adapter, { id: 1 })

src/__tests__/eip712.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from "vitest"
2+
import { hashTypedData } from "../util/eip712.js"
3+
4+
describe("hashTypedData", () => {
5+
const domain = { name: "Test", version: "1", chainId: 1 }
6+
7+
it("rejects an odd-length hex bytes value instead of silently truncating", () => {
8+
const types = { Doc: [{ name: "data", type: "bytes" }] }
9+
// 5 hex digits -> odd length. Previously the last nibble was dropped and a
10+
// wrong hash was signed; it must now throw instead.
11+
expect(() =>
12+
hashTypedData(domain, "Doc", { data: "0xabcde" }, types),
13+
).toThrow(/odd length/i)
14+
})
15+
16+
it("still hashes a valid even-length bytes value", () => {
17+
const types = { Doc: [{ name: "data", type: "bytes" }] }
18+
const hash = hashTypedData(domain, "Doc", { data: "0xabcd" }, types)
19+
expect(hash).toBeInstanceOf(Uint8Array)
20+
expect(hash.length).toBe(32)
21+
})
22+
})

src/bridges/ethers.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,30 @@ export class EthersAdapterSigner {
108108
domain,
109109
types,
110110
message: value,
111-
primaryType:
112-
primaryType ?? Object.keys(types).find(t => t !== "EIP712Domain") ?? "",
111+
primaryType: primaryType ?? inferPrimaryType(types),
113112
})
114113
}
115114

116115
connect(provider: any): EthersAdapterSigner {
117116
return new EthersAdapterSigner(this.adapter, provider)
118117
}
119118
}
119+
120+
/**
121+
* Infer the EIP-712 primary type the way ethers.js does: the struct that is not
122+
* referenced as a field type by any other struct (the root of the type graph).
123+
* The previous heuristic took the first key in `types`, which signs the wrong
124+
* struct when the root is not declared first (e.g. dependencies listed above it).
125+
*/
126+
function inferPrimaryType(types: Record<string, any>): string {
127+
const named = Object.keys(types).filter(t => t !== "EIP712Domain")
128+
const referenced = new Set<string>()
129+
for (const name of named) {
130+
for (const field of types[name] ?? []) {
131+
const base = String(field.type).replace(/(\[\d*\])+$/, "")
132+
if (base in types) referenced.add(base)
133+
}
134+
}
135+
const roots = named.filter(t => !referenced.has(t))
136+
return roots[0] ?? named[0] ?? ""
137+
}

src/util/eip712.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ function encodeValue(
187187

188188
function hexToBytes(hex: string): Uint8Array {
189189
const clean = hex.startsWith("0x") ? hex.slice(2) : hex
190+
// An odd-length hex string would otherwise be silently truncated (the last
191+
// nibble dropped), producing a wrong EIP-712 hash for `bytes` values. Reject
192+
// it so signing fails loudly instead of signing the wrong data.
193+
if (clean.length % 2 !== 0) {
194+
throw new Error(`Invalid hex value: odd length (${clean.length} digits)`)
195+
}
190196
const bytes = new Uint8Array(clean.length / 2)
191197
for (let i = 0; i < bytes.length; i++) {
192198
bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16)

0 commit comments

Comments
 (0)