Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@yieldxyz/shield",
"version": "1.2.4",
"version": "1.2.5",
"description": "Zero-trust transaction validation library for Yield.xyz integrations.",
"packageManager": "pnpm@10.12.2",
"main": "./dist/index.js",
Expand Down Expand Up @@ -69,7 +69,7 @@
"jest": "^29.0.0",
"postject": "1.0.0-alpha.6",
"prettier": "^3.2.5",
"ts-jest": "^29.0.0",
"ts-jest": "^29.4.6",
"typescript": "^5.0.0"
}
}
31 changes: 19 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/json/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const requestSchema = {
cosmosPubKey: { type: 'string', maxLength: 256 },
tezosPubKey: { type: 'string', maxLength: 256 },
nominatorAddress: { type: 'string', maxLength: 128 },
receiverAddress: { type: 'string', minLength: 1, maxLength: 128 },
nftIds: {
type: 'array',
items: { type: 'string', maxLength: 256 },
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type ActionArguments = {
cosmosPubKey?: string;
tezosPubKey?: string;
nominatorAddress?: string;
receiverAddress?: string;
nftIds?: string[];
};

Expand Down
104 changes: 101 additions & 3 deletions src/validators/evm/erc4626/erc4626.validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const MALICIOUS_ADDRESS = '0x000000000000000000000000000000000000bad1';
const PAUSED_VAULT_ADDRESS = '0xDEAD000000000000000000000000000000000001';
const ALLOCATOR_VAULT_ADDRESS = '0xa110ca7040000000000000000000000000000001';
const MORPHO_VAULT_ADDRESS = '0x00000000000000000000000000000000000face2';
const RECEIVER_ADDRESS = '0x2222222222222222222222222222222222222222';
const CHAIN_ID = 42161; // Arbitrum

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -158,16 +159,15 @@ describe('ERC4626Validator', () => {
expect(result.reason).toContain('not a whitelisted vault');
});

it('should reject zero approval amount', () => {
it('should accept zero approval amount (USDT reset pattern)', () => {
const data = erc20Iface.encodeFunctionData('approve', [VAULT_ADDRESS, 0]);
const tx = buildTx({ to: INPUT_TOKEN, data, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.APPROVAL,
USER_ADDRESS,
);
expect(result.isValid).toBe(false);
expect(result.reason).toContain('zero');
expect(result.isValid).toBe(true);
});

it('should reject when ETH value is attached', () => {
Expand Down Expand Up @@ -661,6 +661,104 @@ describe('ERC4626Validator', () => {
});
});

// =========================================================================
// receiverAddress override (args.receiverAddress)
// =========================================================================
describe('receiverAddress override via args', () => {
// ---- SUPPLY ----
it('SUPPLY: should accept when args.receiverAddress matches calldata receiver', () => {
const data = erc4626Iface.encodeFunctionData('deposit', [
ethers.parseUnits('1000', 6),
RECEIVER_ADDRESS,
]);
const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.SUPPLY,
USER_ADDRESS,
{ receiverAddress: RECEIVER_ADDRESS },
);
expect(result.isValid).toBe(true);
});

it('SUPPLY: should block when args.receiverAddress does NOT match calldata receiver', () => {
const data = erc4626Iface.encodeFunctionData('deposit', [
ethers.parseUnits('1000', 6),
MALICIOUS_ADDRESS,
]);
const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.SUPPLY,
USER_ADDRESS,
{ receiverAddress: RECEIVER_ADDRESS },
);
expect(result.isValid).toBe(false);
expect(result.reason).toContain('does not match expected address');
});

it('SUPPLY: without args.receiverAddress, receiver != user is blocked (default behavior)', () => {
const data = erc4626Iface.encodeFunctionData('deposit', [
ethers.parseUnits('1000', 6),
OTHER_ADDRESS,
]);
const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.SUPPLY,
USER_ADDRESS,
);
expect(result.isValid).toBe(false);
expect(result.reason).toContain('does not match expected address');
});

it('SUPPLY: without args.receiverAddress, receiver == user is safe (default behavior)', () => {
const data = erc4626Iface.encodeFunctionData('deposit', [
ethers.parseUnits('1000', 6),
USER_ADDRESS,
]);
const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.SUPPLY,
USER_ADDRESS,
);
expect(result.isValid).toBe(true);
});

// ---- WITHDRAW ----
it('WITHDRAW: should accept when args.receiverAddress matches calldata receiver', () => {
const data = erc4626Iface.encodeFunctionData(
'withdraw(uint256,address,address)',
[ethers.parseUnits('1000', 6), RECEIVER_ADDRESS, USER_ADDRESS],
);
const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.WITHDRAW,
USER_ADDRESS,
{ receiverAddress: RECEIVER_ADDRESS },
);
expect(result.isValid).toBe(true);
});

it('WITHDRAW: should block when args.receiverAddress does NOT match calldata receiver', () => {
const data = erc4626Iface.encodeFunctionData(
'withdraw(uint256,address,address)',
[ethers.parseUnits('1000', 6), MALICIOUS_ADDRESS, USER_ADDRESS],
);
const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.WITHDRAW,
USER_ADDRESS,
{ receiverAddress: RECEIVER_ADDRESS },
);
expect(result.isValid).toBe(false);
expect(result.reason).toContain('does not match expected address');
});
});

