diff --git a/src/validators/evm/rocketpool/rocketpool.validator.test.ts b/src/validators/evm/rocketpool/rocketpool.validator.test.ts index 5989954..1743a48 100644 --- a/src/validators/evm/rocketpool/rocketpool.validator.test.ts +++ b/src/validators/evm/rocketpool/rocketpool.validator.test.ts @@ -35,6 +35,8 @@ describe('RocketPoolValidator via Shield', () => { const lifiSwapIface = new ethers.Interface([ 'function swapTokensSingleV3ERC20ToERC20(bytes32 _transactionId, string _integrator, string _referrer, address _receiver, uint256 _minAmountOut, (address callTo, address approveTo, address sendingAssetId, address receivingAssetId, uint256 fromAmount, bytes callData, bool requiresDeposit) _swapData)', 'function swapTokensSingleV3ERC20ToNative(bytes32 _transactionId, string _integrator, string _referrer, address _receiver, uint256 _minAmountOut, (address callTo, address approveTo, address sendingAssetId, address receivingAssetId, uint256 fromAmount, bytes callData, bool requiresDeposit) _swapData)', + 'function swapTokensMultipleV3ERC20ToERC20(bytes32 _transactionId, string _integrator, string _referrer, address _receiver, uint256 _minAmountOut, (address callTo, address approveTo, address sendingAssetId, address receivingAssetId, uint256 fromAmount, bytes callData, bool requiresDeposit)[] _swapData)', + 'function swapTokensMultipleV3ERC20ToNative(bytes32 _transactionId, string _integrator, string _referrer, address _receiver, uint256 _minAmountOut, (address callTo, address approveTo, address sendingAssetId, address receivingAssetId, uint256 fromAmount, bytes callData, bool requiresDeposit)[] _swapData)', ]); const permit2ProxyIface = new ethers.Interface([ @@ -494,6 +496,96 @@ describe('RocketPoolValidator via Shield', () => { 'Minimum tokens out exceeds ideal tokens out', ); }); + + it('should reject stake with manipulated minTokensOut (far below slippage floor)', () => { + const manipulatedCalldata = iface.encodeFunctionData('swapTo', [ + 5000n, + 5000n, + 1n, // 1 wei — manipulated + 950000000000000000n, + ]); + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', // 1 ETH + data: manipulatedCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(false); + const stakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.STAKE, + ); + expect(stakeAttempt?.reason).toContain('slippage floor'); + }); + + it('should accept stake with minTokensOut at exactly 80% of tx.value', () => { + const oneEthWei = 1000000000000000000n; + const eightyPercent = (oneEthWei * 8000n) / 10000n; // 0.8 ETH + const borderlineCalldata = iface.encodeFunctionData('swapTo', [ + 5000n, + 5000n, + eightyPercent, + oneEthWei, + ]); + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', // 1 ETH + data: borderlineCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(true); + }); + + it('should reject stake with minTokensOut just below 80% floor', () => { + const oneEthWei = 1000000000000000000n; + const justBelow = (oneEthWei * 8000n) / 10000n - 1n; // 0.8 ETH - 1 wei + const belowFloorCalldata = iface.encodeFunctionData('swapTo', [ + 5000n, + 5000n, + justBelow, + oneEthWei, + ]); + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', // 1 ETH + data: belowFloorCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(false); + const stakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.STAKE, + ); + expect(stakeAttempt?.reason).toContain('slippage floor'); + }); }); describe('APPROVAL transactions', () => { @@ -875,6 +967,70 @@ describe('RocketPoolValidator via Shield', () => { expect(result.detectedType).toBe(TransactionType.SWAP); }); + it('should validate a direct Diamond swapTokensMultipleV3ERC20ToNative with matching receiver', () => { + const multipleSwapCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensMultipleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 900000000000000000n, + [sampleSwapDataTuple], + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: multipleSwapCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.SWAP); + }); + + it('should validate a direct Diamond swapTokensMultipleV3ERC20ToERC20 with matching receiver', () => { + const multipleErc20Calldata = lifiSwapIface.encodeFunctionData( + 'swapTokensMultipleV3ERC20ToERC20', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 900000000000000000n, + [sampleSwapDataTuple], + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: multipleErc20Calldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.SWAP); + }); + it('should validate Permit2 Proxy callDiamondWithPermit2 wrapping valid swap', () => { const tx = { to: LIFI_PERMIT2_PROXY, @@ -944,6 +1100,85 @@ describe('RocketPoolValidator via Shield', () => { expect(result.detectedType).toBe(TransactionType.SWAP); }); + it('should validate Permit2-wrapped Multiple variant swap', () => { + const multipleSwapCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensMultipleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 900000000000000000n, + [sampleSwapDataTuple], + ], + ); + const permit2WrappedMultiple = + permit2ProxyIface.encodeFunctionData('callDiamondWithPermit2', [ + multipleSwapCalldata, + dummyPermit, + dummySignature, + ]); + const tx = { + to: LIFI_PERMIT2_PROXY, + from: userAddress, + value: '0x0', + data: permit2WrappedMultiple, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.SWAP); + }); + + it('should validate Multiple swap with multiple swapData entries', () => { + const halfAmountSwapData = [ + '0x0000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000001', + rETHAddress, + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + 500000000000000000n, + '0x', + false, + ]; + const multiEntryCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensMultipleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 900000000000000000n, + [halfAmountSwapData, halfAmountSwapData], + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: multiEntryCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.SWAP); + }); + // --- Rejections --- it('should reject SWAP to unknown contract', () => { @@ -1059,6 +1294,44 @@ describe('RocketPoolValidator via Shield', () => { 'SWAP receiver does not match user address', ); }); + + it('should reject Multiple variant swap with wrong receiver', () => { + const wrongReceiverMultiple = lifiSwapIface.encodeFunctionData( + 'swapTokensMultipleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + '0x0000000000000000000000000000000000000bad', + 900000000000000000n, + [sampleSwapDataTuple], + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: wrongReceiverMultiple, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + const swapAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.SWAP, + ); + expect(swapAttempt?.reason).toContain( + 'SWAP receiver does not match user address', + ); + }); it('should reject SWAP with ETH value', () => { const tx = { @@ -1227,6 +1500,315 @@ describe('RocketPoolValidator via Shield', () => { 'Failed to extract Diamond calldata from Permit2 Proxy', ); }); + + // --- Slippage floor --- + it('should reject SWAP with manipulated _minAmountOut (1 wei)', () => { + const manipulatedSwapCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensSingleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 1n, + sampleSwapDataTuple, + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: manipulatedSwapCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + const swapAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.SWAP, + ); + expect(swapAttempt?.reason).toContain('slippage floor'); + }); + + it('should reject SWAP with zero _minAmountOut', () => { + const zeroMinSwapCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensSingleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 0n, + sampleSwapDataTuple, + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: zeroMinSwapCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + const swapAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.SWAP, + ); + expect(swapAttempt?.reason).toContain( + 'Minimum amount out must be greater than zero', + ); + }); + + it('should accept SWAP with _minAmountOut at exactly 80% of fromAmount', () => { + const exactFloorSwapCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensSingleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 800000000000000000n, + sampleSwapDataTuple, + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: exactFloorSwapCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.SWAP); + }); + + it('should reject SWAP with _minAmountOut just below 80% floor', () => { + const belowFloorSwapCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensSingleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 800000000000000000n - 1n, + sampleSwapDataTuple, + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: belowFloorSwapCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + const swapAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.SWAP, + ); + expect(swapAttempt?.reason).toContain('slippage floor'); + }); + + it('should reject Permit2-wrapped SWAP with manipulated slippage', () => { + const manipulatedDiamondCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensSingleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 1n, + sampleSwapDataTuple, + ], + ); + const permit2WrappedManipulated = + permit2ProxyIface.encodeFunctionData('callDiamondWithPermit2', [ + manipulatedDiamondCalldata, + dummyPermit, + dummySignature, + ]); + const tx = { + to: LIFI_PERMIT2_PROXY, + from: userAddress, + value: '0x0', + data: permit2WrappedManipulated, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + const swapAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.SWAP, + ); + expect(swapAttempt?.reason).toContain('slippage floor'); + }); + + it('should reject a Multiple variant swap with manipulated slippage', () => { + const manipulatedMultipleCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensMultipleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 1n, + [sampleSwapDataTuple], + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: manipulatedMultipleCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + const swapAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.SWAP, + ); + expect(swapAttempt?.reason).toContain('slippage floor'); + }); + + it('should sum fromAmount across multiple swapData entries for slippage check', () => { + const halfAmountSwapData = [ + '0x0000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000001', + rETHAddress, + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + 500000000000000000n, // 0.5 rETH + '0x', + false, + ]; + // Two entries of 0.5 rETH = 1 rETH total, _minAmountOut = 0.9 ETH = 90% + const multiEntryCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensMultipleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 900000000000000000n, + [halfAmountSwapData, halfAmountSwapData], + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: multiEntryCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.SWAP); + }); + + it('should reject Multiple swap when _minAmountOut is below floor of summed fromAmounts', () => { + const halfAmountSwapData = [ + '0x0000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000001', + rETHAddress, + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + 500000000000000000n, // 0.5 rETH + '0x', + false, + ]; + // Two entries of 0.5 rETH = 1 rETH total, _minAmountOut = 1 wei + const manipulatedMultiEntryCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensMultipleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 1n, + [halfAmountSwapData, halfAmountSwapData], + ], + ); + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: manipulatedMultiEntryCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + const swapAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.SWAP, + ); + expect(swapAttempt?.reason).toContain('slippage floor'); + }); }); describe('Auto-detection', () => { diff --git a/src/validators/evm/rocketpool/rocketpool.validator.ts b/src/validators/evm/rocketpool/rocketpool.validator.ts index 92e8f3e..a20677d 100644 --- a/src/validators/evm/rocketpool/rocketpool.validator.ts +++ b/src/validators/evm/rocketpool/rocketpool.validator.ts @@ -24,6 +24,8 @@ const LIFI_CONTRACTS = new Set([ const LIFI_DIAMOND = '0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae'; +const SLIPPAGE_FLOOR_BPS = 8000n; // 80% — rETH/ETH has never traded below 0.90 + const LIFI_SWAP_ABI = [ 'function swapTokensSingleV3ERC20ToERC20(bytes32 _transactionId, string _integrator, string _referrer, address _receiver, uint256 _minAmountOut, (address callTo, address approveTo, address sendingAssetId, address receivingAssetId, uint256 fromAmount, bytes callData, bool requiresDeposit) _swapData)', 'function swapTokensSingleV3ERC20ToNative(bytes32 _transactionId, string _integrator, string _referrer, address _receiver, uint256 _minAmountOut, (address callTo, address approveTo, address sendingAssetId, address receivingAssetId, uint256 fromAmount, bytes callData, bool requiresDeposit) _swapData)', @@ -148,6 +150,20 @@ export class RocketPoolValidator extends BaseEVMValidator { }); } + // Slippage floor: minTokensOut must be >= 80% of tx.value (ETH sent) + // rETH/ETH trades near 1:1; catches gross manipulation (e.g. minTokensOut = 1 wei on 1 ETH) + const slippageFloor = (value * SLIPPAGE_FLOOR_BPS) / 10000n; + if (BigInt(minTokensOut) < slippageFloor) { + return this.blocked( + 'minTokensOut is too low relative to ETH value (slippage floor)', + { + minTokensOut: BigInt(minTokensOut).toString(), + txValue: value.toString(), + slippageFloor: slippageFloor.toString(), + }, + ); + } + return this.safe(); } @@ -276,6 +292,37 @@ export class RocketPoolValidator extends BaseEVMValidator { }); } + // _minAmountOut > 0 + const minAmountOut = BigInt(parsed.args[4]); + if (minAmountOut <= 0n) { + return this.blocked('Minimum amount out must be greater than zero'); + } + // Extract fromAmount from _swapData to compute slippage floor + // Single variants: args[5] is a tuple; Multiple variants: args[5] is tuple[] + let totalFromAmount: bigint; + if (parsed.name.includes('Multiple')) { + const swapDataArray = parsed.args[5] as unknown[]; + totalFromAmount = swapDataArray.reduce( + (sum, sd: any) => sum + BigInt(sd[4]), + 0n, + ); + } else { + totalFromAmount = BigInt(parsed.args[5][4]); + } + if (totalFromAmount > 0n) { + const slippageFloor = (totalFromAmount * SLIPPAGE_FLOOR_BPS) / 10000n; + if (minAmountOut < slippageFloor) { + return this.blocked( + '_minAmountOut is too low relative to fromAmount (slippage floor)', + { + minAmountOut: minAmountOut.toString(), + fromAmount: totalFromAmount.toString(), + slippageFloor: slippageFloor.toString(), + }, + ); + } + } + return this.safe(); } }