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
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,60 @@ Shield validates the following operations for all supported ERC4626 vaults:
| WETH Wrap | WRAP | Convert native ETH to WETH (WETH vaults only) |
| WETH Unwrap | UNWRAP | Convert WETH to native ETH (WETH vaults only) |


### Amount intent (`args`) — ERC-4626 enter & exit

Amount checks are **opt-in**. If you omit `args.amount` / `args.shareAmount`, Shield runs only structural checks (vault whitelist, owner/receiver, method, non-zero).

**Units:** pass **base-unit integer strings (wei)** only — the same scale as calldata. Human values like `"0.01"` are rejected.

| Calldata | Pass to Shield | Match rule |
| -------- | -------------- | ---------- |
| `deposit` / `approve` / `WRAP` | `args.amount` (asset wei) | Exact |
| `withdraw(assets, …)` | `args.amount` (asset wei) | Within **10** wei (monorepo near-max snap) |
| `redeem(shares, …)` | `args.shareAmount` (share wei) | Within margin (see below) |

Do **not** declare both `amount` and `shareAmount`. Do **not** pass asset `amount` on a `redeem`, or `shareAmount` on a `withdraw` (fail-closed).

**Match the built tx, not only what you sent the Yield API:**

| Yield API exit | Typical calldata | Shield args |
| -------------- | ---------------- | ----------- |
| `amount` with withdraw-on-amount (team flag off) | `withdraw(assets)` | `amount` = asset wei |
| `shareAmount` / `shareAmountRaw` | `redeem(shares)` | `shareAmount` = share wei |
| `useMaxAmount: true` | `redeem(maxRedeem)` | `shareAmount` from balances `shareAmountRaw`, or omit intent |

```typescript
// Partial exit — withdraw(assets)
shield.validate({
unsignedTransaction,
yieldId,
userAddress,
args: { amount: '1000000' }, // 1 USDC @ 6 decimals, wei
});

// Share exit — redeem(shares)
shield.validate({
unsignedTransaction,
yieldId,
userAddress,
args: { shareAmount: '1000000000000000000' }, // 1 share @ 18 decimals
});

// Full exit (useMaxAmount) — redeem; declare the share balance, not an asset amount
shield.validate({
unsignedTransaction,
yieldId,
userAddress,
args: { shareAmount: balance.shareAmountRaw },
});
```

**Redeem margin**

