Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/violet-eagles-recover.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -265,10 +266,16 @@ export const TransactionSteps: FC<TransactionStepsProps> = ({

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(
Expand Down
134 changes: 134 additions & 0 deletions apps/frontend/src/utils/signature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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('preserves a high-s signature exactly (r and s are never altered)', () => {
// all known wallets emit low-s, but ecrecover accepts high-s, so a
// high-s signature that would have worked raw must not be rejected
// or rewritten by normalization (adversarial review, 2026-09-22)
const highS =
'b475d99336ce2bf26a5bc6e946769ecc1763cdbce8abf24add0800084e76fbe2';
expect(normalizeSignature(r + highS + '1b')).toBe(r + highS + '1b');
expect(normalizeSignature(r + highS + '1c')).toBe(r + highS + '1c');
});

it.each([2, 26, 29, 30, 34, 35, 37, 38, 255])(
'rejects out-of-range v byte %i instead of folding it silently',
v => {
// ethers' splitSignature parity-folds any v >= 27 (29 -> 27), which
// would change the recovery interpretation of garbage input; only
// {0, 1, 27, 28} are meaningful for typed-data signatures
const sig = base + v.toString(16).padStart(2, '0');
expect(() => normalizeSignature(sig)).toThrowError(
/invalid signature v byte/,
);
},
);

it('rejects signatures that are not 64 or 65 bytes', () => {
expect(() => normalizeSignature(base.slice(0, -2))).toThrowError(
/invalid signature length/,
);
expect(() => normalizeSignature(base + '1b1b')).toThrowError(
/invalid signature length/,
);
});

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);
});
});
});
43 changes: 43 additions & 0 deletions apps/frontend/src/utils/signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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 these, so a signature must be normalized with
* this function before it is sent on-chain, not just verified.
*
* r and s are never altered — a high-s signature stays exactly as the wallet
* produced it (ecrecover accepts it), so canonical signatures pass through
* bit-identical, re-encoded as lowercase hex. Any other v — including
* EIP-155-style values, which have no meaning for typed-data signatures —
* throws here, at signing time, instead of reverting on-chain or recovering
* the wrong signer.
*/
export const normalizeSignature = (signature: ethers.BytesLike): string => {
const bytes = ethers.utils.arrayify(signature);
let v: number;
let rs: Uint8Array;
if (bytes.length === 64) {
// EIP-2098 compact form: yParity lives in the top bit of the s word
v = 27 + (bytes[32] >> 7);
rs = new Uint8Array(bytes);
rs[32] &= 0x7f;
} else if (bytes.length === 65) {
v = bytes[64];
if (v === 0 || v === 1) {
v += 27;
}
if (v !== 27 && v !== 28) {
throw new Error(`invalid signature v byte: ${bytes[64]}`);
}
rs = bytes.slice(0, 64);
} else {
throw new Error(`invalid signature length: ${bytes.length}`);
}
return ethers.utils.hexlify(ethers.utils.concat([rs, [v]]));
};
Loading