From 7e5b1986d13e0e97efa5ea1b5dcfea7528fd80d3 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Wed, 24 Jun 2026 02:02:20 -0700 Subject: [PATCH 1/3] fix: allow zero amount txs on 4626 --- src/types/index.ts | 1 + src/validators/base.validator.ts | 6 +- .../evm/erc4626/erc4626.validator.test.ts | 65 ++++++++++++++++--- .../evm/erc4626/erc4626.validator.ts | 21 ++---- 4 files changed, 64 insertions(+), 29 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index 00ed829..881f5b0 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 a80465c..06f7b08 100644 --- a/src/validators/evm/erc4626/erc4626.validator.test.ts +++ b/src/validators/evm/erc4626/erc4626.validator.test.ts @@ -275,12 +275,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', () => { @@ -501,7 +501,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, @@ -512,8 +512,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'); }); }); @@ -645,7 +675,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], @@ -656,8 +686,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'); }); }); @@ -776,7 +821,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( @@ -784,8 +829,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 e4275e9..cbd3141 100644 --- a/src/validators/evm/erc4626/erc4626.validator.ts +++ b/src/validators/evm/erc4626/erc4626.validator.ts @@ -233,9 +233,6 @@ 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'); - } // Parse the wrap calldata const result = this.parseAndValidateCalldata( @@ -254,7 +251,7 @@ export class ERC4626Validator extends BaseEVMValidator { }); } - return this.safe(); + return this.safe(value === 0n ? ['ZERO_AMOUNT'] : undefined); } /** @@ -306,9 +303,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'); - } // Validate receiver is the intended receiver const expectedReceiver = receiverAddress ?? userAddress; @@ -320,7 +314,7 @@ export class ERC4626Validator extends BaseEVMValidator { }); } - return this.safe(); + return this.safe(amountBigInt === 0n ? ['ZERO_AMOUNT'] : undefined); } /** @@ -372,9 +366,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'); - } // Validate owner is the user (they must own the shares) if (owner.toLowerCase() !== userAddress.toLowerCase()) { @@ -393,7 +384,7 @@ export class ERC4626Validator extends BaseEVMValidator { }); } - return this.safe(); + return this.safe(amountBigInt === 0n ? ['ZERO_AMOUNT'] : undefined); } /** @@ -451,14 +442,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( From 150a4a66ede6b4735a0d8bcc4167ab73f3986439 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Wed, 24 Jun 2026 02:03:45 -0700 Subject: [PATCH 2/3] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e7bc1fe..5893d04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yieldxyz/shield", - "version": "1.3.0", + "version": "1.3.1", "description": "Zero-trust transaction validation library for Yield.xyz integrations.", "packageManager": "pnpm@10.33.1", "engines": { From 34fd6456c3df8852b4a521b97b31148ab2a708fe Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Wed, 9 Sep 2026 21:49:06 -0700 Subject: [PATCH 3/3] feat: tests around zero amount behavior --- .../evm/erc4626/erc4626.validator.test.ts | 28 +++++- .../evm/erc4626/erc4626.validator.ts | 88 ++++++++++++++++++- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/validators/evm/erc4626/erc4626.validator.test.ts b/src/validators/evm/erc4626/erc4626.validator.test.ts index bc15287..245ec9c 100644 --- a/src/validators/evm/erc4626/erc4626.validator.test.ts +++ b/src/validators/evm/erc4626/erc4626.validator.test.ts @@ -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', + ); + }); }); }); @@ -914,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, @@ -926,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', [ diff --git a/src/validators/evm/erc4626/erc4626.validator.ts b/src/validators/evm/erc4626/erc4626.validator.ts index 252cf56..dd1f159 100644 --- a/src/validators/evm/erc4626/erc4626.validator.ts +++ b/src/validators/evm/erc4626/erc4626.validator.ts @@ -325,9 +325,17 @@ export class ERC4626Validator extends BaseEVMValidator { }); } - // WRAP must send ETH value const value = BigInt(tx.value ?? '0'); + // 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)) { + return this.blocked('WRAP amount does not match declared intent', { + expected: declaredAmount, + actual: value.toString(), + }); + } + // Parse the wrap calldata const result = this.parseAndValidateCalldata( tx, @@ -399,6 +407,27 @@ export class ERC4626Validator extends BaseEVMValidator { const [amount, receiver] = parsed.args; const amountBigInt = BigInt(amount); + // Amount intent validation: deposit's first arg is assets (underlying, wei) — + // same unit as the declared amount, so exact-match. mint is share-denominated + // and cannot be verified against an asset-denominated intent offline, so when + // an intent amount is declared, mint is rejected (fail-closed) rather than + // skipped — otherwise rewriting deposit → mint would bypass the amount check. + if (parsed.name === 'mint' && declaredAmount !== undefined) { + return this.blocked( + 'Cannot verify mint (share-denominated) against declared asset amount', + { declared: declaredAmount }, + ); + } + if ( + parsed.name === 'deposit' && + !matchesDeclaredAmount(amountBigInt, declaredAmount) + ) { + return this.blocked('Supply amount does not match declared intent', { + expected: declaredAmount, + actual: amountBigInt.toString(), + }); + } + // Validate receiver is the intended receiver const expectedReceiver = receiverAddress ?? userAddress; @@ -464,6 +493,63 @@ export class ERC4626Validator extends BaseEVMValidator { const [amount, receiver, owner] = parsed.args; const amountBigInt = BigInt(amount); + // --- amount intent (additive / opt-in) --- + if (parsed.name === 'withdraw') { + // share intent on asset calldata → fail-closed (no redeem↔withdraw bypass) + if (declaredShareAmount !== undefined) { + return this.blocked( + 'Cannot verify withdraw (asset-denominated) against declared shareAmount', + { declared: declaredShareAmount }, + ); + } + if ( + !matchesDeclaredAmountWithinMargin( + amountBigInt, + declaredAmount, + ASSET_WITHDRAW_EXIT_MARGIN, + ) + ) { + return this.blocked('Withdraw amount does not match declared intent', { + expected: declaredAmount, + actual: amountBigInt.toString(), + margin: ASSET_WITHDRAW_EXIT_MARGIN, + }); + } + } else { + // redeem + if (declaredAmount !== undefined) { + return this.blocked( + 'Cannot verify redeem (share-denominated) against declared asset amount', + { declared: declaredAmount }, + ); + } + if (declaredShareAmount !== undefined) { + const margin = getErc4626RedeemMargin({ + useDecimalGapMargin: + this.isAllocatorTarget(tx.to!, vaultInfo) && + !isKilnFixedMarginVault(tx.to!), + inputTokenDecimals: vaultInfo.inputTokenDecimals, + vaultTokenDecimals: vaultInfo.vaultTokenDecimals, + }); + if ( + !matchesDeclaredAmountWithinMargin( + amountBigInt, + declaredShareAmount, + margin, + ) + ) { + return this.blocked( + 'Redeem share amount does not match declared intent', + { + expected: declaredShareAmount, + actual: amountBigInt.toString(), + margin, + }, + ); + } + } + } + // Validate owner is the user (they must own the shares) if (owner.toLowerCase() !== userAddress.toLowerCase()) { return this.blocked('Owner address does not match user address', {