- Default / underlying vault: **`"10"`** share wei.
- Allocator / OAV target (`tx.to` in the registry's `allocatorVaults`): decimal-gap margin `10^(abs(inputDecimals − vaultDecimals) + 1)` — e.g. USDC 6 vs shares 18 → `10^13` — except a small hardcoded set of OAVs that stay at `"10"` for parity with the Yield API exit path.

## API Reference

### `shield.validate(request)`
Expand Down Expand Up @@ -248,7 +302,7 @@ Shield is designed with security as a top priority:

### Embedded Vault Registry

ERC4626 vault data is embedded in the package at build time from `vault-registry.json`. The registry includes allocator vault (OAV) addresses for yields with fee configurations. Transactions targeting allocator vaults are validated using the same ERC4626 standard.
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.

### Verifying Binary Integrity

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

describe('ENG-3841: sUSDS referral deposit (client repro, real registry)', () => {
describe('sUSDS referral deposit (client repro, real registry)', () => {
// deposit(uint256 assets, address receiver, uint16 referral), selector 0x9b8d6d38
const susdsYieldId =
'ethereum-usds-susds-0xa3931d71877c0e7a3148cb7eb4463524fec27fbd-4626-vault';
Expand Down Expand Up @@ -383,6 +383,59 @@ 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',
operation: 'validate',
yieldId: 'ethereum-eth-lido-staking',
unsignedTransaction: JSON.stringify(validLidoStakeTx),
userAddress,
args,
});
it('accepts a 78-digit args.shareAmount (maxUint256)', () => {
expect(MAX_UINT256_STRING.length).toBe(78);
const response = call(validRequest({ shareAmount: MAX_UINT256_STRING }));
expect(response.ok).toBe(true);
// Lido ignores shareAmount; schema acceptance is what this pins.
expect(response.result.isValid).toBe(true);
});
it('rejects args.shareAmount longer than 78 characters', () => {
const response = call(validRequest({ shareAmount: '1'.repeat(79) }));
expect(response.ok).toBe(false);
expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR');
});
it('rejects human-readable args.shareAmount ("0.01") — base-unit integers only', () => {
const response = call(validRequest({ shareAmount: '0.01' }));
expect(response.ok).toBe(false);
expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR');
});
it('rejects non-numeric args.shareAmount', () => {
const response = call(validRequest({ shareAmount: '1,000' }));
expect(response.ok).toBe(false);
expect(response.error.code).toBe('SCHEMA_VALIDATION_ERROR');
});
// Schema stays permissive: both fields allowed at AJV layer.
// Semantic reject is covered in erc4626.validator.test.ts
// ("rejects when both amount and shareAmount are declared").
it('accepts both amount and shareAmount at the schema layer', () => {
const response = call(
validRequest({ amount: '1000000', shareAmount: '1000' }),
);
expect(response.ok).toBe(true);
expect(response.result.isValid).toBe(true); // Lido path; no ERC-4626 both-declared check
});
});

describe('isSupported operation', () => {
it('should return supported: true for known yield', () => {
const response = call({
Expand Down
1 change: 1 addition & 0 deletions src/json/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const requestSchema = {

// Future use - include for forward compatibility
amount: { type: 'string', maxLength: 78, pattern: '^[0-9]+$' }, // Max uint256 is 78 digits
shareAmount: { type: 'string', maxLength: 78, pattern: '^[0-9]+$' },
decimals: { type: 'integer', minimum: 0, maximum: 255 },
tronResource: { type: 'string', enum: ['BANDWIDTH', 'ENERGY'] },
providerId: { type: 'string', maxLength: 256 },
Expand Down
6 changes: 6 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export interface ValidationResult {
export type ActionArguments = {
/** Declared intent amount in base units (wei) — must exactly match calldata units. Integer string. */
amount?: string;
/**
* Declared exit share amount in vault-share base units (wei integer string).
* Same contract as `amount`: no decimal conversion.
* (not human-readable `shareAmount`). Used only for ERC-4626 `redeem` intent checks.
*/
shareAmount?: string;
/** Informational only: token decimals for the declared amount. Not used in comparison. */
decimals?: number;
validatorAddress?: string;
Expand Down
84 changes: 83 additions & 1 deletion src/utils/amount.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { MAX_UINT256, matchesDeclaredAmount } from './amount';
import {
MAX_UINT256,
matchesDeclaredAmount,
matchesDeclaredAmountWithinMargin,
getErc4626RedeemMargin,
} from './amount';

describe('MAX_UINT256', () => {
it('equals 2^256 - 1', () => {
Expand Down Expand Up @@ -48,3 +53,80 @@ describe('matchesDeclaredAmount', () => {
expect(() => matchesDeclaredAmount(1000000n, 'abc')).toThrow();
});
});

describe('matchesDeclaredAmountWithinMargin', () => {
const DECLARED = '1000';
const MARGIN = '10';

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

it('returns true when calldata is within margin above declared (snap-up)', () => {
expect(matchesDeclaredAmountWithinMargin(1005n, DECLARED, MARGIN)).toBe(
true,
);
});

it('returns true when calldata is within margin below declared (snap-down)', () => {
expect(matchesDeclaredAmountWithinMargin(995n, DECLARED, MARGIN)).toBe(
true,
);
});

it('returns false when outside margin', () => {
expect(matchesDeclaredAmountWithinMargin(1011n, DECLARED, MARGIN)).toBe(
false,
);
});

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

it('throws on non-integer declared string', () => {
expect(() =>
matchesDeclaredAmountWithinMargin(1000n, '1.5', MARGIN),
).toThrow();
});
});

describe('getErc4626RedeemMargin', () => {
it('returns 10 when useDecimalGapMargin is false', () => {
expect(
getErc4626RedeemMargin({
useDecimalGapMargin: false,
inputTokenDecimals: 6,
vaultTokenDecimals: 18,
}),
).toBe('10');
});

it('returns 10 when decimals are missing even with useDecimalGapMargin', () => {
expect(getErc4626RedeemMargin({ useDecimalGapMargin: true })).toBe('10');
});

it('returns 10^(abs(diff)+1) when gap margin + decimals (6 vs 18 → 10^13)', () => {
expect(
getErc4626RedeemMargin({
useDecimalGapMargin: true,
inputTokenDecimals: 6,
vaultTokenDecimals: 18,
}),
).toBe((10n ** 13n).toString());
});

it('returns 10 for equal decimals with gap margin (diff 0 → 10^1)', () => {
expect(
getErc4626RedeemMargin({
useDecimalGapMargin: true,
inputTokenDecimals: 18,
vaultTokenDecimals: 18,
}),
).toBe('10');
});
});
45 changes: 45 additions & 0 deletions src/utils/amount.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export const MAX_UINT256 = (1n << 256n) - 1n;

/* withdraw(assets) near-max snap band. */
export const ASSET_WITHDRAW_EXIT_MARGIN = '10';

// Opt-in: undefined declared amount → skip (returns true).
export function matchesDeclaredAmount(
calldataAmount: bigint,
Expand All @@ -8,3 +11,45 @@ export function matchesDeclaredAmount(
if (declared === undefined) return true;
return calldataAmount === BigInt(declared);
}

/**
* Opt-in bounded match for redeem clamp: pass if |calldata - declared| <= margin.
* Non-digit `declared` throws via BigInt (same as matchesDeclaredAmount); callers
* must reject non-digits before calling.
*/
export function matchesDeclaredAmountWithinMargin(
calldataAmount: bigint,
declared: string | undefined,
margin: string,
): boolean {
if (declared === undefined) return true;
const declaredAmount = BigInt(declared);
const delta =
calldataAmount >= declaredAmount
? calldataAmount - declaredAmount
: declaredAmount - calldataAmount;
return delta <= BigInt(margin);
}

/**
* Adjusted margin when useDecimalGapMargin is true
* (allocator / OAV target, excluding kiln-forced-fixed set). Otherwise "10".
* Missing decimals → "10".
*/
export function getErc4626RedeemMargin(input: {
useDecimalGapMargin: boolean;
inputTokenDecimals?: number;
vaultTokenDecimals?: number;
}): string {
if (
!input.useDecimalGapMargin ||
input.inputTokenDecimals === undefined ||
input.vaultTokenDecimals === undefined
) {
return '10';
}
const difference = Math.abs(
input.inputTokenDecimals - input.vaultTokenDecimals,
);
return (10n ** BigInt(difference + 1)).toString();
}
Loading
Loading