Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
```

Expand Down Expand Up @@ -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

Expand Down
133 changes: 83 additions & 50 deletions src/json/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -436,6 +406,80 @@ 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({
Expand Down Expand Up @@ -528,17 +572,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,
Expand Down
7 changes: 6 additions & 1 deletion src/json/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}$',
},
},
},
Expand Down
Loading