From b41e8ba0e383a4f5a2f0f4c2d8d47c19708783bd Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Fri, 11 Sep 2026 17:17:41 -0700 Subject: [PATCH 1/2] feat: schema hardening + handler tests + refactor + readme --- README.md | 34 +++++++++- src/json/handler.test.ts | 132 ++++++++++++++++++++++++--------------- src/json/schema.ts | 7 ++- 3 files changed, 120 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 677eea8..78097f1 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ Validates a transaction by auto-detecting its type. yieldId: string; // Yield integration ID userAddress: string; // User's wallet address args?: ActionArguments; // Optional arguments - context?: ValidationContext; // Optional context + context?: ValidationContext; // Trusted control-plane data (see Runtime OAV injection) } ``` @@ -302,7 +302,37 @@ Shield is designed with security as a top priority: ### Embedded Vault Registry -ERC-4626 vault data is embedded at build time from `vault-registry.json` (addresses, token decimals, and `allocatorVaults`). Transactions to known allocator vaults use the same ERC-4626 checks. Newly deployed OAVs are only recognized after a registry re-export and package publish. +ERC-4626 vault data is embedded at build time from the **installed package’s** `vault-registry.json` (addresses, token decimals, and `allocatorVaults`). Transactions to known allocator vaults use the same ERC-4626 checks. +Treat that snapshot as a baseline for third-party vaults, not as the decision gate for “is this project OAV already allowed?” The copy on GitHub `main` can lag the package you actually run, and both can lag newly deployed OAVs. Always pass your project’s OAVs in `context` (additive; see below). A registry re-export + publish is convenience for the static snapshot, not a prerequisite for validating a live OAV. + +### Runtime OAV injection (`context`) + + For OAV-enabled ERC-4626 yields, pass project OAVs on every `validate` call. Injection is **additive**: it can unblock a legitimate OAV that is missing from the baked snapshot; it does not remove static-registry vaults. + + ```typescript + shield.validate({ + unsignedTransaction, + yieldId, + userAddress, + args, // optional + context: { + feeConfiguration: [ + { + allocatorVaultAddress: '0x…', // OAV / allocator vault + // Required for injected-OAV APPROVAL. Omit only if you are not + // validating approvals against this OAV (supply/withdraw still pass). + allocatorVaultInputTokenAddress: '0x…', // that OAV's underlying token + }, + ], + }, + }); + ``` + + `allocatorVaultInputTokenAddress` is required for **injected-OAV APPROVAL**. Shield checks the approval token against this address. If it is omitted or does not match, APPROVAL is blocked. Supply and withdraw still succeed if you pass only `allocatorVaultAddress`. The token may differ from the yield’s base vault (e.g. a meta-vault). + + **Trust boundary:** `context` is trusted control-plane data. Populate it server-side from the project’s own authenticated fee-configuration / OAV records. Never take it from the end user or from `unsignedTransaction`. User-supplied addresses in `context` expand the vault/spender whitelist and defeat Shield. + + Shield stays offline; fetching OAVs is the caller’s job. Injection only widens that whitelist — `from` / owner / receiver, method, calldata, and amount checks still apply. ### Verifying Binary Integrity diff --git a/src/json/handler.test.ts b/src/json/handler.test.ts index c9694fb..4eab4ef 100644 --- a/src/json/handler.test.ts +++ b/src/json/handler.test.ts @@ -6,19 +6,18 @@ describe('handleJsonRequest', () => { const input = typeof req === 'string' ? req : JSON.stringify(req); return JSON.parse(handleJsonRequest(input)); }; + const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; + const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; + const validLidoStakeTx = { + to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + from: userAddress, + value: '0xde0b6b3a7640000', + data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), + chainId: 1, + }; describe('validate operation', () => { // Use the same test data as shield.test.ts - const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; - const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; - - const validLidoStakeTx = { - to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', // Lido stETH - from: userAddress, - value: '0xde0b6b3a7640000', // 1 ETH - data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), // submit(referral) - chainId: 1, - }; const validLidoUnstakeTx = { to: '0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1', // Lido withdrawal queue @@ -232,17 +231,6 @@ describe('handleJsonRequest', () => { }); describe('optional parameters: args and context', () => { - const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; - const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; - - const validLidoStakeTx = { - to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', - from: userAddress, - value: '0xde0b6b3a7640000', - data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), - chainId: 1, - }; - it('should forward args parameter to Shield validator', () => { const response = call({ apiVersion: '1.0', @@ -306,15 +294,6 @@ 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) => ({ @@ -384,15 +363,6 @@ describe('handleJsonRequest', () => { }); describe('schema: args.shareAmount 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, - }; const MAX_UINT256_STRING = (2n ** 256n - 1n).toString(); const validRequest = (args: object) => ({ apiVersion: '1.0', @@ -436,6 +406,79 @@ describe('handleJsonRequest', () => { }); }); + describe('schema: context.feeConfiguration address fields', () => { + const VALID_HEX = '0x1234567890123456789012345678901234567890'; + const validRequest = (context: object) => ({ + apiVersion: '1.0', + operation: 'validate', + yieldId: 'ethereum-eth-lido-staking', + unsignedTransaction: JSON.stringify(validLidoStakeTx), + userAddress, + context, + }); + it('accepts valid hex allocatorVaultAddress and allocatorVaultInputTokenAddress', () => { + const response = call( + validRequest({ + feeConfiguration: [ + { + allocatorVaultAddress: VALID_HEX, + allocatorVaultInputTokenAddress: VALID_HEX, + }, + ], + }), + ); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + }); + it('accepts mixed-case checksum hex', () => { + const response = call( + validRequest({ + feeConfiguration: [ + { + allocatorVaultAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8', + allocatorVaultInputTokenAddress: + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + ], + }), + ); + expect(response.ok).toBe(true); + expect(response.error).toBeUndefined(); + }); + it('accepts allocatorVaultAddress without allocatorVaultInputTokenAddress (schema-optional)', () => { + const response = call( + validRequest({ + feeConfiguration: [{ allocatorVaultAddress: VALID_HEX }], + }), + ); + expect(response.ok).toBe(true); + expect(response.result.isValid).toBe(true); + }); + it('rejects allocatorVaultAddress "not-an-address" with SCHEMA_VALIDATION_ERROR', () => { + const response = call( + validRequest({ + feeConfiguration: [{ allocatorVaultAddress: 'not-an-address' }], + }), + ); + expect(response.ok).toBe(false); + expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR'); + }); + it('rejects allocatorVaultInputTokenAddress "not-an-address" with SCHEMA_VALIDATION_ERROR', () => { + const response = call( + validRequest({ + feeConfiguration: [ + { + allocatorVaultAddress: VALID_HEX, + allocatorVaultInputTokenAddress: 'not-an-address', + }, + ], + }), + ); + 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({ @@ -528,17 +571,6 @@ describe('handleJsonRequest', () => { }); describe('security: tampering detection', () => { - const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8'; - const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be'; - - const validLidoStakeTx = { - to: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', - from: userAddress, - value: '0xde0b6b3a7640000', - data: '0xa1903eab' + referralAddress.slice(2).padStart(64, '0'), - chainId: 1, - }; - it('should reject transaction with appended data', () => { const tamperedTx = { ...validLidoStakeTx, diff --git a/src/json/schema.ts b/src/json/schema.ts index 6fd987b..daf3db2 100644 --- a/src/json/schema.ts +++ b/src/json/schema.ts @@ -73,10 +73,15 @@ export const requestSchema = { properties: { depositFeeBps: { type: 'number', minimum: 0, maximum: 10000 }, feeRecipientAddress: { type: 'string', maxLength: 128 }, - allocatorVaultAddress: { type: 'string', maxLength: 128 }, + allocatorVaultAddress: { + type: 'string', + maxLength: 128, + pattern: '^0x[0-9a-fA-F]{40}$', + }, allocatorVaultInputTokenAddress: { type: 'string', maxLength: 128, + pattern: '^0x[0-9a-fA-F]{40}$', }, }, }, From 6c6d7cf513f88c3608cccbd20cf25ab018964481 Mon Sep 17 00:00:00 2001 From: Akash Jag Date: Fri, 11 Sep 2026 17:25:12 -0700 Subject: [PATCH 2/2] fix: lint --- src/json/handler.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/json/handler.test.ts b/src/json/handler.test.ts index 4eab4ef..563e642 100644 --- a/src/json/handler.test.ts +++ b/src/json/handler.test.ts @@ -435,7 +435,8 @@ describe('handleJsonRequest', () => { validRequest({ feeConfiguration: [ { - allocatorVaultAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8', + allocatorVaultAddress: + '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8', allocatorVaultInputTokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', },