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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
210 changes: 210 additions & 0 deletions src/validators/evm/erc4626/erc4626.validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
82 changes: 54 additions & 28 deletions src/validators/evm/erc4626/erc4626.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
transactionType: TransactionType,
userAddress: string,
args?: ActionArguments,
_context?: ValidationContext,
context?: ValidationContext,
): ValidationResult {
const decoded = this.decodeEVMTransaction(unsignedTransaction);
if (!decoded.isValid || !decoded.transaction) {
Expand All @@ -127,12 +127,12 @@

// Get and validate chain ID from transaction
const chainId = this.getNumericChainId(tx);
if (!chainId) {

Check warning on line 130 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (22.x)

Unexpected nullable number value in conditional. Please handle the nullish/zero/NaN cases explicitly

Check warning on line 130 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (20.19.0)

Unexpected nullable number value in conditional. Please handle the nullish/zero/NaN cases explicitly

Check warning on line 130 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (24.x)

Unexpected nullable number value in conditional. Please handle the nullish/zero/NaN cases explicitly
return this.blocked('Chain ID not found in transaction');
}

// Ensure destination address exists
if (!tx.to) {

Check warning on line 135 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (22.x)

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly

Check warning on line 135 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (20.19.0)

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly

Check warning on line 135 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (24.x)

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
return this.blocked('Transaction has no destination address');
}

Expand Down Expand Up @@ -194,7 +194,7 @@
// 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:
Expand All @@ -204,6 +204,7 @@
chainId,
receiverAddress,
declaredAmount,
context,
);
case TransactionType.WITHDRAW:
return this.validateWithdraw(
Expand All @@ -213,6 +214,7 @@
receiverAddress,
declaredAmount,
declaredShareAmount,
context,
);
case TransactionType.UNWRAP:
return this.validateUnwrap(tx, chainId);
Expand All @@ -230,6 +232,7 @@
tx: EVMTransaction,
chainId: number,
declaredAmount?: string,
context?: ValidationContext,
): ValidationResult {
// APPROVAL should not send ETH
const value = BigInt(tx.value ?? '0');
Expand Down Expand Up @@ -259,10 +262,15 @@
// 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,
Expand Down Expand Up @@ -304,7 +312,7 @@
): ValidationResult {
// Get WETH address for this chain
const wethAddress = this.getWethAddress(chainId);
if (!wethAddress) {

Check warning on line 315 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (22.x)

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly

Check warning on line 315 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (20.19.0)

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly

Check warning on line 315 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (24.x)

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
return this.blocked('WETH address not configured for chain', { chainId });
}

Expand Down Expand Up @@ -368,8 +376,9 @@
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;

Expand Down Expand Up @@ -457,8 +466,9 @@
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;

Expand Down Expand Up @@ -647,32 +657,29 @@
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 },
};
Comment thread
ajag408 marked this conversation as resolved.
}
}

return { vaultInfo };
return {
error: this.blocked('Vault address not whitelisted', {
vaultAddress,
chainId,
}),
};
}

private isAllocatorTarget(txTo: string, vaultInfo: VaultInfo): boolean {
Expand All @@ -691,4 +698,23 @@
private getWethAddress(chainId: number): string | null {
return WETH_ADDRESSES[chainId] || null;
}

private getInjectedAllocatorVaults(context?: ValidationContext): Set<string> {
const injected = new Set<string>();
for (const fee of context?.feeConfiguration ?? []) {
if (isNonEmptyString(fee.allocatorVaultAddress)) {
injected.add(fee.allocatorVaultAddress.toLowerCase());
Comment on lines +702 to +706

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review
Severity: HIGH

Caller-supplied ValidationContext.feeConfiguration[].allocatorVaultAddress is treated as a vault/spender allowlist. Any non-empty string is accepted with no registry membership, checksum, chain binding, or attestation. resolveVault and validateApproval then treat that address as a legitimate ERC-4626 vault and reuse the yield’s base-vault metadata (including inputTokenAddress).

The JSON validate path forwards request.context from the same payload as unsignedTransaction, so the party that builds the transaction can also expand Shield’s destination allowlist. That breaks the embedded-registry / zero-trust model: a wallet that forwards Yield/dApp context can be told that approve(attacker) on the vault input token, or deposit/mint/withdraw/redeem to that attacker, is valid.

Impact: Users who sign after a Shield isValid: true result can grant unlimited allowance or deposit the vault’s underlying asset to an attacker contract that the static registry would have rejected.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit 14074fc. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. context.feeConfiguration is trusted control-plane input, not end-user input. Embedded validation never takes context from the transaction submitter. Standalone/JSON callers must not forward dApp-supplied context; they should inject only their own OAV addresses. Remaining invariants still apply (from/owner/receiver = user, method + calldata checks). We’ll document this; schema address format is hardening only.

}
}
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;
}
}
Loading
Loading