From e8b352f7d83f381eea1361d1c78363685675d86d Mon Sep 17 00:00:00 2001 From: Christian Baroni <7061887+christianbaroni@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:33:28 -0500 Subject: [PATCH 1/3] Correct allowance target handling for wrap/unwrap --- src/raps/approval.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/raps/approval.ts b/src/raps/approval.ts index ff4974d19f9..ac70c4dfd39 100644 --- a/src/raps/approval.ts +++ b/src/raps/approval.ts @@ -1,4 +1,4 @@ -import { type CrosschainQuote, ETH_ADDRESS, type Quote } from '@rainbow-me/swaps'; +import { type CrosschainQuote, ETH_ADDRESS, type Quote, SwapType } from '@rainbow-me/swaps'; import type { Address } from 'viem'; import { type ChainId } from '@/state/backendNetworks/types'; import { needsTokenApproval } from './actions/unlock'; @@ -17,15 +17,6 @@ type ApprovalRequirement = { requiresApprove: boolean; }; -function isNativeSellToken(sellTokenAddress: string): boolean { - return sellTokenAddress.toLowerCase() === ETH_ADDRESS.toLowerCase(); -} - -function resolveAllowanceTargetAddress(quote: SwapLikeQuote): Address | null { - if (isNativeSellToken(quote.sellTokenAddress)) return null; - return getQuoteAllowanceTargetAddress(quote); -} - /** * Resolves whether an ERC20 sell path needs an approval unlock before swap execution. */ @@ -52,3 +43,17 @@ export async function resolveApprovalRequirement({ return { allowanceTargetAddress, requiresApprove }; } + +function resolveAllowanceTargetAddress(quote: SwapLikeQuote): Address | null { + if (isWrappedNativeSwap(quote)) return null; + if (isNativeSellToken(quote.sellTokenAddress)) return null; + return getQuoteAllowanceTargetAddress(quote); +} + +function isWrappedNativeSwap(quote: SwapLikeQuote): boolean { + return quote.swapType === SwapType.wrap || quote.swapType === SwapType.unwrap; +} + +function isNativeSellToken(sellTokenAddress: string): boolean { + return sellTokenAddress.toLowerCase() === ETH_ADDRESS.toLowerCase(); +} From 56ff8181892fa9c805b9e0c5621a71a215128945 Mon Sep 17 00:00:00 2001 From: Christian Baroni <7061887+christianbaroni@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:46:29 -0500 Subject: [PATCH 2/3] Add RAPs tests --- .../claimClaimable.invariants.test.ts | 71 +++++ src/raps/__tests__/execute.invariants.test.ts | 255 ++++++++++++++++++ src/raps/__tests__/fixtures.ts | 217 +++++++++++++++ .../unlockAndSwap.invariants.test.ts | 84 ++++++ src/raps/__tests__/utils.invariants.test.ts | 140 ++++++++++ .../__tests__/validation.invariants.test.ts | 45 ++++ 6 files changed, 812 insertions(+) create mode 100644 src/raps/__tests__/claimClaimable.invariants.test.ts create mode 100644 src/raps/__tests__/execute.invariants.test.ts create mode 100644 src/raps/__tests__/fixtures.ts create mode 100644 src/raps/__tests__/unlockAndSwap.invariants.test.ts create mode 100644 src/raps/__tests__/utils.invariants.test.ts create mode 100644 src/raps/__tests__/validation.invariants.test.ts diff --git a/src/raps/__tests__/claimClaimable.invariants.test.ts b/src/raps/__tests__/claimClaimable.invariants.test.ts new file mode 100644 index 00000000000..b571085d28c --- /dev/null +++ b/src/raps/__tests__/claimClaimable.invariants.test.ts @@ -0,0 +1,71 @@ +import { createClaimClaimableRap } from '../claimClaimable'; +import type { RapAction } from '../references'; +import { createClaimClaimableRapParameters, createQuote, TEST_ALLOWANCE_TARGET, TEST_QUOTE_TO } from './fixtures'; + +jest.mock('../common', () => ({ + createNewAction: jest.fn((type: string, parameters: unknown) => ({ + type, + parameters, + transaction: { hash: null }, + })), + createNewRap: jest.fn((actions: unknown[]) => ({ actions })), +})); + +jest.mock('../actions/unlock', () => ({ + needsTokenApproval: jest.fn(), +})); + +jest.mock('@/__swaps__/utils/quotes', () => ({ + isCrosschainQuote: jest.fn(), +})); + +const unlockModule = jest.requireMock('../actions/unlock'); +const quoteUtilsModule = jest.requireMock('@/__swaps__/utils/quotes'); + +describe('createClaimClaimableRap invariants', () => { + beforeEach(() => { + jest.clearAllMocks(); + quoteUtilsModule.isCrosschainQuote.mockReturnValue(false); + }); + + test('uses allowanceTarget as spender for approval checks and unlock action', async () => { + unlockModule.needsTokenApproval.mockResolvedValue(true); + + const quote = createQuote({ + allowanceNeeded: true, + allowanceTarget: TEST_ALLOWANCE_TARGET, + to: TEST_QUOTE_TO, + }); + const parameters = createClaimClaimableRapParameters({ quote }); + + const result = await createClaimClaimableRap(parameters); + + expect(unlockModule.needsTokenApproval).toHaveBeenCalledWith( + expect.objectContaining({ + spender: TEST_ALLOWANCE_TARGET, + }) + ); + + const unlockAction = result.actions.find(isUnlockAction); + expect(unlockAction).toBeDefined(); + expect(unlockAction?.parameters.contractAddress).toBe(TEST_ALLOWANCE_TARGET); + expect(unlockAction?.parameters.contractAddress).not.toBe(TEST_QUOTE_TO); + }); + + test('still checks on-chain approval and skips unlock action when allowance is not needed', async () => { + unlockModule.needsTokenApproval.mockResolvedValue(false); + + const quote = createQuote({ + allowanceNeeded: false, + }); + + const result = await createClaimClaimableRap(createClaimClaimableRapParameters({ quote })); + + expect(unlockModule.needsTokenApproval).toHaveBeenCalledTimes(1); + expect(result.actions.find(action => action.type === 'unlock')).toBeUndefined(); + }); +}); + +function isUnlockAction(action: RapAction<'claimClaimable' | 'crosschainSwap' | 'unlock' | 'swap'>): action is RapAction<'unlock'> { + return action.type === 'unlock'; +} diff --git a/src/raps/__tests__/execute.invariants.test.ts b/src/raps/__tests__/execute.invariants.test.ts new file mode 100644 index 00000000000..c4678e9d100 --- /dev/null +++ b/src/raps/__tests__/execute.invariants.test.ts @@ -0,0 +1,255 @@ +import { VoidSigner } from '@ethersproject/abstract-signer'; +import { Wallet } from '@ethersproject/wallet'; +import { UserRejectedRequestError } from 'viem'; +import { ChainId } from '@/state/backendNetworks/types'; +import { walletExecuteRap } from '../execute'; +import { createSwapAction, createSwapRapParameters, TEST_ALLOWANCE_TARGET, TEST_OWNER_ADDRESS } from './fixtures'; + +jest.mock('@rainbow-me/delegation', () => ({ + executeBatchedTransaction: jest.fn(), + supportsDelegation: jest.fn(), +})); + +jest.mock('@/state/performance/performance', () => ({ + Screens: { SWAPS: 'SWAPS' }, + TimeToSignOperation: { + CreateRap: 'CreateRap', + BroadcastTransaction: 'BroadcastTransaction', + }, + executeFn: jest.fn(), +})); + +jest.mock('@/state/swaps/swapsStore', () => ({ + swapsStore: { + getState: jest.fn(() => ({ degenMode: false })), + }, +})); + +jest.mock('@/systems/delegation/featureFlags', () => ({ + isDelegationEnabled: jest.fn(), +})); + +jest.mock('@/config/experimental', () => ({ + DELEGATION: 'delegation', + getExperimentalFlag: jest.fn(() => false), +})); + +jest.mock('@/model/remoteConfig', () => ({ + getRemoteConfig: jest.fn(() => ({ + delegation_enabled: true, + })), +})); + +jest.mock('@/handlers/web3', () => ({ + getProvider: jest.fn(), +})); + +jest.mock('@/state/pendingTransactions', () => ({ + addNewTransaction: jest.fn(), +})); + +jest.mock('../actions', () => ({ + claim: jest.fn(), + swap: jest.fn(), + unlock: jest.fn(), +})); + +jest.mock('../actions/crosschainSwap', () => ({ + crosschainSwap: jest.fn(), + prepareCrosschainSwap: jest.fn(), +})); + +jest.mock('../actions/claimBridge', () => ({ + claimBridge: jest.fn(), +})); + +jest.mock('../actions/claimClaimable', () => ({ + claimClaimable: jest.fn(), +})); + +jest.mock('../actions/swap', () => ({ + prepareSwap: jest.fn(), +})); + +jest.mock('../actions/unlock', () => ({ + prepareUnlock: jest.fn(), +})); + +jest.mock('../unlockAndSwap', () => ({ + createUnlockAndSwapRap: jest.fn(), +})); + +jest.mock('../unlockAndCrosschainSwap', () => ({ + createUnlockAndCrosschainSwapRap: jest.fn(), +})); + +jest.mock('../claimAndBridge', () => ({ + createClaimAndBridgeRap: jest.fn(), +})); + +jest.mock('../claimClaimable', () => ({ + createClaimClaimableRap: jest.fn(), +})); + +const SEQUENTIAL_HASH = '0xsequential'; +const ATOMIC_HASH = '0xatomic'; + +const delegationModule = jest.requireMock('@rainbow-me/delegation'); +const performanceModule = jest.requireMock('@/state/performance/performance'); +const delegationFlagsModule = jest.requireMock('@/systems/delegation/featureFlags'); +const web3Module = jest.requireMock('@/handlers/web3'); +const pendingTransactionsModule = jest.requireMock('@/state/pendingTransactions'); +const actionsModule = jest.requireMock('../actions'); +const swapActionModule = jest.requireMock('../actions/swap'); +const unlockAndSwapModule = jest.requireMock('../unlockAndSwap'); + +const mockExecuteBatchedTransaction = delegationModule.executeBatchedTransaction; +const mockSupportsDelegation = delegationModule.supportsDelegation; +const mockExecuteFn = performanceModule.executeFn; +const mockIsDelegationEnabled = delegationFlagsModule.isDelegationEnabled; +const mockGetProvider = web3Module.getProvider; +const mockAddNewTransaction = pendingTransactionsModule.addNewTransaction; +const mockSwap = actionsModule.swap; +const mockPrepareSwap = swapActionModule.prepareSwap; +const mockCreateUnlockAndSwapRap = unlockAndSwapModule.createUnlockAndSwapRap; + +function createWallet() { + return new Wallet('0x59c6995e998f97a5a0044966f0945382d9f4f95f5e7e1c8f3f6f2f1a6a6f7c89'); +} + +function setupExecuteHarness() { + jest.clearAllMocks(); + + mockExecuteFn.mockImplementation((fn: (...args: unknown[]) => unknown) => fn); + mockIsDelegationEnabled.mockReturnValue(true); + mockSupportsDelegation.mockResolvedValue({ supported: true, reason: null }); + + mockExecuteBatchedTransaction.mockResolvedValue({ + hash: ATOMIC_HASH, + type: 'eip7702', + transaction: { + to: TEST_OWNER_ADDRESS, + data: '0xbatched', + value: 0n, + gas: 101n, + }, + }); + + mockGetProvider.mockReturnValue({ chainId: ChainId.mainnet }); + + mockCreateUnlockAndSwapRap.mockResolvedValue({ + actions: [createSwapAction()], + }); + + mockSwap.mockResolvedValue({ + nonce: 42, + hash: SEQUENTIAL_HASH, + }); + + mockPrepareSwap.mockResolvedValue({ + call: { + to: TEST_ALLOWANCE_TARGET, + data: '0x1234', + value: '0x0', + }, + transaction: { + from: TEST_OWNER_ADDRESS, + to: TEST_ALLOWANCE_TARGET, + chainId: ChainId.mainnet, + type: 'swap', + status: 'pending', + }, + }); +} + +describe('walletExecuteRap invariants', () => { + beforeEach(() => { + setupExecuteHarness(); + }); + + test('falls back to sequential execution when nonce is missing', async () => { + const parameters = createSwapRapParameters({ nonce: undefined, atomic: true }); + + const result = await walletExecuteRap(createWallet(), 'swap', parameters); + + expect(mockExecuteBatchedTransaction).not.toHaveBeenCalled(); + expect(mockSwap).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + errorMessage: null, + hash: SEQUENTIAL_HASH, + nonce: 42, + }); + }); + + test('falls back to sequential execution on non-user-rejection atomic errors', async () => { + mockExecuteBatchedTransaction.mockRejectedValue(new Error('RPC invalid params')); + + const result = await walletExecuteRap(createWallet(), 'swap', createSwapRapParameters({ nonce: 7, atomic: true })); + + expect(mockExecuteBatchedTransaction).toHaveBeenCalledTimes(1); + expect(mockSwap).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + errorMessage: null, + hash: SEQUENTIAL_HASH, + nonce: 42, + }); + }); + + test('does not fall back to sequential execution on explicit user rejection', async () => { + const rejection = Object.assign(new Error('User rejected transaction'), { + code: UserRejectedRequestError.code, + name: UserRejectedRequestError.name, + }); + mockExecuteBatchedTransaction.mockRejectedValue(rejection); + + const result = await walletExecuteRap(createWallet(), 'swap', createSwapRapParameters({ nonce: 9, atomic: true })); + + expect(mockExecuteBatchedTransaction).toHaveBeenCalledTimes(1); + expect(mockSwap).not.toHaveBeenCalled(); + expect(result).toEqual({ + errorMessage: 'User rejected transaction', + hash: null, + nonce: undefined, + }); + }); + + test('falls back to sequential execution for non-software signers', async () => { + const signer = new VoidSigner(TEST_OWNER_ADDRESS); + + const result = await walletExecuteRap(signer, 'swap', createSwapRapParameters({ nonce: 11, atomic: true })); + + expect(mockExecuteBatchedTransaction).not.toHaveBeenCalled(); + expect(mockSwap).toHaveBeenCalledTimes(1); + expect(result.hash).toBe(SEQUENTIAL_HASH); + expect(result.errorMessage).toBeNull(); + }); + + test('uses atomic execution and records pending transaction metadata when eligible', async () => { + const parameters = createSwapRapParameters({ nonce: 12, atomic: true }); + + const result = await walletExecuteRap(createWallet(), 'swap', parameters); + + expect(mockExecuteBatchedTransaction).toHaveBeenCalledTimes(1); + expect(mockSwap).not.toHaveBeenCalled(); + expect(result).toEqual({ + errorMessage: null, + hash: ATOMIC_HASH, + nonce: 12, + }); + + const [atomicRequest] = mockExecuteBatchedTransaction.mock.calls[0]; + expect(atomicRequest.transactionOptions.gasLimit).toBeNull(); + expect(atomicRequest.nonce).toBe(12); + + expect(mockAddNewTransaction).toHaveBeenCalledTimes(1); + const [pendingTransactionPayload] = mockAddNewTransaction.mock.calls[0]; + expect(pendingTransactionPayload.address).toBe(parameters.quote.from); + expect(pendingTransactionPayload.chainId).toBe(parameters.chainId); + expect(pendingTransactionPayload.transaction.hash).toBe(ATOMIC_HASH); + expect(pendingTransactionPayload.transaction.batch).toBe(true); + expect(pendingTransactionPayload.transaction.delegation).toBe(true); + expect(pendingTransactionPayload.transaction.to).toBe(TEST_OWNER_ADDRESS); + expect(pendingTransactionPayload.transaction.data).toBe('0xbatched'); + expect(pendingTransactionPayload.transaction.value).toBe('0'); + }); +}); diff --git a/src/raps/__tests__/fixtures.ts b/src/raps/__tests__/fixtures.ts new file mode 100644 index 00000000000..fef2011ec70 --- /dev/null +++ b/src/raps/__tests__/fixtures.ts @@ -0,0 +1,217 @@ +import { type Quote, SwapType } from '@rainbow-me/swaps'; +import { type GasFeeParam, type GasFeeParamsBySpeed, type TransactionGasParamAmounts } from '@/entities/gas'; +import { ChainId } from '@/state/backendNetworks/types'; +import { type ParsedAsset } from '@/__swaps__/types/assets'; +import { type RapAction, type RapSwapActionParameters } from '../references'; +import { type TransactionClaimableTxPayload } from '@/screens/claimables/transaction/types'; +import { getAddress } from 'viem'; + +export const TEST_OWNER_ADDRESS = '0x1111111111111111111111111111111111111111'; +export const TEST_ALLOWANCE_TARGET = '0x2222222222222222222222222222222222222222'; +export const TEST_QUOTE_TO = '0x3333333333333333333333333333333333333333'; +export const TEST_SELL_TOKEN = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; +export const TEST_BUY_TOKEN = '0x4200000000000000000000000000000000000006'; + +function createGasFeeParam(amount: string): GasFeeParam { + return { + amount, + display: amount, + gwei: amount, + }; +} + +export function createGasFeeParamsBySpeed(): GasFeeParamsBySpeed { + const fastBaseFee = createGasFeeParam('10'); + const fastPriorityFee = createGasFeeParam('2'); + + return { + fast: { + maxBaseFee: fastBaseFee, + maxPriorityFeePerGas: fastPriorityFee, + option: 'fast', + estimatedTime: { + amount: 15, + display: '15s', + }, + }, + }; +} + +export function createEip1559GasParams(): TransactionGasParamAmounts { + return { + maxFeePerGas: '12', + maxPriorityFeePerGas: '2', + }; +} + +export function createAsset({ + address, + symbol, + decimals = 18, + chainId = ChainId.mainnet, +}: { + address: string; + symbol: string; + decimals?: number; + chainId?: number; +}): ParsedAsset { + const normalizedAddress = getAddress(address); + const normalizedChainId: ChainId = chainId; + + return { + address: normalizedAddress, + chainId: normalizedChainId, + chainName: 'mainnet', + decimals, + isNativeAsset: false, + name: symbol, + symbol, + uniqueId: `${normalizedAddress}_${normalizedChainId}`, + native: {}, + mainnetAddress: normalizedAddress, + networks: { + [normalizedChainId]: { + address: normalizedAddress, + decimals, + }, + }, + price: { + value: 1, + }, + }; +} + +export function createQuote(overrides: Partial = {}): Quote { + const sellTokenAddress = overrides.sellTokenAddress ?? TEST_SELL_TOKEN; + const buyTokenAddress = overrides.buyTokenAddress ?? TEST_BUY_TOKEN; + const chainId = overrides.chainId ?? ChainId.mainnet; + + return { + from: TEST_OWNER_ADDRESS, + to: TEST_QUOTE_TO, + data: '0xabcdef', + value: '0', + sellAmount: '1000000', + sellAmountDisplay: '1', + sellAmountInEth: '0.0003', + sellAmountMinusFees: '1000000', + sellTokenAddress, + buyTokenAddress, + buyAmount: '997000000000000000', + buyAmountDisplay: '0.997', + buyAmountDisplayMinimum: '0.99', + buyAmountInEth: '0.997', + buyAmountMinusFees: '997000000000000000', + fee: '0', + feeInEth: '0', + feePercentageBasisPoints: 0, + swapType: SwapType.normal, + tradeAmountUSD: 1, + tradeFeeAmountUSD: 0, + chainId, + allowanceTarget: TEST_ALLOWANCE_TARGET, + allowanceNeeded: true, + ...overrides, + }; +} + +export function createSwapRapParameters(overrides: Partial> = {}): RapSwapActionParameters<'swap'> { + const quote = overrides.quote ?? createQuote(); + const chainId = overrides.chainId ?? quote.chainId; + + return { + sellAmount: String(quote.sellAmount), + chainId, + assetToSell: createAsset({ address: quote.sellTokenAddress, symbol: 'USDC', chainId }), + assetToBuy: createAsset({ address: quote.buyTokenAddress, symbol: 'WETH', chainId }), + gasParams: createEip1559GasParams(), + gasFeeParamsBySpeed: createGasFeeParamsBySpeed(), + quote, + atomic: true, + ...overrides, + }; +} + +export function createSwapAction(parameters: RapSwapActionParameters<'swap'> = createSwapRapParameters()): RapAction<'swap'> { + return { + type: 'swap', + parameters, + transaction: { + hash: null, + }, + }; +} + +type ClaimTxPayloadOverrides = { + to?: string; + from?: string; + nonce?: number; + gasLimit?: string; + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + data?: string; + value?: '0x0'; + chainId?: number; +}; + +export function createClaimTxPayload(overrides: ClaimTxPayloadOverrides = {}): TransactionClaimableTxPayload { + const { + to = TEST_QUOTE_TO, + from = TEST_OWNER_ADDRESS, + nonce = 1, + gasLimit = '21000', + gasPrice, + maxFeePerGas = '12', + maxPriorityFeePerGas = '2', + data = '0x', + value = '0x0', + chainId = ChainId.mainnet, + } = overrides; + + if (gasPrice !== undefined) { + return { + to, + from, + nonce, + gasLimit, + gasPrice, + data, + value, + chainId, + }; + } + + return { + to, + from, + nonce, + gasLimit, + maxFeePerGas, + maxPriorityFeePerGas, + data, + value, + chainId, + }; +} + +export function createClaimClaimableRapParameters( + overrides: Partial> = {} +): RapSwapActionParameters<'claimClaimable'> { + const quote = overrides.quote ?? createQuote(); + const chainId = overrides.chainId ?? quote.chainId; + + return { + sellAmount: String(quote.sellAmount), + chainId, + assetToSell: createAsset({ address: quote.sellTokenAddress, symbol: 'USDC', chainId }), + assetToBuy: createAsset({ address: quote.buyTokenAddress, symbol: 'WETH', chainId }), + gasParams: createEip1559GasParams(), + gasFeeParamsBySpeed: createGasFeeParamsBySpeed(), + quote, + additionalParams: { + claimTxns: [createClaimTxPayload({ from: quote.from, chainId })], + }, + ...overrides, + }; +} diff --git a/src/raps/__tests__/unlockAndSwap.invariants.test.ts b/src/raps/__tests__/unlockAndSwap.invariants.test.ts new file mode 100644 index 00000000000..229577d25bb --- /dev/null +++ b/src/raps/__tests__/unlockAndSwap.invariants.test.ts @@ -0,0 +1,84 @@ +import { ETH_ADDRESS, SwapType } from '@rainbow-me/swaps'; +import { ChainId } from '@/state/backendNetworks/types'; +import { createUnlockAndSwapRap } from '../unlockAndSwap'; +import { createQuote, createSwapRapParameters, TEST_ALLOWANCE_TARGET } from './fixtures'; + +jest.mock('@rainbow-me/swaps', () => { + const actual = jest.requireActual('@rainbow-me/swaps'); + return { + ...actual, + isAllowedTargetContract: jest.fn(() => false), + }; +}); + +jest.mock('../common', () => ({ + createNewAction: jest.fn((type: string, parameters: unknown) => ({ + type, + parameters, + transaction: { hash: null }, + })), + createNewRap: jest.fn((actions: unknown[]) => ({ actions })), +})); + +jest.mock('../actions/unlock', () => ({ + needsTokenApproval: jest.fn(), +})); + +const swapsModule = jest.requireMock('@rainbow-me/swaps'); +const unlockModule = jest.requireMock('../actions/unlock'); + +describe('createUnlockAndSwapRap invariants', () => { + beforeEach(() => { + jest.clearAllMocks(); + swapsModule.isAllowedTargetContract.mockReturnValue(false); + }); + + test('skips allowance target validation for unwrap quotes', async () => { + unlockModule.needsTokenApproval.mockResolvedValue(true); + + const quote = createQuote({ + swapType: SwapType.unwrap, + allowanceNeeded: false, + allowanceTarget: TEST_ALLOWANCE_TARGET, + }); + + const result = await createUnlockAndSwapRap(createSwapRapParameters({ quote })); + + expect(swapsModule.isAllowedTargetContract).not.toHaveBeenCalled(); + expect(unlockModule.needsTokenApproval).not.toHaveBeenCalled(); + expect(result.actions).toHaveLength(1); + expect(result.actions[0].type).toBe('swap'); + expect(result.actions.some(action => action.type === 'unlock')).toBe(false); + }); + + test('skips allowance target validation for wrap quotes', async () => { + unlockModule.needsTokenApproval.mockResolvedValue(true); + + const quote = createQuote({ + swapType: SwapType.wrap, + sellTokenAddress: ETH_ADDRESS, + allowanceNeeded: false, + allowanceTarget: TEST_ALLOWANCE_TARGET, + }); + + const result = await createUnlockAndSwapRap(createSwapRapParameters({ quote })); + + expect(swapsModule.isAllowedTargetContract).not.toHaveBeenCalled(); + expect(unlockModule.needsTokenApproval).not.toHaveBeenCalled(); + expect(result.actions).toHaveLength(1); + expect(result.actions[0].type).toBe('swap'); + }); + + test('keeps target allowlist checks for standard ERC20 swaps', async () => { + unlockModule.needsTokenApproval.mockResolvedValue(false); + + const quote = createQuote({ + swapType: SwapType.normal, + allowanceNeeded: true, + allowanceTarget: TEST_ALLOWANCE_TARGET, + }); + + await expect(createUnlockAndSwapRap(createSwapRapParameters({ quote }))).rejects.toThrow('Target address not allowed'); + expect(swapsModule.isAllowedTargetContract).toHaveBeenCalledWith(TEST_ALLOWANCE_TARGET, ChainId.mainnet); + }); +}); diff --git a/src/raps/__tests__/utils.invariants.test.ts b/src/raps/__tests__/utils.invariants.test.ts new file mode 100644 index 00000000000..8ef5725ea61 --- /dev/null +++ b/src/raps/__tests__/utils.invariants.test.ts @@ -0,0 +1,140 @@ +import { ChainId } from '@/state/backendNetworks/types'; +import { type Transaction } from '@/graphql/__generated__/metadataPOST'; +import { estimateTransactionsGasLimit } from '../utils'; + +jest.mock('@/handlers/web3', () => ({ + toHexNoLeadingZeros: jest.fn((value: string) => value), +})); + +jest.mock('@/resources/transactions/transactionSimulation', () => ({ + simulateTransactions: jest.fn(), +})); + +const simulationModule = jest.requireMock('@/resources/transactions/transactionSimulation'); + +function createTransaction({ + from, + to, + data = '0x', + value = '0x0', +}: { + from: string; + to: string; + data?: string; + value?: string; +}): Transaction { + return { from, to, data, value }; +} + +describe('estimateTransactionsGasLimit invariants', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('fails closed when simulation result count does not match step count', async () => { + simulationModule.simulateTransactions.mockResolvedValue([ + { + gas: { estimate: '10000' }, + }, + ]); + + const approveFallback = jest.fn(async () => '21000'); + const swapFallback = jest.fn(async () => '250000'); + + const result = await estimateTransactionsGasLimit({ + chainId: ChainId.mainnet, + steps: [ + { + transaction: createTransaction({ + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + }), + label: 'approve', + fallbackEstimate: approveFallback, + }, + { + transaction: createTransaction({ + from: '0x1111111111111111111111111111111111111111', + to: '0x3333333333333333333333333333333333333333', + }), + label: 'swap', + fallbackEstimate: swapFallback, + }, + ], + }); + + expect(result).toBeUndefined(); + expect(approveFallback).not.toHaveBeenCalled(); + expect(swapFallback).not.toHaveBeenCalled(); + }); + + test('uses per-step fallback estimates when simulation omits gas for a step', async () => { + simulationModule.simulateTransactions.mockResolvedValue([ + { + gas: { estimate: '120000' }, + }, + { + error: { message: 'reverted' }, + }, + ]); + + const fallbackEstimate = jest.fn(async () => '80000'); + + const result = await estimateTransactionsGasLimit({ + chainId: ChainId.mainnet, + steps: [ + { + transaction: createTransaction({ + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + }), + label: 'approve', + }, + { + transaction: createTransaction({ + from: '0x1111111111111111111111111111111111111111', + to: '0x3333333333333333333333333333333333333333', + }), + label: 'swap', + fallbackEstimate, + }, + ], + }); + + expect(result).toBe('200000'); + expect(fallbackEstimate).toHaveBeenCalledTimes(1); + }); + + test('returns undefined when a step has no simulation estimate and no fallback estimate', async () => { + simulationModule.simulateTransactions.mockResolvedValue([ + { + gas: { estimate: '150000' }, + }, + { + error: { message: 'reverted' }, + }, + ]); + + const result = await estimateTransactionsGasLimit({ + chainId: ChainId.mainnet, + steps: [ + { + transaction: createTransaction({ + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + }), + label: 'approve', + }, + { + transaction: createTransaction({ + from: '0x1111111111111111111111111111111111111111', + to: '0x3333333333333333333333333333333333333333', + }), + label: 'swap', + }, + ], + }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/raps/__tests__/validation.invariants.test.ts b/src/raps/__tests__/validation.invariants.test.ts new file mode 100644 index 00000000000..b5103bfffb3 --- /dev/null +++ b/src/raps/__tests__/validation.invariants.test.ts @@ -0,0 +1,45 @@ +import { getAddress } from 'viem'; +import { RainbowError } from '@/logger'; +import { createQuote } from './fixtures'; +import { getQuoteAllowanceTargetAddress, requireAddress, requireHex } from '../validation'; + +describe('RAP validation boundary invariants', () => { + test('requireAddress validates and normalizes to viem Address shape', () => { + const rawAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const normalizedAddress = requireAddress(rawAddress, 'token address'); + + expect(normalizedAddress).toBe(getAddress(rawAddress)); + }); + + test('requireAddress throws RainbowError when missing or invalid', () => { + expect(() => requireAddress(undefined, 'token address')).toThrow(RainbowError); + expect(() => requireAddress(undefined, 'token address')).toThrow('[raps/validation]: Missing token address'); + + expect(() => requireAddress('not-an-address', 'token address')).toThrow(RainbowError); + expect(() => requireAddress('not-an-address', 'token address')).toThrow('[raps/validation]: Invalid token address'); + }); + + test('requireHex narrows valid hex values and throws for invalid input', () => { + expect(requireHex('0x1234', 'tx data')).toBe('0x1234'); + + expect(() => requireHex('1234', 'tx data')).toThrow(RainbowError); + expect(() => requireHex('1234', 'tx data')).toThrow('[raps/validation]: Invalid tx data'); + }); + + test('getQuoteAllowanceTargetAddress validates and normalizes quote allowanceTarget', () => { + const quote = createQuote({ + allowanceTarget: '0x00000000009726632680fb29d3f7a9734e3010e2', + }); + + expect(getQuoteAllowanceTargetAddress(quote)).toBe(getAddress(quote.allowanceTarget)); + }); + + test('getQuoteAllowanceTargetAddress throws when quote allowanceTarget is invalid', () => { + const quote = createQuote({ + allowanceTarget: 'invalid', + }); + + expect(() => getQuoteAllowanceTargetAddress(quote)).toThrow(RainbowError); + expect(() => getQuoteAllowanceTargetAddress(quote)).toThrow('[raps/validation]: Invalid quote.allowanceTarget'); + }); +}); From e4bcd98524befc467c41c2814316aaee74236a41 Mon Sep 17 00:00:00 2001 From: Christian Baroni <7061887+christianbaroni@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:17:55 -0500 Subject: [PATCH 3/3] Align test fixtures with remote implementation --- src/raps/__tests__/execute.invariants.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/raps/__tests__/execute.invariants.test.ts b/src/raps/__tests__/execute.invariants.test.ts index c4678e9d100..7dd74b711ea 100644 --- a/src/raps/__tests__/execute.invariants.test.ts +++ b/src/raps/__tests__/execute.invariants.test.ts @@ -25,10 +25,6 @@ jest.mock('@/state/swaps/swapsStore', () => ({ }, })); -jest.mock('@/systems/delegation/featureFlags', () => ({ - isDelegationEnabled: jest.fn(), -})); - jest.mock('@/config/experimental', () => ({ DELEGATION: 'delegation', getExperimentalFlag: jest.fn(() => false), @@ -96,7 +92,8 @@ const ATOMIC_HASH = '0xatomic'; const delegationModule = jest.requireMock('@rainbow-me/delegation'); const performanceModule = jest.requireMock('@/state/performance/performance'); -const delegationFlagsModule = jest.requireMock('@/systems/delegation/featureFlags'); +const experimentalModule = jest.requireMock('@/config/experimental'); +const remoteConfigModule = jest.requireMock('@/model/remoteConfig'); const web3Module = jest.requireMock('@/handlers/web3'); const pendingTransactionsModule = jest.requireMock('@/state/pendingTransactions'); const actionsModule = jest.requireMock('../actions'); @@ -106,7 +103,8 @@ const unlockAndSwapModule = jest.requireMock('../unlockAndSwap'); const mockExecuteBatchedTransaction = delegationModule.executeBatchedTransaction; const mockSupportsDelegation = delegationModule.supportsDelegation; const mockExecuteFn = performanceModule.executeFn; -const mockIsDelegationEnabled = delegationFlagsModule.isDelegationEnabled; +const mockGetExperimentalFlag = experimentalModule.getExperimentalFlag; +const mockGetRemoteConfig = remoteConfigModule.getRemoteConfig; const mockGetProvider = web3Module.getProvider; const mockAddNewTransaction = pendingTransactionsModule.addNewTransaction; const mockSwap = actionsModule.swap; @@ -121,7 +119,8 @@ function setupExecuteHarness() { jest.clearAllMocks(); mockExecuteFn.mockImplementation((fn: (...args: unknown[]) => unknown) => fn); - mockIsDelegationEnabled.mockReturnValue(true); + mockGetExperimentalFlag.mockReturnValue(false); + mockGetRemoteConfig.mockReturnValue({ delegation_enabled: true }); mockSupportsDelegation.mockResolvedValue({ supported: true, reason: null }); mockExecuteBatchedTransaction.mockResolvedValue({