Skip to content

Instantly share code, notes, and snippets.

@benwoody
Last active May 13, 2026 03:50
Show Gist options
  • Select an option

  • Save benwoody/72f6c43ab9e743fef739dd31abd0465c to your computer and use it in GitHub Desktop.

Select an option

Save benwoody/72f6c43ab9e743fef739dd31abd0465c to your computer and use it in GitHub Desktop.
─── 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.
/**
* 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);
});
@benwoody
Copy link
Copy Markdown
Author

benwoody commented May 13, 2026

The dGEN1 wallet produces invalid signatures across all three standard signing primitives:

  • personal_sign with hex-encoded message (per WC spec) → signs literal hex string instead of decoded bytes
  • personal_sign with plain UTF-8 message → falls back to signing the wallet's own checksum address
  • eth_signTypedData_v4 with valid EIP-712 typed data → ignores the typed data entirely, falls back to signing the wallet's own lowercase address

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.

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