diff --git a/package.json b/package.json index 5965256..c9df6cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yieldxyz/shield", - "version": "1.5.0", + "version": "1.6.0", "description": "Zero-trust transaction validation library for Yield.xyz integrations.", "packageManager": "pnpm@10.33.1", "engines": { diff --git a/src/types/index.ts b/src/types/index.ts index 81faec3..fb09af1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,7 @@ export interface ValidationResult { matchedTypes?: TransactionType[]; supportedTypes?: TransactionType[]; warning?: string; + flags?: string[]; // non-blocking signals, e.g. 'ZERO_AMOUNT' attempts?: { type?: TransactionType; reason?: string; diff --git a/src/validators/base.validator.ts b/src/validators/base.validator.ts index d7c5290..51ee9e9 100644 --- a/src/validators/base.validator.ts +++ b/src/validators/base.validator.ts @@ -6,8 +6,10 @@ import { } from '../types'; export abstract class BaseValidator { - protected safe(): ValidationResult { - return { isValid: true }; + protected safe(flags?: string[]): ValidationResult { + return flags && flags.length > 0 + ? { isValid: true, details: { flags } } + : { isValid: true }; } protected blocked( diff --git a/src/validators/evm/erc4626/erc4626.validator.test.ts b/src/validators/evm/erc4626/erc4626.validator.test.ts index f340559..245ec9c 100644 --- a/src/validators/evm/erc4626/erc4626.validator.test.ts +++ b/src/validators/evm/erc4626/erc4626.validator.test.ts @@ -397,12 +397,12 @@ describe('ERC4626Validator', () => { expect(result.reason).toContain('not to WETH contract'); }); - it('should reject zero ETH value', () => { + it('should accept zero ETH value wrap with ZERO_AMOUNT flag', () => { const data = wethIface.encodeFunctionData('deposit', []); const tx = buildTx({ to: WETH_ARBITRUM, data, value: '0x0' }); const result = validator.validate(tx, TransactionType.WRAP, USER_ADDRESS); - expect(result.isValid).toBe(false); - expect(result.reason).toContain('must send ETH value'); + expect(result.isValid).toBe(true); + expect(result.details?.flags).toContain('ZERO_AMOUNT'); }); it('should reject non-deposit function selector', () => { @@ -482,6 +482,28 @@ describe('ERC4626Validator', () => { ); expect(result.isValid).toBe(true); }); + it('accepts wrap value 0 matching declared amount 0 with ZERO_AMOUNT flag', () => { + const result = validator.validate( + wrapTx(0n), + TransactionType.WRAP, + USER_ADDRESS, + { amount: '0' }, + ); + expect(result.isValid).toBe(true); + expect(result.details?.flags).toContain('ZERO_AMOUNT'); + }); + it('rejects wrap value 0 when declared amount is non-zero', () => { + const result = validator.validate( + wrapTx(0n), + TransactionType.WRAP, + USER_ADDRESS, + { amount: ONE_ETH.toString() }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain( + 'WRAP amount does not match declared intent', + ); + }); }); }); @@ -673,7 +695,7 @@ describe('ERC4626Validator', () => { expect(result.reason).toContain('should not send ETH'); }); - it('should reject zero-amount deposit', () => { + it('should accept zero-amount deposit with ZERO_AMOUNT flag', () => { const data = erc4626Iface.encodeFunctionData('deposit', [ 0, USER_ADDRESS, @@ -684,8 +706,38 @@ describe('ERC4626Validator', () => { TransactionType.SUPPLY, USER_ADDRESS, ); + expect(result.isValid).toBe(true); + expect(result.details?.flags).toContain('ZERO_AMOUNT'); + }); + + it('should still block zero-amount supply to a non-whitelisted vault', () => { + const data = erc4626Iface.encodeFunctionData('deposit', [ + 0, + USER_ADDRESS, + ]); + const tx = buildTx({ to: MALICIOUS_ADDRESS, data, value: '0x0' }); + const result = validator.validate( + tx, + TransactionType.SUPPLY, + USER_ADDRESS, + ); expect(result.isValid).toBe(false); - expect(result.reason).toContain('zero'); + expect(result.reason).toContain('not whitelisted'); + }); + + it('should still block zero-amount supply when receiver != expected', () => { + const data = erc4626Iface.encodeFunctionData('deposit', [ + 0, + OTHER_ADDRESS, + ]); + const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' }); + const result = validator.validate( + tx, + TransactionType.SUPPLY, + USER_ADDRESS, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('does not match expected address'); }); describe('amount intent validation', () => { const depositTx = (assets: bigint) => { @@ -884,7 +936,7 @@ describe('ERC4626Validator', () => { expect(result.isValid).toBe(false); expect(result.reason).toContain('tampered'); }); - it('should reject zero-amount 3-arg deposit', () => { + it('should accept zero-amount 3-arg deposit with ZERO_AMOUNT flag', () => { const data = erc4626ReferralIface.encodeFunctionData('deposit', [ 0, USER_ADDRESS, @@ -896,8 +948,8 @@ describe('ERC4626Validator', () => { TransactionType.SUPPLY, USER_ADDRESS, ); - expect(result.isValid).toBe(false); - expect(result.reason).toContain('zero'); + expect(result.isValid).toBe(true); + expect(result.details?.flags).toContain('ZERO_AMOUNT'); }); it('should reject 3-arg deposit to non-whitelisted vault', () => { const data = erc4626ReferralIface.encodeFunctionData('deposit', [ @@ -1045,7 +1097,7 @@ describe('ERC4626Validator', () => { expect(result.isValid).toBe(false); }); - it('should reject zero-amount withdraw', () => { + it('should accept zero-amount withdraw with ZERO_AMOUNT flag', () => { const data = erc4626Iface.encodeFunctionData( 'withdraw(uint256,address,address)', [0, USER_ADDRESS, USER_ADDRESS], @@ -1056,8 +1108,23 @@ describe('ERC4626Validator', () => { TransactionType.WITHDRAW, USER_ADDRESS, ); + expect(result.isValid).toBe(true); + expect(result.details?.flags).toContain('ZERO_AMOUNT'); + }); + + it('should still block zero-amount withdraw when owner != user', () => { + const data = erc4626Iface.encodeFunctionData( + 'withdraw(uint256,address,address)', + [0, USER_ADDRESS, OTHER_ADDRESS], + ); + const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' }); + const result = validator.validate( + tx, + TransactionType.WITHDRAW, + USER_ADDRESS, + ); expect(result.isValid).toBe(false); - expect(result.reason).toContain('zero'); + expect(result.reason).toContain('Owner address does not match'); }); describe('amount intent validation', () => { @@ -1555,7 +1622,7 @@ describe('ERC4626Validator', () => { expect(result.isValid).toBe(true); }); - it('should reject zero amount', () => { + it('should accept zero amount with ZERO_AMOUNT flag', () => { const data = wethIface.encodeFunctionData('withdraw', [0]); const tx = buildTx({ to: WETH_ARBITRUM, data, value: '0x0' }); const result = validator.validate( @@ -1563,8 +1630,8 @@ describe('ERC4626Validator', () => { TransactionType.UNWRAP, USER_ADDRESS, ); - expect(result.isValid).toBe(false); - expect(result.reason).toContain('UNWRAP amount is zero'); + expect(result.isValid).toBe(true); + expect(result.details?.flags).toContain('ZERO_AMOUNT'); }); it('should reject wrong WETH address', () => { diff --git a/src/validators/evm/erc4626/erc4626.validator.ts b/src/validators/evm/erc4626/erc4626.validator.ts index 1552af4..dd1f159 100644 --- a/src/validators/evm/erc4626/erc4626.validator.ts +++ b/src/validators/evm/erc4626/erc4626.validator.ts @@ -325,11 +325,8 @@ export class ERC4626Validator extends BaseEVMValidator { }); } - // WRAP must send ETH value const value = BigInt(tx.value ?? '0'); - if (value === 0n) { - return this.blocked('WRAP transaction must send ETH value'); - } + // Amount intent validation: the wrapped amount is tx.value (native wei), // same unit as the declared amount for WETH-vault enters — exact-match. if (!matchesDeclaredAmount(value, declaredAmount)) { @@ -356,7 +353,7 @@ export class ERC4626Validator extends BaseEVMValidator { }); } - return this.safe(); + return this.safe(value === 0n ? ['ZERO_AMOUNT'] : undefined); } /** @@ -409,9 +406,6 @@ export class ERC4626Validator extends BaseEVMValidator { // Both deposit and mint have receiver as second parameter const [amount, receiver] = parsed.args; const amountBigInt = BigInt(amount); - if (amountBigInt === 0n) { - return this.blocked('Supply amount is zero'); - } // Amount intent validation: deposit's first arg is assets (underlying, wei) — // same unit as the declared amount, so exact-match. mint is share-denominated @@ -444,7 +438,7 @@ export class ERC4626Validator extends BaseEVMValidator { }); } - return this.safe(); + return this.safe(amountBigInt === 0n ? ['ZERO_AMOUNT'] : undefined); } /** @@ -498,9 +492,6 @@ export class ERC4626Validator extends BaseEVMValidator { // Both withdraw and redeem have: (amount, receiver, owner) const [amount, receiver, owner] = parsed.args; const amountBigInt = BigInt(amount); - if (amountBigInt === 0n) { - return this.blocked('Withdraw amount is zero'); - } // --- amount intent (additive / opt-in) --- if (parsed.name === 'withdraw') { @@ -576,7 +567,7 @@ export class ERC4626Validator extends BaseEVMValidator { }); } - return this.safe(); + return this.safe(amountBigInt === 0n ? ['ZERO_AMOUNT'] : undefined); } /** @@ -634,14 +625,10 @@ export class ERC4626Validator extends BaseEVMValidator { }); } - // Validate amount is not zero const [amount] = parsed.args; const amountBigInt = BigInt(amount); - if (amountBigInt === 0n) { - return this.blocked('UNWRAP amount is zero'); - } - return this.safe(); + return this.safe(amountBigInt === 0n ? ['ZERO_AMOUNT'] : undefined); } private resolveVault(