diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b4c8043..f3d5ff8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,7 @@ # Workflow/CI files specifically -.github/ @ajag408 @Philippoes @petar-omni @jdomingos @raiseerco \ No newline at end of file +.github/ @ajag408 @Philippoes @petar-omni @jdomingos @raiseerco + +# Supply chain critical files -- lockfile, install config, package manifest +.npmrc @ajag408 @Philippoes @petar-omni @jdomingos @raiseerco +package.json @ajag408 @Philippoes @petar-omni @jdomingos @raiseerco +pnpm-lock.yaml @ajag408 @Philippoes @petar-omni @jdomingos @raiseerco \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2481efc..8306693 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,15 @@ jobs: node-version: [20.17.0, 22.x] steps: + - name: Harden runner + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 @@ -27,25 +34,21 @@ jobs: node-version: ${{ matrix.node-version }} - name: Install pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 - with: - version: 10.12.2 - - - name: Get pnpm store directory shell: bash run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + npm install -g corepack@latest + corepack enable + corepack prepare pnpm@10.12.2 --activate - - name: Setup pnpm cache - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 + - name: Set up Socket Firewall + uses: socketdev/action@937f824ec476dfd164d4a4d9995751427b0be143 # v1 with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- + mode: firewall + env: + SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_SECURITY_API_KEY }} - name: Install dependencies - run: pnpm install --frozen-lockfile + run: sfw pnpm install --frozen-lockfile - name: Run linter run: pnpm run lint @@ -60,7 +63,7 @@ jobs: if: matrix.node-version == '20.17.0' uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de with: - file: ./coverage/lcov.info + files: ./coverage/lcov.info flags: unittests name: codecov-umbrella fail_ci_if_error: false @@ -70,8 +73,15 @@ jobs: runs-on: ubuntu-latest steps: + - name: Harden runner + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 @@ -79,16 +89,45 @@ jobs: node-version: 20.17.0 - name: Install pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 + shell: bash + run: | + npm install -g corepack@latest + corepack enable + corepack prepare pnpm@10.12.2 --activate + + - name: Set up Socket Firewall + uses: socketdev/action@937f824ec476dfd164d4a4d9995751427b0be143 # v1 with: - version: 10.12.2 + mode: firewall + env: + SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_SECURITY_API_KEY }} - name: Install dependencies - run: pnpm install --frozen-lockfile + run: sfw pnpm install --frozen-lockfile - name: Run pnpm audit run: pnpm audit --audit-level=critical + continue-on-error: true - name: Check for dependency updates run: pnpm outdated continue-on-error: true + + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Harden runner + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Dependency Review + uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28f82ff..d01ee79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,10 +20,16 @@ jobs: contents: read steps: + - name: Harden runner + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 + persist-credentials: false - name: Verify tag is on main shell: bash @@ -40,12 +46,21 @@ jobs: node-version: "22" - name: Install pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 + shell: bash + run: | + npm install -g corepack@latest + corepack enable + corepack prepare pnpm@10.12.2 --activate + + - name: Set up Socket Firewall + uses: socketdev/action@937f824ec476dfd164d4a4d9995751427b0be143 # v1 with: - version: 10.12.2 + mode: firewall + env: + SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_SECURITY_API_KEY }} - name: Install dependencies - run: pnpm install --frozen-lockfile + run: sfw pnpm install --frozen-lockfile - name: Security audit run: pnpm audit --audit-level=critical @@ -63,10 +78,16 @@ jobs: id-token: write steps: + - name: Harden runner + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 + persist-credentials: false - name: Verify tag is on main shell: bash @@ -84,16 +105,25 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Install pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 - with: - version: 10.12.2 + shell: bash + run: | + npm install -g corepack@latest + corepack enable + corepack prepare pnpm@10.12.2 --activate # npm 11.5.1 or later is required for trusted publishing - name: Update npm run: npm install -g npm@latest + - name: Set up Socket Firewall + uses: socketdev/action@937f824ec476dfd164d4a4d9995751427b0be143 # v1 + with: + mode: firewall + env: + SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_SECURITY_API_KEY }} + - name: Install dependencies - run: pnpm install --frozen-lockfile + run: sfw pnpm install --frozen-lockfile - name: Run tests run: pnpm test @@ -103,6 +133,8 @@ jobs: - name: Publish to NPM run: pnpm publish --access public --no-git-checks + env: + NPM_CONFIG_PROVENANCE: "true" # ========================================== # Job 3: Build binaries for all platforms @@ -134,10 +166,16 @@ jobs: arch: x64 steps: + - name: Harden runner + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 + persist-credentials: false - name: Verify tag is on main shell: bash @@ -162,25 +200,21 @@ jobs: architecture: ${{ matrix.node_arch }} - name: Install pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 - with: - version: 10.12.2 - - - name: Get pnpm store directory shell: bash run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + npm install -g corepack@latest + corepack enable + corepack prepare pnpm@10.12.2 --activate - - name: Setup pnpm cache - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 + - name: Set up Socket Firewall + uses: socketdev/action@937f824ec476dfd164d4a4d9995751427b0be143 # v1 with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- + mode: firewall + env: + SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_SECURITY_API_KEY }} - name: Install dependencies - run: pnpm install --frozen-lockfile + run: sfw pnpm install --frozen-lockfile - name: Build TypeScript run: pnpm build @@ -237,6 +271,11 @@ jobs: contents: write steps: + - name: Harden runner + uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + with: + egress-policy: audit + - name: Download all artifacts uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: diff --git a/.npmrc b/.npmrc index d6bf63f..25efeed 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,2 @@ -enable-pre-post-scripts=true -strict-peer-dependencies=false \ No newline at end of file +strict-peer-dependencies=false +minimum-release-age=4320 \ No newline at end of file diff --git a/package.json b/package.json index d0ec433..f6d4db4 100644 --- a/package.json +++ b/package.json @@ -71,5 +71,10 @@ "prettier": "^3.2.5", "ts-jest": "^29.4.6", "typescript": "^5.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] } } 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(); } }