From 3a39bc7380e422a84c7a65ae8552bd6b38d87690 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Tue, 3 Mar 2026 00:57:32 -0800 Subject: [PATCH 1/6] feat: rocketpool stake + approval --- src/validators/evm/index.ts | 1 + .../rocketpool/rocketpool.validator.test.ts | 741 ++++++++++++++++++ .../evm/rocketpool/rocketpool.validator.ts | 160 ++++ src/validators/index.ts | 3 +- 4 files changed, 904 insertions(+), 1 deletion(-) create mode 100644 src/validators/evm/rocketpool/rocketpool.validator.test.ts create mode 100644 src/validators/evm/rocketpool/rocketpool.validator.ts diff --git a/src/validators/evm/index.ts b/src/validators/evm/index.ts index 1c8112a..112e31b 100644 --- a/src/validators/evm/index.ts +++ b/src/validators/evm/index.ts @@ -1,3 +1,4 @@ export { BaseEVMValidator } from './base.validator'; export type { EVMTransaction } from './base.validator'; export { LidoValidator } from './lido/lido.validator'; +export { RocketPoolValidator } from './rocketpool/rocketpool.validator'; \ No newline at end of file diff --git a/src/validators/evm/rocketpool/rocketpool.validator.test.ts b/src/validators/evm/rocketpool/rocketpool.validator.test.ts new file mode 100644 index 0000000..f9cc94d --- /dev/null +++ b/src/validators/evm/rocketpool/rocketpool.validator.test.ts @@ -0,0 +1,741 @@ +import { Shield } from '../../../shield'; +import { TransactionType } from '../../../types'; +import { ethers } from 'ethers'; + +describe('RocketPoolValidator via Shield', () => { + const shield = new Shield(); + const yieldId = 'ethereum-eth-reth-staking'; + const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; + const rETHAddress = '0xae78736Cd615f374D3085123A210448E74Fc6393'; + const rocketSwapRouterAddress = + '0x16D5A408e807db8eF7c578279BEeEe6b228f1c1C'; + + const iface = new ethers.Interface([ + 'function swapTo(uint256 _uniswapPortion, uint256 _balancerPortion, uint256 _minTokensOut, uint256 _idealTokensOut) payable', + 'function approve(address spender, uint256 amount) returns (bool)', + ]); + + const lifiSpender = '0x1111111254EEB25477B68fb85Ed929f73A960582'; + + const stakeCalldata = iface.encodeFunctionData('swapTo', [ + 5000n, + 5000n, + 900000000000000000n, + 950000000000000000n, + ]); + + const approveCalldata = iface.encodeFunctionData('approve', [ + lifiSpender, + 1000000000000000000n, + ]); + + describe('isSupported', () => { + it('should support ethereum-eth-reth-staking yield', () => { + expect(shield.isSupported(yieldId)).toBe(true); + expect(shield.getSupportedYieldIds()).toContain(yieldId); + }); + }); + + describe('STAKE transactions', () => { + it('should validate a valid stake transaction', () => { + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: stakeCalldata, + 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 validate EIP-1559 stake transaction', () => { + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: stakeCalldata, + nonce: 0, + gasLimit: '0x30d40', + maxFeePerGas: '0x6fc23ac00', + maxPriorityFeePerGas: '0x3b9aca00', + chainId: 1, + type: 2, + }; + + const result = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(tx), + userAddress, + }); + + expect(result.isValid).toBe(true); + }); + + it('should accept string chainId "1"', () => { + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: stakeCalldata, + 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 to wrong contract', () => { + const tx = { + to: '0x0000000000000000000000000000000000000001', + from: userAddress, + value: '0xde0b6b3a7640000', + data: stakeCalldata, + 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 stakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.STAKE, + ); + expect(stakeAttempt?.reason).toContain( + 'not to RocketPool SwapRouter contract', + ); + }); + + it('should reject stake with wrong method', () => { + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: approveCalldata, + 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'); + }); + + it('should reject stake with zero ETH value', () => { + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0x0', + data: stakeCalldata, + 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 stakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.STAKE, + ); + expect(stakeAttempt?.reason).toContain('must send ETH value'); + }); + + it('should reject stake with no value field', () => { + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + data: stakeCalldata, + 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 stakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.STAKE, + ); + expect(stakeAttempt?.reason).toContain('must send ETH value'); + }); + + it('should reject stake from wrong user', () => { + const wrongUser = '0x0000000000000000000000000000000000000001'; + const tx = { + to: rocketSwapRouterAddress, + from: wrongUser, + value: '0xde0b6b3a7640000', + data: stakeCalldata, + 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'); + }); + + it('should reject stake on wrong network', () => { + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: stakeCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 137, + 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'); + }); + + it('should reject stake with appended bytes', () => { + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: stakeCalldata + 'deadbeef', + 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 stakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.STAKE, + ); + expect(stakeAttempt?.reason).toContain('calldata has been tampered'); + }); + + it('should reject invalid JSON transaction', () => { + const result = shield.validate({ + yieldId, + unsignedTransaction: 'invalid-json', + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + }); + }); + + describe('APPROVAL transactions', () => { + it('should validate a valid approval transaction', () => { + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: approveCalldata, + 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 approval to wrong contract', () => { + const tx = { + to: '0x0000000000000000000000000000000000000001', + from: userAddress, + value: '0x0', + data: approveCalldata, + 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 approvalAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.APPROVAL, + ); + expect(approvalAttempt?.reason).toContain( + 'not to RocketPool rETH contract', + ); + }); + + it('should reject approval with wrong method', () => { + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: stakeCalldata, + 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'); + }); + + it('should reject approval with ETH value', () => { + const tx = { + to: rETHAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: approveCalldata, + 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 approvalAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.APPROVAL, + ); + expect(approvalAttempt?.reason).toContain( + 'should not send ETH value', + ); + }); + + it('should reject approval with zero amount', () => { + const zeroApproveCalldata = iface.encodeFunctionData('approve', [ + lifiSpender, + 0n, + ]); + + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: zeroApproveCalldata, + 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 approvalAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.APPROVAL, + ); + expect(approvalAttempt?.reason).toContain( + 'amount must be greater than zero', + ); + }); + + it('should reject approval from wrong user', () => { + const wrongUser = '0x0000000000000000000000000000000000000001'; + const tx = { + to: rETHAddress, + from: wrongUser, + value: '0x0', + data: approveCalldata, + 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'); + }); + + it('should reject approval on wrong network', () => { + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: approveCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 137, + 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'); + }); + + it('should reject approval with appended bytes', () => { + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: approveCalldata + 'deadbeef', + 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 approvalAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.APPROVAL, + ); + expect(approvalAttempt?.reason).toContain('calldata has been tampered'); + }); + + it('should accept max uint256 approval amount', () => { + const maxUint256 = (1n << 256n) - 1n; + const maxApproveCalldata = iface.encodeFunctionData('approve', [ + lifiSpender, + maxUint256, + ]); + + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: maxApproveCalldata, + 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 accept approval with any spender address', () => { + const randomSpender = '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF'; + const dynamicApproveCalldata = iface.encodeFunctionData('approve', [ + randomSpender, + 1000000000000000000n, + ]); + + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: dynamicApproveCalldata, + 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); + }); + }); + + describe('Auto-detection', () => { + it('should detect swapTo as STAKE', () => { + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: stakeCalldata, + 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.STAKE); + }); + + it('should detect approve as APPROVAL', () => { + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: approveCalldata, + 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.APPROVAL); + }); + + it('should reject unknown calldata', () => { + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: '0xdeadbeef', + 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'); + }); + + it('should not produce ambiguous matches', () => { + const stakeTx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: stakeCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + + const stakeResult = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(stakeTx), + userAddress, + }); + + expect(stakeResult.isValid).toBe(true); + expect(stakeResult.detectedType).toBeDefined(); + + const approveTx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: approveCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + + const approveResult = shield.validate({ + yieldId, + unsignedTransaction: JSON.stringify(approveTx), + userAddress, + }); + + expect(approveResult.isValid).toBe(true); + expect(approveResult.detectedType).toBeDefined(); + }); + }); + + describe('General validation', () => { + it('should reject transaction from wrong user', () => { + const wrongUser = '0x0000000000000000000000000000000000000001'; + const tx = { + to: rocketSwapRouterAddress, + from: wrongUser, + value: '0xde0b6b3a7640000', + data: stakeCalldata, + 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'); + }); + + it('should reject transaction on wrong network', () => { + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: stakeCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 137, + 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'); + }); + + it('should reject malformed transaction data', () => { + const result = shield.validate({ + yieldId, + unsignedTransaction: 'not-json', + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + }); + }); +}); \ No newline at end of file diff --git a/src/validators/evm/rocketpool/rocketpool.validator.ts b/src/validators/evm/rocketpool/rocketpool.validator.ts new file mode 100644 index 0000000..2f3138c --- /dev/null +++ b/src/validators/evm/rocketpool/rocketpool.validator.ts @@ -0,0 +1,160 @@ +import { ethers } from 'ethers'; +import { + ActionArguments, + TransactionType, + ValidationContext, + ValidationResult, +} from '../../../types'; +import { BaseEVMValidator, EVMTransaction } from '../base.validator'; + +const ROCKETPOOL_CONTRACTS = { + rETH: '0xae78736Cd615f374D3085123A210448E74Fc6393', + rocketSwapRouter: '0x16D5A408e807db8eF7c578279BEeEe6b228f1c1C', +}; + +const ROCKETPOOL_ABI = [ + 'function swapTo(uint256 _uniswapPortion, uint256 _balancerPortion, uint256 _minTokensOut, uint256 _idealTokensOut) payable', + 'function approve(address spender, uint256 amount) returns (bool)', +]; + +export class RocketPoolValidator extends BaseEVMValidator { + private readonly rocketPoolInterface: ethers.Interface; + + constructor() { + super(); + this.rocketPoolInterface = new ethers.Interface(ROCKETPOOL_ABI); + } + + getSupportedTransactionTypes(): TransactionType[] { + return [TransactionType.STAKE, TransactionType.APPROVAL]; + } + + validate( + unsignedTransaction: string, + transactionType: TransactionType, + userAddress: string, + _args?: ActionArguments, + _context?: ValidationContext, + ): ValidationResult { + // 1. Decode JSON → EVMTransaction + const decoded = this.decodeEVMTransaction(unsignedTransaction); + if (!decoded.isValid || !decoded.transaction) { + return this.blocked('Failed to decode EVM transaction', { + error: decoded.error, + }); + } + const tx = decoded.transaction; + + // 2. Verify from == userAddress + const fromErr = this.ensureTransactionFromIsUser(tx, userAddress); + if (fromErr) return fromErr; + + // 3. Verify chainId == 1 + const chainErr = this.ensureChainIdEquals( + tx, 1, + 'RocketPool only supported on Ethereum mainnet', + ); + if (chainErr) return chainErr; + + // 4. Route to specific validation + switch (transactionType) { + case TransactionType.STAKE: + return this.validateStake(tx); + case TransactionType.APPROVAL: + return this.validateApproval(tx); + default: + return this.blocked('Unsupported transaction type', { + transactionType, + }); + } + } + + private validateStake(tx: EVMTransaction): ValidationResult { + // Verify target is RocketSwapRouter + if (tx.to?.toLowerCase() !== ROCKETPOOL_CONTRACTS.rocketSwapRouter.toLowerCase()) { + return this.blocked('Transaction not to RocketPool SwapRouter contract', { + expected: ROCKETPOOL_CONTRACTS.rocketSwapRouter, + actual: tx.to, + }); + } + + // Verify ETH value > 0 (swapTo is payable — must send ETH to receive rETH) + const value = BigInt(tx.value ?? '0'); + if (value <= 0n) { + return this.blocked('Stake must send ETH value', { + value: value.toString(), + }); + } + + // Parse calldata and verify it matches swapTo(...) + const result = this.parseAndValidateCalldata(tx, this.rocketPoolInterface); + if ('error' in result) return result.error; + + if (result.parsed.name !== 'swapTo') { + return this.blocked('Invalid method for staking', { + expected: 'swapTo', + actual: result.parsed.name, + }); + } + + const [, , minTokensOut, idealTokensOut] = result.parsed.args; + + if (BigInt(minTokensOut) <= 0n) { + return this.blocked('Minimum tokens out must be greater than zero'); + } + + if (BigInt(idealTokensOut) <= 0n) { + return this.blocked('Ideal tokens out must be greater than zero'); + } + + if (BigInt(minTokensOut) > BigInt(idealTokensOut)) { + return this.blocked('Minimum tokens out exceeds ideal tokens out', { + minTokensOut: BigInt(minTokensOut).toString(), + idealTokensOut: BigInt(idealTokensOut).toString(), + }); + } + + return this.safe(); + } + + private validateApproval(tx: EVMTransaction): ValidationResult { + // Verify target is rETH contract + if (tx.to?.toLowerCase() !== ROCKETPOOL_CONTRACTS.rETH.toLowerCase()) { + return this.blocked('Transaction not to RocketPool rETH contract', { + expected: ROCKETPOOL_CONTRACTS.rETH, + actual: tx.to, + }); + } + + // Verify no ETH value + const value = BigInt(tx.value ?? '0'); + if (value > 0n) { + return this.blocked('Approval should not send ETH value', { + value: value.toString(), + }); + } + + // Parse calldata and verify it matches approve(...) + const result = this.parseAndValidateCalldata(tx, this.rocketPoolInterface); + if ('error' in result) return result.error; + + if (result.parsed.name !== 'approve') { + return this.blocked('Invalid method for approval', { + expected: 'approve', + actual: result.parsed.name, + }); + } + + // Verify amount > 0 + const [, amount] = result.parsed.args; + if (BigInt(amount) <= 0n) { + return this.blocked('Approval amount must be greater than zero'); + } + + // Note: spender (result.parsed.args[0]) is NOT validated — it's dynamic + // from LI.FI and changes per quote. The spender validation is intentionally + // omitted because the exit path routes through LI.FI aggregator. + + return this.safe(); + } +} \ No newline at end of file diff --git a/src/validators/index.ts b/src/validators/index.ts index a4c1247..c2ab505 100644 --- a/src/validators/index.ts +++ b/src/validators/index.ts @@ -1,6 +1,6 @@ import { BaseValidator } from './base.validator'; import { SolanaNativeStakingValidator } from './solana'; -import { LidoValidator } from './evm'; +import { LidoValidator, RocketPoolValidator } from './evm'; import { TronValidator } from './tron'; import { ERC4626Validator, loadEmbeddedRegistry } from './evm/erc4626'; @@ -13,6 +13,7 @@ const registry = new Map([ ], ['ethereum-eth-lido-staking', new LidoValidator()], ['tron-trx-native-staking', new TronValidator()], + ['ethereum-eth-reth-staking', new RocketPoolValidator()], ]); export const GENERIC_ERC4626_PROTOCOLS = new Set([ From 83bb216df6fd460736744b4504e87147123076ae Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Tue, 3 Mar 2026 18:35:34 -0800 Subject: [PATCH 2/6] feat: thorough validation of swapTo params --- .../rocketpool/rocketpool.validator.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/validators/evm/rocketpool/rocketpool.validator.test.ts b/src/validators/evm/rocketpool/rocketpool.validator.test.ts index f9cc94d..b18b15d 100644 --- a/src/validators/evm/rocketpool/rocketpool.validator.test.ts +++ b/src/validators/evm/rocketpool/rocketpool.validator.test.ts @@ -293,6 +293,113 @@ describe('RocketPoolValidator via Shield', () => { expect(result.isValid).toBe(false); expect(result.reason).toContain('No matching operation pattern found'); }); + it('should reject stake with zero minTokensOut', () => { + const zeroMinCalldata = iface.encodeFunctionData('swapTo', [ + 5000n, + 5000n, + 0n, + 950000000000000000n, + ]); + + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: zeroMinCalldata, + 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 stakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.STAKE, + ); + expect(stakeAttempt?.reason).toContain( + 'Minimum tokens out must be greater than zero', + ); + }); + + it('should reject stake with zero idealTokensOut', () => { + const zeroIdealCalldata = iface.encodeFunctionData('swapTo', [ + 5000n, + 5000n, + 900000000000000000n, + 0n, + ]); + + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: zeroIdealCalldata, + 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 stakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.STAKE, + ); + expect(stakeAttempt?.reason).toContain( + 'Ideal tokens out must be greater than zero', + ); + }); + + it('should reject stake where minTokensOut exceeds idealTokensOut', () => { + const invertedCalldata = iface.encodeFunctionData('swapTo', [ + 5000n, + 5000n, + 1000000000000000000n, + 500000000000000000n, + ]); + + const tx = { + to: rocketSwapRouterAddress, + from: userAddress, + value: '0xde0b6b3a7640000', + data: invertedCalldata, + 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 stakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.STAKE, + ); + expect(stakeAttempt?.reason).toContain( + 'Minimum tokens out exceeds ideal tokens out', + ); + }); }); describe('APPROVAL transactions', () => { From 4652e7247567159bc5a90bc3df754aa63e465aa1 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Tue, 3 Mar 2026 18:45:39 -0800 Subject: [PATCH 3/6] fix: lint --- src/validators/evm/index.ts | 2 +- .../evm/rocketpool/rocketpool.validator.test.ts | 9 +++------ .../evm/rocketpool/rocketpool.validator.ts | 12 ++++++++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/validators/evm/index.ts b/src/validators/evm/index.ts index 112e31b..6449fbb 100644 --- a/src/validators/evm/index.ts +++ b/src/validators/evm/index.ts @@ -1,4 +1,4 @@ export { BaseEVMValidator } from './base.validator'; export type { EVMTransaction } from './base.validator'; export { LidoValidator } from './lido/lido.validator'; -export { RocketPoolValidator } from './rocketpool/rocketpool.validator'; \ No newline at end of file +export { RocketPoolValidator } from './rocketpool/rocketpool.validator'; diff --git a/src/validators/evm/rocketpool/rocketpool.validator.test.ts b/src/validators/evm/rocketpool/rocketpool.validator.test.ts index b18b15d..7857a93 100644 --- a/src/validators/evm/rocketpool/rocketpool.validator.test.ts +++ b/src/validators/evm/rocketpool/rocketpool.validator.test.ts @@ -7,8 +7,7 @@ describe('RocketPoolValidator via Shield', () => { const yieldId = 'ethereum-eth-reth-staking'; const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; const rETHAddress = '0xae78736Cd615f374D3085123A210448E74Fc6393'; - const rocketSwapRouterAddress = - '0x16D5A408e807db8eF7c578279BEeEe6b228f1c1C'; + const rocketSwapRouterAddress = '0x16D5A408e807db8eF7c578279BEeEe6b228f1c1C'; const iface = new ethers.Interface([ 'function swapTo(uint256 _uniswapPortion, uint256 _balancerPortion, uint256 _minTokensOut, uint256 _idealTokensOut) payable', @@ -501,9 +500,7 @@ describe('RocketPoolValidator via Shield', () => { const approvalAttempt = result.details?.attempts?.find( (a: any) => a.type === TransactionType.APPROVAL, ); - expect(approvalAttempt?.reason).toContain( - 'should not send ETH value', - ); + expect(approvalAttempt?.reason).toContain('should not send ETH value'); }); it('should reject approval with zero amount', () => { @@ -845,4 +842,4 @@ describe('RocketPoolValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); }); }); -}); \ No newline at end of file +}); diff --git a/src/validators/evm/rocketpool/rocketpool.validator.ts b/src/validators/evm/rocketpool/rocketpool.validator.ts index 2f3138c..3525e45 100644 --- a/src/validators/evm/rocketpool/rocketpool.validator.ts +++ b/src/validators/evm/rocketpool/rocketpool.validator.ts @@ -51,7 +51,8 @@ export class RocketPoolValidator extends BaseEVMValidator { // 3. Verify chainId == 1 const chainErr = this.ensureChainIdEquals( - tx, 1, + tx, + 1, 'RocketPool only supported on Ethereum mainnet', ); if (chainErr) return chainErr; @@ -71,7 +72,10 @@ export class RocketPoolValidator extends BaseEVMValidator { private validateStake(tx: EVMTransaction): ValidationResult { // Verify target is RocketSwapRouter - if (tx.to?.toLowerCase() !== ROCKETPOOL_CONTRACTS.rocketSwapRouter.toLowerCase()) { + if ( + tx.to?.toLowerCase() !== + ROCKETPOOL_CONTRACTS.rocketSwapRouter.toLowerCase() + ) { return this.blocked('Transaction not to RocketPool SwapRouter contract', { expected: ROCKETPOOL_CONTRACTS.rocketSwapRouter, actual: tx.to, @@ -113,7 +117,7 @@ export class RocketPoolValidator extends BaseEVMValidator { idealTokensOut: BigInt(idealTokensOut).toString(), }); } - + return this.safe(); } @@ -157,4 +161,4 @@ export class RocketPoolValidator extends BaseEVMValidator { return this.safe(); } -} \ No newline at end of file +} From 86b3e98f01e1fe37b4d244b1a2edefcef5bb7bf2 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Tue, 3 Mar 2026 18:46:59 -0800 Subject: [PATCH 4/6] fix: bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b000dad..1153866 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yieldxyz/shield", - "version": "1.2.2", + "version": "1.2.3", "description": "Zero-trust transaction validation library for Yield.xyz integrations.", "packageManager": "pnpm@10.12.2", "main": "./dist/index.js", From e102ab661d7ae98a8828566fe31c9bae3654310c Mon Sep 17 00:00:00 2001 From: JAK Date: Wed, 4 Mar 2026 20:37:16 -0800 Subject: [PATCH 5/6] feat: add LI.FI SWAP validation, APPROVAL spender whitelist, and comprehensive tests for RocketPool validator (#14) * feat: rocketpool exit validation * fix: remove unreachable lifi swap selectors --- .../rocketpool/rocketpool.validator.test.ts | 611 +++++++++++++++++- .../evm/rocketpool/rocketpool.validator.ts | 125 +++- 2 files changed, 730 insertions(+), 6 deletions(-) diff --git a/src/validators/evm/rocketpool/rocketpool.validator.test.ts b/src/validators/evm/rocketpool/rocketpool.validator.test.ts index 7857a93..b5f216a 100644 --- a/src/validators/evm/rocketpool/rocketpool.validator.test.ts +++ b/src/validators/evm/rocketpool/rocketpool.validator.test.ts @@ -14,7 +14,7 @@ describe('RocketPoolValidator via Shield', () => { 'function approve(address spender, uint256 amount) returns (bool)', ]); - const lifiSpender = '0x1111111254EEB25477B68fb85Ed929f73A960582'; + const lifiSpender = '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE'; // LI.FI Diamond const stakeCalldata = iface.encodeFunctionData('swapTo', [ 5000n, @@ -28,6 +28,101 @@ describe('RocketPoolValidator via Shield', () => { 1000000000000000000n, ]); + // --- LI.FI SWAP test setup --- + const LIFI_DIAMOND = '0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae'; + const LIFI_PERMIT2_PROXY = '0x89c6340b1a1f4b25d36cd8b063d49045caf3f818'; + + 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)' + ]); + + const permit2ProxyIface = new ethers.Interface([ + 'function callDiamondWithPermit2(bytes _diamondCalldata, ((address token, uint256 amount) permitted, uint256 nonce, uint256 deadline) _permit, bytes _signature) payable returns (bytes)', + 'function callDiamondWithPermit2Witness(bytes _diamondCalldata, address _signer, ((address token, uint256 amount) permitted, uint256 nonce, uint256 deadline) _permit, bytes _signature) payable returns (bytes)', + 'function callDiamondWithEIP2612Signature(address tokenAddress, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s, bytes diamondCalldata) payable returns (bytes)', + ]); + + const sampleSwapDataTuple = [ + '0x0000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000001', + rETHAddress, + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + 1000000000000000000n, + '0x', + false, + ]; + + const diamondSwapCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensSingleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + userAddress, + 900000000000000000n, + sampleSwapDataTuple, + ], + ); + + const diamondSingleV3SwapCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensSingleV3ERC20ToERC20', + [ + ethers.zeroPadValue('0x02', 32), + 'stakekit', + '', + userAddress, + 900000000000000000n, + sampleSwapDataTuple, + ], + ); + + const wrongReceiverSwapCalldata = lifiSwapIface.encodeFunctionData( + 'swapTokensSingleV3ERC20ToNative', + [ + ethers.zeroPadValue('0x01', 32), + 'stakekit', + '', + '0x0000000000000000000000000000000000000bad', + 900000000000000000n, + sampleSwapDataTuple, + ], + ); + + const dummyPermit = [[rETHAddress, 1000000000000000000n], 0n, 9999999999n]; + const dummySignature = '0x' + '00'.repeat(65); + + const permit2WrappedSwapCalldata = permit2ProxyIface.encodeFunctionData( + 'callDiamondWithPermit2', + [diamondSwapCalldata, dummyPermit, dummySignature], + ); + + const permit2WitnessWrappedSwapCalldata = + permit2ProxyIface.encodeFunctionData('callDiamondWithPermit2Witness', [ + diamondSwapCalldata, + userAddress, + dummyPermit, + dummySignature, + ]); + + const eip2612WrappedSwapCalldata = permit2ProxyIface.encodeFunctionData( + 'callDiamondWithEIP2612Signature', + [ + rETHAddress, + 1000000000000000000n, + 9999999999n, + 27, + ethers.zeroPadValue('0x01', 32), + ethers.zeroPadValue('0x02', 32), + diamondSwapCalldata, + ], + ); + + const permit2WrongReceiverCalldata = permit2ProxyIface.encodeFunctionData( + 'callDiamondWithPermit2', + [wrongReceiverSwapCalldata, dummyPermit, dummySignature], + ); + describe('isSupported', () => { it('should support ethereum-eth-reth-staking yield', () => { expect(shield.isSupported(yieldId)).toBe(true); @@ -639,7 +734,7 @@ describe('RocketPoolValidator via Shield', () => { expect(result.isValid).toBe(true); }); - it('should accept approval with any spender address', () => { + it('should reject approval with unknown spender', () => { const randomSpender = '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF'; const dynamicApproveCalldata = iface.encodeFunctionData('approve', [ randomSpender, @@ -664,10 +759,476 @@ describe('RocketPoolValidator via Shield', () => { userAddress, }); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + const approvalAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.APPROVAL, + ); + expect(approvalAttempt?.reason).toContain( + 'Approval spender is not a known LI.FI contract', + ); + }); + + it('should accept approval with Permit2 Proxy as spender', () => { + const permit2ApproveCalldata = iface.encodeFunctionData('approve', [ + LIFI_PERMIT2_PROXY, + 1000000000000000000n, + ]); + + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: permit2ApproveCalldata, + 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.APPROVAL); + }); + + it('should accept approval with checksummed LI.FI Diamond spender', () => { + const checksummedDiamond = '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE'; + const checksumApproveCalldata = iface.encodeFunctionData('approve', [ + checksummedDiamond, + 1000000000000000000n, + ]); + + const tx = { + to: rETHAddress, + from: userAddress, + value: '0x0', + data: checksumApproveCalldata, + 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); }); }); + describe('SWAP transactions', () => { + // --- Happy paths --- + + it('should validate a direct Diamond swapTokensSingleV3ERC20ToNative with matching receiver', () => { + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: diamondSwapCalldata, + 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 swapTokensSingleV3ERC20ToERC20 with matching receiver', () => { + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: diamondSingleV3SwapCalldata, + 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, + from: userAddress, + value: '0x0', + data: permit2WrappedSwapCalldata, + 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 callDiamondWithPermit2Witness wrapping valid swap', () => { + const tx = { + to: LIFI_PERMIT2_PROXY, + from: userAddress, + value: '0x0', + data: permit2WitnessWrappedSwapCalldata, + 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 callDiamondWithEIP2612Signature wrapping valid swap', () => { + const tx = { + to: LIFI_PERMIT2_PROXY, + from: userAddress, + value: '0x0', + data: eip2612WrappedSwapCalldata, + 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', () => { + const tx = { + to: '0x0000000000000000000000000000000000000001', + from: userAddress, + value: '0x0', + data: diamondSwapCalldata, + 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 target is not a known LI.FI contract', + ); + }); + + it('should reject SWAP with no calldata', () => { + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: '0x', + 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 transaction has no calldata'); + }); + + it('should reject SWAP with unknown Diamond function selector', () => { + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: '0xdeadbeef' + '00'.repeat(128), + 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( + 'Failed to parse LI.FI swap calldata', + ); + }); + + it('should reject SWAP with receiver not matching user address', () => { + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: wrongReceiverSwapCalldata, + 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 = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0xde0b6b3a7640000', + data: diamondSwapCalldata, + 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 should not send ETH value'); + }); + + it('should reject SWAP from wrong user', () => { + const wrongUser = '0x0000000000000000000000000000000000000001'; + const tx = { + to: LIFI_DIAMOND, + from: wrongUser, + value: '0x0', + data: diamondSwapCalldata, + 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'); + }); + + it('should reject SWAP on wrong network', () => { + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: diamondSwapCalldata, + nonce: 0, + gasLimit: '0x30d40', + gasPrice: '0x4a817c800', + chainId: 137, + 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'); + }); + + // --- Permit2 Proxy-specific rejections --- + + it('should reject Permit2 Proxy SWAP with wrong receiver in inner calldata', () => { + const tx = { + to: LIFI_PERMIT2_PROXY, + from: userAddress, + value: '0x0', + data: permit2WrongReceiverCalldata, + 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 Permit2 Proxy SWAP with garbage inner calldata', () => { + const garbageInnerCalldata = permit2ProxyIface.encodeFunctionData( + 'callDiamondWithPermit2', + ['0xdeadbeef' + '00'.repeat(128), dummyPermit, dummySignature], + ); + + const tx = { + to: LIFI_PERMIT2_PROXY, + from: userAddress, + value: '0x0', + data: garbageInnerCalldata, + 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( + 'Failed to parse LI.FI swap calldata', + ); + }); + + it('should reject Permit2 Proxy target with unparseable outer calldata', () => { + const tx = { + to: LIFI_PERMIT2_PROXY, + from: userAddress, + value: '0x0', + data: '0xffffffff' + '00'.repeat(64), + 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( + 'Failed to extract Diamond calldata from Permit2 Proxy', + ); + }); + }); + describe('Auto-detection', () => { it('should detect swapTo as STAKE', () => { const tx = { @@ -715,6 +1276,52 @@ describe('RocketPoolValidator via Shield', () => { expect(result.detectedType).toBe(TransactionType.APPROVAL); }); + it('should detect direct Diamond swap as SWAP', () => { + const tx = { + to: LIFI_DIAMOND, + from: userAddress, + value: '0x0', + data: diamondSwapCalldata, + 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 detect Permit2 Proxy swap as SWAP', () => { + const tx = { + to: LIFI_PERMIT2_PROXY, + from: userAddress, + value: '0x0', + data: permit2WrappedSwapCalldata, + 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 unknown calldata', () => { const tx = { to: rETHAddress, diff --git a/src/validators/evm/rocketpool/rocketpool.validator.ts b/src/validators/evm/rocketpool/rocketpool.validator.ts index 3525e45..92e8f3e 100644 --- a/src/validators/evm/rocketpool/rocketpool.validator.ts +++ b/src/validators/evm/rocketpool/rocketpool.validator.ts @@ -17,16 +17,44 @@ const ROCKETPOOL_ABI = [ 'function approve(address spender, uint256 amount) returns (bool)', ]; +const LIFI_CONTRACTS = new Set([ + '0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae', // LI.FI Diamond + '0x89c6340b1a1f4b25d36cd8b063d49045caf3f818', // Permit2 Proxy +]); + +const LIFI_DIAMOND = '0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae'; + +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)', + '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 PERMIT2_PROXY_ABI = [ + 'function callDiamondWithPermit2(bytes _diamondCalldata, ((address token, uint256 amount) permitted, uint256 nonce, uint256 deadline) _permit, bytes _signature) payable returns (bytes)', + 'function callDiamondWithPermit2Witness(bytes _diamondCalldata, address _signer, ((address token, uint256 amount) permitted, uint256 nonce, uint256 deadline) _permit, bytes _signature) payable returns (bytes)', + 'function callDiamondWithEIP2612Signature(address tokenAddress, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s, bytes diamondCalldata) payable returns (bytes)', +]; + export class RocketPoolValidator extends BaseEVMValidator { private readonly rocketPoolInterface: ethers.Interface; + private readonly lifiSwapInterface: ethers.Interface; + private readonly permit2ProxyInterface: ethers.Interface; constructor() { super(); this.rocketPoolInterface = new ethers.Interface(ROCKETPOOL_ABI); + this.lifiSwapInterface = new ethers.Interface(LIFI_SWAP_ABI); + this.permit2ProxyInterface = new ethers.Interface(PERMIT2_PROXY_ABI); } getSupportedTransactionTypes(): TransactionType[] { - return [TransactionType.STAKE, TransactionType.APPROVAL]; + return [ + TransactionType.STAKE, + TransactionType.APPROVAL, + TransactionType.SWAP, + ]; } validate( @@ -63,6 +91,8 @@ export class RocketPoolValidator extends BaseEVMValidator { return this.validateStake(tx); case TransactionType.APPROVAL: return this.validateApproval(tx); + case TransactionType.SWAP: + return this.validateSwap(tx, userAddress); default: return this.blocked('Unsupported transaction type', { transactionType, @@ -155,9 +185,96 @@ export class RocketPoolValidator extends BaseEVMValidator { return this.blocked('Approval amount must be greater than zero'); } - // Note: spender (result.parsed.args[0]) is NOT validated — it's dynamic - // from LI.FI and changes per quote. The spender validation is intentionally - // omitted because the exit path routes through LI.FI aggregator. + const [spender] = result.parsed.args; + if (!LIFI_CONTRACTS.has(spender.toLowerCase())) { + return this.blocked('Approval spender is not a known LI.FI contract', { + spender, + knownContracts: Array.from(LIFI_CONTRACTS), + }); + } + + return this.safe(); + } + + private validateSwap( + tx: EVMTransaction, + userAddress: string, + ): ValidationResult { + if (!tx.to || !LIFI_CONTRACTS.has(tx.to.toLowerCase())) { + return this.blocked('SWAP target is not a known LI.FI contract', { + actual: tx.to, + knownContracts: Array.from(LIFI_CONTRACTS), + }); + } + + const value = BigInt(tx.value ?? '0'); + if (value > 0n) { + return this.blocked('SWAP should not send ETH value', { + value: value.toString(), + }); + } + + if (!tx.data || tx.data === '0x' || tx.data.length < 10) { + return this.blocked('SWAP transaction has no calldata'); + } + + const diamondCalldata = this.extractDiamondCalldata(tx); + if (!diamondCalldata) { + return this.blocked( + 'Failed to extract Diamond calldata from Permit2 Proxy', + ); + } + + return this.validateLifiSwapReceiver(diamondCalldata, userAddress); + } + + private extractDiamondCalldata(tx: EVMTransaction): string | null { + if (tx.to!.toLowerCase() === LIFI_DIAMOND) { + return tx.data!; + } + + // Permit2 Proxy: parse outer function to extract inner diamondCalldata + try { + const parsed = this.permit2ProxyInterface.parseTransaction({ + data: tx.data!, + }); + if (!parsed) return null; + + // callDiamondWithEIP2612Signature has diamondCalldata at param index 6 + // callDiamondWithPermit2 and callDiamondWithPermit2Witness have it at param index 0 + if (parsed.name === 'callDiamondWithEIP2612Signature') { + return parsed.args[6]; + } + return parsed.args[0]; + } catch { + return null; + } + } + + private validateLifiSwapReceiver( + calldata: string, + userAddress: string, + ): ValidationResult { + let parsed: ethers.TransactionDescription | null; + try { + parsed = this.lifiSwapInterface.parseTransaction({ data: calldata }); + } catch { + return this.blocked('Unknown LI.FI Diamond function selector', { + selector: calldata.slice(0, 10), + }); + } + + if (!parsed) { + return this.blocked('Failed to parse LI.FI swap calldata'); + } + + const receiver: string = parsed.args[3]; + if (receiver.toLowerCase() !== userAddress.toLowerCase()) { + return this.blocked('SWAP receiver does not match user address', { + receiver, + userAddress, + }); + } return this.safe(); } From da699194947e526b04564e792b0b2e37ba3c7be6 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Wed, 4 Mar 2026 20:42:50 -0800 Subject: [PATCH 6/6] fix: lint --- src/validators/evm/rocketpool/rocketpool.validator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/validators/evm/rocketpool/rocketpool.validator.test.ts b/src/validators/evm/rocketpool/rocketpool.validator.test.ts index b5f216a..5989954 100644 --- a/src/validators/evm/rocketpool/rocketpool.validator.test.ts +++ b/src/validators/evm/rocketpool/rocketpool.validator.test.ts @@ -34,7 +34,7 @@ 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 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)', ]); const permit2ProxyIface = new ethers.Interface([