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.3.0",
"version": "1.4.1",
"description": "Zero-trust transaction validation library for Yield.xyz integrations.",
"packageManager": "pnpm@10.33.1",
"engines": {
Expand Down
72 changes: 72 additions & 0 deletions src/json/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,78 @@ describe('handleJsonRequest', () => {
});
});

describe('ENG-3841: sUSDS referral deposit (client repro, real registry)', () => {
// deposit(uint256 assets, address receiver, uint16 referral), selector 0x9b8d6d38
const susdsYieldId =
'ethereum-usds-susds-0xa3931d71877c0e7a3148cb7eb4463524fec27fbd-4626-vault';
const susdsUserAddress = '0xA87D2b790668d51023A3A354a4FEAd156A37dd27';
const DEPOSIT_REFERRAL_SELECTOR = '0x9b8d6d38';
// 9986836000000000000 wei (9.986836 USDS)
const ASSETS_WORD =
'0000000000000000000000000000000000000000000000008a985e6df2154000';
// receiver = user
const RECEIVER_WORD =
'000000000000000000000000a87d2b790668d51023a3a354a4fead156a37dd27';
// referral = 3008 (0xbc0, Sky Ethereum ref code)
const REFERRAL_WORD =
'0000000000000000000000000000000000000000000000000000000000000bc0';
// The ticket's unsignedTransaction, verbatim
const susdsSupplyTx = {
from: susdsUserAddress,
gasLimit: '0x0f4240',
to: '0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD', // sUSDS vault
data:
DEPOSIT_REFERRAL_SELECTOR + ASSETS_WORD + RECEIVER_WORD + REFERRAL_WORD,
nonce: 67,
type: 2,
maxFeePerGas: '0x272417a9',
maxPriorityFeePerGas: '0x09f815',
chainId: 1,
};
it('should validate the client’s exact rejected SUPPLY transaction as SAFE', () => {
const response = call({
apiVersion: '1.0',
operation: 'validate',
yieldId: susdsYieldId,
unsignedTransaction: JSON.stringify(susdsSupplyTx),
userAddress: susdsUserAddress,
});
expect(response.ok).toBe(true);
expect(response.result.isValid).toBe(true);
expect(response.result.detectedType).toBe('SUPPLY');
});
it('should reject the same calldata with the receiver word redirected', () => {
const attackerWord =
'000000000000000000000000000000000000000000000000000000000000bad1';
const tamperedTx = {
...susdsSupplyTx,
data:
DEPOSIT_REFERRAL_SELECTOR +
ASSETS_WORD +
attackerWord +
REFERRAL_WORD,
};
const response = call({
apiVersion: '1.0',
operation: 'validate',
yieldId: susdsYieldId,
unsignedTransaction: JSON.stringify(tamperedTx),
userAddress: susdsUserAddress,
});
expect(response.ok).toBe(true); // request succeeded
expect(response.result.isValid).toBe(false); // but validation blocked
expect(response.result.reason).toContain(
'No matching operation pattern found',
);
const supplyAttempt = response.result.details?.attempts?.find(
(a: { type: string; reason?: string }) => a.type === 'SUPPLY',
);
expect(supplyAttempt?.reason).toContain(
'Receiver address does not match',
);
});
});

