From 6a1303ae35a7c071bfea1858faafec2b037a252c Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Thu, 25 Jun 2026 03:48:39 -0700 Subject: [PATCH 1/2] unlock context for hot path --- .../evm/erc4626/erc4626.validator.ts | 59 +++++++++++-------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/src/validators/evm/erc4626/erc4626.validator.ts b/src/validators/evm/erc4626/erc4626.validator.ts index e4275e9..929ebf1 100644 --- a/src/validators/evm/erc4626/erc4626.validator.ts +++ b/src/validators/evm/erc4626/erc4626.validator.ts @@ -100,7 +100,7 @@ export class ERC4626Validator extends BaseEVMValidator { transactionType: TransactionType, userAddress: string, args?: ActionArguments, - _context?: ValidationContext, + context?: ValidationContext, ): ValidationResult { const decoded = this.decodeEVMTransaction(unsignedTransaction); if (!decoded.isValid || !decoded.transaction) { @@ -137,9 +137,9 @@ export class ERC4626Validator extends BaseEVMValidator { case TransactionType.WRAP: return this.validateWrap(tx, chainId); case TransactionType.SUPPLY: - return this.validateSupply(tx, userAddress, chainId, receiverAddress); + return this.validateSupply(tx, userAddress, chainId, receiverAddress, context); case TransactionType.WITHDRAW: - return this.validateWithdraw(tx, userAddress, chainId, receiverAddress); + return this.validateWithdraw(tx, userAddress, chainId, receiverAddress, context); case TransactionType.UNWRAP: return this.validateUnwrap(tx, chainId); default: @@ -464,32 +464,26 @@ export class ERC4626Validator extends BaseEVMValidator { private resolveVault( tx: EVMTransaction, chainId: number, + context?: ValidationContext, ): { vaultInfo: VaultInfo } | { error: ValidationResult } { const vaultAddress = tx.to?.toLowerCase(); if (!vaultAddress) { return { error: this.blocked('Transaction has no destination address') }; } - - if (!this.vaultInfoMap.has(`${chainId}:${vaultAddress}`)) { - return { - error: this.blocked('Vault address not whitelisted', { - vaultAddress, - chainId, - }), - }; - } - - const vaultInfo = this.vaultInfoMap.get(`${chainId}:${vaultAddress}`); - if (!vaultInfo) { - return { - error: this.blocked('Vault address not whitelisted', { - vaultAddress, - chainId, - }), - }; + const staticVault = this.vaultInfoMap.get(`${chainId}:${vaultAddress}`); + if (staticVault) return { vaultInfo: staticVault }; + // Runtime, DB-sourced OAV: accept if injected via context + if (this.getInjectedAllocatorVaults(context).has(vaultAddress)) { + const base = this.getBaseVaultForChain(chainId); + if (base) { + return { + vaultInfo: { ...base, address: vaultAddress }, + }; + } } - - return { vaultInfo }; + return { + error: this.blocked('Vault address not whitelisted', { vaultAddress, chainId }), + }; } /** @@ -499,4 +493,23 @@ export class ERC4626Validator extends BaseEVMValidator { private getWethAddress(chainId: number): string | null { return WETH_ADDRESSES[chainId] || null; } + + private getInjectedAllocatorVaults(context?: ValidationContext): Set { + const injected = new Set(); + for (const fee of context?.feeConfiguration ?? []) { + if (isNonEmptyString(fee.allocatorVaultAddress)) { + injected.add(fee.allocatorVaultAddress.toLowerCase()); + } + } + return injected; + } + + // The instance is yield-scoped to one base vault; use it as the template + // for a context-injected OAV (input token + protocol metadata). + private getBaseVaultForChain(chainId: number): VaultInfo | undefined { + for (const vault of this.vaultInfoMap.values()) { + if (vault.chainId === chainId) return vault; + } + return undefined; + } } From 14074fcc37f1f3e0134837ab6943b7e49484122a Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Thu, 10 Sep 2026 15:15:45 -0700 Subject: [PATCH 2/2] feat: cover runtime oav injection + tests + registry re-export --- package.json | 2 +- .../evm/erc4626/erc4626.validator.test.ts | 210 ++++++++++++++++++ .../evm/erc4626/erc4626.validator.ts | 10 +- .../evm/erc4626/vault-registry.json | 38 +++- 4 files changed, 251 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 5965256..a0e79c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yieldxyz/shield", - "version": "1.5.0", + "version": "1.7.0", "description": "Zero-trust transaction validation library for Yield.xyz integrations.", "packageManager": "pnpm@10.33.1", "engines": { diff --git a/src/validators/evm/erc4626/erc4626.validator.test.ts b/src/validators/evm/erc4626/erc4626.validator.test.ts index f340559..5dec195 100644 --- a/src/validators/evm/erc4626/erc4626.validator.test.ts +++ b/src/validators/evm/erc4626/erc4626.validator.test.ts @@ -2066,4 +2066,214 @@ describe('ERC4626Validator', () => { expect(result.isValid).toBe(true); }); }); + describe('context-injected allocator vaults (runtime OAV)', () => { + const INJECTED_ALLOCATOR_VAULT_ADDRESS = + '0x0bb69b79bc829e1cfcc34a740110886d98d2bd14'; + const baseVault = { + address: VAULT_ADDRESS.toLowerCase(), + chainId: CHAIN_ID, + protocol: 'morpho', + yieldId: 'arbitrum-usdc-runtime-oav-base-vault', + inputTokenAddress: INPUT_TOKEN.toLowerCase(), + vaultTokenAddress: VAULT_ADDRESS.toLowerCase(), + network: 'arbitrum', + isWethVault: false, + canEnter: true, + canExit: true, + inputTokenDecimals: 6, + vaultTokenDecimals: 18, + }; + const runtimeValidator = new ERC4626Validator({ + vaults: [baseVault], + lastUpdated: Date.now(), + }); + const staticAllocatorValidator = new ERC4626Validator({ + vaults: [ + { + ...baseVault, + allocatorVaults: [ALLOCATOR_VAULT_ADDRESS], + }, + ], + lastUpdated: Date.now(), + }); + const runtimeContext = { + feeConfiguration: [ + { + allocatorVaultAddress: INJECTED_ALLOCATOR_VAULT_ADDRESS, + }, + ], + }; + it('should validate SUPPLY deposit to a context-injected allocator vault', () => { + const data = erc4626Iface.encodeFunctionData('deposit', [ + ethers.parseUnits('1000', 6), + USER_ADDRESS, + ]); + const tx = buildTx({ + to: INJECTED_ALLOCATOR_VAULT_ADDRESS, + data, + value: '0x0', + }); + const result = runtimeValidator.validate( + tx, + TransactionType.SUPPLY, + USER_ADDRESS, + undefined, + runtimeContext, + ); + expect(result.isValid).toBe(true); + }); + it('should validate SUPPLY mint to a context-injected allocator vault', () => { + const data = erc4626Iface.encodeFunctionData('mint', [ + ethers.parseUnits('500', 18), + USER_ADDRESS, + ]); + const tx = buildTx({ + to: INJECTED_ALLOCATOR_VAULT_ADDRESS, + data, + value: '0x0', + }); + const result = runtimeValidator.validate( + tx, + TransactionType.SUPPLY, + USER_ADDRESS, + undefined, + runtimeContext, + ); + expect(result.isValid).toBe(true); + }); + it('should validate WITHDRAW withdraw from a context-injected allocator vault', () => { + const data = erc4626Iface.encodeFunctionData( + 'withdraw(uint256,address,address)', + [ethers.parseUnits('1000', 6), USER_ADDRESS, USER_ADDRESS], + ); + const tx = buildTx({ + to: INJECTED_ALLOCATOR_VAULT_ADDRESS, + data, + value: '0x0', + }); + const result = runtimeValidator.validate( + tx, + TransactionType.WITHDRAW, + USER_ADDRESS, + undefined, + runtimeContext, + ); + expect(result.isValid).toBe(true); + }); + it('should validate WITHDRAW redeem from a context-injected allocator vault', () => { + const data = erc4626Iface.encodeFunctionData( + 'redeem(uint256,address,address)', + [ethers.parseUnits('500', 18), USER_ADDRESS, USER_ADDRESS], + ); + const tx = buildTx({ + to: INJECTED_ALLOCATOR_VAULT_ADDRESS, + data, + value: '0x0', + }); + const result = runtimeValidator.validate( + tx, + TransactionType.WITHDRAW, + USER_ADDRESS, + undefined, + runtimeContext, + ); + expect(result.isValid).toBe(true); + }); + it('should validate APPROVAL of the base input token to a context-injected allocator vault', () => { + const data = erc20Iface.encodeFunctionData('approve', [ + INJECTED_ALLOCATOR_VAULT_ADDRESS, + ethers.parseUnits('1000', 6), + ]); + const tx = buildTx({ + to: INPUT_TOKEN, + data, + value: '0x0', + }); + const result = runtimeValidator.validate( + tx, + TransactionType.APPROVAL, + USER_ADDRESS, + undefined, + runtimeContext, + ); + expect(result.isValid).toBe(true); + }); + it('should reject APPROVAL of the wrong token to a context-injected allocator vault', () => { + const data = erc20Iface.encodeFunctionData('approve', [ + INJECTED_ALLOCATOR_VAULT_ADDRESS, + ethers.parseUnits('1000', 6), + ]); + const tx = buildTx({ + to: OTHER_ADDRESS, + data, + value: '0x0', + }); + const result = runtimeValidator.validate( + tx, + TransactionType.APPROVAL, + USER_ADDRESS, + undefined, + runtimeContext, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain( + 'Approval token does not match vault input token', + ); + }); + it('should reject the injected allocator SUPPLY when context is omitted', () => { + const data = erc4626Iface.encodeFunctionData('deposit', [ + ethers.parseUnits('1000', 6), + USER_ADDRESS, + ]); + const tx = buildTx({ + to: INJECTED_ALLOCATOR_VAULT_ADDRESS, + data, + value: '0x0', + }); + const result = runtimeValidator.validate( + tx, + TransactionType.SUPPLY, + USER_ADDRESS, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('not whitelisted'); + }); + it('should reject a non-injected address when context is present', () => { + const data = erc4626Iface.encodeFunctionData('deposit', [ + ethers.parseUnits('1000', 6), + USER_ADDRESS, + ]); + const tx = buildTx({ + to: MALICIOUS_ADDRESS, + data, + value: '0x0', + }); + const result = runtimeValidator.validate( + tx, + TransactionType.SUPPLY, + USER_ADDRESS, + undefined, + runtimeContext, + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('not whitelisted'); + }); + it('should continue validating a static allocator vault without context', () => { + const data = erc4626Iface.encodeFunctionData('deposit', [ + ethers.parseUnits('1000', 6), + USER_ADDRESS, + ]); + const tx = buildTx({ + to: ALLOCATOR_VAULT_ADDRESS, + data, + value: '0x0', + }); + const result = staticAllocatorValidator.validate( + tx, + TransactionType.SUPPLY, + USER_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 ead1cfc..cfa6bc4 100644 --- a/src/validators/evm/erc4626/erc4626.validator.ts +++ b/src/validators/evm/erc4626/erc4626.validator.ts @@ -265,7 +265,10 @@ export class ERC4626Validator extends BaseEVMValidator { // Validate spender is a whitelisted vault (static registry, then injected OAV) const spenderAddress = spender.toLowerCase(); let vaultInfo = this.vaultInfoMap.get(`${chainId}:${spenderAddress}`); - if (!vaultInfo && this.getInjectedAllocatorVaults(context).has(spenderAddress)) { + if ( + !vaultInfo && + this.getInjectedAllocatorVaults(context).has(spenderAddress) + ) { vaultInfo = this.getBaseVaultForChain(chainId); } if (!vaultInfo) { @@ -672,7 +675,10 @@ export class ERC4626Validator extends BaseEVMValidator { } } return { - error: this.blocked('Vault address not whitelisted', { vaultAddress, chainId }), + error: this.blocked('Vault address not whitelisted', { + vaultAddress, + chainId, + }), }; } diff --git a/src/validators/evm/erc4626/vault-registry.json b/src/validators/evm/erc4626/vault-registry.json index b738138..6c40fb9 100644 --- a/src/validators/evm/erc4626/vault-registry.json +++ b/src/validators/evm/erc4626/vault-registry.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-08-08T05:07:04.643Z", + "generatedAt": "2026-09-10T22:01:48.570Z", "vaults": [ { "yieldId": "arbitrum-arb-earb-1-0x7ed866d2d66c3149fafe854c30c68a8ba7cee8b9-4626-vault", @@ -1510,6 +1510,18 @@ "inputTokenDecimals": 6, "vaultTokenDecimals": 6 }, + { + "yieldId": "arc-testnet-usdc-musdc-0x8f2d33b5d4b9b5f02df635ae308f7b4c9da8d2dc-4626-vault", + "address": "0x8f2d33b5d4b9b5f02df635ae308f7b4c9da8d2dc", + "chainId": 5042002, + "protocol": "morpho", + "network": "arc-testnet", + "inputTokenAddress": "0x3600000000000000000000000000000000000000", + "vaultTokenAddress": "0x8f2d33b5d4b9b5f02df635ae308f7b4c9da8d2dc", + "isWethVault": false, + "inputTokenDecimals": 6, + "vaultTokenDecimals": 6 + }, { "yieldId": "avalanche-c-ausd-eausd-2-0x2137568666f12fc5a026f5430ae7194f1c1362ab-4626-vault", "address": "0x2137568666f12fc5a026f5430ae7194f1c1362ab", @@ -3666,7 +3678,9 @@ "0xe1d3edd70824449102e2636093d5fe48980a96fc", "0xea6acb881ee1ff0ea972f87ae112f923f579928f", "0x3090da9ca38f8412bc7d3542406af866f9ebe962", - "0x9baea149f51b0023e4de6897d50998aa7144f408" + "0x8135fccf37c160c42c0bf5e4ca5d5c8730bb6b07", + "0x9baea149f51b0023e4de6897d50998aa7144f408", + "0x552073d6b43edf382e76898a68b5e71cbd6480d9" ] }, { @@ -6698,7 +6712,10 @@ "vaultTokenAddress": "0xcf9ceacf5c7d6d2fe6e8650d81fbe4240c72443f", "isWethVault": false, "inputTokenDecimals": 18, - "vaultTokenDecimals": 18 + "vaultTokenDecimals": 18, + "allocatorVaults": [ + "0x0261e1d195ccae0d3de8ec001a1bf00b5feee990" + ] }, { "yieldId": "ethereum-frxusd-efrxusd-1-0x20622fcd4476fbc9d5ef36ebd371307a56d9028c-4626-vault", @@ -9665,7 +9682,10 @@ "vaultTokenAddress": "0x5dc53a23adc9f2bed98de6f59f7f309a7c71ff2b", "isWethVault": false, "inputTokenDecimals": 6, - "vaultTokenDecimals": 18 + "vaultTokenDecimals": 18, + "allocatorVaults": [ + "0x5e34bbf38aa7f1b86c378da3ffecfca9ce73cbd2" + ] }, { "yieldId": "ethereum-usdc-armusdcs-0xa2eaad0d586cf9fd73bb2c09cf6a7e3e187d68cd-4626-vault", @@ -9677,7 +9697,10 @@ "vaultTokenAddress": "0xa2eaad0d586cf9fd73bb2c09cf6a7e3e187d68cd", "isWethVault": false, "inputTokenDecimals": 6, - "vaultTokenDecimals": 18 + "vaultTokenDecimals": 18, + "allocatorVaults": [ + "0xce09b85bd1ee3a3f49d65d5dbe1fdccf0f4a9550" + ] }, { "yieldId": "ethereum-usdc-augustusdc-0x711a68a82dd80cb0435b281af76b0b80804efab9-4626-vault", @@ -12100,7 +12123,10 @@ "vaultTokenAddress": "0x0e609b710da5e0aa476224b6c0e5445ccc21251e", "isWethVault": false, "inputTokenDecimals": 6, - "vaultTokenDecimals": 6 + "vaultTokenDecimals": 6, + "allocatorVaults": [ + "0xcc8228562d08c07207096fe70fa778e483334c53" + ] }, { "yieldId": "ethereum-usdt-dusdtv3-0x05a811275fe9b4de503b3311f51edf6a856d936e-4626-vault",