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 1552af4..cfa6bc4 100644 --- a/src/validators/evm/erc4626/erc4626.validator.ts +++ b/src/validators/evm/erc4626/erc4626.validator.ts @@ -110,7 +110,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) { @@ -194,7 +194,7 @@ export class ERC4626Validator extends BaseEVMValidator { // Route to appropriate validation based on transaction type switch (transactionType) { case TransactionType.APPROVAL: - return this.validateApproval(tx, chainId, declaredAmount); + return this.validateApproval(tx, chainId, declaredAmount, context); case TransactionType.WRAP: return this.validateWrap(tx, chainId, declaredAmount); case TransactionType.SUPPLY: @@ -204,6 +204,7 @@ export class ERC4626Validator extends BaseEVMValidator { chainId, receiverAddress, declaredAmount, + context, ); case TransactionType.WITHDRAW: return this.validateWithdraw( @@ -213,6 +214,7 @@ export class ERC4626Validator extends BaseEVMValidator { receiverAddress, declaredAmount, declaredShareAmount, + context, ); case TransactionType.UNWRAP: return this.validateUnwrap(tx, chainId); @@ -230,6 +232,7 @@ export class ERC4626Validator extends BaseEVMValidator { tx: EVMTransaction, chainId: number, declaredAmount?: string, + context?: ValidationContext, ): ValidationResult { // APPROVAL should not send ETH const value = BigInt(tx.value ?? '0'); @@ -259,10 +262,15 @@ export class ERC4626Validator extends BaseEVMValidator { // Get spender (should be vault address) const [spender] = parsed.args; - // Validate spender is a whitelisted vault - const vaultInfo = this.vaultInfoMap.get( - `${chainId}:${spender.toLowerCase()}`, - ); + // 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) + ) { + vaultInfo = this.getBaseVaultForChain(chainId); + } if (!vaultInfo) { return this.blocked('Approval spender is not a whitelisted vault', { spender, @@ -368,8 +376,9 @@ export class ERC4626Validator extends BaseEVMValidator { chainId: number, receiverAddress?: string, declaredAmount?: string, + context?: ValidationContext, ): ValidationResult { - const resolved = this.resolveVault(tx, chainId); + const resolved = this.resolveVault(tx, chainId, context); if ('error' in resolved) return resolved.error; const { vaultInfo } = resolved; @@ -457,8 +466,9 @@ export class ERC4626Validator extends BaseEVMValidator { receiverAddress?: string, declaredAmount?: string, declaredShareAmount?: string, + context?: ValidationContext, ): ValidationResult { - const resolved = this.resolveVault(tx, chainId); + const resolved = this.resolveVault(tx, chainId, context); if ('error' in resolved) return resolved.error; const { vaultInfo } = resolved; @@ -647,32 +657,29 @@ 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, + }), + }; } private isAllocatorTarget(txTo: string, vaultInfo: VaultInfo): boolean { @@ -691,4 +698,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; + } } 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",