Last active
May 13, 2026 03:50
-
-
Save benwoody/72f6c43ab9e743fef739dd31abd0465c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ─── Step 2: Request personal_sign ───────────────────────────────────── | |
| Message (UTF-8): "hello my name is dgen1 my voice is my passport verify me" | |
| Hex-encoded for WC params[0]: 0x68656c6c6f206d79206e616d65206973206467656e31206d7920766f696365206973206d792070617373706f727420766572696679206d65 | |
| Approve on the phone... | |
| ✓ Signature received (544 bytes) | |
| ─── Step 3: Signature shape ─────────────────────────────────────────── | |
| Size: 544 bytes | |
| ERC-6492 magic suffix: NOT present (rules out predeploy hypothesis) | |
| Embedded clientDataJSON: {"type":"webauthn.get","challenge":"LM1zzmD2KIyykr_T1U2I4xMjqDPT71-uNljF3cByprk"} | |
| Decoded WebAuthn challenge: 0x2ccd73ce60f6288cb292bfd3d54d88e31323a833d3ef5fae3658c5ddc072a6b9 | |
| ↑ this is the hash the wallet's passkey actually signed | |
| ─── Step 4: What the verifier expects ───────────────────────────────── | |
| EIP-191 hash of decoded message: 0x4ca7a7db2efcb65897a0b1de4874aebcb42562462262f16f3a269fb0ffaa8d6f | |
| CSW.replaySafeHash(decoded): 0xee869e7212d9da2642c7a4182ef600e0b3ebb46023ef17e8a367030193ae8f62 | |
| ↑ this is what CSW.isValidSignature will check the | |
| signature's challenge against | |
| ─── Step 5: What the wallet actually signed ─────────────────────────── | |
| Hypothesis: the wallet skipped hex-decoding params[0] and treated | |
| the literal string "0x68656c6c6f206d79206e616d65206973206467656e31206d7920766f696365206973206d792070617373706f727420766572696679206d65" as the message bytes. | |
| EIP-191 hash of literal hex string: 0x17c97c9991ee9de07f35b6156f5fa0b80a7843558d8ec2441dc4ec7bcb3b731b | |
| CSW.replaySafeHash(literal): 0x2ccd73ce60f6288cb292bfd3d54d88e31323a833d3ef5fae3658c5ddc072a6b9 | |
| Embedded challenge vs decoded: ✗ no match | |
| Embedded challenge vs literal: ✓ MATCH (this is the bug) | |
| ─── Step 6: Direct on-chain isValidSignature(hash, sig) ─────────────── | |
| Wallet: 0xfe4Cc.......5e | |
| Returned: 0xffffffff | |
| Verdict: ✗ INVALID (returned 0xffffffff — EIP-1271 sentinel) | |
| ─── Step 7: viem.verifyMessage (stock library path) ─────────────────── | |
| Result: ✗ INVALID | |
| (same code path SIWE, Safe, OpenSea/Blur, Privy, Dynamic, etc. use) | |
| ════════════════════════════════════════════════════════════════════════ | |
| Conclusion | |
| ════════════════════════════════════════════════════════════════════════ | |
| The dGEN1 wallet's WC personal_sign handler does NOT hex-decode | |
| params[0] before hashing. It signs: | |
| replaySafeHash(EIP-191(literal hex string)) | |
| But verifiers (viem, ethers, SIWE, Safe, the CSW contract itself) | |
| compute: | |
| replaySafeHash(EIP-191(decoded message bytes)) | |
| These will NEVER match for any message — every signature fails | |
| EIP-1271 verification universally. | |
| Fix (one line in the wallet's WC handler): hex-decode params[0] | |
| before applying EIP-191. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * dGEN1 personal_sign / EIP-1271 Verification Test | |
| * ───────────────────────────────────────────────── | |
| * | |
| * Reproduces and pinpoints a signing bug in the dGEN1 wallet's | |
| * WalletConnect `personal_sign` handler. | |
| * | |
| * The bug: the wallet does NOT hex-decode `params[0]` before hashing. | |
| * It treats the hex-encoded message string as the literal message bytes, | |
| * applies EIP-191, then signs that. Every standard verifier | |
| * (`viem.verifyMessage`, SIWE, Safe, OpenSea, etc.) computes EIP-191 | |
| * over the *decoded* bytes, so the hashes never match and signatures | |
| * universally fail EIP-1271 verification. | |
| * | |
| * What this script does: | |
| * 1. Pairs with the dGEN1 over WalletConnect v2 | |
| * 2. Requests a properly hex-encoded `personal_sign` (per the WC spec) | |
| * 3. Decodes the WebAuthn challenge embedded in the returned signature | |
| * 4. Computes BOTH candidate hashes on-chain via the wallet's CSW | |
| * `replaySafeHash` view: | |
| * - what the verifier expects: replaySafeHash(EIP-191(decoded_message)) | |
| * - what the wallet actually signed: replaySafeHash(EIP-191(literal_hex_string)) | |
| * 5. Shows which one matches the embedded challenge | |
| * 6. Runs `viem.verifyMessage` to confirm INVALID | |
| * | |
| * Requirements: | |
| * - .env with WC_PROJECT_ID (Reown / WalletConnect Cloud) | |
| * - .env with INBOX_ADDRESS (the dGEN1 wallet address) | |
| * - Node 22+ | |
| * | |
| * Run: | |
| * npm install | |
| * npm run verify-test | |
| */ | |
| import "dotenv/config"; | |
| import { SignClient } from "@walletconnect/sign-client"; | |
| import qrcode from "qrcode-terminal"; | |
| import { | |
| createPublicClient, | |
| http, | |
| hashMessage, | |
| parseAbi, | |
| toHex, | |
| type Hex, | |
| } from "viem"; | |
| import { mainnet } from "viem/chains"; | |
| // ── Configuration ──────────────────────────────────────────────────────── | |
| const PROJECT_ID = process.env.WC_PROJECT_ID!; | |
| const INBOX_ADDRESS = process.env.INBOX_ADDRESS! as `0x${string}`; | |
| const TEST_MESSAGE = "hello my name is dgen1 my voice is my passport verify me"; | |
| // Use a reliable public RPC. Cloudflare/Tenderly have intermittently rate-limited | |
| // or rejected the `eth_call` patterns this script makes; 1rpc.io has been stable. | |
| const RPC_URL = "https://1rpc.io/eth"; | |
| const ERC6492_MAGIC = | |
| "6492649264926492649264926492649264926492649264926492649264926492"; | |
| const EIP1271_MAGIC = "0x1626ba7e"; | |
| const EIP1271_INVALID = "0xffffffff"; | |
| const CSW_ABI = parseAbi([ | |
| "function isValidSignature(bytes32 hash, bytes signature) view returns (bytes4)", | |
| "function replaySafeHash(bytes32 hash) view returns (bytes32)", | |
| ]); | |
| // ── Helpers ────────────────────────────────────────────────────────────── | |
| /** Pull the WebAuthn clientDataJSON out of an ABI-encoded CSW signature | |
| * and decode the `challenge` field back to bytes. */ | |
| function extractWebAuthnChallenge(sigHex: Hex): { json: string; challengeHex: Hex } | null { | |
| const bytes = Buffer.from(sigHex.slice(2), "hex"); | |
| const text = bytes.toString("utf8"); | |
| const match = text.match(/\{"type":"webauthn\.get"[^}]+\}/); | |
| if (!match) return null; | |
| try { | |
| const cdj = JSON.parse(match[0]); | |
| if (!cdj.challenge) return null; | |
| const padded = cdj.challenge.replace(/-/g, "+").replace(/_/g, "/"); | |
| const b64 = padded + "=".repeat((4 - (padded.length % 4)) % 4); | |
| const decoded = Buffer.from(b64, "base64"); | |
| return { json: match[0], challengeHex: ("0x" + decoded.toString("hex")) as Hex }; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function row(label: string, value: string) { | |
| console.log(` ${label.padEnd(42)}${value}`); | |
| } | |
| // ── Main ───────────────────────────────────────────────────────────────── | |
| async function main() { | |
| console.log("═".repeat(72)); | |
| console.log(" dGEN1 personal_sign / EIP-1271 Verification Test"); | |
| console.log("═".repeat(72)); | |
| // ── Step 1: WC pair ──────────────────────────────────────────────────── | |
| console.log("\n─── Step 1: Pair with the dGEN1 via WalletConnect ─────────────────────"); | |
| const signClient = await SignClient.init({ | |
| projectId: PROJECT_ID, | |
| metadata: { | |
| name: "dgen1-verify-test", | |
| description: "Standalone EIP-1271 verification test for dGEN1 signatures", | |
| url: "https://localhost", | |
| icons: [], | |
| }, | |
| }); | |
| const { uri, approval } = await signClient.connect({ | |
| requiredNamespaces: { | |
| eip155: { | |
| methods: ["personal_sign"], | |
| chains: ["eip155:1"], | |
| events: ["accountsChanged", "chainChanged"], | |
| }, | |
| }, | |
| }); | |
| if (!uri) throw new Error("no WC URI returned"); | |
| console.log("\nScan this QR with the dGEN1's wallet (Connect Wallet → Scan):\n"); | |
| qrcode.generate(uri, { small: true }); | |
| console.log(`\nFallback URI (copy-paste if QR fails):\n ${uri}\n`); | |
| console.log("Waiting for approval on the phone..."); | |
| const session = await approval(); | |
| const connected = session.namespaces.eip155.accounts[0].split(":")[2].toLowerCase(); | |
| console.log(`✓ Paired with ${connected}`); | |
| // ── Step 2: Request signature ────────────────────────────────────────── | |
| console.log("\n─── Step 2: Request personal_sign ─────────────────────────────────────"); | |
| // WC `personal_sign` spec: params[0] MUST be hex-encoded UTF-8 bytes. | |
| const messageHex = toHex(TEST_MESSAGE); | |
| row("Message (UTF-8):", `"${TEST_MESSAGE}"`); | |
| row("Hex-encoded for WC params[0]:", messageHex); | |
| console.log("\nApprove on the phone..."); | |
| const signature = (await signClient.request<string>({ | |
| topic: session.topic, | |
| chainId: "eip155:1", | |
| request: { | |
| method: "personal_sign", | |
| params: [messageHex, INBOX_ADDRESS], | |
| }, | |
| })) as Hex; | |
| console.log(`✓ Signature received (${(signature.length - 2) / 2} bytes)`); | |
| // ── Step 3: Signature shape ──────────────────────────────────────────── | |
| console.log("\n─── Step 3: Signature shape ───────────────────────────────────────────"); | |
| const sigBytes = (signature.length - 2) / 2; | |
| const endsWith6492 = signature.toLowerCase().endsWith(ERC6492_MAGIC); | |
| row("Size:", `${sigBytes} bytes`); | |
| row( | |
| "ERC-6492 magic suffix:", | |
| endsWith6492 ? "PRESENT" : "NOT present (rules out predeploy hypothesis)" | |
| ); | |
| const wa = extractWebAuthnChallenge(signature); | |
| if (!wa) { | |
| console.log(" Could not decode WebAuthn challenge from signature. Aborting."); | |
| process.exit(1); | |
| } | |
| console.log(`\n Embedded clientDataJSON: ${wa.json}`); | |
| row("Decoded WebAuthn challenge:", wa.challengeHex); | |
| console.log(" ↑ this is the hash the wallet's passkey actually signed"); | |
| // ── Step 4: What the verifier expects ────────────────────────────────── | |
| console.log("\n─── Step 4: What the verifier expects ─────────────────────────────────"); | |
| const eth = createPublicClient({ chain: mainnet, transport: http(RPC_URL) }); | |
| const eip191DecodedHash = hashMessage(TEST_MESSAGE); | |
| row("EIP-191 hash of decoded message:", eip191DecodedHash); | |
| const expectedChallenge = (await eth.readContract({ | |
| address: INBOX_ADDRESS, | |
| abi: CSW_ABI, | |
| functionName: "replaySafeHash", | |
| args: [eip191DecodedHash], | |
| })) as Hex; | |
| row("CSW.replaySafeHash(decoded):", expectedChallenge); | |
| console.log(" ↑ this is what CSW.isValidSignature will check the"); | |
| console.log(" signature's challenge against"); | |
| // ── Step 5: What the wallet actually signed ──────────────────────────── | |
| console.log("\n─── Step 5: What the wallet actually signed ───────────────────────────"); | |
| console.log(" Hypothesis: the wallet skipped hex-decoding params[0] and treated"); | |
| console.log(` the literal string "${messageHex}" as the message bytes.\n`); | |
| // EIP-191 of the literal hex string (treating its characters as message bytes) | |
| const eip191LiteralHash = hashMessage(messageHex); | |
| row("EIP-191 hash of literal hex string:", eip191LiteralHash); | |
| const literalChallenge = (await eth.readContract({ | |
| address: INBOX_ADDRESS, | |
| abi: CSW_ABI, | |
| functionName: "replaySafeHash", | |
| args: [eip191LiteralHash], | |
| })) as Hex; | |
| row("CSW.replaySafeHash(literal):", literalChallenge); | |
| console.log(); | |
| const expectedMatch = wa.challengeHex.toLowerCase() === expectedChallenge.toLowerCase(); | |
| const literalMatch = wa.challengeHex.toLowerCase() === literalChallenge.toLowerCase(); | |
| row("Embedded challenge vs decoded:", expectedMatch ? "✓ MATCH" : "✗ no match"); | |
| row("Embedded challenge vs literal:", literalMatch ? "✓ MATCH (this is the bug)" : "✗ no match"); | |
| // ── Step 6: On-chain EIP-1271 verification ───────────────────────────── | |
| console.log("\n─── Step 6: Direct on-chain isValidSignature(hash, sig) ───────────────"); | |
| try { | |
| const result = (await eth.readContract({ | |
| address: INBOX_ADDRESS, | |
| abi: CSW_ABI, | |
| functionName: "isValidSignature", | |
| args: [eip191DecodedHash, signature], | |
| })) as Hex; | |
| row("Wallet:", INBOX_ADDRESS); | |
| row("Returned:", result); | |
| if (result.toLowerCase() === EIP1271_MAGIC) { | |
| row("Verdict:", `✓ VALID (matches EIP-1271 magic ${EIP1271_MAGIC})`); | |
| } else if (result.toLowerCase() === EIP1271_INVALID) { | |
| row("Verdict:", `✗ INVALID (returned ${EIP1271_INVALID} — EIP-1271 sentinel)`); | |
| } else { | |
| row("Verdict:", `✗ INVALID (returned ${result})`); | |
| } | |
| } catch (e) { | |
| const msg = e instanceof Error ? e.message.split("\n")[0] : String(e); | |
| row("Reverted:", msg.slice(0, 100)); | |
| } | |
| // ── Step 7: viem.verifyMessage ──────────────────────────────────────── | |
| console.log("\n─── Step 7: viem.verifyMessage (stock library path) ───────────────────"); | |
| const isValid = await eth.verifyMessage({ | |
| address: INBOX_ADDRESS, | |
| message: TEST_MESSAGE, | |
| signature, | |
| }); | |
| row("Result:", isValid ? "✓ VALID" : "✗ INVALID"); | |
| console.log(" (same code path SIWE, Safe, OpenSea/Blur, Privy, Dynamic, etc. use)"); | |
| // ── Summary ──────────────────────────────────────────────────────────── | |
| console.log("\n" + "═".repeat(72)); | |
| console.log(" Conclusion"); | |
| console.log("═".repeat(72)); | |
| if (literalMatch && !expectedMatch) { | |
| console.log( | |
| `\n The dGEN1 wallet's WC personal_sign handler does NOT hex-decode\n` + | |
| ` params[0] before hashing. It signs:\n\n` + | |
| ` replaySafeHash(EIP-191(literal hex string))\n\n` + | |
| ` But verifiers (viem, ethers, SIWE, Safe, the CSW contract itself)\n` + | |
| ` compute:\n\n` + | |
| ` replaySafeHash(EIP-191(decoded message bytes))\n\n` + | |
| ` These will NEVER match for any message — every signature fails\n` + | |
| ` EIP-1271 verification universally.\n\n` + | |
| ` Fix (one line in the wallet's WC handler): hex-decode params[0]\n` + | |
| ` before applying EIP-191.\n` | |
| ); | |
| } else if (expectedMatch) { | |
| console.log("\n Signature verified successfully — something has changed!\n"); | |
| } else { | |
| console.log( | |
| "\n Embedded challenge matches neither the decoded-message nor the\n" + | |
| " literal-hex-string transformation. There is a different bug at play.\n" | |
| ); | |
| } | |
| await signClient.disconnect({ | |
| topic: session.topic, | |
| reason: { code: 6000, message: "verify-test complete" }, | |
| }); | |
| } | |
| main().catch((err) => { | |
| console.error("\nFailed:", err); | |
| process.exit(1); | |
| }); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The dGEN1 wallet produces invalid signatures across all three standard signing primitives:
The wallet visibly confirms the bug for personal_sign: the phone screen displays the literal hex-encoded params[0] (e.g., "SIGN MESSAGE: 0x6869" when the dApp requested signing of "hi"). Users cannot visually verify what they're actually signing.
Result: the wallet cannot produce any signature that verifies under standard EIP-1271 for any reasonable input. SIWE, Safe co-signing, OpenSea/Blur listings, Snapshot voting, allowlist proofs... all broken.