Skip to content
Open
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/sdk-permit2-signature-normalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sovryn/sdk': patch
---

Canonicalize the Permit2 typed-data signature in the MocIntegration route before encoding it on-chain: wallets that return the ECDSA v byte as a raw recovery id (0/1 — Frame, onboard-ledger, some MPC wallets) or an EIP-2098 compact signature previously produced calldata that Permit2's ecrecover rejects. Signatures are normalized to the 65-byte r||s||v form with v in {27, 28}; canonical signatures pass through unchanged, malformed ones now throw at build time instead of reverting on-chain. SDK-side counterpart of the frontend fix in PR #1147.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BigNumber, constants, Contract } from 'ethers';
import { BigNumber, constants, Contract, ethers } from 'ethers';
import { parseUnits } from 'ethers/lib/utils';

import { getAssetContract, getProtocolContract } from '@sovryn/contracts';
Expand Down Expand Up @@ -117,6 +117,50 @@ describe('Moc Integration Route', () => {
).rejects.toThrowError(/Permit2 is required for swap/);
});

it('canonicalizes a raw recovery-id signature (v=1) before encoding the Permit2 call', async () => {
// Frame, onboard-ledger and some MPC wallets return v as the raw
// recovery id (0/1); Permit2's on-chain ecrecover accepts only 27/28
const rawSignature = `${FAKE_SIGNATURE.slice(0, -2)}01`;

const tx = await route.swap(
dllr,
rbtc,
parseUnits('20'),
constants.AddressZero,
{
typedDataValue: FAKE_PERMIT_TRANSFER_FROM,
typedDataSignature: rawSignature,
},
);

const { abi } = await getProtocolContract('mocIntegrationProxy');
const decoded = new ethers.utils.Interface(abi).decodeFunctionData(
'getDocFromDllrAndRedeemRbtcWithPermit2',
tx.data!,
);
expect(decoded[1]).toEqual(FAKE_SIGNATURE); // ...01 -> ...1c (v=28)
});

it('passes an already-canonical signature through unchanged', async () => {
const tx = await route.swap(
dllr,
rbtc,
parseUnits('20'),
constants.AddressZero,
{
typedDataValue: FAKE_PERMIT_TRANSFER_FROM,
typedDataSignature: FAKE_SIGNATURE,
},
);

const { abi } = await getProtocolContract('mocIntegrationProxy');
const decoded = new ethers.utils.Interface(abi).decodeFunctionData(
'getDocFromDllrAndRedeemRbtcWithPermit2',
tx.data!,
);
expect(decoded[1]).toEqual(FAKE_SIGNATURE);
});

it('fails build swap tx data for RBTC -> DLLR', async () => {
const amount = parseUnits('0.01');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ChainIds } from '@sovryn/ethers-provider';
import { getMinReturn } from '../../../internal/utils';
import { DEFAULT_SWAP_ROUTES, smartRoutes } from '../../../swaps/smart-router';
import { SwapRoute } from '../../../swaps/smart-router/types';
import { FAKE_SIGNATURE } from '../../_fixtures/permit';

/**
* Cross-route slippage invariant (regression guard for GHSA-jx33-xg6c-px39).
Expand Down Expand Up @@ -353,7 +354,9 @@ describe('cross-route slippage invariant', () => {
nonce: 1,
deadline: 2_000_000_000,
};
const typedDataSignature = `0x${'ab'.repeat(65)}`;
// a well-formed canonical signature — the moc route canonicalizes
// signatures and rejects malformed ones at build time
const typedDataSignature = FAKE_SIGNATURE;

const txs = await buildAcrossSlippages(
() =>
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/src/internal/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ export function defineProperties<T>(
}
}

// Wallets exposing raw signer output (Frame, onboard-ledger, some MPC
// wallets) return the ECDSA v byte as the recovery id (0/1), and EIP-2098
// signers return a 64-byte compact form; contracts that feed v straight into
// ecrecover (e.g. Permit2's SignatureVerification) accept only the 65-byte
// r||s||v form with v in {27, 28}. Canonical signatures pass through
// byte-for-byte unchanged; malformed ones throw here instead of on-chain.
export const normalizeSignature = (signature: ethers.BytesLike): string =>
ethers.utils.joinSignature(ethers.utils.splitSignature(signature));

// slippage 100% = 10000, 1% = 100
export const getMinReturn = (
amount: BigNumberish,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
areAddressesEqual,
hasEnoughAllowance,
makeApproveRequest,
normalizeSignature,
} from '../../../internal/utils';
import { SwapPairs, SwapRouteFunction } from '../types';
import {
Expand Down Expand Up @@ -192,7 +193,10 @@ export const mocIntegrationSwapRoute: SwapRouteFunction = (
to: mocIntegration.address,
data: mocIntegration.interface.encodeFunctionData(
'getDocFromDllrAndRedeemRbtcWithPermit2',
[options?.typedDataValue, options?.typedDataSignature],
[
options?.typedDataValue,
normalizeSignature(options.typedDataSignature),
],
),
value: '0',
gasLimit: 800_000,
Expand Down
Loading