// =========================================================================
// UNWRAP
// =========================================================================
Expand Down
40 changes: 22 additions & 18 deletions src/validators/evm/erc4626/erc4626.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { BaseEVMValidator, EVMTransaction } from '../base.validator';
import { VaultInfo, VaultConfiguration } from './types';
import { WETH_ADDRESSES } from './constants';
import { isNonEmptyString } from '../../../utils/validation';

/**
* Standard ERC4626 ABI - only the functions we need to validate
Expand Down Expand Up @@ -98,7 +99,7 @@
unsignedTransaction: string,
transactionType: TransactionType,
userAddress: string,
_args?: ActionArguments,
args?: ActionArguments,
_context?: ValidationContext,
): ValidationResult {
const decoded = this.decodeEVMTransaction(unsignedTransaction);
Expand All @@ -116,25 +117,29 @@

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

Check warning on line 120 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 120 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (20.17.0)

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 125 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 125 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (20.17.0)

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

const receiverAddress = isNonEmptyString(args?.receiverAddress)
? args.receiverAddress
: undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

agree with the bot, receiverAddress looks wired correctly e2e, one small follow-up might be adding a runtime type check here as well, since the schema only covers the JSON path and a truthy no string value could still reach .toLowerCase

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.

Good catch, now using isNonEmptyString(args?.receiverAddress) to guard the extraction, which matches the pattern used by the Tron and Solana validators for their args.validatorAddress access. An empty string or non-string value now normalizes to undefined, falling back to userAddress as expected.

Esp since this is a security library good to have clear errors instead of unhandled TypeErrors, thanks for flagging!

// Route to appropriate validation based on transaction type
switch (transactionType) {
case TransactionType.APPROVAL:
return this.validateApproval(tx, chainId);
case TransactionType.WRAP:
return this.validateWrap(tx, chainId);
case TransactionType.SUPPLY:
return this.validateSupply(tx, userAddress, chainId);
return this.validateSupply(tx, userAddress, chainId, receiverAddress);
case TransactionType.WITHDRAW:
return this.validateWithdraw(tx, userAddress, chainId);
return this.validateWithdraw(tx, userAddress, chainId, receiverAddress);
case TransactionType.UNWRAP:
return this.validateUnwrap(tx, chainId);
default:
Expand Down Expand Up @@ -177,7 +182,7 @@
}

// Get spender (should be vault address)
const [spender, amount] = parsed.args;
const [spender] = parsed.args;

// Validate spender is a whitelisted vault
const vaultInfo = this.vaultInfoMap.get(
Expand All @@ -196,12 +201,6 @@
});
}

// Validate amount is not zero
const amountBigInt = BigInt(amount);
if (amountBigInt === 0n) {
return this.blocked('Approval amount is zero');
}

return this.safe();
}

Expand All @@ -211,7 +210,7 @@
private validateWrap(tx: EVMTransaction, chainId: number): ValidationResult {
// Get WETH address for this chain
const wethAddress = this.getWethAddress(chainId);
if (!wethAddress) {

Check warning on line 213 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 213 in src/validators/evm/erc4626/erc4626.validator.ts

View workflow job for this annotation

GitHub Actions / Test & Build (20.17.0)

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 @@ -265,6 +264,7 @@
tx: EVMTransaction,
userAddress: string,
chainId: number,
receiverAddress?: string,
): ValidationResult {
const resolved = this.resolveVault(tx, chainId);
if ('error' in resolved) return resolved.error;
Expand Down Expand Up @@ -310,10 +310,12 @@
return this.blocked('Supply amount is zero');
}

// Validate receiver is the user
if (receiver.toLowerCase() !== userAddress.toLowerCase()) {
return this.blocked('Receiver address does not match user address', {
expected: userAddress,
// Validate receiver is the intended receiver
const expectedReceiver = receiverAddress ?? userAddress;

if (receiver.toLowerCase() !== expectedReceiver.toLowerCase()) {
return this.blocked('Receiver address does not match expected address', {
expected: expectedReceiver,
actual: receiver,
});
}
Expand All @@ -328,6 +330,7 @@
tx: EVMTransaction,
userAddress: string,
chainId: number,
receiverAddress?: string,
): ValidationResult {
const resolved = this.resolveVault(tx, chainId);
if ('error' in resolved) return resolved.error;
Expand Down Expand Up @@ -381,10 +384,11 @@
});
}

// Validate receiver is the user (for safety)
if (receiver.toLowerCase() !== userAddress.toLowerCase()) {
return this.blocked('Receiver address does not match user address', {
expected: userAddress,
const expectedReceiver = receiverAddress ?? userAddress;

if (receiver.toLowerCase() !== expectedReceiver.toLowerCase()) {
return this.blocked('Receiver address does not match expected address', {
expected: expectedReceiver,
actual: receiver,
});
}
Expand Down
Loading