Skip to content

fix: canonicalize typed-data signature v-byte before forwarding on-chain (Permit2 flows failing for Frame/Ledger wallets) - #1147

Open
tjcloa wants to merge 2 commits into
developfrom
fix/permit2-signature-v-normalization
Open

fix: canonicalize typed-data signature v-byte before forwarding on-chain (Permit2 flows failing for Frame/Ledger wallets)#1147
tjcloa wants to merge 2 commits into
developfrom
fix/permit2-signature-v-normalization

Conversation

@tjcloa

@tjcloa tjcloa commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Problem

Wallets that expose raw signer output — Frame, the first-party @sovryn/onboard-ledger module (which deliberately converts Ledger's v 27/28 down to 0/1), and some WalletConnect/MPC wallets — return EIP-712 signatures with the recovery byte as 0/1 instead of the canonical 27/28.

The dapp's post-signing check (ethers.utils.verifyTypedData) silently tolerates that form, so signing appears to succeed — but the raw signature is then forwarded on-chain, where Permit2's SignatureVerification feeds v straight into ecrecover. For v=0/1 ecrecover returns the zero address and the tx reverts with InvalidSignature() (0x8baa579f).

This breaks every Permit2 flow for affected wallets: Zero trove close/adjust/repay with DLLR, Stability Pool DLLR deposits, and the DLLR conversion route.

Real-world incident: RSK tx 0xa53eef8b…0020b2closeNueTroveWithPermit2 from a Frame wallet. Replaying it at its original block with only the final signature byte rewritten 0x00 → 0x1b succeeds, proving all other protocol conditions were valid. The Permit2 at the canonical CREATE2 address on Rootstock is byte-identical to Uniswap's Ethereum deployment (modulo chain-id immutable), so no contract change is applicable.

Fix

All typed-data signatures pass through a single chokepoint (TransactionSteps.tsx). Canonicalize there, before the signature is verified/stored/forwarded, via new normalizeSignature() (utils/signature.ts = joinSignature(splitSignature(sig))):

  • v = 0/127/28
  • 64-byte EIP-2098 compact → canonical 65-byte
  • canonical signatures pass through byte-for-byte unchanged (no-op for MetaMask-style signers — covered by a live _signTypedData roundtrip test)
  • malformed v throws at signing time instead of burning gas on a doomed tx

Tests

8 regression tests pin all four v-byte cases, the compact form, the standard-signer no-op, and the exact signature from the failed incident tx (r/s/v + typed data lifted from calldata) recovering the original sender. Frontend suite 60/60 green; tsc + eslint clean; changeset included.

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-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0bddb7a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
frontend Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@netlify

netlify Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploy Preview for sovryn-dapp ready!

Name Link
🔨 Latest commit 0bddb7a
🔍 Latest deploy log https://app.netlify.com/projects/sovryn-dapp/deploys/6a75bca324d51e0008178150
😎 Deploy Preview https://deploy-preview-1147.preview.sovryns.dev
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Permit2 typed-data flows for wallets that return non-canonical EIP-712 signatures (notably v=0/1 and EIP-2098 compact signatures) by normalizing signatures at the frontend “typed-data signing” chokepoint before verification and on-chain forwarding.

Changes:

  • Add normalizeSignature() utility that canonicalizes signatures via joinSignature(splitSignature(...)).
  • Normalize _signTypedData results inside TransactionSteps.tsx before verifyTypedData and before storing/forwarding the signature.
  • Add regression tests covering v=0/1, v=27/28, EIP-2098 compact, malformed v, and a real incident signature; include a changeset.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
apps/frontend/src/utils/signature.ts Introduces a signature normalization helper for on-chain-compatible `r
apps/frontend/src/utils/signature.test.ts Adds unit tests covering normalization behavior across wallet/signature variants and an incident reproduction.
apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.tsx Applies signature normalization to typed-data signing flow before verification/storage/forwarding.
.changeset/violet-eagles-recover.md Publishes the fix as a patch changeset for the frontend package.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to +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));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants