From 9899c3c1894f2fe442cec0fd1cb522cbd76d0478 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Fri, 7 Aug 2026 01:49:14 +0300 Subject: [PATCH] fix: canonicalize typed-data signatures before forwarding on-chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallets that expose raw signer output (Frame, onboard-ledger, some WalletConnect/MPC wallets) return the ECDSA recovery byte as 0/1. ethers' verifyTypedData tolerates that form, so the dapp's own post-signing check passed, but Permit2's SignatureVerification feeds v straight into ecrecover, which returns the zero address for v=0/1 and reverts with InvalidSignature (0x8baa579f). This broke every Permit2 flow (DLLR trove close/adjust/repay, stability pool deposits, DLLR conversion) for affected wallets — e.g. rsk tx 0xa53eef8b687f745c9b012f383310f3c38fc2a7e2113b371fa9f710c1050020b2, whose replay succeeds with only the v byte rewritten 0x00->0x1b. Normalize the signature at the single signing chokepoint (TransactionSteps) via joinSignature(splitSignature(...)): 0/1 maps to 27/28, 64-byte EIP-2098 compact expands to canonical 65 bytes, canonical signatures pass through byte-for-byte unchanged, malformed v throws at signing time instead of burning gas on-chain. Regression tests pin all four v-byte cases, the compact form, and the exact signature from the failed tx. --- .changeset/violet-eagles-recover.md | 5 + .../TransactionSteps/TransactionSteps.tsx | 15 ++- apps/frontend/src/utils/signature.test.ts | 102 ++++++++++++++++++ apps/frontend/src/utils/signature.ts | 18 ++++ 4 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 .changeset/violet-eagles-recover.md create mode 100644 apps/frontend/src/utils/signature.test.ts create mode 100644 apps/frontend/src/utils/signature.ts diff --git a/.changeset/violet-eagles-recover.md b/.changeset/violet-eagles-recover.md new file mode 100644 index 000000000..c2a941ee4 --- /dev/null +++ b/.changeset/violet-eagles-recover.md @@ -0,0 +1,5 @@ +--- +'frontend': patch +--- + +fix: normalize typed-data signature v-byte (0/1 -> 27/28) before on-chain use so Permit2 flows work with Frame, Ledger and other raw-recovery-id wallets diff --git a/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.tsx b/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.tsx index 868ef576b..bb6905baf 100644 --- a/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.tsx +++ b/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.tsx @@ -23,6 +23,7 @@ import { translations } from '../../../../../locales/i18n'; import { findNativeAsset } from '../../../../../utils/asset'; import { sleep } from '../../../../../utils/helpers'; import { fromWei, toWei } from '../../../../../utils/math'; +import { normalizeSignature } from '../../../../../utils/signature'; import { Transaction, TransactionReceiptStatus, @@ -265,10 +266,16 @@ export const TransactionSteps: FC = ({ await handleUpdates(); } else if (isTypedDataRequest(request)) { - const signature = await request.signer._signTypedData( - request.domain, - request.types, - request.values, + // Wallets like Frame or ledger modules return v as 0/1; on-chain + // verifiers (Permit2's ecrecover) accept only 27/28, while the + // ethers verification below tolerates both — so canonicalize before + // the signature is stored and forwarded on-chain. + const signature = normalizeSignature( + await request.signer._signTypedData( + request.domain, + request.types, + request.values, + ), ); const verifiedAddress = ethers.utils.verifyTypedData( diff --git a/apps/frontend/src/utils/signature.test.ts b/apps/frontend/src/utils/signature.test.ts new file mode 100644 index 000000000..9abcccfc9 --- /dev/null +++ b/apps/frontend/src/utils/signature.test.ts @@ -0,0 +1,102 @@ +import { ethers } from 'ethers'; + +import { normalizeSignature } from './signature'; + +describe('utils/signature.ts', () => { + describe('normalizeSignature()', () => { + // Wallets that expose raw signer output (Frame, the onboard-ledger module, + // some WalletConnect/MPC wallets) return the recovery byte as 0/1 instead + // of 27/28. ethers' verifyTypedData tolerates both forms, but on-chain + // verifiers that feed v straight into ecrecover (Permit2's + // SignatureVerification) accept ONLY 27/28 — ecrecover returns the zero + // address for v=0/1 and the tx reverts with InvalidSignature (0x8baa579f). + // These cases pin that every wallet-returned form is canonicalized to + // 65-byte r||s||v with v ∈ {27, 28} before being sent on-chain. + + const r = + '0x0d8bcec44c865f8f0dd7d3a480ad0eb80cd8fe3575b13d39f7e421ed7c859f00'; + const s = + '0x3475d99336ce2bf26a5bc6e946769ecc1763cdbce8abf24add0800084e76fbe2'; + const base = r + s.slice(2); + + it('rewrites recovery-id form v=0x00 to v=0x1b (27)', () => { + expect(normalizeSignature(base + '00')).toBe(base + '1b'); + }); + + it('rewrites recovery-id form v=0x01 to v=0x1c (28)', () => { + expect(normalizeSignature(base + '01')).toBe(base + '1c'); + }); + + it('returns canonical v=0x1b signatures byte-for-byte unchanged', () => { + expect(normalizeSignature(base + '1b')).toBe(base + '1b'); + }); + + it('returns canonical v=0x1c signatures byte-for-byte unchanged', () => { + expect(normalizeSignature(base + '1c')).toBe(base + '1c'); + }); + + it('expands 64-byte EIP-2098 compact signatures to canonical 65 bytes', () => { + const canonical = base + '1b'; + const compact = ethers.utils.splitSignature(canonical).compact; + expect(ethers.utils.hexDataLength(compact)).toBe(64); + expect(normalizeSignature(compact)).toBe(canonical); + }); + + it('throws on a malformed recovery byte instead of passing it through', () => { + expect(() => normalizeSignature(base + '05')).toThrow(); + }); + + it('is a no-op for signatures produced by standard (MetaMask-style) signers', async () => { + const wallet = new ethers.Wallet( + '0x1111111111111111111111111111111111111111111111111111111111111111', + ); + const domain = { name: 'Test', version: '1', chainId: 30 }; + const types = { + Message: [{ name: 'content', type: 'string' }], + }; + const values = { content: 'hello' }; + const signature = await wallet._signTypedData(domain, types, values); + expect(normalizeSignature(signature)).toBe(signature); + }); + + it('repairs the exact Frame-wallet Permit2 signature that reverted on-chain (rsk tx 0xa53eef8b…0020b2)', () => { + // r/s/v and the signed values below are lifted verbatim from the failed + // closeNueTroveWithPermit2 calldata; the wallet (Frame) returned v=0x00. + // Replaying that tx with only this byte rewritten to 0x1b succeeds, so + // the normalized form must recover the original sender. + const signer = '0xdEd56Aef779B5b30845FF2cb7f1Fc6adC1169ded'; + const normalized = normalizeSignature(base + '00'); + const recovered = ethers.utils.verifyTypedData( + { + name: 'Permit2', + chainId: 30, + verifyingContract: '0x000000000022D473030F116dDEE9F6B43aC78BA3', + }, + { + PermitTransferFrom: [ + { name: 'permitted', type: 'TokenPermissions' }, + { name: 'spender', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + TokenPermissions: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + }, + { + permitted: { + token: '0xc1411567d2670e24d9c4daaa7cda95686e1250aa', + amount: '0x015d094cf05b0b8a838e', + }, + spender: '0x5B9dB4B8bdeF3e57323187a9AC2639C5DEe5FD39', + nonce: '0x019fd8ee9eaf', + deadline: 0x6a9c8680, + }, + normalized, + ); + expect(normalized.slice(-2)).toBe('1b'); + expect(recovered).toBe(signer); + }); + }); +}); diff --git a/apps/frontend/src/utils/signature.ts b/apps/frontend/src/utils/signature.ts new file mode 100644 index 000000000..6e0e919c7 --- /dev/null +++ b/apps/frontend/src/utils/signature.ts @@ -0,0 +1,18 @@ +import { ethers } from 'ethers'; + +/** + * Canonicalizes a wallet-returned ECDSA signature to the 65-byte r||s||v hex + * form with v ∈ {27, 28} — the only encoding contracts that feed v straight + * into ecrecover (e.g. Permit2's SignatureVerification) accept. + * + * Wallets that expose raw signer output (Frame, the onboard-ledger module, + * some WalletConnect/MPC wallets) return v as the recovery id (0/1), and + * EIP-2098 signers return a 64-byte compact form. ethers' own verification + * helpers silently tolerate all of these, so a signature must be normalized + * with this function before it is sent on-chain, not just verified. + * + * Canonical signatures pass through byte-for-byte unchanged; a malformed + * recovery byte throws here, at signing time, instead of reverting on-chain. + */ +export const normalizeSignature = (signature: ethers.BytesLike): string => + ethers.utils.joinSignature(ethers.utils.splitSignature(signature));