diff --git a/.changeset/sdk-permit2-signature-normalization.md b/.changeset/sdk-permit2-signature-normalization.md new file mode 100644 index 000000000..c8811a628 --- /dev/null +++ b/.changeset/sdk-permit2-signature-normalization.md @@ -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. diff --git a/packages/sdk/src/_tests/swaps/routes/moc-integration-route.test.ts b/packages/sdk/src/_tests/swaps/routes/moc-integration-route.test.ts index 4cfd72ddb..b2de06f10 100644 --- a/packages/sdk/src/_tests/swaps/routes/moc-integration-route.test.ts +++ b/packages/sdk/src/_tests/swaps/routes/moc-integration-route.test.ts @@ -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'; @@ -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'); diff --git a/packages/sdk/src/_tests/swaps/routes/slippage-invariant.test.ts b/packages/sdk/src/_tests/swaps/routes/slippage-invariant.test.ts index ecc609d1a..b81c3ee4b 100644 --- a/packages/sdk/src/_tests/swaps/routes/slippage-invariant.test.ts +++ b/packages/sdk/src/_tests/swaps/routes/slippage-invariant.test.ts @@ -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). @@ -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( () => diff --git a/packages/sdk/src/internal/utils.ts b/packages/sdk/src/internal/utils.ts index bb34849fb..01fc6faf1 100644 --- a/packages/sdk/src/internal/utils.ts +++ b/packages/sdk/src/internal/utils.ts @@ -18,6 +18,15 @@ export function defineProperties( } } +// 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, diff --git a/packages/sdk/src/swaps/smart-router/routes/moc-integration-swap-route.ts b/packages/sdk/src/swaps/smart-router/routes/moc-integration-swap-route.ts index f857ead6b..977e9f716 100644 --- a/packages/sdk/src/swaps/smart-router/routes/moc-integration-swap-route.ts +++ b/packages/sdk/src/swaps/smart-router/routes/moc-integration-swap-route.ts @@ -8,6 +8,7 @@ import { areAddressesEqual, hasEnoughAllowance, makeApproveRequest, + normalizeSignature, } from '../../../internal/utils'; import { SwapPairs, SwapRouteFunction } from '../types'; import { @@ -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,