describe('optional parameters: args and context', () => {
const userAddress = '0x742d35cc6634c0532925a3b844bc9e7595f0beb8';
const referralAddress = '0x371240E80Bf84eC2bA8b55aE2fD0B467b16Db2be';
Expand Down
2 changes: 1 addition & 1 deletion src/validators/evm/base.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export abstract class BaseEVMValidator extends BaseValidator {
try {
// Re-encode the function call with the parsed arguments
const expectedCalldata = iface.encodeFunctionData(
parsedTx.name,
parsedTx.fragment,
parsedTx.args,
);

Expand Down
111 changes: 111 additions & 0 deletions src/validators/evm/erc4626/erc4626.validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const ALLOCATOR_VAULT_ADDRESS = '0xa110ca7040000000000000000000000000000001';
const MORPHO_VAULT_ADDRESS = '0x00000000000000000000000000000000000face2';
const RECEIVER_ADDRESS = '0x2222222222222222222222222222222222222222';
const CHAIN_ID = 42161; // Arbitrum
const DEPOSIT_WEI = ethers.parseUnits('1000', 6); // 1000 USDC (6 decimals)

// ---------------------------------------------------------------------------
// ABI interfaces for building calldata
Expand All @@ -40,6 +41,12 @@ const erc4626Iface = new ethers.Interface([
'function redeem(uint256 shares, address receiver, address owner) returns (uint256)',
]);

// Sky/Spark Savings referral overloads
const erc4626ReferralIface = new ethers.Interface([
'function deposit(uint256 assets, address receiver, uint16 referral) returns (uint256)',
'function mint(uint256 shares, address receiver, uint16 referral) returns (uint256)',
]);

// ---------------------------------------------------------------------------
// Mock configuration
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -515,6 +522,110 @@ describe('ERC4626Validator', () => {
expect(result.isValid).toBe(false);
expect(result.reason).toContain('zero');
});
// -----------------------------------------------------------------------
// Sky/Spark referral overloads — deposit/mint(..., uint16 referral)
// -----------------------------------------------------------------------
describe('referral overloads (uint16)', () => {
it.each([
[3008, 'Sky Ethereum ref code'],
[200, 'Spark L2 ref code'],
[0, 'zero referral'],
[65535, 'uint16 max'],
])(
'should validate a valid 3-arg deposit with referral %i (%s) — accept-any',
(referral) => {
const data = erc4626ReferralIface.encodeFunctionData('deposit', [
DEPOSIT_WEI,
USER_ADDRESS,
referral,
]);
const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.SUPPLY,
USER_ADDRESS,
);
expect(result.isValid).toBe(true);
},
);
it('should validate a valid 3-arg mint (no declared amount)', () => {
const data = erc4626ReferralIface.encodeFunctionData('mint', [
ethers.parseUnits('500', 18),
USER_ADDRESS,
3008,
]);
const tx = buildTx({ to: VAULT_ADDRESS, data, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.SUPPLY,
USER_ADDRESS,
);
expect(result.isValid).toBe(true);
});
it('should reject 3-arg deposit when receiver != user', () => {
const data = erc4626ReferralIface.encodeFunctionData('deposit', [
DEPOSIT_WEI,
MALICIOUS_ADDRESS, // receiver redirected
3008,
]);
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('Receiver address does not match');
});
it('should reject tampered 3-arg calldata (appended bytes)', () => {
// Also regression-covers the fragment-based tamper re-encode in
// BaseEVMValidator (name-based lookup throws on overloaded ABIs).
const data = erc4626ReferralIface.encodeFunctionData('deposit', [
DEPOSIT_WEI,
USER_ADDRESS,
3008,
]);
const tampered = data + 'deadbeef';
const tx = buildTx({ to: VAULT_ADDRESS, data: tampered, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.SUPPLY,
USER_ADDRESS,
);
expect(result.isValid).toBe(false);
expect(result.reason).toContain('tampered');
});
it('should reject zero-amount 3-arg deposit', () => {
const data = erc4626ReferralIface.encodeFunctionData('deposit', [
0,
USER_ADDRESS,
3008,
]);
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('zero');
});
it('should reject 3-arg deposit to non-whitelisted vault', () => {
const data = erc4626ReferralIface.encodeFunctionData('deposit', [
DEPOSIT_WEI,
USER_ADDRESS,
3008,
]);
const tx = buildTx({ to: MALICIOUS_ADDRESS, data, value: '0x0' });
const result = validator.validate(
tx,
TransactionType.SUPPLY,
USER_ADDRESS,
);
expect(result.isValid).toBe(false);
expect(result.reason).toContain('not whitelisted');
});
});
});

// =========================================================================
Expand Down
3 changes: 3 additions & 0 deletions src/validators/evm/erc4626/erc4626.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
const ERC4626_ABI = [
'function deposit(uint256 assets, address receiver) returns (uint256)',
'function mint(uint256 shares, address receiver) returns (uint256)',
// Sky/Spark Savings referral overloads (sUSDS, sUSDC, sDAI …).
'function deposit(uint256 assets, address receiver, uint16 referral) returns (uint256)',
'function mint(uint256 shares, address receiver, uint16 referral) returns (uint256)',
'function withdraw(uint256 assets, address receiver, address owner) returns (uint256)',
'function redeem(uint256 shares, address receiver, address owner) returns (uint256)',
];
Expand Down Expand Up @@ -117,12 +120,12 @@

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

Check warning on line 123 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 123 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 123 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 128 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 128 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 128 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 @@ -210,7 +213,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 216 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 216 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 216 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
Loading