From 118202918c8808cd8217590d8a501f79e5e028b0 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Fri, 3 Jul 2026 00:07:52 -0700 Subject: [PATCH 1/2] fix + tests --- src/json/handler.test.ts | 72 ++++++++++++ src/validators/evm/base.validator.ts | 2 +- .../evm/erc4626/erc4626.validator.test.ts | 111 ++++++++++++++++++ .../evm/erc4626/erc4626.validator.ts | 3 + 4 files changed, 187 insertions(+), 1 deletion(-) diff --git a/src/json/handler.test.ts b/src/json/handler.test.ts index 2106d4c..ce74b5b 100644 --- a/src/json/handler.test.ts +++ b/src/json/handler.test.ts @@ -159,6 +159,78 @@ describe('handleJsonRequest', () => { }); }); + describe('ENG-3841: sUSDS referral deposit (client repro, real registry)', () => { + // deposit(uint256 assets, address receiver, uint16 referral), selector 0x9b8d6d38 + const susdsYieldId = + 'ethereum-usds-susds-0xa3931d71877c0e7a3148cb7eb4463524fec27fbd-4626-vault'; + const susdsUserAddress = '0xA87D2b790668d51023A3A354a4FEAd156A37dd27'; + const DEPOSIT_REFERRAL_SELECTOR = '0x9b8d6d38'; + // 9986836000000000000 wei (9.986836 USDS) + const ASSETS_WORD = + '0000000000000000000000000000000000000000000000008a985e6df2154000'; + // receiver = user + const RECEIVER_WORD = + '000000000000000000000000a87d2b790668d51023a3a354a4fead156a37dd27'; + // referral = 3008 (0xbc0, Sky Ethereum ref code) + const REFERRAL_WORD = + '0000000000000000000000000000000000000000000000000000000000000bc0'; + // The ticket's unsignedTransaction, verbatim + const susdsSupplyTx = { + from: susdsUserAddress, + gasLimit: '0x0f4240', + to: '0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD', // sUSDS vault + data: + DEPOSIT_REFERRAL_SELECTOR + ASSETS_WORD + RECEIVER_WORD + REFERRAL_WORD, + nonce: 67, + type: 2, + maxFeePerGas: '0x272417a9', + maxPriorityFeePerGas: '0x09f815', + chainId: 1, + }; + it('should validate the client’s exact rejected SUPPLY transaction as SAFE', () => { + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: susdsYieldId, + unsignedTransaction: JSON.stringify(susdsSupplyTx), + userAddress: susdsUserAddress, + }); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + expect(response.result.detectedType).toBe('SUPPLY'); + }); + it('should reject the same calldata with the receiver word redirected', () => { + const attackerWord = + '000000000000000000000000000000000000000000000000000000000000bad1'; + const tamperedTx = { + ...susdsSupplyTx, + data: + DEPOSIT_REFERRAL_SELECTOR + + ASSETS_WORD + + attackerWord + + REFERRAL_WORD, + }; + const response = call({ + apiVersion: '1.0', + operation: 'validate', + yieldId: susdsYieldId, + unsignedTransaction: JSON.stringify(tamperedTx), + userAddress: susdsUserAddress, + }); + expect(response.ok).toBe(true); // request succeeded + expect(response.result.isValid).toBe(false); // but validation blocked + expect(response.result.reason).toContain( + 'No matching operation pattern found', + ); + const supplyAttempt = response.result.details?.attempts?.find( + (a: { type: string; reason?: string }) => a.type === 'SUPPLY', + ); + expect(supplyAttempt?.reason).toContain( + 'Receiver address does not match', + ); + }); + }); + describe('optional parameters: args and context', () => { const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; diff --git a/src/validators/evm/base.validator.ts b/src/validators/evm/base.validator.ts index 0f0c53f..f7257e5 100644 --- a/src/validators/evm/base.validator.ts +++ b/src/validators/evm/base.validator.ts @@ -147,7 +147,7 @@ export abstract class BaseEVMValidator extends BaseValidator { try { // Re-encode the function call with the parsed arguments const expectedCalldata = iface.encodeFunctionData( - parsedTx.name, + parsedTx.fragment, parsedTx.args, ); diff --git a/src/validators/evm/erc4626/erc4626.validator.test.ts b/src/validators/evm/erc4626/erc4626.validator.test.ts index a80465c..6752894 100644 --- a/src/validators/evm/erc4626/erc4626.validator.test.ts +++ b/src/validators/evm/erc4626/erc4626.validator.test.ts @@ -19,6 +19,7 @@ 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 (6 decimals) // --------------------------------------------------------------------------- // ABI interfaces for building calldata @@ -40,6 +41,12 @@ const erc4626Iface = new ethers.Interface([ 'function redeem(uint256 shares, address receiver, address owner) returns (uint256)', ]); +// Sky/Spark Savings referral overloads +const erc4626ReferralIface = new ethers.Interface([ + 'function deposit(uint256 assets, address receiver, uint16 referral) returns (uint256)', + 'function mint(uint256 shares, address receiver, uint16 referral) returns (uint256)', +]); + // --------------------------------------------------------------------------- // Mock configuration // --------------------------------------------------------------------------- @@ -515,6 +522,110 @@ describe('ERC4626Validator', () => { expect(result.isValid).toBe(false); expect(result.reason).toContain('zero'); }); + // ----------------------------------------------------------------------- + // Sky/Spark referral overloads — deposit/mint(..., uint16 referral) + // ----------------------------------------------------------------------- + describe('referral overloads (uint16)', () => { + it.each([ + [3008, 'Sky Ethereum ref code'], + [200, 'Spark L2 ref code'], + [0, 'zero referral'], + [65535, 'uint16 max'], + ])( + 'should validate a valid 3-arg deposit with referral %i (%s) — accept-any', + (referral) => { + const data = erc4626ReferralIface.encodeFunctionData('deposit', [ + DEPOSIT_WEI, + USER_ADDRESS, + referral, + ]); + const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' }); + const result = validator.validate( + tx, + TransactionType.SUPPLY, + USER_ADDRESS, + ); + expect(result.isValid).toBe(true); + }, + ); + it('should validate a valid 3-arg mint (no declared amount)', () => { + const data = erc4626ReferralIface.encodeFunctionData('mint', [ + ethers.parseUnits('500', 18), + USER_ADDRESS, + 3008, + ]); + const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' }); + const result = validator.validate( + tx, + TransactionType.SUPPLY, + USER_ADDRESS, + ); + expect(result.isValid).toBe(true); + }); + it('should reject 3-arg deposit when receiver != user', () => { + const data = erc4626ReferralIface.encodeFunctionData('deposit', [ + DEPOSIT_WEI, + MALICIOUS_ADDRESS, // receiver redirected + 3008, + ]); + 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('Receiver address does not match'); + }); + it('should reject tampered 3-arg calldata (appended bytes)', () => { + // Also regression-covers the fragment-based tamper re-encode in + // BaseEVMValidator (name-based lookup throws on overloaded ABIs). + const data = erc4626ReferralIface.encodeFunctionData('deposit', [ + DEPOSIT_WEI, + USER_ADDRESS, + 3008, + ]); + const tampered = data + 'deadbeef'; + const tx = buildTx({ to: VAULT_ADDRESS, data: tampered, value: '0x0' }); + const result = validator.validate( + tx, + TransactionType.SUPPLY, + USER_ADDRESS, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('tampered'); + }); + it('should reject zero-amount 3-arg deposit', () => { + const data = erc4626ReferralIface.encodeFunctionData('deposit', [ + 0, + USER_ADDRESS, + 3008, + ]); + 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('zero'); + }); + it('should reject 3-arg deposit to non-whitelisted vault', () => { + const data = erc4626ReferralIface.encodeFunctionData('deposit', [ + DEPOSIT_WEI, + USER_ADDRESS, + 3008, + ]); + 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('not whitelisted'); + }); + }); }); // ========================================================================= diff --git a/src/validators/evm/erc4626/erc4626.validator.ts b/src/validators/evm/erc4626/erc4626.validator.ts index e4275e9..92c86c8 100644 --- a/src/validators/evm/erc4626/erc4626.validator.ts +++ b/src/validators/evm/erc4626/erc4626.validator.ts @@ -16,6 +16,9 @@ import { isNonEmptyString } from '../../../utils/validation'; const ERC4626_ABI = [ 'function deposit(uint256 assets, address receiver) returns (uint256)', 'function mint(uint256 shares, address receiver) returns (uint256)', + // Sky/Spark Savings referral overloads (sUSDS, sUSDC, sDAI …). + 'function deposit(uint256 assets, address receiver, uint16 referral) returns (uint256)', + 'function mint(uint256 shares, address receiver, uint16 referral) returns (uint256)', 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256)', 'function redeem(uint256 shares, address receiver, address owner) returns (uint256)', ]; From eca2a8f862f7e413c67ab794103cf7d8482024d4 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Fri, 3 Jul 2026 00:17:51 -0700 Subject: [PATCH 2/2] fix: package version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e7bc1fe..508fe11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yieldxyz/shield", - "version": "1.3.0", + "version": "1.4.1", "description": "Zero-trust transaction validation library for Yield.xyz integrations.", "packageManager": "pnpm@10.33.1", "engines": {