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
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.0",
"description": "Zero-trust transaction validation library for Yield.xyz integrations.",
"packageManager": "pnpm@10.33.1",
"engines": {
Expand Down
78 changes: 78 additions & 0 deletions src/json/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,84 @@ 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) => ({
apiVersion: '1.0',
operation: 'validate',
yieldId: 'ethereum-eth-lido-staking',
unsignedTransaction: JSON.stringify(validLidoStakeTx),
userAddress,
args,
});
it('accepts args.decimals as an integer', () => {
const response = call(validRequest({ amount: '1000000', decimals: 6 }));
// Schema accepted (not a SCHEMA_VALIDATION_ERROR) and the request
// proceeded to actual validation.
expect(response.ok).toBe(true);
expect(response.result.isValid).toBe(true);
});
it('accepts args.decimals at the bounds (0 and 255)', () => {
const zero = call(validRequest({ decimals: 0 }));
expect(zero.ok).toBe(true);
const max = call(validRequest({ decimals: 255 }));
expect(max.ok).toBe(true);
});
it('rejects fractional args.decimals', () => {
const response = call(validRequest({ decimals: 6.5 }));
expect(response.ok).toBe(false);
expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR');
});
it('rejects args.decimals above 255', () => {
const response = call(validRequest({ decimals: 256 }));
expect(response.ok).toBe(false);
expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR');
});
it('rejects negative args.decimals', () => {
const response = call(validRequest({ decimals: -1 }));
expect(response.ok).toBe(false);
expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR');
});
it('rejects non-numeric args.decimals', () => {
const response = call(validRequest({ decimals: '6' }));
expect(response.ok).toBe(false);
expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR');
});
it('accepts a 78-digit args.amount (maxUint256)', () => {
expect(MAX_UINT256_STRING.length).toBe(78); // pin the schema boundary
const response = call(validRequest({ amount: MAX_UINT256_STRING }));
expect(response.ok).toBe(true);
// Lido ignores args.amount today (amount validation is ERC-4626-only in
// Phase 1), so a valid stake tx still validates.
expect(response.result.isValid).toBe(true);
});
it('rejects args.amount longer than 78 characters', () => {
const response = call(validRequest({ amount: '1'.repeat(79) }));
expect(response.ok).toBe(false);
expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR');
});
it('rejects human-readable args.amount ("0.01") — base-unit integers only', () => {
const response = call(validRequest({ amount: '0.01', decimals: 6 }));
expect(response.ok).toBe(false);
expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR');
});
it('rejects non-numeric args.amount', () => {
const response = call(validRequest({ amount: '1,000' }));
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
3 changes: 2 additions & 1 deletion src/json/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export const requestSchema = {
},

// Future use - include for forward compatibility
amount: { type: 'string', maxLength: 78 }, // Max uint256 is 78 digits
amount: { type: 'string', maxLength: 78, pattern: '^[0-9]+$' }, // Max uint256 is 78 digits
decimals: { type: 'integer', minimum: 0, maximum: 255 },
tronResource: { type: 'string', enum: ['BANDWIDTH', 'ENERGY'] },
providerId: { type: 'string', maxLength: 256 },
duration: { type: 'number', minimum: 0 },
Expand Down
3 changes: 3 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ export interface ValidationResult {
}

export type ActionArguments = {
/** Declared intent amount in base units (wei) — must exactly match calldata units. Integer string. */
amount?: string;
/** Informational only: token decimals for the declared amount. Not used in comparison. */
decimals?: number;
validatorAddress?: string;
validatorAddresses?: string[];
tronResource?: TronResourceType;
Expand Down
50 changes: 50 additions & 0 deletions src/utils/amount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { MAX_UINT256, matchesDeclaredAmount } from './amount';

describe('MAX_UINT256', () => {
it('equals 2^256 - 1', () => {
expect(MAX_UINT256).toBe(2n ** 256n - 1n);
});
});

describe('matchesDeclaredAmount', () => {
const DECLARED = '1000000'; // 1 USDC (6 decimals), wei

it('returns true on exact match', () => {
expect(matchesDeclaredAmount(1000000n, DECLARED)).toBe(true);
});

it('returns false when calldata amount is one above declared', () => {
expect(matchesDeclaredAmount(1000001n, DECLARED)).toBe(false);
});

it('returns false when calldata amount is one below declared', () => {
expect(matchesDeclaredAmount(999999n, DECLARED)).toBe(false);
});

it('returns false on gross inflation', () => {
expect(matchesDeclaredAmount(1000000000000n, DECLARED)).toBe(false);
});

it('returns true when declared is undefined (opt-in skip)', () => {
expect(matchesDeclaredAmount(123456789n, undefined)).toBe(true);
});

it('matches maxUint256 against a declared maxUint256 (sanctioned infinite)', () => {
expect(matchesDeclaredAmount(MAX_UINT256, MAX_UINT256.toString())).toBe(
true,
);
});

it('rejects maxUint256 against a finite declared amount', () => {
expect(matchesDeclaredAmount(MAX_UINT256, DECLARED)).toBe(false);
});

it('matches zero against declared "0"', () => {
expect(matchesDeclaredAmount(0n, '0')).toBe(true);
});

it('throws on a non-integer declared string (defense-in-depth: unreachable via schema pattern + validator guard, which reject non-digit amounts first)', () => {
expect(() => matchesDeclaredAmount(1000000n, '1.5')).toThrow();
expect(() => matchesDeclaredAmount(1000000n, 'abc')).toThrow();
});
});
10 changes: 10 additions & 0 deletions src/utils/amount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const MAX_UINT256 = (1n << 256n) - 1n;

// Opt-in: undefined declared amount → skip (returns true).
export function matchesDeclaredAmount(
calldataAmount: bigint,
declared?: string,
): boolean {
if (declared === undefined) return true;
return calldataAmount === BigInt(declared);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

args.amount is now treated as base-unit bigInt, but the public contract still suggests passing api args like "0.01", since decimals is accepted but unused, this could false block valid 4626 txs...

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.

Great catch...Shield expects base-unit (wei) integer strings, matching calldata units exactly; Shield deliberately doesn't convert (a validator that infers units from decimals becomes ambiguous — "1000000" could be 1 USDC wei or 1M USDC human — and an ambiguous security comparison is gameable).

But you're right that the API surface didn't say that anywhere and decimals invites the wrong inference. Fixed by: (1) schema pattern: '^[0-9]+$' on amount so JSON callers get a clear schema error, (2) an explicit block reason ("Declared amount must be a base-unit integer string (wei)") for library callers instead of the generic BigInt parse failure, (3) JSDoc on ActionArguments.amount/decimals pinning the unit (decimals is informational context only).

}
Loading
Loading