diff --git a/package.json b/package.json index e7bc1fe..6849708 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yieldxyz/shield", - "version": "1.3.0", + "version": "1.4.0", "description": "Zero-trust transaction validation library for Yield.xyz integrations.", "packageManager": "pnpm@10.33.1", "engines": { diff --git a/src/json/handler.test.ts b/src/json/handler.test.ts index 2106d4c..f2cd802 100644 --- a/src/json/handler.test.ts +++ b/src/json/handler.test.ts @@ -233,6 +233,74 @@ describe('handleJsonRequest', () => { }); }); + describe('schema: args.amount and args.decimals boundaries', () => { + const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; + const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; + const validLidoStakeTx = { + to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + from: userAddress, + value: '0xde0b6b3a7640000', + data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), + chainId: 1, + }; + // 78 decimal digits — the schema's maxLength for args.amount + const MAX_UINT256_STRING = (2n ** 256n - 1n).toString(); + const validRequest = (args: object) => ({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(validLidoStakeTx), + userAddress, + args, + }); + it('accepts args.decimals as an integer', () => { + const response = call(validRequest({ amount: '1000000', decimals: 6 })); + // Schema accepted (not a SCHEMA_VALIDATION_ERROR) and the request + // proceeded to actual validation. + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + }); + it('accepts args.decimals at the bounds (0 and 255)', () => { + const zero = call(validRequest({ decimals: 0 })); + expect(zero.ok).toBe(true); + const max = call(validRequest({ decimals: 255 })); + expect(max.ok).toBe(true); + }); + it('rejects fractional args.decimals', () => { + const response = call(validRequest({ decimals: 6.5 })); + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + }); + it('rejects args.decimals above 255', () => { + const response = call(validRequest({ decimals: 256 })); + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + }); + it('rejects negative args.decimals', () => { + const response = call(validRequest({ decimals: -1 })); + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + }); + it('rejects non-numeric args.decimals', () => { + const response = call(validRequest({ decimals: '6' })); + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + }); + it('accepts a 78-digit args.amount (maxUint256)', () => { + expect(MAX_UINT256_STRING.length).toBe(78); // pin the schema boundary + const response = call(validRequest({ amount: MAX_UINT256_STRING })); + expect(response.ok).toBe(true); + // Lido ignores args.amount today (amount validation is ERC-4626-only in + // Phase 1), so a valid stake tx still validates. + expect(response.result.isValid).toBe(true); + }); + it('rejects args.amount longer than 78 characters', () => { + const response = call(validRequest({ amount: '1'.repeat(79) })); + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + }); + }); + describe('isSupported operation', () => { it('should return supported: true for known yield', () => { const response = call({ diff --git a/src/json/schema.ts b/src/json/schema.ts index 641fdb9..b7d83b3 100644 --- a/src/json/schema.ts +++ b/src/json/schema.ts @@ -41,6 +41,7 @@ export const requestSchema = { // Future use - include for forward compatibility amount: { type: 'string', maxLength: 78 }, // Max uint256 is 78 digits + decimals: { type: 'integer', minimum: 0, maximum: 255 }, tronResource: { type: 'string', enum: ['BANDWIDTH', 'ENERGY'] }, providerId: { type: 'string', maxLength: 256 }, duration: { type: 'number', minimum: 0 }, diff --git a/src/types/index.ts b/src/types/index.ts index 00ed829..f0f7b24 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,6 +16,7 @@ export interface ValidationResult { export type ActionArguments = { amount?: string; + decimals?: number; // declared-amount token decimals (intent context) validatorAddress?: string; validatorAddresses?: string[]; tronResource?: TronResourceType; diff --git a/src/utils/amount.test.ts b/src/utils/amount.test.ts new file mode 100644 index 0000000..7d7550a --- /dev/null +++ b/src/utils/amount.test.ts @@ -0,0 +1,50 @@ +import { MAX_UINT256, matchesDeclaredAmount } from './amount'; + +describe('MAX_UINT256', () => { + it('equals 2^256 - 1', () => { + expect(MAX_UINT256).toBe(2n ** 256n - 1n); + }); +}); + +describe('matchesDeclaredAmount', () => { + const DECLARED = '1000000'; // 1 USDC (6 decimals), wei + + it('returns true on exact match', () => { + expect(matchesDeclaredAmount(1000000n, DECLARED)).toBe(true); + }); + + it('returns false when calldata amount is one above declared', () => { + expect(matchesDeclaredAmount(1000001n, DECLARED)).toBe(false); + }); + + it('returns false when calldata amount is one below declared', () => { + expect(matchesDeclaredAmount(999999n, DECLARED)).toBe(false); + }); + + it('returns false on gross inflation', () => { + expect(matchesDeclaredAmount(1000000000000n, DECLARED)).toBe(false); + }); + + it('returns true when declared is undefined (opt-in skip)', () => { + expect(matchesDeclaredAmount(123456789n, undefined)).toBe(true); + }); + + it('matches maxUint256 against a declared maxUint256 (sanctioned infinite)', () => { + expect(matchesDeclaredAmount(MAX_UINT256, MAX_UINT256.toString())).toBe( + true, + ); + }); + + it('rejects maxUint256 against a finite declared amount', () => { + expect(matchesDeclaredAmount(MAX_UINT256, DECLARED)).toBe(false); + }); + + it('matches zero against declared "0"', () => { + expect(matchesDeclaredAmount(0n, '0')).toBe(true); + }); + + it('throws on a malformed declared string (fail-safe: Shield.validate catches per-type throws)', () => { + expect(() => matchesDeclaredAmount(1000000n, '1.5')).toThrow(); + expect(() => matchesDeclaredAmount(1000000n, 'abc')).toThrow(); + }); +}); diff --git a/src/utils/amount.ts b/src/utils/amount.ts new file mode 100644 index 0000000..7eaeda9 --- /dev/null +++ b/src/utils/amount.ts @@ -0,0 +1,10 @@ +export const MAX_UINT256 = (1n << 256n) - 1n; + +// Opt-in: undefined declared amount → skip (returns true). +export function matchesDeclaredAmount( + calldataAmount: bigint, + declared?: string, +): boolean { + if (declared === undefined) return true; + return calldataAmount === BigInt(declared); +} diff --git a/src/validators/evm/erc4626/erc4626.validator.test.ts b/src/validators/evm/erc4626/erc4626.validator.test.ts index a80465c..c64d2ec 100644 --- a/src/validators/evm/erc4626/erc4626.validator.test.ts +++ b/src/validators/evm/erc4626/erc4626.validator.test.ts @@ -19,6 +19,8 @@ const ALLOCATOR_VAULT_ADDRESS = '0xa110ca7040000000000000000000000000000001'; const MORPHO_VAULT_ADDRESS = '0x00000000000000000000000000000000000face2'; const RECEIVER_ADDRESS = '0x2222222222222222222222222222222222222222'; const CHAIN_ID = 42161; // Arbitrum +const DEPOSIT_WEI = ethers.parseUnits('1000', 6); // 1000 USDC +const DECLARED = DEPOSIT_WEI.toString(); // '1000000000' // --------------------------------------------------------------------------- // ABI interfaces for building calldata @@ -246,6 +248,100 @@ describe('ERC4626Validator', () => { ); expect(result.isValid).toBe(true); }); + describe('amount intent validation', () => { + const approveTx = (amount: bigint) => { + const data = erc20Iface.encodeFunctionData('approve', [ + VAULT_ADDRESS, + amount, + ]); + return buildTx({ to: INPUT_TOKEN, data, value: '0x0' }); + }; + it('accepts approval exactly matching the declared amount', () => { + const result = validator.validate( + approveTx(DEPOSIT_WEI), + TransactionType.APPROVAL, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(true); + }); + it('rejects approval one wei above the declared amount', () => { + const result = validator.validate( + approveTx(DEPOSIT_WEI + 1n), + TransactionType.APPROVAL, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('does not match declared intent'); + }); + it('rejects approval one wei below the declared amount', () => { + const result = validator.validate( + approveTx(DEPOSIT_WEI - 1n), + TransactionType.APPROVAL, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('does not match declared intent'); + }); + it('rejects grossly inflated approval', () => { + const result = validator.validate( + approveTx(DEPOSIT_WEI * 1000n), + TransactionType.APPROVAL, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('does not match declared intent'); + }); + it('rejects maxUint256 approval against a finite declared amount', () => { + const result = validator.validate( + approveTx(ethers.MaxUint256), + TransactionType.APPROVAL, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('does not match declared intent'); + }); + it('accepts maxUint256 approval when declared amount is maxUint256 (useMaxAllowance)', () => { + const result = validator.validate( + approveTx(ethers.MaxUint256), + TransactionType.APPROVAL, + USER_ADDRESS, + { amount: ethers.MaxUint256.toString() }, + ); + expect(result.isValid).toBe(true); + }); + it('accepts zero approval with a declared amount (USDT reset)', () => { + const result = validator.validate( + approveTx(0n), + TransactionType.APPROVAL, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(true); + }); + it('skips amount enforcement when args.amount is absent (back-compat)', () => { + const result = validator.validate( + approveTx(DEPOSIT_WEI * 1000n), + TransactionType.APPROVAL, + USER_ADDRESS, + { receiverAddress: RECEIVER_ADDRESS }, + ); + expect(result.isValid).toBe(true); + }); + it('skips amount enforcement when args.amount is an empty string', () => { + const result = validator.validate( + approveTx(DEPOSIT_WEI), + TransactionType.APPROVAL, + USER_ADDRESS, + { amount: '' }, + ); + expect(result.isValid).toBe(true); + }); + }); }); // ========================================================================= @@ -311,6 +407,56 @@ describe('ERC4626Validator', () => { expect(result.isValid).toBe(false); expect(result.reason).toContain('WETH address not configured'); }); + describe('amount intent validation', () => { + const ONE_ETH = ethers.parseEther('1'); // 10^18 + const wrapTx = (value: bigint) => + buildTx({ + to: WETH_ARBITRUM, + data: wethIface.encodeFunctionData('deposit', []), + value: '0x' + value.toString(16), + }); + it('accepts wrap value exactly matching the declared amount', () => { + const result = validator.validate( + wrapTx(ONE_ETH), + TransactionType.WRAP, + USER_ADDRESS, + { amount: ONE_ETH.toString() }, + ); + expect(result.isValid).toBe(true); + }); + it('rejects wrap value above the declared amount', () => { + const result = validator.validate( + wrapTx(ONE_ETH * 2n), + TransactionType.WRAP, + USER_ADDRESS, + { amount: ONE_ETH.toString() }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain( + 'WRAP amount does not match declared intent', + ); + }); + it('rejects wrap value below the declared amount', () => { + const result = validator.validate( + wrapTx(ONE_ETH / 2n), + TransactionType.WRAP, + USER_ADDRESS, + { amount: ONE_ETH.toString() }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain( + 'WRAP amount does not match declared intent', + ); + }); + it('skips amount enforcement when args.amount is absent (back-compat)', () => { + const result = validator.validate( + wrapTx(ONE_ETH * 5n), + TransactionType.WRAP, + USER_ADDRESS, + ); + expect(result.isValid).toBe(true); + }); + }); }); // ========================================================================= @@ -515,6 +661,108 @@ describe('ERC4626Validator', () => { expect(result.isValid).toBe(false); expect(result.reason).toContain('zero'); }); + describe('amount intent validation', () => { + const depositTx = (assets: bigint) => { + const data = erc4626Iface.encodeFunctionData('deposit', [ + assets, + USER_ADDRESS, + ]); + return buildTx({ to: VAULT_ADDRESS, data, value: '0x0' }); + }; + const mintTx = (shares: bigint) => { + const data = erc4626Iface.encodeFunctionData('mint', [ + shares, + USER_ADDRESS, + ]); + return buildTx({ to: VAULT_ADDRESS, data, value: '0x0' }); + }; + it('accepts deposit exactly matching the declared amount', () => { + const result = validator.validate( + depositTx(DEPOSIT_WEI), + TransactionType.SUPPLY, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(true); + }); + it('rejects deposit one wei above the declared amount', () => { + const result = validator.validate( + depositTx(DEPOSIT_WEI + 1n), + TransactionType.SUPPLY, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('does not match declared intent'); + }); + it('rejects deposit one wei below the declared amount', () => { + const result = validator.validate( + depositTx(DEPOSIT_WEI - 1n), + TransactionType.SUPPLY, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('does not match declared intent'); + }); + it('rejects grossly inflated deposit', () => { + const result = validator.validate( + depositTx(DEPOSIT_WEI * 1_000_000n), + TransactionType.SUPPLY, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('does not match declared intent'); + }); + it('rejects mint when a declared amount is present (fail-closed, no deposit→mint bypass)', () => { + const result = validator.validate( + mintTx(DEPOSIT_WEI), + TransactionType.SUPPLY, + USER_ADDRESS, + { amount: DECLARED }, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('Cannot verify mint'); + }); + it('still accepts mint without a declared amount (back-compat)', () => { + const result = validator.validate( + mintTx(DEPOSIT_WEI), + TransactionType.SUPPLY, + USER_ADDRESS, + ); + expect(result.isValid).toBe(true); + }); + it('skips amount enforcement when args.amount is absent (back-compat)', () => { + const result = validator.validate( + depositTx(DEPOSIT_WEI * 7n), + TransactionType.SUPPLY, + USER_ADDRESS, + { receiverAddress: RECEIVER_ADDRESS }, + ); + // receiver in calldata is USER_ADDRESS but declared receiver differs → this + // exercises that amount skip doesn't short-circuit the receiver check + expect(result.isValid).toBe(false); + expect(result.reason).toContain('Receiver address'); + }); + it('enforces declared amount together with the receiver check (both must pass)', () => { + const data = erc4626Iface.encodeFunctionData('deposit', [ + DEPOSIT_WEI, + RECEIVER_ADDRESS, + ]); + const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' }); + const result = validator.validate( + tx, + TransactionType.SUPPLY, + USER_ADDRESS, + { + amount: DECLARED, + receiverAddress: RECEIVER_ADDRESS, + }, + ); + expect(result.isValid).toBe(true); + }); + }); }); // ========================================================================= diff --git a/src/validators/evm/erc4626/erc4626.validator.ts b/src/validators/evm/erc4626/erc4626.validator.ts index e4275e9..2069363 100644 --- a/src/validators/evm/erc4626/erc4626.validator.ts +++ b/src/validators/evm/erc4626/erc4626.validator.ts @@ -9,6 +9,7 @@ import { BaseEVMValidator, EVMTransaction } from '../base.validator'; import { VaultInfo, VaultConfiguration } from './types'; import { WETH_ADDRESSES } from './constants'; import { isNonEmptyString } from '../../../utils/validation'; +import { matchesDeclaredAmount } from '../../../utils/amount'; /** * Standard ERC4626 ABI - only the functions we need to validate @@ -130,14 +131,25 @@ export class ERC4626Validator extends BaseEVMValidator { ? args.receiverAddress : undefined; + // Declared intent amount (underlying, wei). Enforcement is opt-in: absent → skipped. + const declaredAmount = isNonEmptyString(args?.amount) + ? args.amount + : undefined; + // Route to appropriate validation based on transaction type switch (transactionType) { case TransactionType.APPROVAL: - return this.validateApproval(tx, chainId); + return this.validateApproval(tx, chainId, declaredAmount); case TransactionType.WRAP: - return this.validateWrap(tx, chainId); + return this.validateWrap(tx, chainId, declaredAmount); case TransactionType.SUPPLY: - return this.validateSupply(tx, userAddress, chainId, receiverAddress); + return this.validateSupply( + tx, + userAddress, + chainId, + receiverAddress, + declaredAmount, + ); case TransactionType.WITHDRAW: return this.validateWithdraw(tx, userAddress, chainId, receiverAddress); case TransactionType.UNWRAP: @@ -155,6 +167,7 @@ export class ERC4626Validator extends BaseEVMValidator { private validateApproval( tx: EVMTransaction, chainId: number, + declaredAmount?: string, ): ValidationResult { // APPROVAL should not send ETH const value = BigInt(tx.value ?? '0'); @@ -201,13 +214,32 @@ export class ERC4626Validator extends BaseEVMValidator { }); } + // Amount intent validation + // approve(0) is always allowed — USDT-style allowance reset + const [, approveAmount] = parsed.args; + const approveAmountBigInt = BigInt(approveAmount); + + if ( + approveAmountBigInt !== 0n && + !matchesDeclaredAmount(approveAmountBigInt, declaredAmount) + ) { + return this.blocked('Approval amount does not match declared intent', { + expected: declaredAmount, + actual: approveAmountBigInt.toString(), + }); + } + return this.safe(); } /** * Validate WRAP transaction (ETH → WETH) */ - private validateWrap(tx: EVMTransaction, chainId: number): ValidationResult { + private validateWrap( + tx: EVMTransaction, + chainId: number, + declaredAmount?: string, + ): ValidationResult { // Get WETH address for this chain const wethAddress = this.getWethAddress(chainId); if (!wethAddress) { @@ -236,6 +268,14 @@ export class ERC4626Validator extends BaseEVMValidator { 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)) { + return this.blocked('WRAP amount does not match declared intent', { + expected: declaredAmount, + actual: value.toString(), + }); + } // Parse the wrap calldata const result = this.parseAndValidateCalldata( @@ -265,6 +305,7 @@ export class ERC4626Validator extends BaseEVMValidator { userAddress: string, chainId: number, receiverAddress?: string, + declaredAmount?: string, ): ValidationResult { const resolved = this.resolveVault(tx, chainId); if ('error' in resolved) return resolved.error; @@ -310,6 +351,27 @@ export class ERC4626Validator extends BaseEVMValidator { 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 + // 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;