From 3a39bc7380e422a84c7a65ae8552bd6b38d87690 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Tue, 3 Mar 2026 00:57:32 -0800 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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",