diff --git a/.changeset/cool-feet-fall.md b/.changeset/cool-feet-fall.md new file mode 100644 index 00000000..1cb73395 --- /dev/null +++ b/.changeset/cool-feet-fall.md @@ -0,0 +1,5 @@ +--- +'@aave-dao/aave-helpers-js': minor +--- + +Add Aave V4 snapshot diff support and CLI command diff --git a/.gitignore b/.gitignore index dfed2b22..68686938 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ yarn-error.log .vscode/ dist + +reports/v4* +diffs/v4* diff --git a/foundry.toml b/foundry.toml index 6d3c4ce4..1a2749fa 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,7 +5,10 @@ script = 'scripts' out = 'out' libs = ['lib'] remappings = [] -fs_permissions = [{ access = "read-write", path = "./reports" }] +fs_permissions = [ + { access = "read-write", path = "./reports" }, + { access = "read-write", path = "./diffs" }, +] ffi = true evm_version = 'cancun' decode_external_storage = true diff --git a/packages/aave-helpers-js/__tests__/protocol-diff-v4.spec.ts b/packages/aave-helpers-js/__tests__/protocol-diff-v4.spec.ts new file mode 100644 index 00000000..e3768c47 --- /dev/null +++ b/packages/aave-helpers-js/__tests__/protocol-diff-v4.spec.ts @@ -0,0 +1,491 @@ +import { describe, it, expect } from 'vitest'; +import { diffV4Snapshots } from '../protocol-diff-v4'; +import { aaveV4SnapshotSchema, type AaveV4Snapshot } from '../snapshot-types-v4'; +import { formatV4Value } from '../formatters-v4'; + +// --- Fixtures --- + +const SPOKE_ADDR = '0x1111111111111111111111111111111111111111'; +const HUB_ADDR = '0x2222222222222222222222222222222222222222'; +const ORACLE_ADDR = '0x3333333333333333333333333333333333333333'; +const PRICE_SRC = '0x4444444444444444444444444444444444444444'; +const UNDERLYING = '0x5555555555555555555555555555555555555555'; +const IR_STRATEGY = '0x6666666666666666666666666666666666666666'; +const FEE_RECV = '0x7777777777777777777777777777777777777777'; +const REINVEST = '0x8888888888888888888888888888888888888888'; + +function makeSnapshot(overrides?: Partial): AaveV4Snapshot { + return { + chainId: 1, + spokeReserves: { + [SPOKE_ADDR]: { + '0': { + symbol: 'WETH', + underlying: UNDERLYING, + hub: HUB_ADDR, + assetId: 0, + decimals: 18, + collateralRisk: 100, + paused: false, + frozen: false, + borrowable: true, + receiveSharesEnabled: true, + dynamicConfigKey: 0, + collateralFactor: 8000, + maxLiquidationBonus: 500, + liquidationFee: 100, + oracleAddress: ORACLE_ADDR, + priceSource: PRICE_SRC, + oraclePrice: '200000000000', + }, + }, + }, + spokeLiquidationConfigs: { + [SPOKE_ADDR]: { + targetHealthFactor: '1050000000000000000', + healthFactorForMaxBonus: '1000000000000000000', + liquidationBonusFactor: 500, + maxUserReservesLimit: 128, + }, + }, + hubAssets: { + [HUB_ADDR]: { + '0': { + symbol: 'WETH', + underlying: UNDERLYING, + decimals: 18, + liquidityFee: 1000, + irStrategy: IR_STRATEGY, + feeReceiver: FEE_RECV, + reinvestmentController: REINVEST, + optimalUsageRatio: 8000, + baseDrawnRate: 100, + rateGrowthBeforeOptimal: 400, + rateGrowthAfterOptimal: 6000, + maxDrawnRate: '10000', + deficitRay: '0', + swept: '0', + premiumShares: '0', + premiumOffsetRay: '0', + }, + }, + }, + spokeConfigs: { + [`${HUB_ADDR}_0_${SPOKE_ADDR}`]: { + assetSymbol: 'WETH', + addCap: 1000000, + drawCap: 500000, + riskPremiumThreshold: 100, + active: true, + halted: false, + }, + }, + ...overrides, + }; +} + +// --- Schema validation --- + +describe('V4 snapshot Zod schema', () => { + it('validates a well-formed snapshot', () => { + const result = aaveV4SnapshotSchema.safeParse(makeSnapshot()); + expect(result.success).toBe(true); + }); + + it('rejects a snapshot missing required fields', () => { + const result = aaveV4SnapshotSchema.safeParse({ chainId: 1 }); + expect(result.success).toBe(false); + }); + + it('accepts optional raw and logs', () => { + const snap = makeSnapshot({ raw: {}, logs: [] }); + const result = aaveV4SnapshotSchema.safeParse(snap); + expect(result.success).toBe(true); + }); +}); + +// --- Formatter --- + +describe('BPS formatting via formatV4Value', () => { + const ctx = { chainId: 1 }; + + it('formats 8000 as 80.00 %', () => { + expect(formatV4Value('spokeReserve', 'collateralFactor', 8000, ctx)).toBe('80.00 % [8000]'); + }); + + it('formats 100 as 1.00 %', () => { + expect(formatV4Value('spokeReserve', 'liquidationFee', 100, ctx)).toBe('1.00 % [100]'); + }); + + it('formats 50 as 0.50 %', () => { + expect(formatV4Value('spokeReserve', 'collateralFactor', 50, ctx)).toBe('0.50 % [50]'); + }); + + it('formats 0 as 0.00 %', () => { + expect(formatV4Value('spokeReserve', 'collateralFactor', 0, ctx)).toBe('0.00 % [0]'); + }); + + it('formats collateralRisk as BPS', () => { + expect(formatV4Value('spokeReserve', 'collateralRisk', 5000, ctx)).toBe('50.00 % [5000]'); + }); + + it('formats maxLiquidationBonus as BPS', () => { + expect(formatV4Value('spokeReserve', 'maxLiquidationBonus', 10100, ctx)).toBe( + '101.00 % [10100]' + ); + }); + + it('formats hub asset IR strategy fields as BPS', () => { + expect(formatV4Value('hubAsset', 'optimalUsageRatio', 9200, ctx)).toBe('92.00 % [9200]'); + expect(formatV4Value('hubAsset', 'baseDrawnRate', 25, ctx)).toBe('0.25 % [25]'); + expect(formatV4Value('hubAsset', 'rateGrowthBeforeOptimal', 450, ctx)).toBe('4.50 % [450]'); + expect(formatV4Value('hubAsset', 'rateGrowthAfterOptimal', 3000, ctx)).toBe('30.00 % [3000]'); + expect(formatV4Value('hubAsset', 'maxDrawnRate', '3450', ctx)).toBe('34.50 % [3450]'); + }); + + it('formats hub asset liquidityFee as BPS', () => { + expect(formatV4Value('hubAsset', 'liquidityFee', 1500, ctx)).toBe('15.00 % [1500]'); + }); + + it('formats WAD fields for spoke liquidation', () => { + expect(formatV4Value('spokeLiq', 'targetHealthFactor', '1050000000000000000', ctx)).toBe( + '1.05 [1050000000000000000]' + ); + expect(formatV4Value('spokeLiq', 'healthFactorForMaxBonus', '1000000000000000000', ctx)).toBe( + '1 [1000000000000000000]' + ); + }); + + it('formats spokeLiq liquidationBonusFactor as BPS', () => { + expect(formatV4Value('spokeLiq', 'liquidationBonusFactor', 500, ctx)).toBe('5.00 % [500]'); + }); + + it('formats RAY fields for hub asset state', () => { + expect(formatV4Value('hubAsset', 'deficitRay', '1000000000000000000000000000', ctx)).toBe( + '1 [1000000000000000000000000000]' + ); + expect(formatV4Value('hubAsset', 'premiumOffsetRay', '-500000000000000000000000000', ctx)).toBe( + '-0.5 [-500000000000000000000000000]' + ); + }); + + it('formats booleans as checkmarks', () => { + expect(formatV4Value('spokeReserve', 'paused', true, ctx)).toBe(':white_check_mark:'); + expect(formatV4Value('spokeReserve', 'frozen', false, ctx)).toBe(':x:'); + expect(formatV4Value('spokeConfig', 'active', true, ctx)).toBe(':white_check_mark:'); + expect(formatV4Value('spokeConfig', 'halted', false, ctx)).toBe(':x:'); + }); + + it('formats addresses as explorer links', () => { + const result = formatV4Value('spokeReserve', 'underlying', UNDERLYING, ctx); + expect(result).toContain(UNDERLYING); + expect(result).toContain(']('); // markdown link + }); + + it('falls back to raw string for unformatted fields', () => { + expect(formatV4Value('spokeLiq', 'maxUserReservesLimit', 128, ctx)).toBe('128'); + }); + + it('formats spoke cap uint40 fields with separators, asset symbol, and exponential', () => { + const capCtx = { + ...ctx, + spokeConfig: { + assetSymbol: 'USDT', + addCap: 0, + drawCap: 0, + riskPremiumThreshold: 0, + active: true, + halted: false, + }, + }; + expect(formatV4Value('spokeConfig', 'addCap', 1000000, capCtx)).toBe('1,000,000 (1e6) USDT'); + expect(formatV4Value('spokeConfig', 'drawCap', 1880000, capCtx)).toBe( + '1,880,000 (1.88e6) USDT' + ); + // Falls back gracefully when symbol unavailable + expect(formatV4Value('spokeConfig', 'addCap', 1000000, ctx)).toBe('1,000,000 (1e6)'); + // Small caps (< 1000) skip the exponential + expect(formatV4Value('spokeConfig', 'addCap', 500, capCtx)).toBe('500 USDT'); + }); + + it('formats numeric-string fields (oraclePrice, swept, premiumShares) with separators + exp', () => { + // Price feed: uint256 oracle price serialized as string — full precision in exp + expect(formatV4Value('spokeReserve', 'oraclePrice', '99999850', ctx)).toBe( + '99,999,850 (9.999985e7)' + ); + expect(formatV4Value('spokeReserve', 'oraclePrice', '150000000', ctx)).toBe( + '150,000,000 (1.5e8)' + ); + // Sub-1000 trailing digits stay (1234 -> 1.234e3, not 1.23e3) + expect(formatV4Value('spokeReserve', 'oraclePrice', '1234', ctx)).toBe('1,234 (1.234e3)'); + // uint120 hub-asset state fields, including values that exceed JS safe-int range. + // Comma-separated form preserves exact value; exponent's mantissa is rounded by Number. + expect(formatV4Value('hubAsset', 'swept', '12345678901234567890', ctx)).toBe( + '12,345,678,901,234,567,890 (1.2345678901234567e19)' + ); + expect(formatV4Value('hubAsset', 'premiumShares', '1000000', ctx)).toBe('1,000,000 (1e6)'); + // Small numbers (< 1000) — no separators, no exponential + expect(formatV4Value('spokeReserve', 'oraclePrice', '999', ctx)).toBe('999'); + // Zero + expect(formatV4Value('hubAsset', 'swept', '0', ctx)).toBe('0'); + }); +}); + +// --- Diff --- + +describe('diffV4Snapshots', () => { + it('returns no-change message for identical snapshots', async () => { + const snap = makeSnapshot(); + const result = await diffV4Snapshots(snap, snap); + expect(result).toBe('No configuration changes detected.\n'); + }); + + it('detects spoke reserve changes', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + after.spokeReserves[SPOKE_ADDR]['0'] = { + ...after.spokeReserves[SPOKE_ADDR]['0'], + collateralFactor: 7500, + frozen: true, + }; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Spoke Reserve Changes'); + expect(md).toContain('WETH'); + expect(md).toContain('collateralFactor'); + expect(md).toContain('80.00 % [8000]'); + expect(md).toContain('75.00 % [7500]'); + expect(md).toContain('frozen'); + }); + + it('detects new spoke reserve', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + after.spokeReserves[SPOKE_ADDR]['1'] = { + symbol: 'USDC', + underlying: '0x9999999999999999999999999999999999999999', + hub: HUB_ADDR, + assetId: 1, + decimals: 6, + collateralRisk: 50, + paused: false, + frozen: false, + borrowable: true, + receiveSharesEnabled: false, + dynamicConfigKey: 0, + collateralFactor: 8500, + maxLiquidationBonus: 400, + liquidationFee: 50, + oracleAddress: ORACLE_ADDR, + priceSource: PRICE_SRC, + oraclePrice: '100000000', + }; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('NEW RESERVE'); + expect(md).toContain('USDC'); + }); + + it('detects removed spoke reserve', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + delete after.spokeReserves[SPOKE_ADDR]['0']; + after.spokeReserves[SPOKE_ADDR] = {}; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('REMOVED'); + expect(md).toContain('WETH'); + }); + + it('detects hub asset changes', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + after.hubAssets[HUB_ADDR]['0'] = { + ...after.hubAssets[HUB_ADDR]['0'], + baseDrawnRate: 200, + optimalUsageRatio: 7500, + }; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Hub Asset Changes'); + expect(md).toContain('baseDrawnRate'); + expect(md).toContain('optimalUsageRatio'); + }); + + it('detects spoke cap changes', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + const capKey = `${HUB_ADDR}_0_${SPOKE_ADDR}`; + after.spokeConfigs[capKey] = { + ...after.spokeConfigs[capKey], + addCap: 2000000, + halted: true, + }; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Hub Spoke Config Changes'); + expect(md).toContain('addCap'); + expect(md).toContain('halted'); + }); + + it('detects spoke liquidation config changes', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + after.spokeLiquidationConfigs[SPOKE_ADDR] = { + ...after.spokeLiquidationConfigs[SPOKE_ADDR], + liquidationBonusFactor: 600, + }; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Spoke Liquidation Config Changes'); + expect(md).toContain('liquidationBonusFactor'); + }); + + it('includes raw JSON diff section', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + after.spokeReserves[SPOKE_ADDR]['0'] = { + ...after.spokeReserves[SPOKE_ADDR]['0'], + collateralFactor: 7500, + }; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Raw diff'); + expect(md).toContain('```json'); + }); + + // --- New/removed for all section types --- + + it('detects new hub asset', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + after.hubAssets[HUB_ADDR]['1'] = { + symbol: 'USDC', + underlying: '0x9999999999999999999999999999999999999999', + decimals: 6, + liquidityFee: 1500, + irStrategy: IR_STRATEGY, + feeReceiver: FEE_RECV, + reinvestmentController: REINVEST, + optimalUsageRatio: 9200, + baseDrawnRate: 0, + rateGrowthBeforeOptimal: 450, + rateGrowthAfterOptimal: 2000, + maxDrawnRate: '2450', + deficitRay: '0', + swept: '0', + premiumShares: '0', + premiumOffsetRay: '0', + }; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Hub Asset Changes'); + expect(md).toContain('NEW ASSET'); + expect(md).toContain('USDC'); + }); + + it('detects removed hub asset', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + after.hubAssets[HUB_ADDR] = {}; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Hub Asset Changes'); + expect(md).toContain('REMOVED'); + }); + + it('detects new spoke cap', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + const newCapKey = `${HUB_ADDR}_1_${SPOKE_ADDR}`; + after.spokeConfigs[newCapKey] = { + assetSymbol: 'USDC', + addCap: 500000, + drawCap: 250000, + riskPremiumThreshold: 50, + active: true, + halted: false, + }; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Hub Spoke Config Changes'); + expect(md).toContain('NEW SPOKE'); + expect(md).toContain('USDC'); + }); + + it('detects removed spoke cap', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + const capKey = `${HUB_ADDR}_0_${SPOKE_ADDR}`; + delete after.spokeConfigs[capKey]; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Hub Spoke Config Changes'); + expect(md).toContain('REMOVED'); + }); + + it('detects new spoke liquidation config', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + const newSpoke = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + after.spokeLiquidationConfigs[newSpoke] = { + targetHealthFactor: '1100000000000000000', + healthFactorForMaxBonus: '1050000000000000000', + liquidationBonusFactor: 400, + maxUserReservesLimit: 64, + }; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Spoke Liquidation Config Changes'); + expect(md).toContain('NEW'); + }); + + it('detects removed spoke liquidation config', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + delete after.spokeLiquidationConfigs[SPOKE_ADDR]; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Spoke Liquidation Config Changes'); + expect(md).toContain('REMOVED'); + }); + + // --- Hub asset state fields --- + + it('detects hub asset state changes (deficit, swept, premiumShares)', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + after.hubAssets[HUB_ADDR]['0'] = { + ...after.hubAssets[HUB_ADDR]['0'], + deficitRay: '1000000000000000000000000000', + swept: '5000000', + premiumShares: '100000', + premiumOffsetRay: '-500000000000000000000000000', + }; + + const md = await diffV4Snapshots(before, after); + expect(md).toContain('## Hub Asset Changes'); + expect(md).toContain('deficitRay'); + expect(md).toContain('swept'); + expect(md).toContain('premiumShares'); + expect(md).toContain('premiumOffsetRay'); + }); + + // --- Spoke cap composite key parsing --- + + it('parses spoke cap composite key correctly in headers', async () => { + const before = makeSnapshot(); + const after = makeSnapshot(); + const capKey = `${HUB_ADDR}_0_${SPOKE_ADDR}`; + after.spokeConfigs[capKey] = { + ...after.spokeConfigs[capKey], + addCap: 9999999, + }; + + const md = await diffV4Snapshots(before, after); + // Header should contain the hub and spoke addresses from the parsed key + expect(md).toContain(HUB_ADDR); + expect(md).toContain(SPOKE_ADDR); + expect(md).toContain('assetId: 0'); + }); +}); diff --git a/packages/aave-helpers-js/cli.ts b/packages/aave-helpers-js/cli.ts index b7e5d7dc..c5ab92b3 100644 --- a/packages/aave-helpers-js/cli.ts +++ b/packages/aave-helpers-js/cli.ts @@ -16,6 +16,7 @@ import { } from '@bgd-labs/toolbox'; import { getAddressBookReferences } from '@aave-dao/aave-address-book/utils'; import { diffSnapshots } from './protocol-diff'; +import { diffV4Snapshots } from './protocol-diff-v4'; import { Address, encodeFunctionData, Hex, parseAbi, zeroAddress } from 'viem'; import { readContract } from 'viem/actions'; import { toAccount } from 'viem/accounts'; @@ -40,6 +41,22 @@ program writeFileSync(opts.out, md, 'utf-8'); }); +program + .command('diff-v4-snapshots') + .description('Diff two Aave V4 protocol snapshot JSON files and produce a markdown report') + .argument('', 'path to the before snapshot JSON') + .argument('', 'path to the after snapshot JSON') + .requiredOption('-o, --out ', 'output path for the markdown report') + .action(async (beforePath: string, afterPath: string, opts: { out: string }) => { + const before = JSON.parse(readFileSync(beforePath, 'utf-8')); + const after = JSON.parse(readFileSync(afterPath, 'utf-8')); + + const md = await diffV4Snapshots(before, after); + + mkdirSync(dirname(opts.out), { recursive: true }); + writeFileSync(opts.out, md, 'utf-8'); + }); + async function uploadToPinata(source: string) { const PINATA_KEY = process.env.PINATA_KEY; if (!PINATA_KEY) throw new Error('PINATA_KEY env must be set'); diff --git a/packages/aave-helpers-js/formatters-v4.ts b/packages/aave-helpers-js/formatters-v4.ts new file mode 100644 index 00000000..46a704f9 --- /dev/null +++ b/packages/aave-helpers-js/formatters-v4.ts @@ -0,0 +1,217 @@ +import { type Hex, formatUnits } from 'viem'; +import { getClient } from '@bgd-labs/toolbox'; +import { toAddressLink, boolToMarkdown } from './utils/markdown'; +import type { + V4SpokeReserve, + V4HubAsset, + V4SpokeConfig, + V4SpokeLiquidationConfig, +} from './snapshot-types-v4'; + +// --- Formatter context --- + +export interface V4FormatterContext { + chainId: number; + spokeConfig?: V4SpokeConfig; +} + +export type FieldFormatter = (value: T, ctx: V4FormatterContext) => string; + +// --- Helpers --- + +function getExplorerClient(chainId: number) { + return getClient(chainId, {}); +} + +function addressLink(value: string, chainId: number): string { + return toAddressLink(value as Hex, true, getExplorerClient(chainId)); +} + +function isAddress(value: unknown): boolean { + return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value); +} + +/** Format BPS value as percentage, matching Solidity _ps(): "W.FF % [bps]" */ +function formatBps(bps: number): string { + const w = Math.floor(bps / 100); + const f = bps % 100; + const fs = f < 10 ? `0${f}` : `${f}`; + return `${w}.${fs} % [${bps}]`; +} + +/** Render a uint as "1,500,000 (1.5e6) USDT"; suffix (e.g. asset symbol) goes + * at the end after the exponential. Values < 1000 skip the exponential. + * Exponential uses `Number.toExponential()`, so bigints exceeding + * Number.MAX_SAFE_INTEGER (~9e15) the exponent is rounded */ +export function formatBigIntWithExp(value: bigint, suffix?: string): string { + const commas = value.toLocaleString('en-US'); + const useExp = value >= 1000n || value <= -1000n; + const expPart = useExp ? ` (${Number(value).toExponential().replace('e+', 'e')})` : ''; + const suf = suffix ? ` ${suffix}` : ''; + return `${commas}${expPart}${suf}`; +} + +// --- Spoke Reserve formatters --- + +type SpokeReserveKey = keyof V4SpokeReserve; + +const SPOKE_RESERVE_BPS_FIELDS: readonly SpokeReserveKey[] = [ + 'collateralRisk', + 'collateralFactor', + 'maxLiquidationBonus', + 'liquidationFee', +] as const; + +const SPOKE_RESERVE_BOOL_FIELDS: readonly SpokeReserveKey[] = [ + 'paused', + 'frozen', + 'borrowable', + 'receiveSharesEnabled', +] as const; + +const SPOKE_RESERVE_ADDRESS_FIELDS: readonly SpokeReserveKey[] = [ + 'underlying', + 'hub', + 'oracleAddress', + 'priceSource', +] as const; + +export const spokeReserveFormatters: Partial<{ + [K in SpokeReserveKey]: FieldFormatter; +}> = {}; + +for (const field of SPOKE_RESERVE_BPS_FIELDS) { + (spokeReserveFormatters[field] as FieldFormatter) = (value) => formatBps(value); +} + +for (const field of SPOKE_RESERVE_BOOL_FIELDS) { + (spokeReserveFormatters[field] as FieldFormatter) = (value) => boolToMarkdown(value); +} + +for (const field of SPOKE_RESERVE_ADDRESS_FIELDS) { + (spokeReserveFormatters[field] as FieldFormatter) = (value, ctx) => + addressLink(value, ctx.chainId); +} + +// --- Hub Asset formatters --- + +type HubAssetKey = keyof V4HubAsset; + +const HUB_ASSET_BPS_FIELDS: readonly HubAssetKey[] = ['liquidityFee'] as const; + +/** IR strategy fields — per-asset on V4, BPS scale (unlike V3 RAY). */ +const HUB_ASSET_IR_STRATEGY_BPS_FIELDS: readonly HubAssetKey[] = [ + 'optimalUsageRatio', + 'baseDrawnRate', + 'rateGrowthBeforeOptimal', + 'rateGrowthAfterOptimal', +] as const; + +const HUB_ASSET_ADDRESS_FIELDS: readonly HubAssetKey[] = [ + 'underlying', + 'irStrategy', + 'feeReceiver', + 'reinvestmentController', +] as const; + +export const hubAssetFormatters: Partial<{ + [K in HubAssetKey]: FieldFormatter; +}> = {}; + +for (const field of HUB_ASSET_BPS_FIELDS) { + (hubAssetFormatters[field] as FieldFormatter) = (value) => formatBps(value); +} + +for (const field of HUB_ASSET_IR_STRATEGY_BPS_FIELDS) { + (hubAssetFormatters[field] as FieldFormatter) = (value) => formatBps(value); +} + +hubAssetFormatters['maxDrawnRate'] = (value) => formatBps(Number(value)); + +for (const field of HUB_ASSET_ADDRESS_FIELDS) { + (hubAssetFormatters[field] as FieldFormatter) = (value, ctx) => + addressLink(value, ctx.chainId); +} + +// Asset state — RAY fields (1e27) +hubAssetFormatters['deficitRay'] = (value) => `${formatUnits(BigInt(value), 27)} [${value}]`; +hubAssetFormatters['premiumOffsetRay'] = (value) => `${formatUnits(BigInt(value), 27)} [${value}]`; + +// --- Spoke Cap formatters --- + +type SpokeConfigKey = keyof V4SpokeConfig; + +const SPOKE_CONFIG_FLAGS: readonly SpokeConfigKey[] = ['active', 'halted'] as const; + +/** uint40 token-unit cap fields — formatted with thousands separators + asset symbol. */ +const SPOKE_CONFIG_TOKEN_AMOUNT_FIELDS: readonly SpokeConfigKey[] = ['addCap', 'drawCap'] as const; + +export const spokeConfigFormatters: Partial<{ + [K in SpokeConfigKey]: FieldFormatter; +}> = {}; + +for (const field of SPOKE_CONFIG_FLAGS) { + (spokeConfigFormatters[field] as FieldFormatter) = (value) => boolToMarkdown(value); +} + +for (const field of SPOKE_CONFIG_TOKEN_AMOUNT_FIELDS) { + (spokeConfigFormatters[field] as FieldFormatter) = (value, ctx) => + formatBigIntWithExp(BigInt(value), ctx.spokeConfig?.assetSymbol); +} + +spokeConfigFormatters['riskPremiumThreshold'] = (value) => formatBps(value); + +// --- Spoke Liquidation Config formatters --- + +type SpokeLiqKey = keyof V4SpokeLiquidationConfig; + +export const spokeLiqFormatters: Partial<{ + [K in SpokeLiqKey]: FieldFormatter; +}> = {}; + +// WAD fields (1e18) — serialized as strings +spokeLiqFormatters['targetHealthFactor'] = (value) => + `${formatUnits(BigInt(value), 18)} [${value}]`; +spokeLiqFormatters['healthFactorForMaxBonus'] = (value) => + `${formatUnits(BigInt(value), 18)} [${value}]`; + +// BPS field +spokeLiqFormatters['liquidationBonusFactor'] = (value) => formatBps(value); + +// --- Generic format function --- + +type V4SectionFormatters = { + spokeReserve: typeof spokeReserveFormatters; + hubAsset: typeof hubAssetFormatters; + spokeConfig: typeof spokeConfigFormatters; + spokeLiq: typeof spokeLiqFormatters; +}; + +const formattersMap: V4SectionFormatters = { + spokeReserve: spokeReserveFormatters, + hubAsset: hubAssetFormatters, + spokeConfig: spokeConfigFormatters, + spokeLiq: spokeLiqFormatters, +} as const; + +export function formatV4Value( + section: keyof V4SectionFormatters, + key: string, + value: unknown, + ctx: V4FormatterContext +): string { + const formatter = (formattersMap[section] as Record)[key]; + if (formatter) return formatter(value, ctx); + + // Default formatting + if (typeof value === 'boolean') return boolToMarkdown(value); + if (typeof value === 'number') return value.toLocaleString('en-US'); + if (isAddress(value)) return addressLink(value as string, ctx.chainId); + // Pure numeric strings (uint serialized as string) — render with thousand separators + // and exponent in parens ("1,500,000 (1.5e6)") so price feeds and large uints + // are easy to scan at both scales. + if (typeof value === 'string' && /^-?\d+$/.test(value)) { + return formatBigIntWithExp(BigInt(value)); + } + return String(value); +} diff --git a/packages/aave-helpers-js/index.ts b/packages/aave-helpers-js/index.ts index 784d68e0..9fcf47af 100644 --- a/packages/aave-helpers-js/index.ts +++ b/packages/aave-helpers-js/index.ts @@ -1,4 +1,5 @@ export { diffSnapshots } from './protocol-diff'; +export { diffV4Snapshots } from './protocol-diff-v4'; export { eventDb } from './utils/eventDb'; export { diff, isChange, hasChanges } from './diff'; export type { Change, DiffResult } from './diff'; @@ -14,3 +15,10 @@ export type { Log, CHAIN_ID, } from './snapshot-types'; +export type { + AaveV4Snapshot, + V4SpokeReserve, + V4HubAsset, + V4SpokeConfig, + V4SpokeLiquidationConfig, +} from './snapshot-types-v4'; diff --git a/packages/aave-helpers-js/protocol-diff-v4.ts b/packages/aave-helpers-js/protocol-diff-v4.ts new file mode 100644 index 00000000..5c366ffe --- /dev/null +++ b/packages/aave-helpers-js/protocol-diff-v4.ts @@ -0,0 +1,57 @@ +import { diff } from './diff'; +import type { AaveV4Snapshot } from './snapshot-types-v4'; +import type { RawStorage, Log } from './snapshot-types'; +import { renderSpokeReservesSection } from './sections/spoke-reserves'; +import { renderHubAssetsSection } from './sections/hub-assets'; +import { renderSpokeConfigsSection } from './sections/spoke-configs'; +import { renderSpokeLiquidationSection } from './sections/spoke-liquidation'; +import { renderRawSection } from './sections/raw'; +import { renderLogsSection } from './sections/logs'; + +/** + * Diff two Aave V4 protocol snapshots and produce a formatted markdown report. + * + * The `raw` and `logs` sections only exist in the "after" snapshot and are + * rendered as-is (they already represent the diff / changes). + */ +export async function diffV4Snapshots( + before: AaveV4Snapshot, + after: AaveV4Snapshot +): Promise { + // Extract raw & logs from "after" — they don't participate in the structural diff + let raw: RawStorage | undefined; + let logs: Log[] | undefined; + + const postCopy: AaveV4Snapshot = { ...after }; + if (postCopy.raw) { + raw = postCopy.raw; + delete postCopy.raw; + } + if (postCopy.logs) { + logs = [...postCopy.logs]; + delete postCopy.logs; + } + + // Build the markdown report from each section + let md = ''; + + md += renderSpokeReservesSection(before, postCopy); + md += renderHubAssetsSection(before, postCopy); + md += renderSpokeConfigsSection(before, postCopy); + md += renderSpokeLiquidationSection(before, postCopy); + md += await renderLogsSection(logs, after.chainId); + md += renderRawSection(raw, after.chainId); + + // Append raw JSON diff as fallback + const preCopy: Record = { ...before }; + delete preCopy.raw; + delete preCopy.logs; + const diffWithoutUnchanged = diff(preCopy as any, postCopy as any, true); + md += `## Raw diff\n\n\`\`\`json\n${JSON.stringify(diffWithoutUnchanged, null, 2)}\n\`\`\`\n`; + + if (!md.trim() || md.trim() === '## Raw diff\n\n```json\n{}\n```') { + return 'No configuration changes detected.\n'; + } + + return md; +} diff --git a/packages/aave-helpers-js/sections/hub-assets.ts b/packages/aave-helpers-js/sections/hub-assets.ts new file mode 100644 index 00000000..580a7f76 --- /dev/null +++ b/packages/aave-helpers-js/sections/hub-assets.ts @@ -0,0 +1,108 @@ +import type { Hex } from 'viem'; +import { getClient } from '@bgd-labs/toolbox'; +import type { AaveV4Snapshot, V4HubAsset } from '../snapshot-types-v4'; +import { formatV4Value, type V4FormatterContext } from '../formatters-v4'; +import { toAddressLink } from '../utils/markdown'; + +/** All fields in display order. Every field is compared — even identity fields + * that "shouldn't" change — so unexpected mutations are never silently missed. */ +const FIELD_ORDER: (keyof V4HubAsset)[] = [ + 'symbol', + 'underlying', + 'decimals', + 'liquidityFee', + 'irStrategy', + 'feeReceiver', + 'reinvestmentController', + 'optimalUsageRatio', + 'baseDrawnRate', + 'rateGrowthBeforeOptimal', + 'rateGrowthAfterOptimal', + 'maxDrawnRate', + // Asset state + 'deficitRay', + 'swept', + 'premiumShares', + 'premiumOffsetRay', +]; + +function hubAssetHeader( + asset: V4HubAsset, + hubAddr: string, + assetId: string, + chainId: number +): string { + const client = getClient(chainId, {}); + const hubLink = toAddressLink(hubAddr as Hex, true, client); + return `### ${asset.symbol} (assetId: ${assetId}) on Hub ${hubLink}\n\n`; +} + +function renderNewHubAsset( + asset: V4HubAsset, + hubAddr: string, + assetId: string, + ctx: V4FormatterContext +): string { + let md = hubAssetHeader(asset, hubAddr, assetId, ctx.chainId); + md += '**NEW ASSET**\n\n'; + md += '| description | value |\n| --- | --- |\n'; + for (const key of FIELD_ORDER) { + md += `| ${key} | ${formatV4Value('hubAsset', key, asset[key], ctx)} |\n`; + } + return md + '\n'; +} + +function renderHubAssetDiff( + before: V4HubAsset, + after: V4HubAsset, + hubAddr: string, + assetId: string, + ctx: V4FormatterContext +): string { + const rows: string[] = []; + for (const key of FIELD_ORDER) { + const bVal = before[key]; + const aVal = after[key]; + if (String(bVal) === String(aVal)) continue; + const fromFmt = formatV4Value('hubAsset', key, bVal, ctx); + const toFmt = formatV4Value('hubAsset', key, aVal, ctx); + rows.push(`| ${key} | ${fromFmt} | ${toFmt} |`); + } + if (rows.length === 0) return ''; + + let md = hubAssetHeader(after, hubAddr, assetId, ctx.chainId); + md += '| description | value before | value after |\n| --- | --- | --- |\n'; + md += rows.join('\n') + '\n'; + return md + '\n'; +} + +export function renderHubAssetsSection(before: AaveV4Snapshot, after: AaveV4Snapshot): string { + const ctx: V4FormatterContext = { chainId: after.chainId }; + + const allHubAddrs = new Set([...Object.keys(before.hubAssets), ...Object.keys(after.hubAssets)]); + + let body = ''; + + for (const hubAddr of allHubAddrs) { + const beforeHub = before.hubAssets[hubAddr] ?? {}; + const afterHub = after.hubAssets[hubAddr] ?? {}; + + const allAssetIds = new Set([...Object.keys(beforeHub), ...Object.keys(afterHub)]); + + for (const assetId of allAssetIds) { + const bAsset = beforeHub[assetId]; + const aAsset = afterHub[assetId]; + + if (bAsset && aAsset) { + body += renderHubAssetDiff(bAsset, aAsset, hubAddr, assetId, ctx); + } else if (aAsset) { + body += renderNewHubAsset(aAsset, hubAddr, assetId, ctx); + } else if (bAsset) { + body += hubAssetHeader(bAsset, hubAddr, assetId, ctx.chainId) + '**REMOVED**\n\n'; + } + } + } + + if (!body) return ''; + return `## Hub Asset Changes\n\n${body}`; +} diff --git a/packages/aave-helpers-js/sections/spoke-configs.ts b/packages/aave-helpers-js/sections/spoke-configs.ts new file mode 100644 index 00000000..b43b5a97 --- /dev/null +++ b/packages/aave-helpers-js/sections/spoke-configs.ts @@ -0,0 +1,112 @@ +import type { Hex } from 'viem'; +import { getClient } from '@bgd-labs/toolbox'; +import type { AaveV4Snapshot, V4SpokeConfig } from '../snapshot-types-v4'; +import { formatV4Value, type V4FormatterContext } from '../formatters-v4'; +import { toAddressLink } from '../utils/markdown'; + +/** All fields in display order. Every field is compared */ +const FIELD_ORDER: (keyof V4SpokeConfig)[] = [ + 'assetSymbol', + 'addCap', + 'drawCap', + 'riskPremiumThreshold', + 'active', + 'halted', +]; + +/** + * Parse the composite key "hubAddr_assetId_spokeAddr". + */ +function parseConfigKey(key: string): { hubAddr: string; assetId: string; spokeAddr: string } { + const hubAddr = key.slice(0, 42); + // skip the underscore after hub address + const rest = key.slice(43); + const underscoreIdx = rest.indexOf('_'); + const assetId = rest.slice(0, underscoreIdx); + const spokeAddr = rest.slice(underscoreIdx + 1); + return { hubAddr, assetId, spokeAddr }; +} + +function configHeader( + cfg: V4SpokeConfig, + hubAddr: string, + assetId: string, + spokeAddr: string, + chainId: number +): string { + const client = getClient(chainId, {}); + const hubLink = toAddressLink(hubAddr as Hex, true, client); + const spokeLink = toAddressLink(spokeAddr as Hex, true, client); + return `### ${cfg.assetSymbol} (assetId: ${assetId}) on Hub ${hubLink} / Spoke ${spokeLink}\n\n`; +} + +function renderNewConfig( + cfg: V4SpokeConfig, + hubAddr: string, + assetId: string, + spokeAddr: string, + ctx: V4FormatterContext +): string { + const cfgCtx: V4FormatterContext = { ...ctx, spokeConfig: cfg }; + let md = configHeader(cfg, hubAddr, assetId, spokeAddr, ctx.chainId); + md += '**NEW SPOKE**\n\n'; + md += '| description | value |\n| --- | --- |\n'; + for (const key of FIELD_ORDER) { + md += `| ${key} | ${formatV4Value('spokeConfig', key, cfg[key], cfgCtx)} |\n`; + } + return md + '\n'; +} + +function renderConfigDiff( + before: V4SpokeConfig, + after: V4SpokeConfig, + hubAddr: string, + assetId: string, + spokeAddr: string, + ctx: V4FormatterContext +): string { + const cfgCtx: V4FormatterContext = { ...ctx, spokeConfig: after }; + const rows: string[] = []; + for (const key of FIELD_ORDER) { + const bVal = before[key]; + const aVal = after[key]; + if (String(bVal) === String(aVal)) continue; + const fromFmt = formatV4Value('spokeConfig', key, bVal, cfgCtx); + const toFmt = formatV4Value('spokeConfig', key, aVal, cfgCtx); + rows.push(`| ${key} | ${fromFmt} | ${toFmt} |`); + } + if (rows.length === 0) return ''; + + let md = configHeader(after, hubAddr, assetId, spokeAddr, ctx.chainId); + md += '| description | value before | value after |\n| --- | --- | --- |\n'; + md += rows.join('\n') + '\n'; + return md + '\n'; +} + +export function renderSpokeConfigsSection(before: AaveV4Snapshot, after: AaveV4Snapshot): string { + const ctx: V4FormatterContext = { chainId: after.chainId }; + + const allKeys = new Set([ + ...Object.keys(before.spokeConfigs), + ...Object.keys(after.spokeConfigs), + ]); + + let body = ''; + + for (const key of allKeys) { + const { hubAddr, assetId, spokeAddr } = parseConfigKey(key); + const bCfg = before.spokeConfigs[key]; + const aCfg = after.spokeConfigs[key]; + + if (bCfg && aCfg) { + body += renderConfigDiff(bCfg, aCfg, hubAddr, assetId, spokeAddr, ctx); + } else if (aCfg) { + body += renderNewConfig(aCfg, hubAddr, assetId, spokeAddr, ctx); + } else if (bCfg) { + body += configHeader(bCfg, hubAddr, assetId, spokeAddr, ctx.chainId) + '**REMOVED**\n\n'; + } + } + + if (!body) return ''; + return `## Hub Spoke Config Changes\n\n${body}`; +} diff --git a/packages/aave-helpers-js/sections/spoke-liquidation.ts b/packages/aave-helpers-js/sections/spoke-liquidation.ts new file mode 100644 index 00000000..804470e9 --- /dev/null +++ b/packages/aave-helpers-js/sections/spoke-liquidation.ts @@ -0,0 +1,86 @@ +import type { Hex } from 'viem'; +import { getClient } from '@bgd-labs/toolbox'; +import type { AaveV4Snapshot, V4SpokeLiquidationConfig } from '../snapshot-types-v4'; +import { formatV4Value, type V4FormatterContext } from '../formatters-v4'; +import { toAddressLink } from '../utils/markdown'; + +/** All fields in display order (this section already covers every field). */ +const FIELD_ORDER: (keyof V4SpokeLiquidationConfig)[] = [ + 'targetHealthFactor', + 'healthFactorForMaxBonus', + 'liquidationBonusFactor', + 'maxUserReservesLimit', +]; + +function spokeHeader(spokeAddr: string, chainId: number): string { + const client = getClient(chainId, {}); + const spokeLink = toAddressLink(spokeAddr as Hex, true, client); + return `### Spoke ${spokeLink}\n\n`; +} + +function renderNewSpokeLiq( + config: V4SpokeLiquidationConfig, + spokeAddr: string, + ctx: V4FormatterContext +): string { + let md = spokeHeader(spokeAddr, ctx.chainId); + md += '**NEW**\n\n'; + md += '| description | value |\n| --- | --- |\n'; + for (const key of FIELD_ORDER) { + md += `| ${key} | ${formatV4Value('spokeLiq', key, config[key], ctx)} |\n`; + } + return md + '\n'; +} + +function renderSpokeLiqDiff( + before: V4SpokeLiquidationConfig, + after: V4SpokeLiquidationConfig, + spokeAddr: string, + ctx: V4FormatterContext +): string { + const rows: string[] = []; + for (const key of FIELD_ORDER) { + const bVal = before[key]; + const aVal = after[key]; + if (String(bVal) === String(aVal)) continue; + const fromFmt = formatV4Value('spokeLiq', key, bVal, ctx); + const toFmt = formatV4Value('spokeLiq', key, aVal, ctx); + rows.push(`| ${key} | ${fromFmt} | ${toFmt} |`); + } + if (rows.length === 0) return ''; + + let md = spokeHeader(spokeAddr, ctx.chainId); + md += '| description | value before | value after |\n| --- | --- | --- |\n'; + md += rows.join('\n') + '\n'; + return md + '\n'; +} + +export function renderSpokeLiquidationSection( + before: AaveV4Snapshot, + after: AaveV4Snapshot +): string { + const ctx: V4FormatterContext = { chainId: after.chainId }; + + const allSpokeAddrs = new Set([ + ...Object.keys(before.spokeLiquidationConfigs), + ...Object.keys(after.spokeLiquidationConfigs), + ]); + + let body = ''; + + for (const spokeAddr of allSpokeAddrs) { + const bConfig = before.spokeLiquidationConfigs[spokeAddr]; + const aConfig = after.spokeLiquidationConfigs[spokeAddr]; + + if (bConfig && aConfig) { + body += renderSpokeLiqDiff(bConfig, aConfig, spokeAddr, ctx); + } else if (aConfig) { + body += renderNewSpokeLiq(aConfig, spokeAddr, ctx); + } else if (bConfig) { + body += spokeHeader(spokeAddr, ctx.chainId) + '**REMOVED**\n\n'; + } + } + + if (!body) return ''; + return `## Spoke Liquidation Config Changes\n\n${body}`; +} diff --git a/packages/aave-helpers-js/sections/spoke-reserves.ts b/packages/aave-helpers-js/sections/spoke-reserves.ts new file mode 100644 index 00000000..b7a0e748 --- /dev/null +++ b/packages/aave-helpers-js/sections/spoke-reserves.ts @@ -0,0 +1,112 @@ +import type { Hex } from 'viem'; +import { getClient } from '@bgd-labs/toolbox'; +import type { AaveV4Snapshot, V4SpokeReserve } from '../snapshot-types-v4'; +import { formatV4Value, type V4FormatterContext } from '../formatters-v4'; +import { toAddressLink } from '../utils/markdown'; + +/** All fields in display order. Every field is compared — even identity fields + * that "shouldn't" change — so unexpected mutations are never silently missed. */ +const FIELD_ORDER: (keyof V4SpokeReserve)[] = [ + 'symbol', + 'underlying', + 'hub', + 'assetId', + 'decimals', + 'collateralRisk', + 'paused', + 'frozen', + 'borrowable', + 'receiveSharesEnabled', + 'dynamicConfigKey', + 'collateralFactor', + 'maxLiquidationBonus', + 'liquidationFee', + 'oracleAddress', + 'priceSource', + 'oraclePrice', +]; + +function reserveHeader( + reserve: V4SpokeReserve, + spokeAddr: string, + reserveId: string, + chainId: number +): string { + const client = getClient(chainId, {}); + const underlyingLink = toAddressLink(reserve.underlying as Hex, true, client); + const spokeLink = toAddressLink(spokeAddr as Hex, true, client); + return `### ${reserve.symbol} (${underlyingLink}) on Spoke ${spokeLink} [reserveId: ${reserveId}]\n\n`; +} + +function renderNewReserve( + reserve: V4SpokeReserve, + spokeAddr: string, + reserveId: string, + ctx: V4FormatterContext +): string { + let md = reserveHeader(reserve, spokeAddr, reserveId, ctx.chainId); + md += '**NEW RESERVE**\n\n'; + md += '| description | value |\n| --- | --- |\n'; + for (const key of FIELD_ORDER) { + md += `| ${key} | ${formatV4Value('spokeReserve', key, reserve[key], ctx)} |\n`; + } + return md + '\n'; +} + +function renderReserveDiff( + before: V4SpokeReserve, + after: V4SpokeReserve, + spokeAddr: string, + reserveId: string, + ctx: V4FormatterContext +): string { + const rows: string[] = []; + for (const key of FIELD_ORDER) { + const bVal = before[key]; + const aVal = after[key]; + if (String(bVal) === String(aVal)) continue; + const fromFmt = formatV4Value('spokeReserve', key, bVal, ctx); + const toFmt = formatV4Value('spokeReserve', key, aVal, ctx); + rows.push(`| ${key} | ${fromFmt} | ${toFmt} |`); + } + if (rows.length === 0) return ''; + + let md = reserveHeader(after, spokeAddr, reserveId, ctx.chainId); + md += '| description | value before | value after |\n| --- | --- | --- |\n'; + md += rows.join('\n') + '\n'; + return md + '\n'; +} + +export function renderSpokeReservesSection(before: AaveV4Snapshot, after: AaveV4Snapshot): string { + const ctx: V4FormatterContext = { chainId: after.chainId }; + + const allSpokeAddrs = new Set([ + ...Object.keys(before.spokeReserves), + ...Object.keys(after.spokeReserves), + ]); + + let body = ''; + + for (const spokeAddr of allSpokeAddrs) { + const beforeSpoke = before.spokeReserves[spokeAddr] ?? {}; + const afterSpoke = after.spokeReserves[spokeAddr] ?? {}; + + const allReserveIds = new Set([...Object.keys(beforeSpoke), ...Object.keys(afterSpoke)]); + + for (const reserveId of allReserveIds) { + const bRes = beforeSpoke[reserveId]; + const aRes = afterSpoke[reserveId]; + + if (bRes && aRes) { + body += renderReserveDiff(bRes, aRes, spokeAddr, reserveId, ctx); + } else if (aRes) { + body += renderNewReserve(aRes, spokeAddr, reserveId, ctx); + } else if (bRes) { + body += reserveHeader(bRes, spokeAddr, reserveId, ctx.chainId) + '**REMOVED**\n\n'; + } + } + } + + if (!body) return ''; + return `## Spoke Reserve Changes\n\n${body}`; +} diff --git a/packages/aave-helpers-js/snapshot-types-v4.ts b/packages/aave-helpers-js/snapshot-types-v4.ts new file mode 100644 index 00000000..0f2eeccf --- /dev/null +++ b/packages/aave-helpers-js/snapshot-types-v4.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; +import { rawStorageSchema, logSchema } from './snapshot-types'; + +// --- Spoke Reserve --- + +export const v4SpokeReserveSchema = z.object({ + symbol: z.string(), + underlying: z.string(), + hub: z.string(), + assetId: z.number(), + decimals: z.number(), + collateralRisk: z.number(), + paused: z.boolean(), + frozen: z.boolean(), + borrowable: z.boolean(), + receiveSharesEnabled: z.boolean(), + dynamicConfigKey: z.number(), + collateralFactor: z.number(), + maxLiquidationBonus: z.number(), + liquidationFee: z.number(), + oracleAddress: z.string(), + priceSource: z.string(), + oraclePrice: z.string(), // uint256 serialized as string +}); + +export type V4SpokeReserve = z.infer; + +// --- Spoke Liquidation Config --- + +export const v4SpokeLiquidationConfigSchema = z.object({ + targetHealthFactor: z.string(), // uint128 serialized as string + healthFactorForMaxBonus: z.string(), // uint64 serialized as string + liquidationBonusFactor: z.number(), + maxUserReservesLimit: z.number(), +}); + +export type V4SpokeLiquidationConfig = z.infer; + +// --- Hub Asset --- + +export const v4HubAssetSchema = z.object({ + symbol: z.string(), + underlying: z.string(), + decimals: z.number(), + liquidityFee: z.number(), + irStrategy: z.string(), + feeReceiver: z.string(), + reinvestmentController: z.string(), + optimalUsageRatio: z.number(), + baseDrawnRate: z.number(), + rateGrowthBeforeOptimal: z.number(), + rateGrowthAfterOptimal: z.number(), + maxDrawnRate: z.string(), // uint256 serialized as string + // Asset state + deficitRay: z.string(), // uint200 serialized as string (RAY) + swept: z.string(), // uint120 serialized as string + premiumShares: z.string(), // uint120 serialized as string + premiumOffsetRay: z.string(), // int200 serialized as string (RAY, signed) +}); + +export type V4HubAsset = z.infer; + +// --- Spoke Config --- + +export const v4SpokeConfigSchema = z.object({ + assetSymbol: z.string(), + addCap: z.number(), // uint40 — fits in JS safe int + drawCap: z.number(), // uint40 — fits in JS safe int + riskPremiumThreshold: z.number(), + active: z.boolean(), + halted: z.boolean(), +}); + +export type V4SpokeConfig = z.infer; + +// --- Full V4 Snapshot --- + +export const aaveV4SnapshotSchema = z.object({ + chainId: z.number(), + spokeReserves: z.record(z.string(), z.record(z.string(), v4SpokeReserveSchema)), + spokeLiquidationConfigs: z.record(z.string(), v4SpokeLiquidationConfigSchema), + hubAssets: z.record(z.string(), z.record(z.string(), v4HubAssetSchema)), + spokeConfigs: z.record(z.string(), v4SpokeConfigSchema), + raw: rawStorageSchema.optional(), + logs: z.array(logSchema).optional(), +}); + +export type AaveV4Snapshot = z.infer; diff --git a/remappings.txt b/remappings.txt index 628b256f..b120f383 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,4 +1,6 @@ aave-address-book/=lib/aave-address-book/src/ +aave-v4/=lib/aave-address-book/lib/aave-v4/src/ +lib/aave-address-book/lib/aave-v4/:src=lib/aave-address-book/lib/aave-v4/src aave-v3-origin/=lib/aave-address-book/lib/aave-v3-origin/src/ aave-v3-origin-tests/=lib/aave-address-book/lib/aave-v3-origin/tests forge-std/=lib/forge-std/src/ diff --git a/src/ProtocolV4TestBase.sol b/src/ProtocolV4TestBase.sol new file mode 100644 index 00000000..d45c6546 --- /dev/null +++ b/src/ProtocolV4TestBase.sol @@ -0,0 +1,827 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'forge-std/Test.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from 'openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol'; +import {Strings} from 'openzeppelin-contracts/contracts/utils/Strings.sol'; + +import {ISpoke, IHub, ITokenizationSpoke, INativeTokenGateway, ISignatureGateway, IGiverPositionManager, ITakerPositionManager, IConfigPositionManager} from 'aave-address-book/AaveV4.sol'; +import {AaveV4EthereumPositionManagers} from 'aave-address-book/AaveV4Ethereum.sol'; +import {AaveV4EthereumHubHelpers} from 'src/dependencies/v4/AaveV4EthereumHelpers.sol'; +import {IPayloadsControllerCore, PayloadsControllerUtils} from 'aave-address-book/GovernanceV3.sol'; +import {GovV3Helpers} from 'src/GovV3Helpers.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; +import {SnapshotV4} from 'src/dependencies/v4/SnapshotV4.sol'; +import {Scenarios} from 'src/dependencies/v4/Scenarios.sol'; +import {TokenizationScenarios} from 'src/dependencies/v4/TokenizationScenarios.sol'; +import {GatewayScenarios} from 'src/dependencies/v4/GatewayScenarios.sol'; + +/// @title ProtocolV4TestBase +/// @notice E2E test base for Aave V4 hub/spoke architecture. +/// Tests supply, withdraw, borrow, repay, and liquidation for each reserve on a spoke. +/// Tests deposit, mint, withdraw, redeem for each tokenization spoke. +/// Tests NativeTokenGateway and SignatureGateway for each spoke. +/// Loops over all good collaterals and uses randomized amounts. +contract ProtocolV4TestBase is SnapshotV4, Scenarios, TokenizationScenarios, GatewayScenarios { + using SafeERC20 for IERC20; + + /// @notice Run the full V4 test suite: snapshot before, execute payload, snapshot after, diff, then e2e. + function defaultTest( + string memory reportName, + ISpoke[] memory spokes, + address[] memory tokenizationSpokes, + address payload + ) public { + return + defaultTest({ + reportName: reportName, + spokes: spokes, + tokenizationSpokes: tokenizationSpokes, + payload: payload, + runE2E: true, + testPositionManagers: false + }); + } + + function defaultTest( + string memory reportName, + ISpoke[] memory spokes, + address[] memory tokenizationSpokes, + address payload, + bool runE2E, + bool testPositionManagers + ) public { + if (payload != address(0)) { + Types.V4Snapshot memory snapshotAfter = _snapshotDiffAndExecute(reportName, spokes, payload); + configChangePlausibilityTest(snapshotAfter); + } + + if (runE2E) { + vm.pauseGasMetering(); + e2eTestAllSpokes({spokes: spokes, testPositionManagers: testPositionManagers}); + e2eTestAllTokenizationSpokes(tokenizationSpokes); + vm.resumeGasMetering(); + } + } + + /// @notice Sanity-check post-payload spoke caps for known invariants. + /// @dev Liquidity is pooled at the hub, so invariant is per-asset aggregate across all spokes + function configChangePlausibilityTest(Types.V4Snapshot memory snapshotAfter) public pure { + Types.SpokeConfigSnapshot[] memory caps = snapshotAfter.spokeConfigs; + for (uint256 i; i < caps.length; i++) { + // Skip (hub, assetId) groups already aggregated in a prior iteration. + bool alreadyAggregated = false; + for (uint256 j; j < i; j++) { + if (caps[j].hubAddress == caps[i].hubAddress && caps[j].assetId == caps[i].assetId) { + alreadyAggregated = true; + break; + } + } + if (alreadyAggregated) { + continue; + } + + uint256 sumAdd; + uint256 sumDraw; + for (uint256 k; k < caps.length; k++) { + if (caps[k].hubAddress == caps[i].hubAddress && caps[k].assetId == caps[i].assetId) { + sumAdd += uint256(caps[k].addCap); + sumDraw += uint256(caps[k].drawCap); + } + } + if (sumDraw == 0) { + continue; + } + require(sumDraw <= sumAdd, 'PL_ADD_LT_DRAW'); + } + } + + function _snapshotDiffAndExecute( + string memory reportName, + ISpoke[] memory spokes, + address payload + ) internal virtual returns (Types.V4Snapshot memory snapshotAfter) { + IHub[] memory hubs = AaveV4EthereumHubHelpers.getHubs(); + string memory beforeName = string.concat(reportName, '_before'); + string memory afterName = string.concat(reportName, '_after'); + + Types.V4Snapshot memory snapshotBefore = createV4Snapshot(spokes, hubs); + writeV4SnapshotJson(beforeName, snapshotBefore); + + (string memory rawDiff, string memory logsJson) = _executePayloadWithRecording(payload); + + // as executor does delegateCall to the payload, the executor should have no storage changes + { + IPayloadsControllerCore pc = GovV3Helpers.getPayloadsController(block.chainid); + _validateNoExecutorStorageChange( + rawDiff, + pc + .getExecutorSettingsByAccessControl(PayloadsControllerUtils.AccessControl.Level_1) + .executor + ); + } + + snapshotAfter = createV4Snapshot(spokes, hubs); + writeV4SnapshotJson(afterName, snapshotAfter); + + string memory afterPath = string.concat('./reports/', afterName, '.json'); + vm.writeJson(rawDiff, afterPath, '$.raw'); + vm.writeJson(logsJson, afterPath, '$.logs'); + + diffV4Snapshots(reportName); + } + + function _executePayloadWithRecording( + address payload + ) private returns (string memory rawDiff, string memory logsJson) { + uint256 startGas = gasleft(); + vm.startStateDiffRecording(); + vm.recordLogs(); + + GovV3Helpers.executePayload( + vm, + payload, + address(GovV3Helpers.getPayloadsController(block.chainid)) + ); + + uint256 gasUsed = startGas - gasleft(); + assertLt(gasUsed, (block.gaslimit * 95) / 100, 'BLOCK_GAS_LIMIT_EXCEEDED'); + + rawDiff = vm.getStateDiffJson(); + logsJson = vm.getRecordedLogsJson(); + } + + /// @notice Test all reserves on every spoke in the array. + function e2eTestAllSpokes(ISpoke[] memory spokes, bool testPositionManagers) public { + for (uint256 i; i < spokes.length; i++) { + console.log('--- E2E: Testing spoke %s ---', address(spokes[i])); + console.log('--------------------------------'); + e2eTestSpoke(spokes[i]); + if (testPositionManagers) { + e2eTestPositionManagers(spokes[i]); + } + } + } + + /// @notice Test all reserves on one spoke, looping over ALL good collaterals, then gateway tests. + function e2eTestSpoke(ISpoke spoke) public { + Types.ReserveInfo[] memory allReserves = _getReserveInfo(spoke); + Types.ReserveInfo[] memory goodCollaterals = _getAllUsableCollaterals(allReserves); + require(goodCollaterals.length > 0, 'No usable collateral found'); + + uint256 numCollateralsToTest = 5; + numCollateralsToTest = goodCollaterals.length < numCollateralsToTest + ? goodCollaterals.length + : numCollateralsToTest; + + for (uint256 collateralIndex; collateralIndex < numCollateralsToTest; collateralIndex++) { + console.log('--- E2E: Using collateral %s ---', goodCollaterals[collateralIndex].symbol); + + uint256 spokeSnapshot = vm.snapshotState(); + + for (uint256 assetIndex; assetIndex < allReserves.length; assetIndex++) { + if (allReserves[assetIndex].paused) { + e2eTestPausedAsset({spoke: spoke, pausedAsset: allReserves[assetIndex]}); + vm.revertToState(spokeSnapshot); + continue; + } + + if (allReserves[assetIndex].frozen) { + e2eTestFrozenAsset({spoke: spoke, frozenAsset: allReserves[assetIndex]}); + vm.revertToState(spokeSnapshot); + continue; + } + + e2eTestAsset({ + spoke: spoke, + goodCollaterals: goodCollaterals, + primaryCollateralIndex: collateralIndex, + testAssetInfo: allReserves[assetIndex] + }); + vm.revertToState(spokeSnapshot); + } + } + } + + /// @notice Test all position managers on a spoke. + function e2eTestPositionManagers(ISpoke spoke) public { + e2eTestGateways(spoke); + e2eTestRegularPositionManagers(spoke); + } + + /// @notice Test all gateways on a spoke. + function e2eTestGateways(ISpoke spoke) public { + // set caps to max to simplify user ops + _setCapsToMax(spoke); + + Types.ReserveInfo[] memory allReserves = _getReserveInfo(spoke); + Types.ReserveInfo[] memory goodCollaterals = _getAllUsableCollaterals(allReserves); + Types.ReserveInfo[] memory goodDebtReserves = _getAllUsableDebtReserves(allReserves); + + // NativeTokenGateway — only if spoke lists WETH + { + INativeTokenGateway nativeGateway = AaveV4EthereumPositionManagers.NATIVE_TOKEN_GATEWAY; + (bool hasWeth, Types.ReserveInfo memory wethInfo) = _findNativeTokenReserveInfo( + nativeGateway, + spoke + ); + if (hasWeth) { + uint256 gatewaySnapshot = vm.snapshotState(); + _testNativeGateway(nativeGateway, spoke, wethInfo); + vm.revertToState(gatewaySnapshot); + } + } + + // SignatureGateway — on first usable debt reserve + collateral + if (goodCollaterals.length > 0 && goodDebtReserves.length > 0) { + uint256 gatewaySnapshot = vm.snapshotState(); + _testSignatureGateway({ + gateway: AaveV4EthereumPositionManagers.SIGNATURE_GATEWAY, + spoke: spoke, + reserveInfo: goodDebtReserves[0], + collateralInfo: goodCollaterals[0] + }); + vm.revertToState(gatewaySnapshot); + } + } + + /// @notice Test all regular position managers on a spoke. + function e2eTestRegularPositionManagers(ISpoke spoke) public { + _setCapsToMax(spoke); + + Types.ReserveInfo[] memory allReserves = _getReserveInfo(spoke); + Types.ReserveInfo[] memory goodCollaterals = _getAllUsableCollaterals(allReserves); + Types.ReserveInfo[] memory goodDebtReserves = _getAllUsableDebtReserves(allReserves); + + if (goodCollaterals.length == 0 || goodDebtReserves.length == 0) { + console.log('POSITION_MANAGERS: Skipping spoke (no collateral or debt reserves)'); + return; + } + + Types.ReserveInfo memory collateralInfo = goodCollaterals[0]; + Types.ReserveInfo memory debtReserveInfo = goodDebtReserves[0]; + + _testGiverPositionManager(spoke, debtReserveInfo, collateralInfo); + _testTakerPositionManager(spoke, debtReserveInfo, collateralInfo); + _testConfigPositionManager(spoke, collateralInfo); + } + + /// @notice Test GiverPositionManager: supplyOnBehalfOf and repayOnBehalfOf. + function _testGiverPositionManager( + ISpoke spoke, + Types.ReserveInfo memory debtReserveInfo, + Types.ReserveInfo memory collateralInfo + ) internal { + uint256 snapshot = vm.snapshotState(); + console.log('GIVER_PM: Testing supplyOnBehalfOf and repayOnBehalfOf'); + + IGiverPositionManager giverPositionManager = AaveV4EthereumPositionManagers + .GIVER_POSITION_MANAGER; + address oracleAddr = spoke.ORACLE(); + address owner = makeAddr('GIVER_OWNER'); + address supplier = makeAddr('GIVER_SUPPLIER'); + + // Owner approves GiverPositionManager + vm.prank(owner); + spoke.setUserPositionManager(address(giverPositionManager), true); + + // --- supplyOnBehalfOf --- + uint256 supplyAmount = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: collateralInfo, + dollarValue: 10_000 + }); + + uint256 ownerSupplyBefore = spoke.getUserSuppliedAssets(collateralInfo.reserveId, owner); + + vm.startPrank(supplier); + deal2(collateralInfo.underlying, supplier, supplyAmount); + IERC20(collateralInfo.underlying).forceApprove(address(giverPositionManager), supplyAmount); + giverPositionManager.supplyOnBehalfOf({ + spoke: address(spoke), + reserveId: collateralInfo.reserveId, + amount: supplyAmount, + onBehalfOf: owner + }); + vm.stopPrank(); + + uint256 ownerSupplyAfter = spoke.getUserSuppliedAssets(collateralInfo.reserveId, owner); + assertApproxEqAbs( + ownerSupplyAfter, + ownerSupplyBefore + supplyAmount, + 1, + 'GIVER_PM: supplyOnBehalfOf owner balance mismatch' + ); + + // --- repayOnBehalfOf --- + // Setup: owner needs a borrow position first + vm.prank(owner); + spoke.setUsingAsCollateral({ + reserveId: collateralInfo.reserveId, + usingAsCollateral: true, + onBehalfOf: owner + }); + + uint256 borrowAmount = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: debtReserveInfo, + dollarValue: 1_000 + }); + _ensureLiquidity({spoke: spoke, reserveInfo: debtReserveInfo, amount: borrowAmount}); + + vm.prank(owner); + spoke.borrow({reserveId: debtReserveInfo.reserveId, amount: borrowAmount, onBehalfOf: owner}); + + uint256 ownerDebtBefore = spoke.getUserTotalDebt(debtReserveInfo.reserveId, owner); + assertGt(ownerDebtBefore, 0, 'GIVER_PM: owner should have debt before repay'); + + uint256 repayAmount = borrowAmount / 2; + vm.startPrank(supplier); + deal2(debtReserveInfo.underlying, supplier, repayAmount); + IERC20(debtReserveInfo.underlying).forceApprove(address(giverPositionManager), repayAmount); + giverPositionManager.repayOnBehalfOf({ + spoke: address(spoke), + reserveId: debtReserveInfo.reserveId, + amount: repayAmount, + onBehalfOf: owner + }); + vm.stopPrank(); + + uint256 ownerDebtAfter = spoke.getUserTotalDebt(debtReserveInfo.reserveId, owner); + assertApproxEqAbs( + ownerDebtBefore - ownerDebtAfter, + repayAmount, + 2, + 'GIVER_PM: repayOnBehalfOf debt should decrease' + ); + vm.revertToState(snapshot); + } + + /// @notice Test TakerPositionManager: withdrawOnBehalfOf and borrowOnBehalfOf. + function _testTakerPositionManager( + ISpoke spoke, + Types.ReserveInfo memory debtReserveInfo, + Types.ReserveInfo memory collateralInfo + ) internal { + uint256 snapshot = vm.snapshotState(); + console.log('TAKER_PM: Testing withdrawOnBehalfOf and borrowOnBehalfOf'); + + ITakerPositionManager takerPositionManager = AaveV4EthereumPositionManagers + .TAKER_POSITION_MANAGER; + address owner = makeAddr('TAKER_OWNER'); + address taker = makeAddr('TAKER_DELEGATEE'); + + // Owner approves TakerPositionManager + vm.prank(owner); + spoke.setUserPositionManager(address(takerPositionManager), true); + + // Supply collateral for owner + uint256 supplyAmount = _getTokenAmountByDollarValue({ + oracleAddr: spoke.ORACLE(), + reserveInfo: collateralInfo, + dollarValue: 10_000 + }); + _supply({spoke: spoke, reserveInfo: collateralInfo, user: owner, amount: supplyAmount}); + + _testTakerWithdraw(spoke, takerPositionManager, collateralInfo, owner, taker, supplyAmount / 4); + _testTakerBorrow(spoke, takerPositionManager, debtReserveInfo, collateralInfo, owner, taker); + vm.revertToState(snapshot); + } + + function _testTakerWithdraw( + ISpoke spoke, + ITakerPositionManager takerPositionManager, + Types.ReserveInfo memory collateralInfo, + address owner, + address taker, + uint256 withdrawAmount + ) internal { + vm.prank(owner); + takerPositionManager.approveWithdraw({ + spoke: address(spoke), + reserveId: collateralInfo.reserveId, + spender: taker, + amount: withdrawAmount + }); + + uint256 ownerSupplyBefore = spoke.getUserSuppliedAssets(collateralInfo.reserveId, owner); + uint256 takerBalanceBefore = IERC20(collateralInfo.underlying).balanceOf(taker); + + vm.prank(taker); + takerPositionManager.withdrawOnBehalfOf({ + spoke: address(spoke), + reserveId: collateralInfo.reserveId, + amount: withdrawAmount, + onBehalfOf: owner + }); + + assertApproxEqAbs( + ownerSupplyBefore - spoke.getUserSuppliedAssets(collateralInfo.reserveId, owner), + withdrawAmount, + 1, + 'TAKER_PM: owner supply should decrease' + ); + assertEq( + takerBalanceBefore + withdrawAmount, + IERC20(collateralInfo.underlying).balanceOf(taker), + 'TAKER_PM: taker should receive withdrawn tokens' + ); + } + + function _testTakerBorrow( + ISpoke spoke, + ITakerPositionManager takerPositionManager, + Types.ReserveInfo memory debtReserveInfo, + Types.ReserveInfo memory collateralInfo, + address owner, + address taker + ) internal { + vm.prank(owner); + spoke.setUsingAsCollateral({ + reserveId: collateralInfo.reserveId, + usingAsCollateral: true, + onBehalfOf: owner + }); + + uint256 borrowAmount = _getTokenAmountByDollarValue({ + oracleAddr: spoke.ORACLE(), + reserveInfo: debtReserveInfo, + dollarValue: 1_000 + }); + _ensureLiquidity({spoke: spoke, reserveInfo: debtReserveInfo, amount: borrowAmount}); + + vm.prank(owner); + takerPositionManager.approveBorrow({ + spoke: address(spoke), + reserveId: debtReserveInfo.reserveId, + spender: taker, + amount: borrowAmount + }); + + uint256 ownerDebtBefore = spoke.getUserTotalDebt(debtReserveInfo.reserveId, owner); + uint256 takerBalanceBefore = IERC20(debtReserveInfo.underlying).balanceOf(taker); + + vm.prank(taker); + takerPositionManager.borrowOnBehalfOf({ + spoke: address(spoke), + reserveId: debtReserveInfo.reserveId, + amount: borrowAmount, + onBehalfOf: owner + }); + + assertApproxEqAbs( + spoke.getUserTotalDebt(debtReserveInfo.reserveId, owner), + ownerDebtBefore + borrowAmount, + 2, + 'TAKER_PM: owner debt should increase' + ); + assertEq( + takerBalanceBefore + borrowAmount, + IERC20(debtReserveInfo.underlying).balanceOf(taker), + 'TAKER_PM: taker should receive borrowed tokens' + ); + } + + /// @notice Test ConfigPositionManager: setUsingAsCollateralOnBehalfOf. + function _testConfigPositionManager( + ISpoke spoke, + Types.ReserveInfo memory collateralInfo + ) internal { + uint256 snapshot = vm.snapshotState(); + console.log('CONFIG_PM: Testing setUsingAsCollateralOnBehalfOf'); + + IConfigPositionManager configPositionManager = AaveV4EthereumPositionManagers + .CONFIG_POSITION_MANAGER; + address oracleAddr = spoke.ORACLE(); + address owner = makeAddr('CONFIG_OWNER'); + address configDelegatee = makeAddr('CONFIG_DELEGATEE'); + + // Owner approves ConfigPositionManager + vm.prank(owner); + spoke.setUserPositionManager(address(configPositionManager), true); + + // Supply collateral for owner + uint256 supplyAmount = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: collateralInfo, + dollarValue: 10_000 + }); + _supply({spoke: spoke, reserveInfo: collateralInfo, user: owner, amount: supplyAmount}); + + // Owner grants global permission to delegatee + vm.prank(owner); + configPositionManager.setGlobalPermission({ + spoke: address(spoke), + delegatee: configDelegatee, + status: true + }); + + // Delegatee enables collateral on behalf of owner + vm.prank(configDelegatee); + configPositionManager.setUsingAsCollateralOnBehalfOf({ + spoke: address(spoke), + reserveId: collateralInfo.reserveId, + usingAsCollateral: true, + onBehalfOf: owner + }); + + (bool usingAsCollateralBeforeDisable, ) = spoke.getUserReserveStatus( + collateralInfo.reserveId, + owner + ); + assertEq(usingAsCollateralBeforeDisable, true, 'CONFIG_PM: collateral should be enabled'); + + // Delegatee disables collateral on behalf of owner + vm.prank(configDelegatee); + configPositionManager.setUsingAsCollateralOnBehalfOf({ + spoke: address(spoke), + reserveId: collateralInfo.reserveId, + usingAsCollateral: false, + onBehalfOf: owner + }); + + (bool usingAsCollateralAfterDisable, ) = spoke.getUserReserveStatus( + collateralInfo.reserveId, + owner + ); + assertEq(usingAsCollateralAfterDisable, false, 'CONFIG_PM: collateral should be disabled'); + vm.revertToState(snapshot); + } + + /// @notice Test that a paused reserve correctly reverts on all actions. + function e2eTestPausedAsset(ISpoke spoke, Types.ReserveInfo memory pausedAsset) public { + console.log('E2E: Testing paused reserve %s (should revert)', pausedAsset.symbol); + + address oracleAddr = spoke.ORACLE(); + address user = vm.randomAddress(); + uint256 amount = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: pausedAsset, + dollarValue: 1_000 + }); + + deal2(pausedAsset.underlying, user, amount); + + // Supply should revert with ReservePaused + vm.startPrank(user); + IERC20(pausedAsset.underlying).forceApprove(address(spoke), amount); + vm.expectRevert(ISpoke.ReservePaused.selector); + spoke.supply({reserveId: pausedAsset.reserveId, amount: amount, onBehalfOf: user}); + vm.stopPrank(); + + // Borrow should revert with ReservePaused + vm.prank(user); + vm.expectRevert(ISpoke.ReservePaused.selector); + spoke.borrow({reserveId: pausedAsset.reserveId, amount: amount, onBehalfOf: user}); + + // Withdraw should revert with ReservePaused + vm.prank(user); + vm.expectRevert(ISpoke.ReservePaused.selector); + spoke.withdraw({reserveId: pausedAsset.reserveId, amount: amount, onBehalfOf: user}); + + // Repay should revert with ReservePaused + vm.startPrank(user); + IERC20(pausedAsset.underlying).forceApprove(address(spoke), amount); + vm.expectRevert(ISpoke.ReservePaused.selector); + spoke.repay({reserveId: pausedAsset.reserveId, amount: amount, onBehalfOf: user}); + vm.stopPrank(); + + // Liquidation should revert with ReservePaused (paused as debt asset) + address liquidator = vm.randomAddress(); + vm.prank(liquidator); + vm.expectRevert(ISpoke.ReservePaused.selector); + spoke.liquidationCall({ + collateralReserveId: pausedAsset.reserveId, + debtReserveId: pausedAsset.reserveId, + user: user, + debtToCover: amount, + receiveShares: false + }); + + // setUsingAsCollateral should revert with ReservePaused + vm.prank(user); + vm.expectRevert(ISpoke.ReservePaused.selector); + spoke.setUsingAsCollateral({ + reserveId: pausedAsset.reserveId, + usingAsCollateral: true, + onBehalfOf: user + }); + } + + /// @notice Test that a frozen reserve correctly reverts on supply and borrow. + function e2eTestFrozenAsset(ISpoke spoke, Types.ReserveInfo memory frozenAsset) public { + console.log('E2E: Testing frozen reserve %s (should revert)', frozenAsset.symbol); + + address oracleAddr = spoke.ORACLE(); + address user = vm.randomAddress(); + uint256 amount = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: frozenAsset, + dollarValue: 1_000 + }); + + deal2(frozenAsset.underlying, user, amount); + + // Supply should revert with ReserveFrozen + vm.startPrank(user); + IERC20(frozenAsset.underlying).forceApprove(address(spoke), amount); + vm.expectRevert(ISpoke.ReserveFrozen.selector); + spoke.supply({reserveId: frozenAsset.reserveId, amount: amount, onBehalfOf: user}); + vm.stopPrank(); + + // Borrow should revert with ReserveFrozen (if borrowable) + if (frozenAsset.borrowable) { + vm.prank(user); + vm.expectRevert(ISpoke.ReserveFrozen.selector); + spoke.borrow({reserveId: frozenAsset.reserveId, amount: amount, onBehalfOf: user}); + } + } + + /// @notice Per-asset e2e test with randomized amounts and extra collaterals. + function e2eTestAsset( + ISpoke spoke, + Types.ReserveInfo[] memory goodCollaterals, + uint256 primaryCollateralIndex, + Types.ReserveInfo memory testAssetInfo + ) public { + Types.ReserveInfo memory collateralInfo = goodCollaterals[primaryCollateralIndex]; + console.log('E2E: Collateral %s, TestAsset %s', collateralInfo.symbol, testAssetInfo.symbol); + require(collateralInfo.collateralEnabled, 'COLLATERAL_CONFIG_MUST_BE_COLLATERAL'); + + uint256 scenarioSnapshot; + + scenarioSnapshot = vm.snapshotState(); + _testZeroAmountReverts({spoke: spoke, reserveInfo: testAssetInfo, user: vm.randomAddress()}); + vm.revertToState(scenarioSnapshot); + + scenarioSnapshot = vm.snapshotState(); + _testCaps({spoke: spoke, reserveInfo: testAssetInfo}); + vm.revertToState(scenarioSnapshot); + + // Set caps to max after cap testing for the rest of the flow + _setCapsToMax(spoke); + + address collateralSupplier = makeAddr('COLLATERAL_SUPPLIER'); + address testAssetSupplier = makeAddr('TEST_ASSET_SUPPLIER'); + + uint256 testAssetAmount = _setupPositions({ + spoke: spoke, + goodCollaterals: goodCollaterals, + primaryCollateralIndex: primaryCollateralIndex, + testAssetInfo: testAssetInfo, + collateralSupplier: collateralSupplier, + testAssetSupplier: testAssetSupplier + }); + + scenarioSnapshot = vm.snapshotState(); + _testPartialWithdrawal({ + spoke: spoke, + testAssetInfo: testAssetInfo, + testAssetSupplier: testAssetSupplier, + testAssetAmount: testAssetAmount + }); + vm.revertToState(scenarioSnapshot); + + scenarioSnapshot = vm.snapshotState(); + _testFullWithdrawal({ + spoke: spoke, + testAssetInfo: testAssetInfo, + testAssetSupplier: testAssetSupplier + }); + vm.revertToState(scenarioSnapshot); + + if (testAssetInfo.borrowable) { + scenarioSnapshot = vm.snapshotState(); + uint256 borrowCeiling = _setupBorrows( + spoke, + testAssetInfo, + collateralSupplier, + testAssetAmount + ); + if (borrowCeiling > 0) { + uint256 postBorrowSnapshot = vm.snapshotState(); + + // Partial repay + _testPartialRepay(spoke, testAssetInfo, collateralSupplier); + vm.revertToState(postBorrowSnapshot); + + // Full repay + _testFullRepay(spoke, testAssetInfo, collateralSupplier); + vm.revertToState(postBorrowSnapshot); + + // Repay after interest accrual + _testRepayAfterInterest(spoke, testAssetInfo, collateralSupplier); + vm.revertToState(postBorrowSnapshot); + + // Liquidation + _testLiquidation(spoke, collateralInfo, testAssetInfo, collateralSupplier); + vm.revertToState(postBorrowSnapshot); + } + vm.revertToState(scenarioSnapshot); + } else { + // Non-borrowable: verify borrow reverts with ReserveNotBorrowable + vm.prank(collateralSupplier); + vm.expectRevert(ISpoke.ReserveNotBorrowable.selector); + spoke.borrow({ + reserveId: testAssetInfo.reserveId, + amount: testAssetAmount, + onBehalfOf: collateralSupplier + }); + } + + // Collateral toggle: disable all, verify borrow fails, re-enable all, verify borrow works + if (collateralInfo.collateralEnabled && testAssetInfo.borrowable) { + scenarioSnapshot = vm.snapshotState(); + _testCollateralToggle({ + spoke: spoke, + goodCollaterals: goodCollaterals, + testAssetInfo: testAssetInfo, + collateralSupplier: collateralSupplier, + testAssetAmount: testAssetAmount + }); + vm.revertToState(scenarioSnapshot); + } + } + + /// @notice Test all tokenization spokes in the array. + function e2eTestAllTokenizationSpokes(address[] memory tokenizationSpokes) public { + for (uint256 i; i < tokenizationSpokes.length; i++) { + console.log('--- E2E: Testing tokenization spoke %s ---', tokenizationSpokes[i]); + console.log('------------------------------------------'); + e2eTestTokenizationSpoke(ITokenizationSpoke(tokenizationSpokes[i])); + } + } + + /// @notice Run all tokenization spoke scenarios for a single spoke. + function e2eTestTokenizationSpoke(ITokenizationSpoke tokenizationSpoke) public { + Types.ReserveInfo memory reserveInfo = _getTokenizationReserveInfo(tokenizationSpoke); + console.log('E2E: TokenizationSpoke asset: %s', reserveInfo.symbol); + + uint256 snapshot = vm.snapshotState(); + + _testTokenizationAddCap(tokenizationSpoke, reserveInfo); + vm.revertToState(snapshot); + + _setTokenizationCapsToMax(tokenizationSpoke); + snapshot = vm.snapshotState(); + + uint256 maxAddAmount = 10_000 * 10 ** reserveInfo.decimals; + + _testTokenizationDepositWithdraw({ + tokenizationSpoke: tokenizationSpoke, + reserveInfo: reserveInfo, + maxAddAmount: maxAddAmount + }); + vm.revertToState(snapshot); + + _testTokenizationMintRedeem({ + tokenizationSpoke: tokenizationSpoke, + reserveInfo: reserveInfo, + maxAddAmount: maxAddAmount + }); + vm.revertToState(snapshot); + + _testTokenizationPermitDeposit({ + tokenizationSpoke: tokenizationSpoke, + reserveInfo: reserveInfo, + maxAddAmount: maxAddAmount + }); + vm.revertToState(snapshot); + + _testTokenizationTimeSkip({ + tokenizationSpoke: tokenizationSpoke, + reserveInfo: reserveInfo, + maxAddAmount: maxAddAmount + }); + vm.revertToState(snapshot); + + _testTokenizationTransferAndWithdraw({ + tokenizationSpoke: tokenizationSpoke, + reserveInfo: reserveInfo, + maxAddAmount: maxAddAmount + }); + vm.revertToState(snapshot); + } + + /// @notice Validate that the executor has no storage changes after payload execution. + function _validateNoExecutorStorageChange( + string memory stateDiffJson, + address executor + ) internal view virtual { + string memory executorKey = Strings.toHexString(uint160(executor), 20); + string memory stateDiffPath = string.concat('.', executorKey, '.stateDiff'); + if (vm.keyExistsJson(stateDiffJson, stateDiffPath)) { + string[] memory slots = vm.parseJsonKeys(stateDiffJson, stateDiffPath); + require( + slots.length == 0, + string.concat( + 'EXECUTOR_MUST_NOT_HAVE_STORAGE_CHANGES: ', + Strings.toString(slots.length), + ' slot(s) modified on executor ', + executorKey + ) + ); + } + } +} diff --git a/src/dependencies/v4/AaveV4EthereumHelpers.sol b/src/dependencies/v4/AaveV4EthereumHelpers.sol new file mode 100644 index 00000000..41f20ddf --- /dev/null +++ b/src/dependencies/v4/AaveV4EthereumHelpers.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IHub, ISpoke, ITokenizationSpoke} from 'aave-address-book/AaveV4.sol'; +import {AaveV4EthereumHubs, AaveV4EthereumSpokes, AaveV4EthereumTokenizationSpokes} from 'aave-address-book/AaveV4Ethereum.sol'; + +/// @dev Helper that returns all hubs as an array (address-book only exposes individual constants). +library AaveV4EthereumHubHelpers { + function getHubs() internal pure returns (IHub[] memory) { + IHub[] memory hubs = new IHub[](3); + hubs[0] = AaveV4EthereumHubs.CORE_HUB; + hubs[1] = AaveV4EthereumHubs.PLUS_HUB; + hubs[2] = AaveV4EthereumHubs.PRIME_HUB; + return hubs; + } +} + +/// @dev Helper that returns spoke arrays (address-book only exposes individual constants). +library AaveV4EthereumSpokeHelpers { + function getUserSpokes() internal pure returns (ISpoke[] memory) { + ISpoke[] memory spokes = new ISpoke[](10); + spokes[0] = AaveV4EthereumSpokes.MAIN_SPOKE; + spokes[1] = AaveV4EthereumSpokes.BLUECHIP_SPOKE; + spokes[2] = AaveV4EthereumSpokes.ETHENA_CORRELATED_SPOKE; + spokes[3] = AaveV4EthereumSpokes.ETHENA_ECOSYSTEM_SPOKE; + spokes[4] = AaveV4EthereumSpokes.ETHERFI_E_SPOKE; + spokes[5] = AaveV4EthereumSpokes.FOREX_SPOKE; + spokes[6] = AaveV4EthereumSpokes.GOLD_SPOKE; + spokes[7] = AaveV4EthereumSpokes.KELP_E_SPOKE; + spokes[8] = AaveV4EthereumSpokes.LIDO_E_SPOKE; + spokes[9] = AaveV4EthereumSpokes.LOMBARD_BTC_SPOKE; + return spokes; + } +} + +/// @dev Helper that returns all tokenization spokes as an address array. +library AaveV4EthereumTokenizationSpokeHelpers { + function getTokenizationSpokes() internal pure returns (address[] memory) { + address[] memory spokes = new address[](31); + // Core Hub + spokes[0] = address(AaveV4EthereumTokenizationSpokes.CORE_WETH_TOKENIZATION_SPOKE); + spokes[1] = address(AaveV4EthereumTokenizationSpokes.CORE_wstETH_TOKENIZATION_SPOKE); + spokes[2] = address(AaveV4EthereumTokenizationSpokes.CORE_weETH_TOKENIZATION_SPOKE); + spokes[3] = address(AaveV4EthereumTokenizationSpokes.CORE_rsETH_TOKENIZATION_SPOKE); + spokes[4] = address(AaveV4EthereumTokenizationSpokes.CORE_WBTC_TOKENIZATION_SPOKE); + spokes[5] = address(AaveV4EthereumTokenizationSpokes.CORE_cbBTC_TOKENIZATION_SPOKE); + spokes[6] = address(AaveV4EthereumTokenizationSpokes.CORE_LBTC_TOKENIZATION_SPOKE); + spokes[7] = address(AaveV4EthereumTokenizationSpokes.CORE_USDT_TOKENIZATION_SPOKE); + spokes[8] = address(AaveV4EthereumTokenizationSpokes.CORE_USDC_TOKENIZATION_SPOKE); + spokes[9] = address(AaveV4EthereumTokenizationSpokes.CORE_LINK_TOKENIZATION_SPOKE); + spokes[10] = address(AaveV4EthereumTokenizationSpokes.CORE_AAVE_TOKENIZATION_SPOKE); + spokes[11] = address(AaveV4EthereumTokenizationSpokes.CORE_GHO_TOKENIZATION_SPOKE); + spokes[12] = address(AaveV4EthereumTokenizationSpokes.CORE_EURC_TOKENIZATION_SPOKE); + spokes[13] = address(AaveV4EthereumTokenizationSpokes.CORE_RLUSD_TOKENIZATION_SPOKE); + spokes[14] = address(AaveV4EthereumTokenizationSpokes.CORE_USDG_TOKENIZATION_SPOKE); + spokes[15] = address(AaveV4EthereumTokenizationSpokes.CORE_frxUSD_TOKENIZATION_SPOKE); + spokes[16] = address(AaveV4EthereumTokenizationSpokes.CORE_XAUt_TOKENIZATION_SPOKE); + // Plus Hub + spokes[17] = address(AaveV4EthereumTokenizationSpokes.PLUS_USDT_TOKENIZATION_SPOKE); + spokes[18] = address(AaveV4EthereumTokenizationSpokes.PLUS_USDC_TOKENIZATION_SPOKE); + spokes[19] = address(AaveV4EthereumTokenizationSpokes.PLUS_GHO_TOKENIZATION_SPOKE); + spokes[20] = address(AaveV4EthereumTokenizationSpokes.PLUS_USDe_TOKENIZATION_SPOKE); + spokes[21] = address(AaveV4EthereumTokenizationSpokes.PLUS_sUSDe_TOKENIZATION_SPOKE); + spokes[22] = address( + AaveV4EthereumTokenizationSpokes.PLUS_PT_sUSDE_7MAY2026_TOKENIZATION_SPOKE + ); + spokes[23] = address(AaveV4EthereumTokenizationSpokes.PLUS_PT_USDe_7MAY2026_TOKENIZATION_SPOKE); + // Prime Hub + spokes[24] = address(AaveV4EthereumTokenizationSpokes.PRIME_WETH_TOKENIZATION_SPOKE); + spokes[25] = address(AaveV4EthereumTokenizationSpokes.PRIME_wstETH_TOKENIZATION_SPOKE); + spokes[26] = address(AaveV4EthereumTokenizationSpokes.PRIME_WBTC_TOKENIZATION_SPOKE); + spokes[27] = address(AaveV4EthereumTokenizationSpokes.PRIME_cbBTC_TOKENIZATION_SPOKE); + spokes[28] = address(AaveV4EthereumTokenizationSpokes.PRIME_USDT_TOKENIZATION_SPOKE); + spokes[29] = address(AaveV4EthereumTokenizationSpokes.PRIME_USDC_TOKENIZATION_SPOKE); + spokes[30] = address(AaveV4EthereumTokenizationSpokes.PRIME_GHO_TOKENIZATION_SPOKE); + return spokes; + } +} diff --git a/src/dependencies/v4/Actions.sol b/src/dependencies/v4/Actions.sol new file mode 100644 index 00000000..42c0c504 --- /dev/null +++ b/src/dependencies/v4/Actions.sol @@ -0,0 +1,601 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'forge-std/Test.sol'; +import {SafeERC20, IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol'; +import {CommonTestBase} from 'src/CommonTestBase.sol'; +import {ISpoke, IAaveOracle} from 'aave-address-book/AaveV4.sol'; +import {IHubBase} from 'aave-v4/hub/interfaces/IHubBase.sol'; +import {WadRayMath} from 'aave-v4/libraries/math/WadRayMath.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; + +/// @title Actions +/// @notice Low-level spoke actions with hub and spoke accounting assertions. +abstract contract Actions is CommonTestBase { + using SafeERC20 for IERC20; + using stdMath for uint256; + + uint256 constant HEALTH_FACTOR_LIQUIDATION_THRESHOLD = 1e18; + uint256 constant MAX_DEAL_UNIT = 1e12; // whole units not accounting for token decimals + + function _getUserAccounting( + ISpoke spoke, + uint256 reserveId, + address user + ) internal view returns (Types.Accounting memory) { + (uint256 drawnDebt, uint256 premiumDebt) = spoke.getUserDebt(reserveId, user); + ISpoke.UserPosition memory position = spoke.getUserPosition(reserveId, user); + return + Types.Accounting({ + collateralShares: position.suppliedShares, + collateralAssets: spoke.getUserSuppliedAssets(reserveId, user), + drawnDebt: drawnDebt, + premiumDebt: premiumDebt, + totalDebt: spoke.getUserTotalDebt(reserveId, user), + drawnShares: position.drawnShares, + premiumShares: position.premiumShares, + premiumOffsetRay: position.premiumOffsetRay + }); + } + + function _getReserveAccounting( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo + ) internal view returns (Types.Accounting memory) { + IHubBase hub = IHubBase(reserveInfo.hub); + uint16 assetId = reserveInfo.assetId; + (uint256 drawnDebt, uint256 premiumDebt) = hub.getSpokeOwed(assetId, address(spoke)); + (uint256 premiumShares, int256 premiumOffsetRay) = hub.getSpokePremiumData( + assetId, + address(spoke) + ); + return + Types.Accounting({ + collateralShares: spoke.getReserveSuppliedShares(reserveInfo.reserveId), + collateralAssets: spoke.getReserveSuppliedAssets(reserveInfo.reserveId), + drawnDebt: drawnDebt, + premiumDebt: premiumDebt, + totalDebt: spoke.getReserveTotalDebt(reserveInfo.reserveId), + drawnShares: hub.getSpokeDrawnShares(assetId, address(spoke)), + premiumShares: premiumShares, + premiumOffsetRay: premiumOffsetRay + }); + } + + function _getSpokeOnHubAccounting( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo + ) internal view returns (Types.Accounting memory) { + IHubBase hub = IHubBase(reserveInfo.hub); + uint16 assetId = reserveInfo.assetId; + address spokeAddr = address(spoke); + (uint256 spokeDrawnOwed, uint256 spokePremiumOwed) = hub.getSpokeOwed(assetId, spokeAddr); + (uint256 premiumShares, int256 premiumOffsetRay) = hub.getSpokePremiumData(assetId, spokeAddr); + return + Types.Accounting({ + collateralShares: hub.getSpokeAddedShares(assetId, spokeAddr), + collateralAssets: hub.getSpokeAddedAssets(assetId, spokeAddr), + drawnDebt: spokeDrawnOwed, + premiumDebt: spokePremiumOwed, + totalDebt: hub.getSpokeTotalOwed(assetId, spokeAddr), + drawnShares: hub.getSpokeDrawnShares(assetId, spokeAddr), + premiumShares: premiumShares, + premiumOffsetRay: premiumOffsetRay + }); + } + + function _getPositionSnapshot( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + address user + ) internal view returns (Types.PositionSnapshot memory) { + return + Types.PositionSnapshot({ + user: _getUserAccounting({spoke: spoke, reserveId: reserveInfo.reserveId, user: user}), + reserve: _getReserveAccounting({spoke: spoke, reserveInfo: reserveInfo}), + spokeOnHub: _getSpokeOnHubAccounting({spoke: spoke, reserveInfo: reserveInfo}) + }); + } + + /// @notice Skip time, assert debt accounting grew as expected, then revert. + function _skipTimeAndCheckAccounting( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + address user, + uint256 skipDays + ) internal { + uint256 snapshot = vm.snapshotState(); + + Types.PositionSnapshot memory snapshotBefore = _getPositionSnapshot(spoke, reserveInfo, user); + + skip(skipDays * 1 days); + + Types.PositionSnapshot memory snapshotAfter = _getPositionSnapshot(spoke, reserveInfo, user); + + // User debt should not decrease over time + assertGe( + snapshotAfter.user.totalDebt, + snapshotBefore.user.totalDebt, + 'TIME_SKIP: user total debt decreased' + ); + assertGe( + snapshotAfter.user.drawnDebt, + snapshotBefore.user.drawnDebt, + 'TIME_SKIP: user drawn debt decreased' + ); + + // Reserve debt should not decrease over time + assertGe( + snapshotAfter.reserve.totalDebt, + snapshotBefore.reserve.totalDebt, + 'TIME_SKIP: reserve total debt decreased' + ); + assertGe( + snapshotAfter.reserve.drawnDebt, + snapshotBefore.reserve.drawnDebt, + 'TIME_SKIP: reserve drawn debt decreased' + ); + + // Hub spoke owed should not decrease over time + assertGe( + snapshotAfter.spokeOnHub.totalDebt, + snapshotBefore.spokeOnHub.totalDebt, + 'TIME_SKIP: hub spoke owed decreased' + ); + assertGe( + snapshotAfter.spokeOnHub.drawnDebt, + snapshotBefore.spokeOnHub.drawnDebt, + 'TIME_SKIP: hub spoke drawn decreased' + ); + + // Hub drawn index should have grown + IHubBase hub = IHubBase(reserveInfo.hub); + uint256 drawnIndexAfter = hub.getAssetDrawnIndex(reserveInfo.assetId); + assertGt( + drawnIndexAfter, + WadRayMath.RAY, + 'TIME_SKIP: drawn index should be greater than default 1 RAY' + ); + + vm.revertToState(snapshot); + } + + function _supply( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + address user, + uint256 amount + ) internal { + require(!reserveInfo.paused, 'SUPPLY: PAUSED_RESERVE'); + require(!reserveInfo.frozen, 'SUPPLY: FROZEN_RESERVE'); + + Types.PositionSnapshot memory snapshotBefore = _getPositionSnapshot(spoke, reserveInfo, user); + + _forceApprove({spoke: spoke, underlying: reserveInfo.underlying, user: user, amount: amount}); + + _logAction('SUPPLY', reserveInfo.symbol, amount); + vm.prank(user); + (uint256 returnedShares, uint256 returnedAssets) = spoke.supply({ + reserveId: reserveInfo.reserveId, + amount: amount, + onBehalfOf: user + }); + + Types.PositionSnapshot memory snapshotAfter = _getPositionSnapshot(spoke, reserveInfo, user); + + assertEq(returnedAssets, amount, 'SUPPLY: returnedAssets mismatch'); + + // User + assertApproxEqAbs( + snapshotAfter.user.collateralAssets, + snapshotBefore.user.collateralAssets + amount, + 2, + 'SUPPLY: user assets mismatch' + ); + assertEq( + snapshotAfter.user.collateralShares, + snapshotBefore.user.collateralShares + returnedShares, + 'SUPPLY: user shares mismatch' + ); + // Spoke accounting on hub + assertApproxEqAbs( + snapshotAfter.spokeOnHub.collateralAssets, + snapshotBefore.spokeOnHub.collateralAssets + amount, + 2, + 'SUPPLY: hub assets mismatch' + ); + uint256 expectedAddedShares = IHubBase(reserveInfo.hub).previewAddByAssets( + reserveInfo.assetId, + amount + ); + assertEq(returnedShares, expectedAddedShares, 'SUPPLY: returnedShares mismatch'); + assertEq( + snapshotAfter.spokeOnHub.collateralShares, + snapshotBefore.spokeOnHub.collateralShares + expectedAddedShares, + 'SUPPLY: hub shares mismatch' + ); + } + + /// @notice Deal to the user and force approve the spoke to spend the amount of the underlying token for the user + function _forceApprove(ISpoke spoke, address underlying, address user, uint256 amount) internal { + deal2(underlying, user, amount); + vm.prank(user); + IERC20(underlying).forceApprove(address(spoke), amount); + } + + function _withdraw( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + address user, + uint256 amount + ) internal { + Types.PositionSnapshot memory snapshotBefore = _getPositionSnapshot(spoke, reserveInfo, user); + + vm.startPrank(user); + _logAction('WITHDRAW', reserveInfo.symbol, amount); + (uint256 returnedShares, uint256 withdrawnAmount) = spoke.withdraw({ + reserveId: reserveInfo.reserveId, + amount: amount, + onBehalfOf: user + }); + vm.stopPrank(); + + Types.PositionSnapshot memory snapshotAfter = _getPositionSnapshot(spoke, reserveInfo, user); + + if (amount >= snapshotBefore.user.collateralAssets) { + assertEq(snapshotAfter.user.collateralAssets, 0, 'WITHDRAW: user assets should be zero'); + assertEq(snapshotAfter.user.collateralShares, 0, 'WITHDRAW: user shares should be zero'); + } else { + assertApproxEqAbs( + snapshotAfter.user.collateralAssets, + snapshotBefore.user.collateralAssets - withdrawnAmount, + 2, + 'WITHDRAW: user assets mismatch' + ); + assertEq( + snapshotBefore.user.collateralShares - snapshotAfter.user.collateralShares, + returnedShares, + 'WITHDRAW: user shares delta mismatch' + ); + } + // Hub spoke + assertApproxEqAbs( + snapshotBefore.spokeOnHub.collateralAssets - snapshotAfter.spokeOnHub.collateralAssets, + withdrawnAmount, + 2, + 'WITHDRAW: hub assets mismatch' + ); + assertEq( + snapshotBefore.spokeOnHub.collateralShares - snapshotAfter.spokeOnHub.collateralShares, + returnedShares, + 'WITHDRAW: hub shares mismatch' + ); + + // Health factor must remain above liquidation threshold after withdraw + uint256 healthFactor = spoke.getUserAccountData(user).healthFactor; + assertGt(healthFactor, HEALTH_FACTOR_LIQUIDATION_THRESHOLD, 'WITHDRAW: health factor below 1'); + } + + function _borrow( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + address user, + uint256 amount + ) internal { + Types.PositionSnapshot memory snapshotBefore = _getPositionSnapshot(spoke, reserveInfo, user); + uint256 expectedDrawnShares = IHubBase(reserveInfo.hub).previewDrawByAssets( + reserveInfo.assetId, + amount + ); + + _logAction('BORROW', reserveInfo.symbol, amount); + vm.prank(user); + (uint256 returnedShares, uint256 returnedAssets) = spoke.borrow({ + reserveId: reserveInfo.reserveId, + amount: amount, + onBehalfOf: user + }); + + Types.PositionSnapshot memory snapshotAfter = _getPositionSnapshot(spoke, reserveInfo, user); + + assertEq(returnedAssets, amount, 'BORROW: returnedAssets mismatch'); + assertEq(returnedShares, expectedDrawnShares, 'BORROW: returnedShares mismatch'); + + // User debt - up to 2 wei diff due to premium/drawn debt + assertApproxEqAbs( + snapshotAfter.user.totalDebt, + snapshotBefore.user.totalDebt + amount, + 2, + 'BORROW: user debt mismatch' + ); + assertApproxEqAbs( + snapshotAfter.user.drawnDebt, + snapshotBefore.user.drawnDebt + returnedAssets, + 2, + 'BORROW: user drawn debt mismatch' + ); + // Hub spoke - up to 2 wei diff due to premium/drawn debt + assertApproxEqAbs( + snapshotAfter.spokeOnHub.totalDebt, + snapshotBefore.spokeOnHub.totalDebt + amount, + 2, + 'BORROW: hub debt mismatch' + ); + assertEq( + snapshotAfter.spokeOnHub.drawnShares, + snapshotBefore.spokeOnHub.drawnShares + expectedDrawnShares, + 'BORROW: hub drawn shares mismatch' + ); + + // Health factor must remain above liquidation threshold after borrow + uint256 healthFactor = spoke.getUserAccountData(user).healthFactor; + assertGt(healthFactor, HEALTH_FACTOR_LIQUIDATION_THRESHOLD, 'BORROW: health factor below 1'); + } + + function _repay( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + address user, + uint256 amount + ) internal { + Types.PositionSnapshot memory snapshotBefore = _getPositionSnapshot(spoke, reserveInfo, user); + uint256 effectiveRepayAmount = amount >= snapshotBefore.user.totalDebt + ? snapshotBefore.user.totalDebt + : amount; + uint256 drawnRepayAmount = effectiveRepayAmount > snapshotBefore.user.premiumDebt + ? effectiveRepayAmount - snapshotBefore.user.premiumDebt + : 0; + uint256 expectedRestoredShares = IHubBase(reserveInfo.hub).previewRestoreByAssets( + reserveInfo.assetId, + drawnRepayAmount + ); + + _forceApprove({ + spoke: spoke, + underlying: reserveInfo.underlying, + user: user, + amount: effectiveRepayAmount + }); + + _logAction('REPAY', reserveInfo.symbol, amount); + vm.prank(user); + (uint256 returnedShares, uint256 returnedAssets) = spoke.repay({ + reserveId: reserveInfo.reserveId, + amount: amount, + onBehalfOf: user + }); + + Types.PositionSnapshot memory snapshotAfter = _getPositionSnapshot(spoke, reserveInfo, user); + + assertEq(returnedAssets, effectiveRepayAmount, 'REPAY: returnedAssets mismatch'); + assertEq(returnedShares, expectedRestoredShares, 'REPAY: returnedShares mismatch'); + + if (amount >= snapshotBefore.user.totalDebt) { + assertEq(snapshotAfter.user.totalDebt, 0, 'REPAY: user debt should be zero'); + } else { + assertGe( + snapshotBefore.user.totalDebt, + snapshotAfter.user.totalDebt, + 'REPAY: user debt did not decrease' + ); + assertApproxEqAbs( + snapshotBefore.user.totalDebt - snapshotAfter.user.totalDebt, + amount, + 2, + 'REPAY: user debt mismatch' + ); + } + // Hub spoke - up to 2 wei diff due to premium/drawn debt + assertGe( + snapshotBefore.spokeOnHub.totalDebt, + snapshotAfter.spokeOnHub.totalDebt, + 'REPAY: hub debt did not decrease' + ); + assertApproxEqAbs( + snapshotBefore.spokeOnHub.totalDebt - snapshotAfter.spokeOnHub.totalDebt, + effectiveRepayAmount, + 2, + 'REPAY: hub debt mismatch' + ); + assertGe( + snapshotBefore.spokeOnHub.drawnShares, + snapshotAfter.spokeOnHub.drawnShares, + 'REPAY: hub drawn shares did not decrease' + ); + assertEq( + snapshotBefore.spokeOnHub.drawnShares - snapshotAfter.spokeOnHub.drawnShares, + expectedRestoredShares, + 'REPAY: hub drawn shares mismatch' + ); + } + + function _liquidationCall( + ISpoke spoke, + Types.ReserveInfo memory collateralInfo, + Types.ReserveInfo memory debtInfo, + address liquidator, + address borrower, + uint256 debtToCover, + bool receiveShares + ) internal { + Types.PositionSnapshot memory collateralSnapshotBefore = _getPositionSnapshot( + spoke, + collateralInfo, + borrower + ); + Types.PositionSnapshot memory debtSnapshotBefore = _getPositionSnapshot( + spoke, + debtInfo, + borrower + ); + assertGt(debtSnapshotBefore.user.totalDebt, 0, 'LIQUIDATE: borrower has no debt'); + + _forceApprove({ + spoke: spoke, + underlying: debtInfo.underlying, + user: liquidator, + amount: debtSnapshotBefore.user.totalDebt + }); + + // Capture pre-liquidation totals (token balance + supplied assets) for profitability check + uint256 liquidatorDebtTotalBefore = IERC20(debtInfo.underlying).balanceOf(liquidator) + + spoke.getUserSuppliedAssets(debtInfo.reserveId, liquidator); + uint256 liquidatorCollateralTotalBefore = IERC20(collateralInfo.underlying).balanceOf( + liquidator + ) + spoke.getUserSuppliedAssets(collateralInfo.reserveId, liquidator); + + ISpoke.UserAccountData memory borrowerAccountDataBefore = spoke.getUserAccountData(borrower); + + if (debtToCover == UINT256_MAX) { + console.log( + 'LIQUIDATE: %s, DebtToCover: UINT256_MAX, TotalDebt: %e', + debtInfo.symbol, + debtSnapshotBefore.user.totalDebt + ); + } else { + console.log( + 'LIQUIDATE: %s, DebtToCover: %e, TotalDebt: %e', + debtInfo.symbol, + debtToCover, + debtSnapshotBefore.user.totalDebt + ); + } + + vm.prank(liquidator); + spoke.liquidationCall({ + collateralReserveId: collateralInfo.reserveId, + debtReserveId: debtInfo.reserveId, + user: borrower, + debtToCover: debtToCover, + receiveShares: receiveShares + }); + + Types.PositionSnapshot memory collateralSnapshotAfter = _getPositionSnapshot( + spoke, + collateralInfo, + borrower + ); + Types.PositionSnapshot memory debtSnapshotAfter = _getPositionSnapshot( + spoke, + debtInfo, + borrower + ); + + // Debt decreased + assertLt( + debtSnapshotAfter.user.totalDebt, + debtSnapshotBefore.user.totalDebt, + 'LIQUIDATE: debt did not decrease' + ); + assertLt( + debtSnapshotAfter.spokeOnHub.totalDebt, + debtSnapshotBefore.spokeOnHub.totalDebt, + 'LIQUIDATE: hub debt did not decrease' + ); + // Collateral decreased + assertLt( + collateralSnapshotAfter.user.collateralAssets, + collateralSnapshotBefore.user.collateralAssets, + 'LIQUIDATE: collateral did not decrease' + ); + // hub collateral can remain unchanged if receiveShares is true + assertLe( + collateralSnapshotAfter.spokeOnHub.collateralAssets, + collateralSnapshotBefore.spokeOnHub.collateralAssets, + 'LIQUIDATE: hub collateral did not decrease or stay the same' + ); + assertLe( + spoke.getUserAccountData(borrower).activeCollateralCount, + borrowerAccountDataBefore.activeCollateralCount, + 'LIQUIDATE: borrower collateral count did not decrease or stay the same' + ); + assertLe( + spoke.getUserAccountData(borrower).borrowCount, + borrowerAccountDataBefore.borrowCount, + 'LIQUIDATE: borrower borrow count did not decrease or stay the same' + ); + assertLe( + spoke.getUserAccountData(borrower).totalCollateralValue, + borrowerAccountDataBefore.totalCollateralValue, + 'LIQUIDATE: borrower collateral value did not decrease or stay the same' + ); + assertLt( + spoke.getUserAccountData(borrower).totalDebtValueRay, + borrowerAccountDataBefore.totalDebtValueRay, + 'LIQUIDATE: borrower debt value did not decrease' + ); + + // Liquidation profitability: collateral value received > debt value paid + _assertLiquidationProfitable({ + spoke: spoke, + collateralInfo: collateralInfo, + debtInfo: debtInfo, + liquidator: liquidator, + liquidatorDebtBefore: liquidatorDebtTotalBefore, + liquidatorCollateralBefore: liquidatorCollateralTotalBefore + }); + } + + /// @dev Totals = token balance + supplied share assets. Caller passes pre-computed before-totals. + function _assertLiquidationProfitable( + ISpoke spoke, + Types.ReserveInfo memory collateralInfo, + Types.ReserveInfo memory debtInfo, + address liquidator, + uint256 liquidatorDebtBefore, + uint256 liquidatorCollateralBefore + ) private view { + uint256 liquidatorDebtAfter = IERC20(debtInfo.underlying).balanceOf(liquidator) + + spoke.getUserSuppliedAssets(debtInfo.reserveId, liquidator); + uint256 liquidatorCollateralAfter = IERC20(collateralInfo.underlying).balanceOf(liquidator) + + spoke.getUserSuppliedAssets(collateralInfo.reserveId, liquidator); + + uint256 debtSpent = liquidatorDebtAfter.delta(liquidatorDebtBefore); + uint256 collateralGained = liquidatorCollateralAfter.delta(liquidatorCollateralBefore); + + if (collateralInfo.underlying == debtInfo.underlying) { + // Same underlying: collateral/debt assets are the same, so debtSpent should == collateralGained and be positive + assertEq(debtSpent, collateralGained); // sanity check + assertGt( + collateralGained, + 0, + 'LIQUIDATE: not profitable (same underlying) - collateral gained <= debt spent' + ); + } else { + // Different underlyings: compare oracle-normalized $ values. + // Cross-multiply to avoid precision loss from division with extreme mocked prices: + // collGained * collPrice * 10^debtDecimals > debtSpent * debtPrice * 10^collDecimals + IAaveOracle oracle = IAaveOracle(spoke.ORACLE()); + uint256 collPrice = oracle.getReservePrice(collateralInfo.reserveId); + uint256 debtPrice = oracle.getReservePrice(debtInfo.reserveId); + + assertGt( + collateralGained * collPrice * (10 ** debtInfo.decimals), + debtSpent * debtPrice * (10 ** collateralInfo.decimals), + 'LIQUIDATE: not profitable - collateral $ value <= debt $ value' + ); + } + } + + /// @notice Convert a token amount to its oracle-denominated value. + function _getOracleValue( + IAaveOracle oracle, + Types.ReserveInfo memory reserveInfo, + uint256 amount + ) internal view returns (uint256) { + uint256 price = oracle.getReservePrice(reserveInfo.reserveId); + return (amount * price) / (10 ** reserveInfo.decimals); + } + + function _maxDealAmount(uint8 decimals) internal pure returns (uint256) { + return MAX_DEAL_UNIT * 10 ** decimals; + } + + function _logAction(string memory action, string memory symbol, uint256 amount) internal pure { + if (amount == UINT256_MAX) { + console.log('%s: %s, Amount: UINT256_MAX', action, symbol); + } else { + console.log('%s: %s, Amount: %e', action, symbol, amount); + } + } +} diff --git a/src/dependencies/v4/GatewayScenarios.sol b/src/dependencies/v4/GatewayScenarios.sol new file mode 100644 index 00000000..ed24f585 --- /dev/null +++ b/src/dependencies/v4/GatewayScenarios.sol @@ -0,0 +1,1007 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'forge-std/Test.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from 'openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol'; +import {ISpoke, IAaveOracle, INativeTokenGateway, ISignatureGateway} from 'aave-address-book/AaveV4.sol'; +import {IHubBase} from 'aave-v4/hub/interfaces/IHubBase.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; +import {Helpers} from 'src/dependencies/v4/Helpers.sol'; + +/// @title GatewayScenarios +/// @notice E2E test scenarios for NativeTokenGateway and SignatureGateway. +abstract contract GatewayScenarios is Helpers { + using SafeERC20 for IERC20; + using stdMath for uint256; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /// @notice Find the ReserveInfo for the native token wrapper (WETH) on a spoke. + /// Returns (true, info) if found, (false, empty) if not. + function _findNativeTokenReserveInfo( + INativeTokenGateway gateway, + ISpoke spoke + ) internal view returns (bool found, Types.ReserveInfo memory info) { + address weth = gateway.NATIVE_TOKEN_WRAPPER(); + Types.ReserveInfo[] memory allReserves = _getReserveInfo(spoke); + for (uint256 i; i < allReserves.length; i++) { + if (allReserves[i].underlying == weth) { + return (true, allReserves[i]); + } + } + return (false, info); + } + + /// @notice Build EIP-712 digest and sign for signature gateway. + function _signForGateway( + ISignatureGateway gateway, + uint256 privateKey, + bytes32 structHash + ) internal view returns (bytes memory) { + bytes32 digest = keccak256( + abi.encodePacked('\x19\x01', gateway.DOMAIN_SEPARATOR(), structHash) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + return abi.encodePacked(r, s, v); + } + + // ------------------------------------------------------------------------- + // NativeTokenGateway scenario + // ------------------------------------------------------------------------- + + /// @dev Test supply, withdraw, borrow, repay via NativeTokenGateway. + function _testNativeGateway( + INativeTokenGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory wethInfo + ) internal { + console.log('NATIVE_GATEWAY: Testing on spoke with WETH reserveId=%s', wethInfo.reserveId); + uint256 gatewaySnapshot = vm.snapshotState(); + + address user = vm.randomAddress(); + uint256 amount = _getTokenAmountByDollarValue( + spoke.ORACLE(), + wethInfo, + vm.randomUint(1_000, 10_000) + ); + + // Authorize gateway as position manager for user + vm.prank(user); + spoke.setUserPositionManager(address(gateway), true); + + _setupPositions({ + gateway: gateway, + spoke: spoke, + wethInfo: wethInfo, + user: user, + amount: amount + }); + + _testWithdrawNative({ + gateway: gateway, + spoke: spoke, + wethInfo: wethInfo, + user: user, + amount: amount + }); + + // --- Setup collateral for borrow --- + if (wethInfo.borrowable) { + _testBorrowRepayNative({ + gateway: gateway, + spoke: spoke, + wethInfo: wethInfo, + user: user, + amount: amount + }); + } + vm.revertToState(gatewaySnapshot); + } + + function _setupPositions( + INativeTokenGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory wethInfo, + address user, + uint256 amount + ) internal { + uint256 snapshot = vm.snapshotState(); + _supplyNative({gateway: gateway, spoke: spoke, wethInfo: wethInfo, user: user, amount: amount}); + vm.revertToState(snapshot); + _supplyAsCollateralNative({ + gateway: gateway, + spoke: spoke, + wethInfo: wethInfo, + user: user, + amount: amount + }); + } + + function _supplyNative( + INativeTokenGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory wethInfo, + address user, + uint256 amount + ) internal { + uint256 snapshot = vm.snapshotState(); + Types.PositionSnapshot memory snapshotBefore = _getPositionSnapshot(spoke, wethInfo, user); + uint256 sharesSupplied; + uint256 amountSupplied; + + { + vm.deal(user, amount); + vm.prank(user); + _logAction('NATIVE_SUPPLY', wethInfo.symbol, amount); + (sharesSupplied, amountSupplied) = gateway.supplyNative{value: amount}( + address(spoke), + wethInfo.reserveId, + amount + ); + assertEq(amountSupplied, amount, 'NATIVE_SUPPLY: amount mismatch'); + assertEq(user.balance, 0, 'NATIVE_SUPPLY: user ETH not fully consumed'); + } + + Types.PositionSnapshot memory snapshotAfter = _getPositionSnapshot(spoke, wethInfo, user); + assertApproxEqAbs( + snapshotAfter.user.collateralAssets.delta(snapshotBefore.user.collateralAssets), + amountSupplied, + 2, + 'NATIVE_SUPPLY: user assets mismatch' + ); + assertEq( + snapshotAfter.user.collateralShares.delta(snapshotBefore.user.collateralShares), + sharesSupplied, + 'NATIVE_SUPPLY: user shares mismatch' + ); + assertApproxEqAbs( + snapshotAfter.spokeOnHub.collateralAssets.delta(snapshotBefore.spokeOnHub.collateralAssets), + amountSupplied, + 2, + 'NATIVE_SUPPLY: hub assets mismatch' + ); + vm.revertToState(snapshot); + } + + function _supplyAsCollateralNative( + INativeTokenGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory wethInfo, + address user, + uint256 amount + ) internal { + Types.PositionSnapshot memory snapshotBefore = _getPositionSnapshot(spoke, wethInfo, user); + uint256 sharesSupplied; + uint256 amountSupplied; + + { + vm.deal(user, amount); + vm.prank(user); + _logAction('NATIVE_SUPPLY_AS_COLLATERAL', wethInfo.symbol, amount); + (sharesSupplied, amountSupplied) = gateway.supplyAsCollateralNative{value: amount}( + address(spoke), + wethInfo.reserveId, + amount + ); + assertEq(amountSupplied, amount, 'NATIVE_SUPPLY_AS_COLLATERAL: amount mismatch'); + assertEq(user.balance, 0, 'NATIVE_SUPPLY_AS_COLLATERAL: user ETH not fully consumed'); + } + + Types.PositionSnapshot memory snapshotAfter = _getPositionSnapshot(spoke, wethInfo, user); + assertApproxEqAbs( + snapshotAfter.user.collateralAssets.delta(snapshotBefore.user.collateralAssets), + amountSupplied, + 2, + 'NATIVE_SUPPLY_AS_COLLATERAL: user assets mismatch' + ); + assertEq( + snapshotAfter.user.collateralShares.delta(snapshotBefore.user.collateralShares), + sharesSupplied, + 'NATIVE_SUPPLY_AS_COLLATERAL: user shares mismatch' + ); + assertApproxEqAbs( + snapshotAfter.spokeOnHub.collateralAssets.delta(snapshotBefore.spokeOnHub.collateralAssets), + amountSupplied, + 2, + 'NATIVE_SUPPLY_AS_COLLATERAL: hub assets mismatch' + ); + } + + function _testWithdrawNative( + INativeTokenGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory wethInfo, + address user, + uint256 amount + ) internal { + uint256 snapshot = vm.snapshotState(); + // --- Partial withdraw native --- + uint256 withdrawAmount = vm.randomUint(1, amount); + _withdrawNative({ + gateway: gateway, + spoke: spoke, + wethInfo: wethInfo, + user: user, + withdrawAmount: withdrawAmount + }); + vm.revertToState(snapshot); + // --- Full withdraw native --- + _withdrawNative({ + gateway: gateway, + spoke: spoke, + wethInfo: wethInfo, + user: user, + withdrawAmount: UINT256_MAX + }); + vm.revertToState(snapshot); + } + + function _withdrawNative( + INativeTokenGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory wethInfo, + address user, + uint256 withdrawAmount + ) internal { + Types.PositionSnapshot memory snapshotBefore = _getPositionSnapshot(spoke, wethInfo, user); + uint256 sharesWithdrawn; + uint256 expectedWithdrawnAmount; + + { + uint256 ethBefore = user.balance; + vm.prank(user); + _logAction('NATIVE_WITHDRAW', wethInfo.symbol, withdrawAmount); + uint256 amountWithdrawn; + (sharesWithdrawn, amountWithdrawn) = gateway.withdrawNative( + address(spoke), + wethInfo.reserveId, + withdrawAmount + ); + expectedWithdrawnAmount = withdrawAmount; + if (withdrawAmount == UINT256_MAX) { + assertEq( + amountWithdrawn, + snapshotBefore.user.collateralAssets, + 'NATIVE_WITHDRAW: amount mismatch' + ); + expectedWithdrawnAmount = amountWithdrawn; + } else { + assertEq(amountWithdrawn, withdrawAmount, 'NATIVE_WITHDRAW: amount mismatch'); + } + assertEq( + user.balance - ethBefore, + expectedWithdrawnAmount, + 'NATIVE_WITHDRAW: user ETH mismatch' + ); + } + + Types.PositionSnapshot memory snapshotAfter = _getPositionSnapshot(spoke, wethInfo, user); + + assertApproxEqAbs( + snapshotBefore.user.collateralAssets - snapshotAfter.user.collateralAssets, + expectedWithdrawnAmount, + 2, + 'NATIVE_WITHDRAW: user assets mismatch' + ); + assertEq( + snapshotBefore.user.collateralShares - snapshotAfter.user.collateralShares, + sharesWithdrawn, + 'NATIVE_WITHDRAW: user shares mismatch' + ); + assertApproxEqAbs( + snapshotBefore.spokeOnHub.collateralAssets - snapshotAfter.spokeOnHub.collateralAssets, + expectedWithdrawnAmount, + 2, + 'NATIVE_WITHDRAW: hub assets mismatch' + ); + assertEq( + snapshotBefore.spokeOnHub.collateralShares - snapshotAfter.spokeOnHub.collateralShares, + sharesWithdrawn, + 'NATIVE_WITHDRAW: hub shares mismatch' + ); + + if (withdrawAmount == UINT256_MAX) { + assertEq( + snapshotAfter.user.collateralAssets, + 0, + 'NATIVE_WITHDRAW: collateral should be zero after full withdraw' + ); + assertEq( + snapshotAfter.user.collateralShares, + 0, + 'NATIVE_WITHDRAW: shares should be zero after full withdraw' + ); + } + } + + function _testBorrowRepayNative( + INativeTokenGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory wethInfo, + address user, + uint256 amount + ) internal { + // Ensure user has enough collateral to borrow (uses any available collateral on spoke) + { + IAaveOracle oracle = IAaveOracle(spoke.ORACLE()); + uint256 price = oracle.getReservePrice(wethInfo.reserveId); + uint256 borrowDollarValue = (amount * price) / 10 ** (oracle.decimals() + wethInfo.decimals); + _ensureBorrowCapacity({ + spoke: spoke, + borrower: user, + borrowAmountInDollars: borrowDollarValue + }); + } + + // Ensure there is liquidity to borrow + _ensureLiquidity({spoke: spoke, reserveInfo: wethInfo, amount: amount}); + + // --- Borrow native --- + // borrow random amount within collateral factor + uint256 borrowAmount = vm.randomUint(1, amount / 2); + _borrowNative({ + gateway: gateway, + spoke: spoke, + wethInfo: wethInfo, + user: user, + borrowAmount: borrowAmount + }); + + // --- Repay native --- + uint256 repayAmount = vm.randomUint(1, borrowAmount); + _repayNative({ + gateway: gateway, + spoke: spoke, + wethInfo: wethInfo, + user: user, + repayAmount: repayAmount + }); + } + + function _borrowNative( + INativeTokenGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory wethInfo, + address user, + uint256 borrowAmount + ) internal { + Types.PositionSnapshot memory snapshotBefore = _getPositionSnapshot(spoke, wethInfo, user); + uint256 sharesBorrowed; + + { + uint256 ethBefore = user.balance; + vm.prank(user); + _logAction('NATIVE_BORROW', wethInfo.symbol, borrowAmount); + uint256 amountBorrowed; + (sharesBorrowed, amountBorrowed) = gateway.borrowNative( + address(spoke), + wethInfo.reserveId, + borrowAmount + ); + assertEq(amountBorrowed, borrowAmount, 'NATIVE_BORROW: amount mismatch'); + assertEq(user.balance.delta(ethBefore), borrowAmount, 'NATIVE_BORROW: user ETH mismatch'); + } + + Types.PositionSnapshot memory snapshotAfter = _getPositionSnapshot(spoke, wethInfo, user); + assertEq( + snapshotAfter.user.drawnShares.delta(snapshotBefore.user.drawnShares), + sharesBorrowed, + 'NATIVE_BORROW: user drawn shares mismatch' + ); + assertApproxEqAbs( + snapshotAfter.user.totalDebt.delta(snapshotBefore.user.totalDebt), + borrowAmount, + 2, + 'NATIVE_BORROW: user debt asset mismatch' + ); + assertApproxEqAbs( + snapshotAfter.spokeOnHub.totalDebt.delta(snapshotBefore.spokeOnHub.totalDebt), + borrowAmount, + 2, + 'NATIVE_BORROW: hub debt mismatch' + ); + assertEq( + snapshotAfter.spokeOnHub.drawnShares.delta(snapshotBefore.spokeOnHub.drawnShares), + sharesBorrowed, + 'NATIVE_BORROW: hub drawn shares mismatch' + ); + } + + function _repayNative( + INativeTokenGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory wethInfo, + address user, + uint256 repayAmount + ) internal { + Types.PositionSnapshot memory snapshotBefore = _getPositionSnapshot(spoke, wethInfo, user); + uint256 sharesRepaid; + + { + vm.deal(user, repayAmount); + uint256 ethBefore = user.balance; + vm.prank(user); + _logAction('NATIVE_REPAY', wethInfo.symbol, repayAmount); + uint256 amountRepaid; + (sharesRepaid, amountRepaid) = gateway.repayNative{value: repayAmount}( + address(spoke), + wethInfo.reserveId, + repayAmount + ); + assertEq(amountRepaid, repayAmount, 'NATIVE_REPAY: amount mismatch'); + assertEq(user.balance.delta(ethBefore), repayAmount, 'NATIVE_REPAY: user ETH mismatch'); + } + + Types.PositionSnapshot memory snapshotAfter = _getPositionSnapshot(spoke, wethInfo, user); + assertEq( + snapshotBefore.user.drawnShares.delta(snapshotAfter.user.drawnShares), + sharesRepaid, + 'NATIVE_REPAY: user drawn shares mismatch' + ); + assertApproxEqAbs( + snapshotBefore.user.totalDebt.delta(snapshotAfter.user.totalDebt), + repayAmount, + 2, + 'NATIVE_REPAY: user debt mismatch' + ); + assertApproxEqAbs( + snapshotBefore.spokeOnHub.totalDebt.delta(snapshotAfter.spokeOnHub.totalDebt), + repayAmount, + 2, + 'NATIVE_REPAY: hub debt mismatch' + ); + assertEq( + snapshotBefore.spokeOnHub.drawnShares.delta(snapshotAfter.spokeOnHub.drawnShares), + sharesRepaid, + 'NATIVE_REPAY: hub drawn shares mismatch' + ); + + if (repayAmount == UINT256_MAX) { + assertEq( + snapshotAfter.user.totalDebt, + 0, + 'NATIVE_REPAY: debt should be zero after full repay' + ); + } + } + + // ------------------------------------------------------------------------- + // SignatureGateway scenario + // ------------------------------------------------------------------------- + + /// @dev Test supply, withdraw, borrow, repay via SignatureGateway with EIP-712 signatures. + function _testSignatureGateway( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + Types.ReserveInfo memory collateralInfo + ) internal { + uint256 privateKey = vm.randomUint(1, type(uint248).max); + address user = vm.addr(privateKey); + uint256 amount = _getTokenAmountByDollarValue( + spoke.ORACLE(), + reserveInfo, + vm.randomUint(1_000, 10_000) + ); + + // Authorize gateway as position manager for user + vm.prank(user); + spoke.setUserPositionManager(address(gateway), true); + + // --- Supply with sig --- + _sigSupply({ + gateway: gateway, + spoke: spoke, + reserveInfo: reserveInfo, + privateKey: privateKey, + user: user, + amount: amount + }); + + // --- Partial withdraw with sig --- + _sigWithdraw({ + gateway: gateway, + spoke: spoke, + reserveInfo: reserveInfo, + privateKey: privateKey, + user: user, + amount: amount / 4 + }); + + // --- Borrow + repay with sig (if borrowable) --- + if (reserveInfo.borrowable) { + _sigSetupCollateralAndBorrowRepay({ + gateway: gateway, + spoke: spoke, + reserveInfo: reserveInfo, + collateralInfo: collateralInfo, + privateKey: privateKey, + user: user + }); + } + } + + function _sigSupply( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user, + uint256 amount + ) internal { + uint256 userAssetsBefore = spoke.getUserSuppliedAssets(reserveInfo.reserveId, user); + uint256 userSharesBefore = spoke.getUserSuppliedShares(reserveInfo.reserveId, user); + uint256 hubAssetsBefore = IHubBase(reserveInfo.hub).getSpokeAddedAssets( + reserveInfo.assetId, + address(spoke) + ); + + (uint256 sharesSupplied, uint256 amountSupplied) = _executeSigSupply({ + gateway: gateway, + spoke: spoke, + reserveInfo: reserveInfo, + privateKey: privateKey, + user: user, + amount: amount + }); + + assertApproxEqAbs( + spoke.getUserSuppliedAssets(reserveInfo.reserveId, user) - userAssetsBefore, + amountSupplied, + 2, + 'SIG_SUPPLY: user assets mismatch' + ); + assertEq( + spoke.getUserSuppliedShares(reserveInfo.reserveId, user) - userSharesBefore, + sharesSupplied, + 'SIG_SUPPLY: user shares mismatch' + ); + assertApproxEqAbs( + IHubBase(reserveInfo.hub).getSpokeAddedAssets(reserveInfo.assetId, address(spoke)) - + hubAssetsBefore, + amountSupplied, + 2, + 'SIG_SUPPLY: hub assets mismatch' + ); + } + + function _executeSigSupply( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user, + uint256 amount + ) internal returns (uint256 sharesSupplied, uint256 amountSupplied) { + uint256 nonceBefore = gateway.nonces(user, 0); + uint256 deadline = vm.getBlockTimestamp() + 1 hours; + + deal2(reserveInfo.underlying, user, amount); + vm.prank(user); + IERC20(reserveInfo.underlying).forceApprove(address(gateway), amount); + + bytes32 structHash = keccak256( + abi.encode( + gateway.SUPPLY_TYPEHASH(), + address(spoke), + reserveInfo.reserveId, + amount, + user, + nonceBefore, + deadline + ) + ); + bytes memory sig = _signForGateway(gateway, privateKey, structHash); + + _logAction('SIG_SUPPLY', reserveInfo.symbol, amount); + ISignatureGateway.Supply memory params = ISignatureGateway.Supply({ + spoke: address(spoke), + reserveId: reserveInfo.reserveId, + amount: amount, + onBehalfOf: user, + nonce: nonceBefore, + deadline: deadline + }); + (sharesSupplied, amountSupplied) = gateway.supplyWithSig(params, sig); + + assertEq(amountSupplied, amount, 'SIG_SUPPLY: amount mismatch'); + assertEq(gateway.nonces(user, 0), nonceBefore + 1, 'SIG_SUPPLY: nonce not incremented'); + } + + function _sigWithdraw( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user, + uint256 amount + ) internal { + uint256 userAssetsBefore = spoke.getUserSuppliedAssets(reserveInfo.reserveId, user); + uint256 userSharesBefore = spoke.getUserSuppliedShares(reserveInfo.reserveId, user); + uint256 hubAssetsBefore = IHubBase(reserveInfo.hub).getSpokeAddedAssets( + reserveInfo.assetId, + address(spoke) + ); + + (uint256 sharesWithdrawn, uint256 amountWithdrawn) = _executeSigWithdraw({ + gateway: gateway, + spoke: spoke, + reserveInfo: reserveInfo, + privateKey: privateKey, + user: user, + amount: amount + }); + + assertApproxEqAbs( + userAssetsBefore - spoke.getUserSuppliedAssets(reserveInfo.reserveId, user), + amountWithdrawn, + 1, + 'SIG_WITHDRAW: user assets mismatch' + ); + assertEq( + userSharesBefore - spoke.getUserSuppliedShares(reserveInfo.reserveId, user), + sharesWithdrawn, + 'SIG_WITHDRAW: user shares mismatch' + ); + assertApproxEqAbs( + hubAssetsBefore - + IHubBase(reserveInfo.hub).getSpokeAddedAssets(reserveInfo.assetId, address(spoke)), + amountWithdrawn, + 1, + 'SIG_WITHDRAW: hub assets mismatch' + ); + + if (amount == UINT256_MAX) { + assertEq( + spoke.getUserSuppliedAssets(reserveInfo.reserveId, user), + 0, + 'SIG_WITHDRAW: collateral should be zero after full withdraw' + ); + } + } + + function _executeSigWithdraw( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user, + uint256 amount + ) internal returns (uint256 sharesWithdrawn, uint256 amountWithdrawn) { + uint256 nonceBefore = gateway.nonces(user, 0); + uint256 deadline = vm.getBlockTimestamp() + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + gateway.WITHDRAW_TYPEHASH(), + address(spoke), + reserveInfo.reserveId, + amount, + user, + nonceBefore, + deadline + ) + ); + bytes memory sig = _signForGateway(gateway, privateKey, structHash); + + _logAction('SIG_WITHDRAW', reserveInfo.symbol, amount); + ISignatureGateway.Withdraw memory params = ISignatureGateway.Withdraw({ + spoke: address(spoke), + reserveId: reserveInfo.reserveId, + amount: amount, + onBehalfOf: user, + nonce: nonceBefore, + deadline: deadline + }); + (sharesWithdrawn, amountWithdrawn) = gateway.withdrawWithSig(params, sig); + + if (amount != UINT256_MAX) { + assertEq(amountWithdrawn, amount, 'SIG_WITHDRAW: amount mismatch'); + } + assertEq(gateway.nonces(user, 0), nonceBefore + 1, 'SIG_WITHDRAW: nonce not incremented'); + } + + function _sigSetupCollateralAndBorrowRepay( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + Types.ReserveInfo memory collateralInfo, + uint256 privateKey, + address user + ) internal { + address oracleAddr = spoke.ORACLE(); + uint256 borrowDollars = vm.randomUint(1_000, 10_000); + uint256 borrowAmount = _getTokenAmountByDollarValue(oracleAddr, reserveInfo, borrowDollars); + // 3x collateral to ensure HF stays above 1 + uint256 collateralAmount = _getTokenAmountByDollarValue( + oracleAddr, + collateralInfo, + borrowDollars * 3 + ); + + // Supply collateral + enable as collateral via sig + _sigSupply({ + gateway: gateway, + spoke: spoke, + reserveInfo: collateralInfo, + privateKey: privateKey, + user: user, + amount: collateralAmount + }); + _sigSetUsingAsCollateral({ + gateway: gateway, + spoke: spoke, + reserveInfo: collateralInfo, + privateKey: privateKey, + user: user + }); + + // Ensure liquidity + borrow + repay + _ensureLiquidity(spoke, reserveInfo, borrowAmount); + _sigBorrow({ + gateway: gateway, + spoke: spoke, + reserveInfo: reserveInfo, + privateKey: privateKey, + user: user, + amount: borrowAmount + }); + // repay partial + _sigRepay({ + gateway: gateway, + spoke: spoke, + reserveInfo: reserveInfo, + privateKey: privateKey, + user: user, + amount: vm.randomUint(1, borrowAmount) + }); + // repay + _sigRepay({ + gateway: gateway, + spoke: spoke, + reserveInfo: reserveInfo, + privateKey: privateKey, + user: user, + amount: UINT256_MAX + }); + } + + function _sigSetUsingAsCollateral( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user + ) internal { + uint256 nonceBefore = gateway.nonces(user, 0); + ISignatureGateway.SetUsingAsCollateral memory setParams = ISignatureGateway + .SetUsingAsCollateral({ + spoke: address(spoke), + reserveId: reserveInfo.reserveId, + useAsCollateral: true, + onBehalfOf: user, + nonce: nonceBefore, + deadline: vm.getBlockTimestamp() + 1 hours + }); + bytes32 structHash = keccak256( + abi.encode( + gateway.SET_USING_AS_COLLATERAL_TYPEHASH(), + setParams.spoke, + setParams.reserveId, + setParams.useAsCollateral, + setParams.onBehalfOf, + setParams.nonce, + setParams.deadline + ) + ); + bytes memory sig = _signForGateway(gateway, privateKey, structHash); + gateway.setUsingAsCollateralWithSig(setParams, sig); + + assertEq(gateway.nonces(user, 0), nonceBefore + 1, 'SIG_SET_COLLATERAL: nonce not incremented'); + (bool isUsingAsCollateral, ) = spoke.getUserReserveStatus(reserveInfo.reserveId, user); + assertTrue(isUsingAsCollateral, 'SIG_SET_COLLATERAL: not set as collateral'); + } + + function _sigBorrow( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user, + uint256 amount + ) internal { + uint256 userDebtBefore = spoke.getUserTotalDebt(reserveInfo.reserveId, user); + uint256 userDrawnSharesBefore = spoke.getUserPosition(reserveInfo.reserveId, user).drawnShares; + uint256 hubDebtBefore = IHubBase(reserveInfo.hub).getSpokeTotalOwed( + reserveInfo.assetId, + address(spoke) + ); + uint256 hubDrawnSharesBefore = IHubBase(reserveInfo.hub).getSpokeDrawnShares( + reserveInfo.assetId, + address(spoke) + ); + + (uint256 sharesBorrowed, uint256 amountBorrowed) = _executeSigBorrow({ + gateway: gateway, + spoke: spoke, + reserveInfo: reserveInfo, + privateKey: privateKey, + user: user, + amount: amount + }); + + assertApproxEqAbs( + spoke.getUserTotalDebt(reserveInfo.reserveId, user) - userDebtBefore, + amountBorrowed, + 2, + 'SIG_BORROW: user debt mismatch' + ); + assertEq( + spoke.getUserPosition(reserveInfo.reserveId, user).drawnShares - userDrawnSharesBefore, + sharesBorrowed, + 'SIG_BORROW: user drawn shares mismatch' + ); + assertApproxEqAbs( + IHubBase(reserveInfo.hub).getSpokeTotalOwed(reserveInfo.assetId, address(spoke)) - + hubDebtBefore, + amountBorrowed, + 2, + 'SIG_BORROW: hub debt mismatch' + ); + assertEq( + IHubBase(reserveInfo.hub).getSpokeDrawnShares(reserveInfo.assetId, address(spoke)) - + hubDrawnSharesBefore, + sharesBorrowed, + 'SIG_BORROW: hub drawn shares mismatch' + ); + } + + function _executeSigBorrow( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user, + uint256 amount + ) internal returns (uint256 sharesBorrowed, uint256 amountBorrowed) { + uint256 nonceBefore = gateway.nonces(user, 0); + uint256 deadline = vm.getBlockTimestamp() + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + gateway.BORROW_TYPEHASH(), + address(spoke), + reserveInfo.reserveId, + amount, + user, + nonceBefore, + deadline + ) + ); + bytes memory sig = _signForGateway(gateway, privateKey, structHash); + + _logAction('SIG_BORROW', reserveInfo.symbol, amount); + ISignatureGateway.Borrow memory params = ISignatureGateway.Borrow({ + spoke: address(spoke), + reserveId: reserveInfo.reserveId, + amount: amount, + onBehalfOf: user, + nonce: nonceBefore, + deadline: deadline + }); + (sharesBorrowed, amountBorrowed) = gateway.borrowWithSig(params, sig); + + assertEq(amountBorrowed, amount, 'SIG_BORROW: amount mismatch'); + assertEq(gateway.nonces(user, 0), nonceBefore + 1, 'SIG_BORROW: nonce not incremented'); + } + + function _sigRepay( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user, + uint256 amount + ) internal { + uint256 userDebtBefore = spoke.getUserTotalDebt(reserveInfo.reserveId, user); + uint256 userDrawnSharesBefore = spoke.getUserPosition(reserveInfo.reserveId, user).drawnShares; + uint256 hubDebtBefore = IHubBase(reserveInfo.hub).getSpokeTotalOwed( + reserveInfo.assetId, + address(spoke) + ); + uint256 hubDrawnSharesBefore = IHubBase(reserveInfo.hub).getSpokeDrawnShares( + reserveInfo.assetId, + address(spoke) + ); + + (uint256 sharesRepaid, uint256 amountRepaid) = _executeSigRepay({ + gateway: gateway, + spoke: spoke, + reserveInfo: reserveInfo, + privateKey: privateKey, + user: user, + amount: amount + }); + + assertApproxEqAbs( + userDebtBefore - spoke.getUserTotalDebt(reserveInfo.reserveId, user), + amountRepaid, + 2, + 'SIG_REPAY: user debt mismatch' + ); + assertEq( + userDrawnSharesBefore - spoke.getUserPosition(reserveInfo.reserveId, user).drawnShares, + sharesRepaid, + 'SIG_REPAY: user drawn shares mismatch' + ); + assertApproxEqAbs( + hubDebtBefore - + IHubBase(reserveInfo.hub).getSpokeTotalOwed(reserveInfo.assetId, address(spoke)), + amountRepaid, + 2, + 'SIG_REPAY: hub debt mismatch' + ); + assertEq( + hubDrawnSharesBefore - + IHubBase(reserveInfo.hub).getSpokeDrawnShares(reserveInfo.assetId, address(spoke)), + sharesRepaid, + 'SIG_REPAY: hub drawn shares mismatch' + ); + + if (amount == UINT256_MAX) { + assertEq( + spoke.getUserTotalDebt(reserveInfo.reserveId, user), + 0, + 'SIG_REPAY: debt should be zero after full repay' + ); + } + } + + function _executeSigRepay( + ISignatureGateway gateway, + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user, + uint256 amount + ) internal returns (uint256 sharesRepaid, uint256 amountRepaid) { + uint256 nonceBefore = gateway.nonces(user, 0); + uint256 deadline = vm.getBlockTimestamp() + 1 hours; + + uint256 mintAmount = amount == UINT256_MAX + ? spoke.getUserTotalDebt(reserveInfo.reserveId, user) + : amount; + deal2(reserveInfo.underlying, user, mintAmount + 2); + vm.prank(user); + IERC20(reserveInfo.underlying).forceApprove(address(gateway), mintAmount + 2); + + bytes32 structHash = keccak256( + abi.encode( + gateway.REPAY_TYPEHASH(), + address(spoke), + reserveInfo.reserveId, + amount, + user, + nonceBefore, + deadline + ) + ); + bytes memory sig = _signForGateway(gateway, privateKey, structHash); + + _logAction('SIG_REPAY', reserveInfo.symbol, amount); + ISignatureGateway.Repay memory params = ISignatureGateway.Repay({ + spoke: address(spoke), + reserveId: reserveInfo.reserveId, + amount: amount, + onBehalfOf: user, + nonce: nonceBefore, + deadline: deadline + }); + (sharesRepaid, amountRepaid) = gateway.repayWithSig(params, sig); + + assertEq(gateway.nonces(user, 0), nonceBefore + 1, 'SIG_REPAY: nonce not incremented'); + } +} diff --git a/src/dependencies/v4/Helpers.sol b/src/dependencies/v4/Helpers.sol new file mode 100644 index 00000000..f9987a9f --- /dev/null +++ b/src/dependencies/v4/Helpers.sol @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC20Metadata} from 'openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol'; +import {IAccessManaged} from 'aave-v4/dependencies/openzeppelin/IAccessManaged.sol'; +import {HubConfigurator} from 'aave-v4/hub/HubConfigurator.sol'; +import {ISpoke, IHub, IHubConfigurator, IAaveOracle} from 'aave-address-book/AaveV4.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; +import {Actions} from 'src/dependencies/v4/Actions.sol'; + +/// @title Helpers +/// @notice Query and utility functions for V4 e2e tests. +abstract contract Helpers is Actions { + /// @notice Build ReserveInfo[] for all reserves on a spoke. + function _getReserveInfo(ISpoke spoke) internal view returns (Types.ReserveInfo[] memory) { + uint256 count = spoke.getReserveCount(); + Types.ReserveInfo[] memory info = new Types.ReserveInfo[](count); + + for (uint256 reserveId; reserveId < count; reserveId++) { + ISpoke.Reserve memory reserve = spoke.getReserve(reserveId); + ISpoke.ReserveConfig memory config = spoke.getReserveConfig(reserveId); + ISpoke.DynamicReserveConfig memory dynamicConfig = spoke.getDynamicReserveConfig( + reserveId, + reserve.dynamicConfigKey + ); + + info[reserveId] = Types.ReserveInfo({ + reserveId: reserveId, + underlying: reserve.underlying, + hub: address(reserve.hub), + assetId: reserve.assetId, + symbol: _safeSymbol(reserve.underlying), + decimals: reserve.decimals, + paused: config.paused, + frozen: config.frozen, + borrowable: config.borrowable, + collateralEnabled: dynamicConfig.collateralFactor > 0, + collateralFactor: dynamicConfig.collateralFactor, + maxLiquidationBonus: dynamicConfig.maxLiquidationBonus, + liquidationFee: dynamicConfig.liquidationFee + }); + } + return info; + } + + /// @notice Return all usable collaterals: not paused, not frozen, collateralFactor > 0. + function _getAllUsableCollaterals( + Types.ReserveInfo[] memory infos + ) internal pure returns (Types.ReserveInfo[] memory) { + uint256 count; + for (uint256 i; i < infos.length; i++) { + if (!infos[i].paused && !infos[i].frozen && infos[i].collateralEnabled) { + count++; + } + } + Types.ReserveInfo[] memory result = new Types.ReserveInfo[](count); + uint256 index; + for (uint256 i; i < infos.length; i++) { + if (!infos[i].paused && !infos[i].frozen && infos[i].collateralEnabled) { + result[index] = infos[i]; + index++; + } + } + return result; + } + + /// @notice Return all usable debt reserves: not paused, not frozen, borrowable. + function _getAllUsableDebtReserves( + Types.ReserveInfo[] memory infos + ) internal pure returns (Types.ReserveInfo[] memory) { + uint256 count; + for (uint256 i; i < infos.length; i++) { + if (!infos[i].paused && !infos[i].frozen && infos[i].borrowable) { + count++; + } + } + Types.ReserveInfo[] memory result = new Types.ReserveInfo[](count); + uint256 index; + for (uint256 i; i < infos.length; i++) { + if (!infos[i].paused && !infos[i].frozen && infos[i].borrowable) { + result[index] = infos[i]; + index++; + } + } + return result; + } + + /// @notice Ensure the hub has enough liquidity for a borrow by supplying on the given spoke. + /// Assumes addCaps have been set to max via _setCapsToMax before calling. + function _ensureLiquidity( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint256 amount + ) internal { + _supply({spoke: spoke, reserveInfo: reserveInfo, user: vm.randomAddress(), amount: amount}); + } + + /// @notice Supply collateral to borrower on the same spoke, then enable as collateral. + /// Assumes addCaps have been set to max via _setCapsToMax before calling. + function _ensureCollateral( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + address borrower, + uint256 amount + ) internal { + _supply(spoke, reserveInfo, borrower, amount); + vm.prank(borrower); + spoke.setUsingAsCollateral({ + reserveId: reserveInfo.reserveId, + usingAsCollateral: true, + onBehalfOf: borrower + }); + } + + /// @notice Ensure borrower has enough collateral to borrow a given dollar amount. + /// Loops over all collateral-enabled reserves, supplying until capacity is sufficient. + /// Compares against CF-adjusted totalCollateralValue, so it may use multiple reserves. + function _ensureBorrowCapacity( + ISpoke spoke, + address borrower, + uint256 borrowAmountInDollars + ) internal { + Types.ReserveInfo[] memory goodCollaterals = _getAllUsableCollaterals(_getReserveInfo(spoke)); + address oracleAddr = spoke.ORACLE(); + uint8 oracleDecimals = IAaveOracle(oracleAddr).decimals(); + uint256 targetCollateralDollarAmount = borrowAmountInDollars * 3; + uint256 targetCollateralValue = targetCollateralDollarAmount * 10 ** oracleDecimals; + + for (uint256 i; i < goodCollaterals.length; i++) { + uint256 supplyAmount = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: goodCollaterals[i], + dollarValue: targetCollateralDollarAmount + }); + + _ensureCollateral({ + spoke: spoke, + reserveInfo: goodCollaterals[i], + borrower: borrower, + amount: supplyAmount + }); + + // Check after supplying — totalCollateralValue is CF-adjusted, so we may need + // multiple reserves to reach the target raw collateral value. + ISpoke.UserAccountData memory account = spoke.getUserAccountData(borrower); + if (account.totalCollateralValue > targetCollateralValue) { + break; + } + } + } + + /// @notice Convert a dollar value to token amount using the spoke oracle. + function _getTokenAmountByDollarValue( + address oracleAddr, + Types.ReserveInfo memory reserveInfo, + uint256 dollarValue + ) internal view returns (uint256) { + IAaveOracle oracle = IAaveOracle(oracleAddr); + uint256 price = oracle.getReservePrice(reserveInfo.reserveId); + uint8 oracleDecimals = oracle.decimals(); + return (dollarValue * 10 ** (oracleDecimals + reserveInfo.decimals)) / price; + } + + /// @notice Supply up to `extraCount` of additional collaterals for the user, up to `maxUserReserves`. + function _supplyRandomExtraCollaterals( + ISpoke spoke, + Types.ReserveInfo[] memory goodCollaterals, + uint256 primaryIndex, + uint256 testAssetReserveId, + address oracleAddr, + address user, + uint256 extraCount + ) internal { + if (goodCollaterals.length <= 1 || extraCount == 0) { + return; + } + + uint16 maxUserReserves = spoke.MAX_USER_RESERVES_LIMIT(); + + // Track collateral count before starting + ISpoke.UserAccountData memory accountBefore = spoke.getUserAccountData(user); + uint256 expectedCollateralCount = accountBefore.activeCollateralCount; + + uint256 supplied; + for (uint256 index; index < goodCollaterals.length && supplied < extraCount; index++) { + // skip the primary collateral and the test asset + if (index == primaryIndex || goodCollaterals[index].reserveId == testAssetReserveId) { + continue; + } + + // When at the limit, assert the next collateral enable reverts, then restore state + if (expectedCollateralCount + 1 > maxUserReserves) { + _assertMaxUserReservesReverts({ + spoke: spoke, + reserveInfo: goodCollaterals[index], + oracleAddr: oracleAddr, + user: user, + isCollateral: true + }); + break; + } + + // adding too much collateral will mean user's HF is too high to make liquidatable easily + uint256 extraDollars = vm.randomUint(1_000, 10_000); + uint256 extraAmount = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: goodCollaterals[index], + dollarValue: extraDollars + }); + + _supply({spoke: spoke, reserveInfo: goodCollaterals[index], user: user, amount: extraAmount}); + vm.prank(user); + spoke.setUsingAsCollateral({ + reserveId: goodCollaterals[index].reserveId, + usingAsCollateral: true, + onBehalfOf: user + }); + + supplied++; + expectedCollateralCount++; + + // Verify activeCollateralCount matches expected + ISpoke.UserAccountData memory accountAfter = spoke.getUserAccountData(user); + assertEq( + accountAfter.activeCollateralCount, + expectedCollateralCount, + 'EXTRA_COLLATERAL: activeCollateralCount mismatch' + ); + assertLe( + accountAfter.activeCollateralCount, + maxUserReserves, + 'EXTRA_COLLATERAL: exceeds MAX_USER_RESERVES_LIMIT' + ); + } + } + + /// @notice Borrow from a random number of extra debt reserves for the user. + /// Supplies liquidity from a separate provider before each borrow. + function _borrowRandomExtraReserves( + ISpoke spoke, + Types.ReserveInfo[] memory usableDebtReserves, + uint256 primaryReserveId, + address oracleAddr, + address user, + uint256 extraCount + ) internal { + if (usableDebtReserves.length <= 1 || extraCount == 0) { + return; + } + + uint16 maxUserReserves = spoke.MAX_USER_RESERVES_LIMIT(); + + ISpoke.UserAccountData memory accountBefore = spoke.getUserAccountData(user); + uint256 expectedBorrowCount = accountBefore.borrowCount; + + uint256 borrowed; + for (uint256 index; index < usableDebtReserves.length && borrowed < extraCount; index++) { + Types.ReserveInfo memory debtReserve = usableDebtReserves[index]; + + if (debtReserve.reserveId == primaryReserveId) { + continue; + } + + // When at the limit, assert the next borrow reverts, then restore state + if (expectedBorrowCount + 1 > maxUserReserves) { + _assertMaxUserReservesReverts({ + spoke: spoke, + reserveInfo: debtReserve, + oracleAddr: oracleAddr, + user: user, + isCollateral: false + }); + break; + } + + uint256 extraDollars = vm.randomUint(100, 1_000); + uint256 extraAmount = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: debtReserve, + dollarValue: extraDollars + }); + + _ensureLiquidity({spoke: spoke, reserveInfo: debtReserve, amount: extraAmount}); + _borrow({spoke: spoke, reserveInfo: debtReserve, user: user, amount: extraAmount}); + + borrowed++; + expectedBorrowCount++; + + // Verify borrowCount within limit + ISpoke.UserAccountData memory accountAfter = spoke.getUserAccountData(user); + assertLe( + accountAfter.borrowCount, + maxUserReserves, + 'EXTRA_BORROW: exceeds MAX_USER_RESERVES_LIMIT' + ); + assertEq(accountAfter.borrowCount, expectedBorrowCount, 'EXTRA_BORROW: borrowCount mismatch'); + } + } + + /// @notice Assert that exceeding MAX_USER_RESERVES_LIMIT reverts, then restore state. + function _assertMaxUserReservesReverts( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + address oracleAddr, + address user, + bool isCollateral + ) internal { + uint256 snapshot = vm.snapshotState(); + + uint256 dollarValue = vm.randomUint(1_000, 50_000); + uint256 amount = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: reserveInfo, + dollarValue: dollarValue + }); + + if (isCollateral) { + _supply({spoke: spoke, reserveInfo: reserveInfo, user: user, amount: amount}); + vm.prank(user); + vm.expectRevert(ISpoke.MaximumUserReservesExceeded.selector); + spoke.setUsingAsCollateral({ + reserveId: reserveInfo.reserveId, + usingAsCollateral: true, + onBehalfOf: user + }); + } else { + _ensureLiquidity({spoke: spoke, reserveInfo: reserveInfo, amount: amount}); + vm.prank(user); + vm.expectRevert(ISpoke.MaximumUserReservesExceeded.selector); + spoke.borrow({reserveId: reserveInfo.reserveId, amount: amount, onBehalfOf: user}); + } + + vm.revertToState(snapshot); + } + + /// @notice Set all addCap/drawCap to max for every reserve on the spoke. + function _setCapsToMax(ISpoke spoke) internal { + _setSpokeCapsToMaxForAllReserves({spoke: spoke, maxAddCap: true, maxDrawCap: true}); + } + + /// @notice Set all addCap to max for every reserve on the spoke (leaves drawCap unchanged). + function _setAddCapsToMax(ISpoke spoke) internal { + _setSpokeCapsToMaxForAllReserves({spoke: spoke, maxAddCap: true, maxDrawCap: false}); + } + + /// @notice Set caps to max for a single hub-asset-spoke combination. + /// @param maxAddCap If true, set addCap to max. + /// @param maxDrawCap If true, set drawCap to max. + function _setSpokeCapsToMax( + IHub hub, + uint256 assetId, + address spoke, + bool maxAddCap, + bool maxDrawCap + ) internal { + IHubConfigurator configurator = _deployMockedHubConfigurator(hub); + IHub.SpokeConfig memory config = hub.getSpokeConfig(assetId, spoke); + if (maxAddCap) { + config.addCap = hub.MAX_ALLOWED_SPOKE_CAP(); + } + if (maxDrawCap) { + config.drawCap = hub.MAX_ALLOWED_SPOKE_CAP(); + } + configurator.updateSpokeCaps({ + hub: address(hub), + assetId: assetId, + spoke: spoke, + addCap: config.addCap, + drawCap: config.drawCap + }); + // clear mocked call from _deployMockedHubConfigurator + vm.clearMockedCalls(); + } + + function _setSpokeCapsToMaxForAllReserves(ISpoke spoke, bool maxAddCap, bool maxDrawCap) private { + Types.ReserveInfo[] memory infos = _getReserveInfo(spoke); + for (uint256 i; i < infos.length; i++) { + _setSpokeCapsToMax({ + hub: IHub(infos[i].hub), + assetId: infos[i].assetId, + spoke: address(spoke), + maxAddCap: maxAddCap, + maxDrawCap: maxDrawCap + }); + } + vm.clearMockedCalls(); + } + + /// @notice Deploy a temporary HubConfigurator with the hub's access manager, mocked to allow all calls. + function _deployMockedHubConfigurator(IHub hub) internal returns (IHubConfigurator) { + address accessManager = IAccessManaged(address(hub)).authority(); + vm.mockCall( + accessManager, + abi.encodeWithSelector(bytes4(keccak256('canCall(address,address,bytes4)'))), + abi.encode(true, uint32(0)) + ); + return IHubConfigurator(address(new HubConfigurator(accessManager))); + } + + function _safeSymbol(address token) internal view returns (string memory) { + return IERC20Metadata(token).symbol(); + } +} diff --git a/src/dependencies/v4/Scenarios.sol b/src/dependencies/v4/Scenarios.sol new file mode 100644 index 00000000..28dcb6c2 --- /dev/null +++ b/src/dependencies/v4/Scenarios.sol @@ -0,0 +1,677 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'forge-std/Test.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from 'openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol'; +import {ISpoke, IHub, IAaveOracle, ISpokeConfigurator} from 'aave-address-book/AaveV4.sol'; +import {AaveV4Ethereum} from 'aave-address-book/AaveV4Ethereum.sol'; +import {IPriceOracle} from 'aave-v4/spoke/interfaces/IPriceOracle.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; +import {Helpers} from 'src/dependencies/v4/Helpers.sol'; + +/// @title Scenarios +/// @notice Test scenario orchestration for V4 e2e tests. +abstract contract Scenarios is Helpers { + using SafeERC20 for IERC20; + + /// @dev Makes a user liquidatable by reducing collateral factors and manipulating oracle prices. + /// Two-passes: CF updates first, then oracle mocks + /// in a separate pass so clearMockedCalls doesn't wipe earlier price mocks. + function _makeUserLiquidatable(ISpoke spoke, address user) internal virtual { + address oracle = spoke.ORACLE(); + uint256 reserveCount = spoke.getReserveCount(); + + // Pass 1: reduce collateral factors (each call mocks ACCESS_MANAGER then clears all mocks) + for (uint256 i; i < reserveCount; i++) { + if (spoke.getUserSuppliedAssets(i, user) > 0) { + _updateCollateralFactor({spoke: spoke, reserveId: i, user: user, collateralFactor: 1}); + } + } + + // Pass 2: manipulate oracle prices (safe from clearMockedCalls now) + for (uint256 i; i < reserveCount; i++) { + uint256 userSupply = spoke.getUserSuppliedAssets(i, user); + uint256 userDebt = spoke.getUserTotalDebt(i, user); + + if (userSupply > 0 && userDebt == 0) { + uint256 currentPrice = IAaveOracle(oracle).getReservePrice(i); + vm.mockCall( + oracle, + abi.encodeWithSelector(IPriceOracle.getReservePrice.selector, i), + abi.encode(currentPrice / 100) + ); + } else if (userDebt > 0 && userSupply == 0) { + uint256 currentPrice = IAaveOracle(oracle).getReservePrice(i); + vm.mockCall( + oracle, + abi.encodeWithSelector(IPriceOracle.getReservePrice.selector, i), + abi.encode(currentPrice * 100) + ); + } + } + + ISpoke.UserAccountData memory accountData = spoke.getUserAccountData(user); + assertLt( + accountData.healthFactor, + HEALTH_FACTOR_LIQUIDATION_THRESHOLD, + 'MAKE_LIQUIDATABLE: health factor not below 1' + ); + } + + /// @dev Update the collateral factor on the user's existing dynamic config key. + /// Mocks ACCESS_MANAGER to bypass auth, then calls SpokeConfigurator.updateCollateralFactor. + function _updateCollateralFactor( + ISpoke spoke, + uint256 reserveId, + address user, + uint16 collateralFactor + ) internal { + uint32 userConfigKey = spoke.getUserPosition(reserveId, user).dynamicConfigKey; + vm.mockCall( + address(AaveV4Ethereum.ACCESS_MANAGER), + abi.encodeWithSelector(bytes4(keccak256('canCall(address,address,bytes4)'))), + abi.encode(true, uint32(0)) + ); + AaveV4Ethereum.SPOKE_CONFIGURATOR.updateCollateralFactor({ + spoke: address(spoke), + reserveId: reserveId, + dynamicConfigKey: userConfigKey, + collateralFactor: collateralFactor + }); + vm.clearMockedCalls(); + + assertEq( + collateralFactor, + spoke.getDynamicReserveConfig(reserveId, userConfigKey).collateralFactor + ); + } + + /// @dev Supply collateral(s) and test asset, return the test asset amount. + function _setupPositions( + ISpoke spoke, + Types.ReserveInfo[] memory goodCollaterals, + uint256 primaryCollateralIndex, + Types.ReserveInfo memory testAssetInfo, + address collateralSupplier, + address testAssetSupplier + ) internal returns (uint256 testAssetAmount) { + Types.ReserveInfo memory collateralInfo = goodCollaterals[primaryCollateralIndex]; + address oracle = spoke.ORACLE(); + + uint256 collateralDollars = vm.randomUint(50_000, 200_000); + uint256 testAssetDollars = vm.randomUint(1_000, 20_000); + uint256 collateralAmount = _getTokenAmountByDollarValue({ + oracleAddr: oracle, + reserveInfo: collateralInfo, + dollarValue: collateralDollars + }); + testAssetAmount = _getTokenAmountByDollarValue({ + oracleAddr: oracle, + reserveInfo: testAssetInfo, + dollarValue: testAssetDollars + }); + + // Supply primary collateral + _supply({ + spoke: spoke, + reserveInfo: collateralInfo, + user: collateralSupplier, + amount: collateralAmount + }); + vm.prank(collateralSupplier); + spoke.setUsingAsCollateral({ + reserveId: collateralInfo.reserveId, + usingAsCollateral: true, + onBehalfOf: collateralSupplier + }); + + { + ISpoke.UserAccountData memory accountAfterCollateral = spoke.getUserAccountData( + collateralSupplier + ); + assertEq( + accountAfterCollateral.activeCollateralCount, + 1, + 'SETUP: activeCollateralCount should be 1 after primary collateral' + ); + } + + // Supply random extra collaterals up to remaining capacity + { + uint256 extraCount = _randomExtraCount({ + spoke: spoke, + user: collateralSupplier, + available: goodCollaterals.length > 1 ? goodCollaterals.length - 1 : 0 + }); + _supplyRandomExtraCollaterals({ + spoke: spoke, + goodCollaterals: goodCollaterals, + primaryIndex: primaryCollateralIndex, + testAssetReserveId: testAssetInfo.reserveId, + oracleAddr: oracle, + user: collateralSupplier, + extraCount: extraCount + }); + } + + // Supply test asset + _supply({ + spoke: spoke, + reserveInfo: testAssetInfo, + user: testAssetSupplier, + amount: testAssetAmount + }); + } + + function _testPartialWithdrawal( + ISpoke spoke, + Types.ReserveInfo memory testAssetInfo, + address testAssetSupplier, + uint256 testAssetAmount + ) internal { + uint256 partialWithdraw = testAssetAmount > 1 + ? vm.randomUint(1, testAssetAmount - 1) + : testAssetAmount; + _withdraw(spoke, testAssetInfo, testAssetSupplier, partialWithdraw); + } + + function _testFullWithdrawal( + ISpoke spoke, + Types.ReserveInfo memory testAssetInfo, + address testAssetSupplier + ) internal { + _withdraw(spoke, testAssetInfo, testAssetSupplier, UINT256_MAX); + } + + /// @dev Setup borrows: calculate ceiling, first+second borrow, extras. + /// Returns the borrow ceiling (0 means no borrow was possible). + function _setupBorrows( + ISpoke spoke, + Types.ReserveInfo memory testAssetInfo, + address collateralSupplier, + uint256 testAssetAmount + ) internal returns (uint256 borrowCeiling) { + // Cap borrow by user's available borrowing power to avoid HealthFactorBelowThreshold. + { + ISpoke.UserAccountData memory accountData = spoke.getUserAccountData(collateralSupplier); + // maxDebtValue = CF-weighted collateral value (HF=1 threshold) + uint256 maxDebtValue = (accountData.totalCollateralValue * accountData.avgCollateralFactor) / + 1e18; + uint256 currentDebtValue = accountData.totalDebtValueRay / 1e27; + uint256 availableDebtValue = maxDebtValue > currentDebtValue + ? maxDebtValue - currentDebtValue + : 0; + + // Convert to test asset tokens + address oracleAddr = spoke.ORACLE(); + uint256 testAssetPrice = IAaveOracle(oracleAddr).getReservePrice(testAssetInfo.reserveId); + uint256 maxBorrowableAmount = (availableDebtValue * 10 ** testAssetInfo.decimals) / + testAssetPrice; + // Use 50% of max for safety margin + maxBorrowableAmount = maxBorrowableAmount / 2; + borrowCeiling = testAssetAmount < maxBorrowableAmount ? testAssetAmount : maxBorrowableAmount; + } + if (borrowCeiling == 0) { + return 0; + } + + // First borrow (random partial amount) + uint256 firstBorrow = borrowCeiling > 2 ? vm.randomUint(1, borrowCeiling / 2) : borrowCeiling; + _borrow({ + spoke: spoke, + reserveInfo: testAssetInfo, + user: collateralSupplier, + amount: firstBorrow + }); + + // Health factor + reserves limit check after first borrow + { + ISpoke.UserAccountData memory accountData = spoke.getUserAccountData(collateralSupplier); + assertGe( + accountData.healthFactor, + HEALTH_FACTOR_LIQUIDATION_THRESHOLD, + 'HEALTH: health factor below 1 after borrow' + ); + assertLe( + accountData.borrowCount, + spoke.MAX_USER_RESERVES_LIMIT(), + 'BORROW: borrowCount exceeds MAX_USER_RESERVES_LIMIT' + ); + } + + // Second sequential borrow on same reserve + uint256 remaining = borrowCeiling - firstBorrow; + if (remaining > 0) { + uint256 secondBorrow = vm.randomUint(1, remaining); + _borrow({ + spoke: spoke, + reserveInfo: testAssetInfo, + user: collateralSupplier, + amount: secondBorrow + }); + + // Verify borrow count unchanged (same reserve, not a new borrow position) + ISpoke.UserAccountData memory accountAfterSecond = spoke.getUserAccountData( + collateralSupplier + ); + assertLe( + accountAfterSecond.borrowCount, + spoke.MAX_USER_RESERVES_LIMIT(), + 'BORROW: borrowCount exceeds MAX_USER_RESERVES_LIMIT after second borrow' + ); + } + + // Borrow from random extra borrowable reserves up to remaining capacity + _borrowExtrasWithinLimit({ + spoke: spoke, + primaryReserveId: testAssetInfo.reserveId, + user: collateralSupplier + }); + } + + function _testPartialRepay( + ISpoke spoke, + Types.ReserveInfo memory testAssetInfo, + address collateralSupplier + ) internal { + uint256 actualDebt = spoke.getUserTotalDebt(testAssetInfo.reserveId, collateralSupplier); + if (actualDebt > 1) { + uint256 partialRepay = vm.randomUint(1, actualDebt - 1); + _repay({ + spoke: spoke, + reserveInfo: testAssetInfo, + user: collateralSupplier, + amount: partialRepay + }); + } + } + + function _testFullRepay( + ISpoke spoke, + Types.ReserveInfo memory testAssetInfo, + address collateralSupplier + ) internal { + uint256 actualDebt = spoke.getUserTotalDebt(testAssetInfo.reserveId, collateralSupplier); + _repay({ + spoke: spoke, + reserveInfo: testAssetInfo, + user: collateralSupplier, + amount: actualDebt + }); + } + + function _testRepayAfterInterest( + ISpoke spoke, + Types.ReserveInfo memory testAssetInfo, + address collateralSupplier + ) internal { + _skipTimeAndCheckAccounting({ + spoke: spoke, + reserveInfo: testAssetInfo, + user: collateralSupplier, + skipDays: vm.randomUint(1, 450) + }); + + skip(vm.randomUint(1, 30) * 1 days); + uint256 debtAfterAccrual = spoke.getUserTotalDebt(testAssetInfo.reserveId, collateralSupplier); + _repay({ + spoke: spoke, + reserveInfo: testAssetInfo, + user: collateralSupplier, + amount: debtAfterAccrual + }); + } + + /// @dev Test liquidation: partial, full (receive underlying), and full (receive shares). + function _testLiquidation( + ISpoke spoke, + Types.ReserveInfo memory collateralInfo, + Types.ReserveInfo memory testAssetInfo, + address collateralSupplier + ) internal { + _makeUserLiquidatable(spoke, collateralSupplier); + + // Skip random 1-90 days to let interest accrue before liquidation + uint256 skipDays = vm.randomUint(1, 90); + skip(skipDays * 1 days); + + // Verify health factor is below 1 after making liquidatable + assertLt( + spoke.getUserAccountData(collateralSupplier).healthFactor, + HEALTH_FACTOR_LIQUIDATION_THRESHOLD, + 'HEALTH: should be below 1 for liquidation' + ); + + address liquidator = vm.randomAddress(); + uint256 snapshotBeforeLiquidation = vm.snapshotState(); + bool receiveShares = vm.randomBool(); + + // Partial liquidation + _testPartialLiquidation({ + spoke: spoke, + collateralInfo: collateralInfo, + testAssetInfo: testAssetInfo, + liquidator: liquidator, + borrower: collateralSupplier, + receiveShares: receiveShares + }); + vm.revertToState(snapshotBeforeLiquidation); + + // Full liquidation - receive underlying + _liquidationCall({ + spoke: spoke, + collateralInfo: collateralInfo, + debtInfo: testAssetInfo, + liquidator: liquidator, + borrower: collateralSupplier, + debtToCover: UINT256_MAX, + receiveShares: false + }); + vm.revertToState(snapshotBeforeLiquidation); + + // Full liquidation - receive shares (only if enabled on collateral reserve) + if (receiveShares) { + _liquidationCall({ + spoke: spoke, + collateralInfo: collateralInfo, + debtInfo: testAssetInfo, + liquidator: liquidator, + borrower: collateralSupplier, + debtToCover: UINT256_MAX, + receiveShares: true + }); + vm.revertToState(snapshotBeforeLiquidation); + } + + // Clear oracle price mocks + vm.clearMockedCalls(); + } + + /// @dev Partial liquidation: only for coll/debt amounts that won't trigger dust threshold reverts + function _testPartialLiquidation( + ISpoke spoke, + Types.ReserveInfo memory collateralInfo, + Types.ReserveInfo memory testAssetInfo, + address liquidator, + address borrower, + bool receiveShares + ) internal { + uint256 snapshot = vm.snapshotState(); + + address oracleAddr = spoke.ORACLE(); + uint256 totalDebt = spoke.getUserTotalDebt(testAssetInfo.reserveId, borrower); + uint256 totalCollateral = spoke.getUserSuppliedAssets(collateralInfo.reserveId, borrower); + // only execute partial liquidations above $1.5k + uint256 liquidationDollarThreshold = 1_500; + uint256 minDebtAssets = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: testAssetInfo, + dollarValue: liquidationDollarThreshold + }); + uint256 minCollateralAssets = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: collateralInfo, + dollarValue: liquidationDollarThreshold + }); + + // Skip if either debt or collateral is too small — partial liq leads to dust + if (totalDebt <= minDebtAssets || totalCollateral <= minCollateralAssets) { + console.log('PARTIAL_LIQUIDATION: skipping, position too small after oracle manipulation'); + vm.revertToState(snapshot); + return; + } + + // liquidate only up to $400 so that remaining amounts won't trigger dust threshold reverts + uint256 partialDebt = _getTokenAmountByDollarValue({ + oracleAddr: oracleAddr, + reserveInfo: testAssetInfo, + dollarValue: vm.randomUint(100, 400) + }); + // simple check - ensure at least 1 share worth of debt assets is liquidated + // technically possible to liquidate less if premium debt exists, but serves as a basic check + if ( + spoke.getReserve(testAssetInfo.reserveId).hub.previewRestoreByAssets( + testAssetInfo.assetId, + partialDebt + ) == 0 + ) { + partialDebt = spoke.getReserve(testAssetInfo.reserveId).hub.previewRestoreByShares( + testAssetInfo.assetId, + 1 + ); + } + _liquidationCall({ + spoke: spoke, + collateralInfo: collateralInfo, + debtInfo: testAssetInfo, + liquidator: liquidator, + borrower: borrower, + debtToCover: partialDebt, + receiveShares: receiveShares + }); + assertGt( + spoke.getUserTotalDebt(testAssetInfo.reserveId, borrower), + 0, + 'PARTIAL_LIQUIDATION: debt should not be fully repaid' + ); + + vm.revertToState(snapshot); + } + + /// @dev Disable all collaterals, verify borrow reverts, re-enable all, verify borrow works. + function _testCollateralToggle( + ISpoke spoke, + Types.ReserveInfo[] memory goodCollaterals, + Types.ReserveInfo memory testAssetInfo, + address collateralSupplier, + uint256 testAssetAmount + ) internal { + // Disable all active collaterals + for (uint256 i; i < goodCollaterals.length; i++) { + uint256 supplied = spoke.getUserSuppliedAssets( + goodCollaterals[i].reserveId, + collateralSupplier + ); + if (supplied == 0) { + continue; + } + vm.prank(collateralSupplier); + spoke.setUsingAsCollateral({ + reserveId: goodCollaterals[i].reserveId, + usingAsCollateral: false, + onBehalfOf: collateralSupplier + }); + } + + // Borrow should revert with HealthFactorBelowThreshold (no collateral backing) + uint256 smallBorrow = testAssetAmount > 10 ? testAssetAmount / 10 : testAssetAmount; + _ensureLiquidity({spoke: spoke, reserveInfo: testAssetInfo, amount: smallBorrow}); + vm.prank(collateralSupplier); + vm.expectRevert(ISpoke.HealthFactorBelowThreshold.selector); + spoke.borrow({ + reserveId: testAssetInfo.reserveId, + amount: smallBorrow, + onBehalfOf: collateralSupplier + }); + + // Re-enable all collaterals + for (uint256 i; i < goodCollaterals.length; i++) { + uint256 supplied = spoke.getUserSuppliedAssets( + goodCollaterals[i].reserveId, + collateralSupplier + ); + if (supplied == 0) { + continue; + } + vm.prank(collateralSupplier); + spoke.setUsingAsCollateral({ + reserveId: goodCollaterals[i].reserveId, + usingAsCollateral: true, + onBehalfOf: collateralSupplier + }); + } + + // Borrow should succeed now + _borrow({ + spoke: spoke, + reserveInfo: testAssetInfo, + user: collateralSupplier, + amount: smallBorrow + }); + } + + /// @dev Compute a random extra count bounded by remaining reserve slots and available reserves. + function _randomExtraCount( + ISpoke spoke, + address user, + uint256 available + ) internal view returns (uint256) { + uint16 maxUserReserves = spoke.MAX_USER_RESERVES_LIMIT(); + uint256 currentCount = spoke.getUserAccountData(user).activeCollateralCount; + uint256 remainingSlots = currentCount < maxUserReserves ? maxUserReserves - currentCount : 0; + uint256 maxExtra = remainingSlots < available ? remainingSlots : available; + return maxExtra > 0 ? vm.randomUint(0, maxExtra) : 0; + } + + /// @dev Borrow from random extra reserves, respecting MAX_USER_RESERVES_LIMIT. + function _borrowExtrasWithinLimit(ISpoke spoke, uint256 primaryReserveId, address user) internal { + Types.ReserveInfo[] memory allReserves = _getReserveInfo(spoke); + Types.ReserveInfo[] memory usableDebtReserves = _getAllUsableDebtReserves(allReserves); + uint16 maxUserReserves = spoke.MAX_USER_RESERVES_LIMIT(); + uint256 currentBorrowCount = spoke.getUserAccountData(user).borrowCount; + uint256 remainingSlots = currentBorrowCount < maxUserReserves + ? maxUserReserves - currentBorrowCount + : 0; + if (remainingSlots == 0) { + return; + } + uint256 extraBorrowCount = vm.randomUint(0, remainingSlots); + _borrowRandomExtraReserves({ + spoke: spoke, + usableDebtReserves: usableDebtReserves, + primaryReserveId: primaryReserveId, + oracleAddr: spoke.ORACLE(), + user: user, + extraCount: extraBorrowCount + }); + } + + /// @dev Test spoke addCap and drawCap by incrementally filling to the cap, then verify overflow reverts. + function _testCaps(ISpoke spoke, Types.ReserveInfo memory reserveInfo) internal { + IHub.SpokeConfig memory spokeConfig = IHub(reserveInfo.hub).getSpokeConfig( + reserveInfo.assetId, + address(spoke) + ); + + if (spokeConfig.addCap > 0 && spokeConfig.addCap < type(uint40).max) { + uint256 snap = vm.snapshotState(); + _testAddCap({spoke: spoke, reserveInfo: reserveInfo, addCap: spokeConfig.addCap}); + vm.revertToState(snap); + } + + if ( + spokeConfig.drawCap > 0 && spokeConfig.drawCap < type(uint40).max && reserveInfo.borrowable + ) { + uint256 snap = vm.snapshotState(); + _testDrawCap({spoke: spoke, reserveInfo: reserveInfo, drawCap: spokeConfig.drawCap}); + vm.revertToState(snap); + } + } + + /// @dev Fill supply up to addCap in random chunks, then verify overflow reverts. + function _testAddCap(ISpoke spoke, Types.ReserveInfo memory reserveInfo, uint40 addCap) internal { + uint256 addCapScaled = uint256(addCap) * 10 ** reserveInfo.decimals; + uint256 currentSupply = spoke.getReserveSuppliedAssets(reserveInfo.reserveId); + if (addCapScaled <= currentSupply) { + return; + } + + uint256 room = addCapScaled - currentSupply; + address supplier = vm.randomAddress(); + + // Supply more than addCap — should revert with AddCapExceeded + uint256 overflowAmount = room + 10 ** reserveInfo.decimals; + vm.startPrank(supplier); + deal2({asset: reserveInfo.underlying, user: supplier, amount: overflowAmount}); + IERC20(reserveInfo.underlying).forceApprove(address(spoke), overflowAmount); + vm.expectRevert(abi.encodeWithSelector(IHub.AddCapExceeded.selector, uint256(addCap))); + spoke.supply({reserveId: reserveInfo.reserveId, amount: overflowAmount, onBehalfOf: supplier}); + vm.stopPrank(); + } + + /// @dev Fill borrows up to drawCap in random chunks, then verify overflow reverts. + function _testDrawCap( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + uint40 drawCap + ) internal { + // Remove addCaps so enough collateral can be supplied to borrow up to drawCap + _setAddCapsToMax(spoke); + + _logAction('TEST_DRAW_CAP', 'drawCap', drawCap); + address borrower = vm.randomAddress(); + uint256 drawCapScaled = uint256(drawCap) * 10 ** reserveInfo.decimals; + uint256 currentDebt = spoke.getReserveTotalDebt(reserveInfo.reserveId); + if (drawCapScaled <= currentDebt) { + return; + } + + uint256 room = drawCapScaled - currentDebt; + + // Supply the debt asset itself as collateral (10x room for borrow headroom) + liquidity + uint256 collateralAmount = room * 10; + _supply({spoke: spoke, reserveInfo: reserveInfo, user: borrower, amount: collateralAmount}); + vm.prank(borrower); + spoke.setUsingAsCollateral({ + reserveId: reserveInfo.reserveId, + usingAsCollateral: true, + onBehalfOf: borrower + }); + + // Supply liquidity from a separate provider + address liquidityProvider = vm.randomAddress(); + _supply({spoke: spoke, reserveInfo: reserveInfo, user: liquidityProvider, amount: room}); + + // Borrow more than drawCap — should revert with DrawCapExceeded + uint256 overflowAmount = room + 10 ** reserveInfo.decimals; + vm.prank(borrower); + vm.expectRevert(abi.encodeWithSelector(IHub.DrawCapExceeded.selector, uint256(drawCap))); + spoke.borrow({reserveId: reserveInfo.reserveId, amount: overflowAmount, onBehalfOf: borrower}); + } + + /// @dev Test that 0-amount operations revert. + function _testZeroAmountReverts( + ISpoke spoke, + Types.ReserveInfo memory reserveInfo, + address user + ) internal { + uint256 reserveId = reserveInfo.reserveId; + + // Supply 0 + vm.startPrank(user); + IERC20(reserveInfo.underlying).forceApprove(address(spoke), 0); + vm.expectRevert(); + spoke.supply({reserveId: reserveId, amount: 0, onBehalfOf: user}); + vm.stopPrank(); + + // Withdraw 0 + vm.prank(user); + vm.expectRevert(); + spoke.withdraw({reserveId: reserveId, amount: 0, onBehalfOf: user}); + + // Borrow 0 + if (reserveInfo.borrowable) { + vm.prank(user); + vm.expectRevert(); + spoke.borrow({reserveId: reserveId, amount: 0, onBehalfOf: user}); + } + + // Repay 0 + vm.startPrank(user); + IERC20(reserveInfo.underlying).forceApprove(address(spoke), 0); + vm.expectRevert(); + spoke.repay({reserveId: reserveId, amount: 0, onBehalfOf: user}); + vm.stopPrank(); + } +} diff --git a/src/dependencies/v4/SnapshotV4.sol b/src/dependencies/v4/SnapshotV4.sol new file mode 100644 index 00000000..addc6e88 --- /dev/null +++ b/src/dependencies/v4/SnapshotV4.sol @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'forge-std/Test.sol'; +import {ISpoke, IHub, IAaveOracle} from 'aave-address-book/AaveV4.sol'; +import {IAssetInterestRateStrategy} from 'aave-v4/hub/interfaces/IAssetInterestRateStrategy.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; +import {V4DiffWriter} from 'src/dependencies/v4/V4DiffWriter.sol'; +import {Helpers} from 'src/dependencies/v4/Helpers.sol'; + +/// @title SnapshotV4 +/// @notice Snapshot capture for Aave V4. JSON serialization via V4DiffWriter, diff via TypeScript FFI. +abstract contract SnapshotV4 is Helpers { + /// @notice Capture a full V4 configuration snapshot from the given spokes and hubs. + function createV4Snapshot( + ISpoke[] memory spokes, + IHub[] memory hubs + ) internal view returns (Types.V4Snapshot memory snapshot) { + snapshot.spokeReserves = _snapshotSpokeReserves(spokes); + snapshot.spokeLiquidationConfigs = _snapshotSpokeLiqConfigs(spokes); + snapshot.hubAssets = _snapshotHubAssets(hubs); + snapshot.spokeConfigs = _snapshotSpokeConfigs(hubs); + } + + /// @notice Write a V4 snapshot to JSON file. + function writeV4SnapshotJson(string memory name, Types.V4Snapshot memory snap) internal { + V4DiffWriter.writeSnapshotJson(name, snap); + } + + /// @notice Generate markdown diff between two snapshots via TypeScript CLI (FFI). + function diffV4Snapshots(string memory reportName) internal { + string memory beforePath = string.concat('./reports/', reportName, '_before.json'); + string memory afterPath = string.concat('./reports/', reportName, '_after.json'); + string memory outPath = string.concat( + './diffs/', + reportName, + '_before_', + reportName, + '_after.md' + ); + + string[] memory inputs = new string[](7); + inputs[0] = 'npx'; + inputs[1] = '@aave-dao/aave-helpers-js@^1.0.1'; + inputs[2] = 'diff-v4-snapshots'; + inputs[3] = beforePath; + inputs[4] = afterPath; + inputs[5] = '-o'; + inputs[6] = outPath; + vm.ffi(inputs); + } + + // Spoke reserves + function _snapshotSpokeReserves( + ISpoke[] memory spokes + ) private view returns (Types.SpokeReserveSnapshot[] memory) { + uint256 total; + for (uint256 s; s < spokes.length; s++) total += spokes[s].getReserveCount(); + + Types.SpokeReserveSnapshot[] memory result = new Types.SpokeReserveSnapshot[](total); + uint256 idx; + for (uint256 s; s < spokes.length; s++) { + uint256 count = spokes[s].getReserveCount(); + for (uint256 i; i < count; i++) { + result[idx++] = _snapshotReserve(spokes[s], i); + } + } + return result; + } + + function _snapshotReserve( + ISpoke spoke, + uint256 reserveId + ) private view returns (Types.SpokeReserveSnapshot memory snap) { + ISpoke.Reserve memory reserve = spoke.getReserve(reserveId); + ISpoke.ReserveConfig memory config = spoke.getReserveConfig(reserveId); + ISpoke.DynamicReserveConfig memory dyn = spoke.getDynamicReserveConfig( + reserveId, + reserve.dynamicConfigKey + ); + + snap.spokeAddress = address(spoke); + snap.reserveId = reserveId; + snap.underlying = reserve.underlying; + snap.symbol = _safeSymbol(reserve.underlying); + snap.hub = address(reserve.hub); + snap.assetId = reserve.assetId; + snap.decimals = reserve.decimals; + snap.collateralRisk = config.collateralRisk; + snap.paused = config.paused; + snap.frozen = config.frozen; + snap.borrowable = config.borrowable; + snap.receiveSharesEnabled = config.receiveSharesEnabled; + snap.dynamicConfigKey = reserve.dynamicConfigKey; + snap.collateralFactor = dyn.collateralFactor; + snap.maxLiquidationBonus = dyn.maxLiquidationBonus; + snap.liquidationFee = dyn.liquidationFee; + + address oracleAddr = spoke.ORACLE(); + snap.oracleAddress = oracleAddr; + snap.priceSource = IAaveOracle(oracleAddr).getReserveSource(reserveId); + snap.oraclePrice = IAaveOracle(oracleAddr).getReservePrice(reserveId); + } + + // Spoke liquidation configs + + function _snapshotSpokeLiqConfigs( + ISpoke[] memory spokes + ) private view returns (Types.SpokeLiquidationSnapshot[] memory) { + Types.SpokeLiquidationSnapshot[] memory result = new Types.SpokeLiquidationSnapshot[]( + spokes.length + ); + for (uint256 s; s < spokes.length; s++) { + ISpoke.LiquidationConfig memory liq = spokes[s].getLiquidationConfig(); + result[s] = Types.SpokeLiquidationSnapshot({ + spokeAddress: address(spokes[s]), + targetHealthFactor: liq.targetHealthFactor, + healthFactorForMaxBonus: liq.healthFactorForMaxBonus, + liquidationBonusFactor: liq.liquidationBonusFactor, + maxUserReservesLimit: spokes[s].MAX_USER_RESERVES_LIMIT() + }); + } + return result; + } + + // Hub assets + + function _snapshotHubAssets( + IHub[] memory hubs + ) private view returns (Types.HubAssetSnapshot[] memory) { + uint256 total; + for (uint256 h; h < hubs.length; h++) total += hubs[h].getAssetCount(); + + Types.HubAssetSnapshot[] memory result = new Types.HubAssetSnapshot[](total); + uint256 idx; + for (uint256 h; h < hubs.length; h++) { + uint256 count = hubs[h].getAssetCount(); + for (uint256 a; a < count; a++) { + result[idx++] = _snapshotHubAsset(hubs[h], a); + } + } + return result; + } + + function _snapshotHubAsset( + IHub hub, + uint256 assetId + ) private view returns (Types.HubAssetSnapshot memory snap) { + IHub.AssetConfig memory config = hub.getAssetConfig(assetId); + IHub.Asset memory asset = hub.getAsset(assetId); + (address underlying, uint8 decimals) = hub.getAssetUnderlyingAndDecimals(assetId); + + snap.hubAddress = address(hub); + snap.assetId = assetId; + snap.underlying = underlying; + snap.symbol = _safeSymbol(underlying); + snap.decimals = decimals; + snap.liquidityFee = config.liquidityFee; + snap.irStrategy = config.irStrategy; + snap.feeReceiver = config.feeReceiver; + snap.reinvestmentController = config.reinvestmentController; + + if (config.irStrategy != address(0)) { + IAssetInterestRateStrategy.InterestRateData memory irData = IAssetInterestRateStrategy( + config.irStrategy + ).getInterestRateData(assetId); + snap.optimalUsageRatio = irData.optimalUsageRatio; + snap.baseDrawnRate = irData.baseDrawnRate; + snap.rateGrowthBeforeOptimal = irData.rateGrowthBeforeOptimal; + snap.rateGrowthAfterOptimal = irData.rateGrowthAfterOptimal; + snap.maxDrawnRate = IAssetInterestRateStrategy(config.irStrategy).getMaxDrawnRate(assetId); + } + + // Asset state + snap.deficitRay = asset.deficitRay; + snap.swept = asset.swept; + snap.premiumShares = asset.premiumShares; + snap.premiumOffsetRay = asset.premiumOffsetRay; + } + + // Hub spoke caps + + function _snapshotSpokeConfigs( + IHub[] memory hubs + ) private view returns (Types.SpokeConfigSnapshot[] memory) { + uint256 total; + for (uint256 h; h < hubs.length; h++) { + uint256 ac = hubs[h].getAssetCount(); + for (uint256 a; a < ac; a++) total += hubs[h].getSpokeCount(a); + } + + Types.SpokeConfigSnapshot[] memory result = new Types.SpokeConfigSnapshot[](total); + uint256 idx; + for (uint256 h; h < hubs.length; h++) { + idx = _snapshotCapsForHub(hubs[h], result, idx); + } + return result; + } + + function _snapshotCapsForHub( + IHub hub, + Types.SpokeConfigSnapshot[] memory result, + uint256 idx + ) private view returns (uint256) { + uint256 ac = hub.getAssetCount(); + for (uint256 a; a < ac; a++) { + (address underlying, ) = hub.getAssetUnderlyingAndDecimals(a); + string memory sym = _safeSymbol(underlying); + uint256 sc = hub.getSpokeCount(a); + for (uint256 sp; sp < sc; sp++) { + address spokeAddr = hub.getSpokeAddress(a, sp); + IHub.SpokeConfig memory cfg = hub.getSpokeConfig(a, spokeAddr); + result[idx++] = Types.SpokeConfigSnapshot({ + hubAddress: address(hub), + assetId: a, + assetSymbol: sym, + spokeAddress: spokeAddr, + addCap: cfg.addCap, + drawCap: cfg.drawCap, + riskPremiumThreshold: cfg.riskPremiumThreshold, + active: cfg.active, + halted: cfg.halted + }); + } + } + return idx; + } +} diff --git a/src/dependencies/v4/TokenizationActions.sol b/src/dependencies/v4/TokenizationActions.sol new file mode 100644 index 00000000..38f55821 --- /dev/null +++ b/src/dependencies/v4/TokenizationActions.sol @@ -0,0 +1,606 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'forge-std/Test.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from 'openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol'; +import {ITokenizationSpoke, ISpoke} from 'aave-address-book/AaveV4.sol'; +import {IHubBase} from 'aave-v4/hub/interfaces/IHubBase.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; +import {Helpers} from 'src/dependencies/v4/Helpers.sol'; + +/// @title TokenizationActions +/// @notice Low-level tokenization spoke (ERC4626) actions with hub accounting assertions. +abstract contract TokenizationActions is Helpers { + using SafeERC20 for IERC20; + + // ------------------------------------------------------------------------- + // Snapshot getter + // ------------------------------------------------------------------------- + function _getTokenizationSnapshot( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + address user + ) internal view returns (Types.TokenizationSnapshot memory) { + uint256 userShares = tokenizationSpoke.balanceOf(user); + return + Types.TokenizationSnapshot({ + userShares: userShares, + userAssets: tokenizationSpoke.convertToAssets(userShares), + totalShares: tokenizationSpoke.totalSupply(), + totalAssets: tokenizationSpoke.totalAssets(), + spokeOnHub: _getSpokeOnHubAccounting(ISpoke(address(tokenizationSpoke)), reserveInfo) + }); + } + + // ------------------------------------------------------------------------- + // Hub invariant: tokenization spoke never borrows + // ------------------------------------------------------------------------- + function _assertTokenizationNoDebt(Types.TokenizationSnapshot memory snapshot) internal pure { + assertEq(snapshot.spokeOnHub.drawnDebt, 0, 'TOKENIZATION: hub drawn debt should be zero'); + assertEq(snapshot.spokeOnHub.drawnShares, 0, 'TOKENIZATION: hub drawn shares should be zero'); + assertEq(snapshot.spokeOnHub.totalDebt, 0, 'TOKENIZATION: hub total debt should be zero'); + assertEq(snapshot.spokeOnHub.premiumDebt, 0, 'TOKENIZATION: hub premium debt should be zero'); + assertEq( + snapshot.spokeOnHub.premiumShares, + 0, + 'TOKENIZATION: hub premium shares should be zero' + ); + assertEq( + snapshot.spokeOnHub.premiumOffsetRay, + 0, + 'TOKENIZATION: hub premium offset should be zero' + ); + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + function _tokenizationDeposit( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + address user, + uint256 assets + ) internal { + Types.TokenizationSnapshot memory snapshotBefore = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + _assertTokenizationNoDebt(snapshotBefore); + + uint256 expectedShares = tokenizationSpoke.previewDeposit(assets); + + vm.startPrank(user); + deal2(reserveInfo.underlying, user, assets); + IERC20(reserveInfo.underlying).forceApprove(address(tokenizationSpoke), assets); + _logAction('TOKENIZATION_DEPOSIT', reserveInfo.symbol, assets); + uint256 sharesReturned = tokenizationSpoke.deposit(assets, user); + vm.stopPrank(); + + Types.TokenizationSnapshot memory snapshotAfter = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + + // Returned shares should match preview + assertEq( + sharesReturned, + expectedShares, + 'TOKENIZATION_DEPOSIT: returned shares mismatch with preview' + ); + // User shares increased + assertEq( + snapshotAfter.userShares, + snapshotBefore.userShares + sharesReturned, + 'TOKENIZATION_DEPOSIT: user shares mismatch' + ); + // Vault totalAssets increased + assertApproxEqAbs( + snapshotAfter.totalAssets, + snapshotBefore.totalAssets + assets, + 1, + 'TOKENIZATION_DEPOSIT: totalAssets mismatch' + ); + // Hub spoke collateral increased + assertApproxEqAbs( + snapshotAfter.spokeOnHub.collateralAssets, + snapshotBefore.spokeOnHub.collateralAssets + assets, + 2, + 'TOKENIZATION_DEPOSIT: hub collateral assets mismatch' + ); + { + uint256 expectedAddedShares = IHubBase(reserveInfo.hub).previewAddByAssets( + reserveInfo.assetId, + assets + ); + assertEq( + snapshotAfter.spokeOnHub.collateralShares, + snapshotBefore.spokeOnHub.collateralShares + expectedAddedShares, + 'TOKENIZATION_DEPOSIT: hub collateral shares mismatch' + ); + } + _assertTokenizationNoDebt(snapshotAfter); + } + + function _tokenizationMint( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + address user, + uint256 shares + ) internal { + Types.TokenizationSnapshot memory snapshotBefore = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + _assertTokenizationNoDebt(snapshotBefore); + + uint256 expectedAssets = tokenizationSpoke.previewMint(shares); + + vm.startPrank(user); + // previewMint rounds assets UP per EIP-4626, so the exact amount is sufficient + deal2(reserveInfo.underlying, user, expectedAssets); + IERC20(reserveInfo.underlying).forceApprove(address(tokenizationSpoke), expectedAssets); + _logAction('TOKENIZATION_MINT', reserveInfo.symbol, shares); + uint256 assetsDeposited = tokenizationSpoke.mint(shares, user); + vm.stopPrank(); + + Types.TokenizationSnapshot memory snapshotAfter = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + + // Assets deposited should match preview + assertEq( + assetsDeposited, + expectedAssets, + 'TOKENIZATION_MINT: deposited assets mismatch with preview' + ); + // User shares increased by exact amount + assertEq( + snapshotAfter.userShares, + snapshotBefore.userShares + shares, + 'TOKENIZATION_MINT: user shares mismatch' + ); + // Vault totalAssets increased + assertApproxEqAbs( + snapshotAfter.totalAssets, + snapshotBefore.totalAssets + assetsDeposited, + 1, + 'TOKENIZATION_MINT: totalAssets mismatch' + ); + // Hub spoke collateral increased + assertApproxEqAbs( + snapshotAfter.spokeOnHub.collateralAssets, + snapshotBefore.spokeOnHub.collateralAssets + assetsDeposited, + 2, + 'TOKENIZATION_MINT: hub collateral assets mismatch' + ); + _assertTokenizationNoDebt(snapshotAfter); + } + + function _tokenizationWithdraw( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + address user, + uint256 assets + ) internal { + Types.TokenizationSnapshot memory snapshotBefore = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + _assertTokenizationNoDebt(snapshotBefore); + + uint256 expectedSharesBurned = tokenizationSpoke.previewWithdraw(assets); + + vm.prank(user); + _logAction('TOKENIZATION_WITHDRAW', reserveInfo.symbol, assets); + uint256 sharesBurned = tokenizationSpoke.withdraw(assets, user, user); + + Types.TokenizationSnapshot memory snapshotAfter = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + + // Shares burned should match preview + assertEq( + sharesBurned, + expectedSharesBurned, + 'TOKENIZATION_WITHDRAW: shares burned mismatch with preview' + ); + // User shares decreased + assertEq( + snapshotAfter.userShares, + snapshotBefore.userShares - sharesBurned, + 'TOKENIZATION_WITHDRAW: user shares mismatch' + ); + // Vault totalAssets decreased + assertApproxEqAbs( + snapshotBefore.totalAssets - snapshotAfter.totalAssets, + assets, + 1, + 'TOKENIZATION_WITHDRAW: totalAssets mismatch' + ); + // Hub spoke collateral decreased + assertApproxEqAbs( + snapshotBefore.spokeOnHub.collateralAssets - snapshotAfter.spokeOnHub.collateralAssets, + assets, + 2, + 'TOKENIZATION_WITHDRAW: hub collateral assets mismatch' + ); + _assertTokenizationNoDebt(snapshotAfter); + } + + function _tokenizationRedeem( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + address user, + uint256 shares + ) internal { + Types.TokenizationSnapshot memory snapshotBefore = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + _assertTokenizationNoDebt(snapshotBefore); + + uint256 expectedAssets = tokenizationSpoke.previewRedeem(shares); + + vm.prank(user); + _logAction('TOKENIZATION_REDEEM', reserveInfo.symbol, shares); + uint256 assetsReceived = tokenizationSpoke.redeem(shares, user, user); + + Types.TokenizationSnapshot memory snapshotAfter = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + + // Assets received should match preview + assertEq( + assetsReceived, + expectedAssets, + 'TOKENIZATION_REDEEM: assets received mismatch with preview' + ); + // User shares decreased by exact amount + assertEq( + snapshotAfter.userShares, + snapshotBefore.userShares - shares, + 'TOKENIZATION_REDEEM: user shares mismatch' + ); + // If full redeem, shares should be zero + if (shares == snapshotBefore.userShares) { + assertEq(snapshotAfter.userShares, 0, 'TOKENIZATION_REDEEM: user shares should be zero'); + } + // Vault totalAssets decreased + assertApproxEqAbs( + snapshotBefore.totalAssets - snapshotAfter.totalAssets, + assetsReceived, + 1, + 'TOKENIZATION_REDEEM: totalAssets mismatch' + ); + // Hub spoke collateral decreased + assertApproxEqAbs( + snapshotBefore.spokeOnHub.collateralAssets - snapshotAfter.spokeOnHub.collateralAssets, + assetsReceived, + 2, + 'TOKENIZATION_REDEEM: hub collateral assets mismatch' + ); + _assertTokenizationNoDebt(snapshotAfter); + } + + function _tokenizationMintWithSig( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + uint256 shares + ) internal { + address user = vm.addr(privateKey); + Types.TokenizationSnapshot memory snapshotBefore = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + _assertTokenizationNoDebt(snapshotBefore); + + uint256 assetsDeposited = _executeTokenizationMintWithSig({ + tokenizationSpoke: tokenizationSpoke, + reserveInfo: reserveInfo, + privateKey: privateKey, + user: user, + shares: shares + }); + + Types.TokenizationSnapshot memory snapshotAfter = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + + assertEq( + snapshotAfter.userShares - snapshotBefore.userShares, + shares, + 'TOKENIZATION_MINT_WITH_SIG: user shares mismatch' + ); + assertApproxEqAbs( + snapshotAfter.totalAssets - snapshotBefore.totalAssets, + assetsDeposited, + 1, + 'TOKENIZATION_MINT_WITH_SIG: totalAssets mismatch' + ); + assertApproxEqAbs( + snapshotAfter.spokeOnHub.collateralAssets - snapshotBefore.spokeOnHub.collateralAssets, + assetsDeposited, + 2, + 'TOKENIZATION_MINT_WITH_SIG: hub collateral assets mismatch' + ); + { + uint256 expectedAddedShares = IHubBase(reserveInfo.hub).previewAddByAssets( + reserveInfo.assetId, + assetsDeposited + ); + assertEq( + snapshotAfter.spokeOnHub.collateralShares, + snapshotBefore.spokeOnHub.collateralShares + expectedAddedShares, + 'TOKENIZATION_MINT_WITH_SIG: hub collateral shares mismatch' + ); + } + _assertTokenizationNoDebt(snapshotAfter); + } + + function _executeTokenizationMintWithSig( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user, + uint256 shares + ) internal returns (uint256 assetsDeposited) { + uint256 expectedAssets = tokenizationSpoke.previewMint(shares); + _logAction('TOKENIZATION_MINT_WITH_SIG', reserveInfo.symbol, shares); + + deal2(reserveInfo.underlying, user, expectedAssets); + vm.prank(user); + IERC20(reserveInfo.underlying).forceApprove(address(tokenizationSpoke), expectedAssets); + + uint192 nonceKey = tokenizationSpoke.PERMIT_NONCE_NAMESPACE(); + uint256 nonce = tokenizationSpoke.nonces(user, nonceKey); + + { + uint256 deadline = vm.getBlockTimestamp() + 1 hours; + ITokenizationSpoke.TokenizedMint memory params = ITokenizationSpoke.TokenizedMint({ + depositor: user, + shares: shares, + receiver: user, + nonce: nonce, + deadline: deadline + }); + bytes32 structHash = keccak256( + abi.encode( + tokenizationSpoke.MINT_TYPEHASH(), + params.depositor, + params.shares, + params.receiver, + params.nonce, + params.deadline + ) + ); + bytes32 digest = keccak256( + abi.encodePacked('\x19\x01', tokenizationSpoke.DOMAIN_SEPARATOR(), structHash) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + assetsDeposited = tokenizationSpoke.mintWithSig(params, abi.encodePacked(r, s, v)); + } + + assertEq( + assetsDeposited, + expectedAssets, + 'TOKENIZATION_MINT_WITH_SIG: assets mismatch with preview' + ); + assertEq( + tokenizationSpoke.nonces(user, nonceKey), + nonce + 1, + 'TOKENIZATION_MINT_WITH_SIG: nonce not incremented' + ); + } + + function _tokenizationRedeemWithSig( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + uint256 userPrivateKey, + uint256 shares + ) internal { + address user = vm.addr(userPrivateKey); + Types.TokenizationSnapshot memory snapshotBefore = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + _assertTokenizationNoDebt(snapshotBefore); + + uint256 assetsReceived = _executeTokenizationRedeemWithSig({ + tokenizationSpoke: tokenizationSpoke, + reserveInfo: reserveInfo, + privateKey: userPrivateKey, + user: user, + shares: shares + }); + + Types.TokenizationSnapshot memory snapshotAfter = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + + assertEq( + snapshotBefore.userShares - snapshotAfter.userShares, + shares, + 'TOKENIZATION_REDEEM_WITH_SIG: user shares mismatch' + ); + if (shares == snapshotBefore.userShares) { + assertEq( + snapshotAfter.userShares, + 0, + 'TOKENIZATION_REDEEM_WITH_SIG: user shares should be zero' + ); + } + assertApproxEqAbs( + snapshotBefore.totalAssets - snapshotAfter.totalAssets, + assetsReceived, + 1, + 'TOKENIZATION_REDEEM_WITH_SIG: totalAssets mismatch' + ); + assertApproxEqAbs( + snapshotBefore.spokeOnHub.collateralAssets - snapshotAfter.spokeOnHub.collateralAssets, + assetsReceived, + 2, + 'TOKENIZATION_REDEEM_WITH_SIG: hub collateral assets mismatch' + ); + _assertTokenizationNoDebt(snapshotAfter); + } + + function _executeTokenizationRedeemWithSig( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + uint256 privateKey, + address user, + uint256 shares + ) internal returns (uint256 assetsReceived) { + uint256 expectedAssets = tokenizationSpoke.previewRedeem(shares); + _logAction('TOKENIZATION_REDEEM_WITH_SIG', reserveInfo.symbol, shares); + + uint192 nonceKey = tokenizationSpoke.PERMIT_NONCE_NAMESPACE(); + uint256 nonce = tokenizationSpoke.nonces(user, nonceKey); + + { + uint256 deadline = vm.getBlockTimestamp() + 1 hours; + ITokenizationSpoke.TokenizedRedeem memory params = ITokenizationSpoke.TokenizedRedeem({ + owner: user, + shares: shares, + receiver: user, + nonce: nonce, + deadline: deadline + }); + bytes32 structHash = keccak256( + abi.encode( + tokenizationSpoke.REDEEM_TYPEHASH(), + params.owner, + params.shares, + params.receiver, + params.nonce, + params.deadline + ) + ); + bytes32 digest = keccak256( + abi.encodePacked('\x19\x01', tokenizationSpoke.DOMAIN_SEPARATOR(), structHash) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + assetsReceived = tokenizationSpoke.redeemWithSig(params, abi.encodePacked(r, s, v)); + } + + assertEq( + assetsReceived, + expectedAssets, + 'TOKENIZATION_REDEEM_WITH_SIG: assets mismatch with preview' + ); + assertEq( + tokenizationSpoke.nonces(user, nonceKey), + nonce + 1, + 'TOKENIZATION_REDEEM_WITH_SIG: nonce not incremented' + ); + } + + /// @dev Build the EIP-2612 permit digest for the underlying token. + /// `depositWithPermit` permits the underlying, not the vault share token. + function _buildUnderlyingPermitDigest( + address underlying, + address owner, + address spender, + uint256 value, + uint256 deadline + ) internal view returns (bytes32) { + // Query nonces and DOMAIN_SEPARATOR from the underlying ERC20Permit token + (bool nonceOk, bytes memory nonceData) = underlying.staticcall( + abi.encodeWithSignature('nonces(address)', owner) + ); + require(nonceOk && nonceData.length == 32, 'PERMIT: underlying missing nonces(address)'); + uint256 nonce = abi.decode(nonceData, (uint256)); + + (bool dsOk, bytes memory dsData) = underlying.staticcall( + abi.encodeWithSignature('DOMAIN_SEPARATOR()') + ); + require(dsOk && dsData.length == 32, 'PERMIT: underlying missing DOMAIN_SEPARATOR()'); + bytes32 domainSeparator = abi.decode(dsData, (bytes32)); + + bytes32 permitTypehash = keccak256( + 'Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)' + ); + bytes32 structHash = keccak256( + abi.encode(permitTypehash, owner, spender, value, nonce, deadline) + ); + return keccak256(abi.encodePacked('\x19\x01', domainSeparator, structHash)); + } + + function _tokenizationDepositWithPermit( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + uint256 assets + ) internal { + (address user, uint256 userPrivateKey) = makeAddrAndKey('user'); + + Types.TokenizationSnapshot memory snapshotBefore = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + _assertTokenizationNoDebt(snapshotBefore); + + deal2(reserveInfo.underlying, user, assets); + + uint256 deadline = vm.getBlockTimestamp() + 1 hours; + bytes32 digest = _buildUnderlyingPermitDigest({ + underlying: reserveInfo.underlying, + owner: user, + spender: address(tokenizationSpoke), + value: assets, + deadline: deadline + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, digest); + + vm.prank(user); + _logAction('TOKENIZATION_DEPOSIT_WITH_PERMIT', reserveInfo.symbol, assets); + uint256 sharesReturned = tokenizationSpoke.depositWithPermit(assets, user, deadline, v, r, s); + + Types.TokenizationSnapshot memory snapshotAfter = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + + // User shares increased + assertEq( + snapshotAfter.userShares, + snapshotBefore.userShares + sharesReturned, + 'TOKENIZATION_DEPOSIT_WITH_PERMIT: user shares mismatch' + ); + // Vault totalAssets increased + assertApproxEqAbs( + snapshotAfter.totalAssets, + snapshotBefore.totalAssets + assets, + 1, + 'TOKENIZATION_DEPOSIT_WITH_PERMIT: totalAssets mismatch' + ); + // Hub spoke collateral increased + assertApproxEqAbs( + snapshotAfter.spokeOnHub.collateralAssets, + snapshotBefore.spokeOnHub.collateralAssets + assets, + 2, + 'TOKENIZATION_DEPOSIT_WITH_PERMIT: hub collateral assets mismatch' + ); + _assertTokenizationNoDebt(snapshotAfter); + } +} diff --git a/src/dependencies/v4/TokenizationScenarios.sol b/src/dependencies/v4/TokenizationScenarios.sol new file mode 100644 index 00000000..25f1b30f --- /dev/null +++ b/src/dependencies/v4/TokenizationScenarios.sol @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'forge-std/Test.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {IERC20Metadata} from 'openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol'; +import {SafeERC20} from 'openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol'; +import {ITokenizationSpoke, IHub} from 'aave-address-book/AaveV4.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; +import {TokenizationActions} from 'src/dependencies/v4/TokenizationActions.sol'; + +/// @title TokenizationScenarios +/// @notice Test scenario orchestration for tokenization spoke (ERC4626) e2e tests. +abstract contract TokenizationScenarios is TokenizationActions { + using SafeERC20 for IERC20; + + /// @notice Build ReserveInfo from a tokenization spoke's identity getters. + function _getTokenizationReserveInfo( + ITokenizationSpoke tokenizationSpoke + ) internal view returns (Types.ReserveInfo memory) { + address hub = tokenizationSpoke.hub(); + uint16 assetId = uint16(tokenizationSpoke.assetId()); + address underlying = tokenizationSpoke.asset(); + uint8 decimals = tokenizationSpoke.decimals(); + require( + decimals == IERC20Metadata(underlying).decimals(), + 'TOKENIZATION: spoke decimals must match underlying' + ); + string memory symbol = _safeSymbol(underlying); + + return + Types.ReserveInfo({ + reserveId: 0, + underlying: underlying, + hub: hub, + assetId: assetId, + symbol: symbol, + decimals: decimals, + paused: false, + frozen: false, + borrowable: false, + collateralEnabled: false, + collateralFactor: 0, + maxLiquidationBonus: 0, + liquidationFee: 0 + }); + } + + /// @notice Set addCap/drawCap to max for a tokenization spoke's asset. + function _setTokenizationCapsToMax(ITokenizationSpoke tokenizationSpoke) internal { + _setSpokeCapsToMax({ + hub: IHub(tokenizationSpoke.hub()), + assetId: tokenizationSpoke.assetId(), + spoke: address(tokenizationSpoke), + maxAddCap: true, + maxDrawCap: true + }); + } + + // ------------------------------------------------------------------------- + // Scenarios + // ------------------------------------------------------------------------- + + /// @dev Test deposit + partial withdraw + full redeem cycle. + function _testTokenizationDepositWithdraw( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + uint256 maxAddAmount + ) internal { + (address user, uint256 userPrivateKey) = makeAddrAndKey('user'); + uint256 depositAmount = vm.randomUint(1, maxAddAmount); + + // Deposit + _tokenizationDeposit(tokenizationSpoke, reserveInfo, user, depositAmount); + + // Partial withdraw + uint256 userAssets = tokenizationSpoke.convertToAssets(tokenizationSpoke.balanceOf(user)); + uint256 snapshot = vm.snapshotState(); + if (userAssets > 1) { + uint256 partialWithdraw = vm.randomUint(1, userAssets - 1); + _tokenizationWithdraw(tokenizationSpoke, reserveInfo, user, partialWithdraw); + vm.revertToState(snapshot); + } + + // Full redeem + _tokenizationRedeem(tokenizationSpoke, reserveInfo, user, tokenizationSpoke.balanceOf(user)); + assertEq(tokenizationSpoke.balanceOf(user), 0, 'DEPOSIT_WITHDRAW: user should have no shares'); + vm.revertToState(snapshot); + + // Full redeem with sig + _tokenizationRedeemWithSig( + tokenizationSpoke, + reserveInfo, + userPrivateKey, + tokenizationSpoke.balanceOf(user) + ); + assertEq(tokenizationSpoke.balanceOf(user), 0, 'DEPOSIT_WITHDRAW: user should have no shares'); + vm.revertToState(snapshot); + } + + /// @dev Test mint + partial redeem + full redeem cycle, including mintWithSig. + function _testTokenizationMintRedeem( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + uint256 maxAddAmount + ) internal { + (address user, uint256 userPrivateKey) = makeAddrAndKey('mintUser'); + uint256 mintAssets = vm.randomUint(1, maxAddAmount); + uint256 mintShares = tokenizationSpoke.convertToShares(mintAssets); + + uint256 snapshot = vm.snapshotState(); + + // Mint + _tokenizationMint(tokenizationSpoke, reserveInfo, user, mintShares); + uint256 userShares = tokenizationSpoke.balanceOf(user); + + // Partial redeem + if (userShares > 1) { + uint256 postMintSnapshot = vm.snapshotState(); + uint256 partialRedeem = vm.randomUint(1, userShares - 1); + _tokenizationRedeem(tokenizationSpoke, reserveInfo, user, partialRedeem); + vm.revertToState(postMintSnapshot); + } + + // Full redeem + _tokenizationRedeem(tokenizationSpoke, reserveInfo, user, userShares); + assertEq(tokenizationSpoke.balanceOf(user), 0, 'MINT_REDEEM: user should have no shares'); + vm.revertToState(snapshot); + + // Mint with sig (clean slate — no prior mint consuming addCap) + _tokenizationMintWithSig({ + tokenizationSpoke: tokenizationSpoke, + reserveInfo: reserveInfo, + privateKey: userPrivateKey, + shares: mintShares + }); + assertEq( + tokenizationSpoke.balanceOf(user), + mintShares, + 'MINT_WITH_SIG: user should have shares' + ); + } + + /// @dev Test deposit with EIP-2612 permit signature. + /// Skips if the underlying token does not support EIP-2612 (WETH). + function _testTokenizationPermitDeposit( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + uint256 maxAddAmount + ) internal { + // Skip tokens that don't support EIP-2612 permit (WETH has no nonces function) + (bool success, ) = reserveInfo.underlying.staticcall( + abi.encodeWithSignature('nonces(address)', address(this)) + ); + if (!success) { + console.log('TOKENIZATION_PERMIT: skipping %s (no EIP-2612 support)', reserveInfo.symbol); + return; + } + + uint256 depositAmount = vm.randomUint(1, maxAddAmount); + _tokenizationDepositWithPermit(tokenizationSpoke, reserveInfo, depositAmount); + } + + /// @dev Test addCap enforcement on tokenization spoke deposits. + function _testTokenizationAddCap( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo + ) internal { + IHub hub = IHub(reserveInfo.hub); + IHub.SpokeConfig memory spokeConfig = hub.getSpokeConfig( + reserveInfo.assetId, + address(tokenizationSpoke) + ); + + if (spokeConfig.addCap == 0 || spokeConfig.addCap == type(uint40).max) { + return; + } + + uint256 addCapScaled = uint256(spokeConfig.addCap) * 10 ** reserveInfo.decimals; + uint256 currentAdded = hub.getSpokeAddedAssets(reserveInfo.assetId, address(tokenizationSpoke)); + if (addCapScaled <= currentAdded) { + return; + } + + uint256 room = addCapScaled - currentAdded; + address depositor = vm.randomAddress(); + + // Deposit more than remaining room — should revert with AddCapExceeded + uint256 overflowAmount = room + 10 ** reserveInfo.decimals; + vm.startPrank(depositor); + deal2(reserveInfo.underlying, depositor, overflowAmount); + IERC20(reserveInfo.underlying).forceApprove(address(tokenizationSpoke), overflowAmount); + vm.expectRevert( + abi.encodeWithSelector(IHub.AddCapExceeded.selector, uint256(spokeConfig.addCap)) + ); + tokenizationSpoke.deposit(overflowAmount, depositor); + vm.stopPrank(); + } + + /// @dev Test that share value does not decrease over time (yield accrual). + function _testTokenizationTimeSkip( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + uint256 maxAddAmount + ) internal { + address user = vm.randomAddress(); + uint256 depositAmount = vm.randomUint(1, maxAddAmount); + + // Deposit first + _tokenizationDeposit(tokenizationSpoke, reserveInfo, user, depositAmount); + + Types.TokenizationSnapshot memory snapshotBefore = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + + uint256 skipDays = vm.randomUint(1, 365); + skip(skipDays * 1 days); + + Types.TokenizationSnapshot memory snapshotAfter = _getTokenizationSnapshot( + tokenizationSpoke, + reserveInfo, + user + ); + + // totalAssets should not decrease + assertGe( + snapshotAfter.totalAssets, + snapshotBefore.totalAssets, + 'TIME_SKIP: totalAssets decreased' + ); + + // User's asset value should not decrease (share value grows with yield) + assertGe( + snapshotAfter.userAssets, + snapshotBefore.userAssets, + 'TIME_SKIP: user asset value decreased' + ); + + // Share count should remain the same (no shares minted/burned) + assertEq( + snapshotAfter.userShares, + snapshotBefore.userShares, + 'TIME_SKIP: user share count changed' + ); + + // Hub spoke collateral should not decrease + assertGe( + snapshotAfter.spokeOnHub.collateralAssets, + snapshotBefore.spokeOnHub.collateralAssets, + 'TIME_SKIP: hub collateral assets decreased' + ); + + _assertTokenizationNoDebt(snapshotAfter); + } + + /// @dev Test that vault shares can be transferred to third parties who can then redeem. + function _testTokenizationTransferAndWithdraw( + ITokenizationSpoke tokenizationSpoke, + Types.ReserveInfo memory reserveInfo, + uint256 maxAddAmount + ) internal { + address depositor = makeAddr('TRANSFER_DEPOSITOR'); + address[3] memory recipients = [ + makeAddr('TRANSFER_RECIPIENT_0'), + makeAddr('TRANSFER_RECIPIENT_1'), + makeAddr('TRANSFER_RECIPIENT_2') + ]; + + uint256 totalSupplyBefore = tokenizationSpoke.totalSupply(); + + uint256 depositAmount = vm.randomUint(3, maxAddAmount); + _tokenizationDeposit(tokenizationSpoke, reserveInfo, depositor, depositAmount); + + uint256 totalShares = tokenizationSpoke.balanceOf(depositor); + uint256 sharePerRecipient = totalShares / 3; + require(sharePerRecipient > 0, 'TRANSFER: share per recipient is zero'); + + // Transfer shares to each recipient + vm.startPrank(depositor); + for (uint256 i; i < 3; i++) { + _logAction('TOKENIZATION_TRANSFER', reserveInfo.symbol, sharePerRecipient); + uint256 amount = i < 2 ? sharePerRecipient : tokenizationSpoke.balanceOf(depositor); + tokenizationSpoke.transfer(recipients[i], amount); + } + vm.stopPrank(); + + assertEq( + tokenizationSpoke.balanceOf(depositor), + 0, + 'TRANSFER: depositor should have 0 shares after transfers' + ); + + // Each recipient redeems all their shares + for (uint256 i; i < 3; i++) { + uint256 recipientShares = tokenizationSpoke.balanceOf(recipients[i]); + assertGt(recipientShares, 0, 'TRANSFER: recipient should have shares'); + + uint256 underlyingBefore = IERC20(reserveInfo.underlying).balanceOf(recipients[i]); + + _logAction('TOKENIZATION_REDEEM', reserveInfo.symbol, recipientShares); + vm.prank(recipients[i]); + uint256 assetsRedeemed = tokenizationSpoke.redeem( + recipientShares, + recipients[i], + recipients[i] + ); + + assertEq( + tokenizationSpoke.balanceOf(recipients[i]), + 0, + 'TRANSFER: recipient should have 0 shares after redeem' + ); + assertEq( + IERC20(reserveInfo.underlying).balanceOf(recipients[i]), + underlyingBefore + assetsRedeemed, + 'TRANSFER: recipient should have received underlying tokens' + ); + } + + assertEq( + tokenizationSpoke.totalSupply(), + totalSupplyBefore, + 'TRANSFER: totalSupply should return to pre-deposit level after all redeems' + ); + } +} diff --git a/src/dependencies/v4/Types.sol b/src/dependencies/v4/Types.sol new file mode 100644 index 00000000..b3b5b4fe --- /dev/null +++ b/src/dependencies/v4/Types.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library Types { + /// @notice Accounting state: collateral + debt (drawn + premium), shares + assets. + struct Accounting { + // Supply / collateral side + uint256 collateralShares; + uint256 collateralAssets; + // Debt side (drawn + premium) + uint256 drawnDebt; + uint256 premiumDebt; + uint256 totalDebt; + // Drawn/premium shares + uint256 drawnShares; + uint256 premiumShares; + int256 premiumOffsetRay; + } + + /// @notice Full position snapshot at spoke-user, spoke-reserve, and hub-spoke levels. + struct PositionSnapshot { + Accounting user; + Accounting reserve; + Accounting spokeOnHub; + } + + /// @notice Tokenization spoke snapshot at user, vault, and hub-spoke levels. + struct TokenizationSnapshot { + uint256 userShares; + uint256 userAssets; + uint256 totalShares; + uint256 totalAssets; + Accounting spokeOnHub; + } + + /// @notice Per-reserve info struct used throughout V4 e2e tests. + struct ReserveInfo { + uint256 reserveId; + address underlying; + address hub; + uint16 assetId; + string symbol; + uint8 decimals; + bool paused; + bool frozen; + bool borrowable; + bool collateralEnabled; // collateralFactor > 0 + uint16 collateralFactor; // BPS + uint32 maxLiquidationBonus; // BPS + uint16 liquidationFee; // BPS + } + + struct SpokeReserveSnapshot { + address spokeAddress; + uint256 reserveId; + address underlying; + string symbol; + address hub; + uint16 assetId; + uint8 decimals; + // ReserveConfig + uint24 collateralRisk; + bool paused; + bool frozen; + bool borrowable; + bool receiveSharesEnabled; + // DynamicReserveConfig (latest key) + uint32 dynamicConfigKey; + uint16 collateralFactor; + uint32 maxLiquidationBonus; + uint16 liquidationFee; + // Oracle + address oracleAddress; + address priceSource; + uint256 oraclePrice; + } + + struct SpokeLiquidationSnapshot { + address spokeAddress; + uint128 targetHealthFactor; + uint64 healthFactorForMaxBonus; + uint16 liquidationBonusFactor; + uint16 maxUserReservesLimit; + } + + struct HubAssetSnapshot { + address hubAddress; + uint256 assetId; + address underlying; + string symbol; + uint8 decimals; + uint16 liquidityFee; + address irStrategy; + address feeReceiver; + address reinvestmentController; + // IR params + uint16 optimalUsageRatio; + uint32 baseDrawnRate; + uint32 rateGrowthBeforeOptimal; + uint32 rateGrowthAfterOptimal; + uint256 maxDrawnRate; + // Asset state (from getAsset) + uint200 deficitRay; + uint120 swept; + uint120 premiumShares; + int200 premiumOffsetRay; + } + + struct SpokeConfigSnapshot { + address hubAddress; + uint256 assetId; + string assetSymbol; + address spokeAddress; + uint40 addCap; + uint40 drawCap; + uint24 riskPremiumThreshold; + bool active; + bool halted; + } + + struct V4Snapshot { + SpokeReserveSnapshot[] spokeReserves; + SpokeLiquidationSnapshot[] spokeLiquidationConfigs; + HubAssetSnapshot[] hubAssets; + SpokeConfigSnapshot[] spokeConfigs; + } +} diff --git a/src/dependencies/v4/V4DiffWriter.sol b/src/dependencies/v4/V4DiffWriter.sol new file mode 100644 index 00000000..c6eefaee --- /dev/null +++ b/src/dependencies/v4/V4DiffWriter.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Vm} from 'forge-std/Vm.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; + +/// @title V4DiffWriter +/// @notice Internal library for V4 JSON serialization. +/// Markdown diff generation is handled by the TypeScript CLI (aave-helpers-js). +/// Using an internal library means functions are inlined via delegatecall context, +/// keeping cheatcodes working while avoiding stack-too-deep in the inheritance chain. +library V4DiffWriter { + Vm private constant vm = Vm(address(uint160(uint256(keccak256('hevm cheat code'))))); + + function writeSnapshotJson(string memory reportName, Types.V4Snapshot memory snapshot) internal { + string memory path = string.concat('./reports/', reportName, '.json'); + vm.writeFile( + path, + '{ "spokeReserves": {}, "spokeLiquidationConfigs": {}, "hubAssets": {}, "spokeConfigs": {} }' + ); + vm.serializeUint('root', 'chainId', block.chainid); + + _writeSpokeReserves(path, snapshot.spokeReserves); + _writeSpokeLiqConfigs(path, snapshot.spokeLiquidationConfigs); + _writeHubAssets(path, snapshot.hubAssets); + _writeSpokeConfigs(path, snapshot.spokeConfigs); + } + + function _writeSpokeReserves( + string memory path, + Types.SpokeReserveSnapshot[] memory reserves + ) internal { + string memory sectionKey = 'spokeReserves'; + string memory content = '{}'; + vm.serializeJson(sectionKey, '{}'); + + for (uint256 i; i < reserves.length; i++) { + string memory obj = _serReserve(reserves[i]); + + string memory spokeKey = string.concat('spoke_', vm.toString(reserves[i].spokeAddress)); + if (reserves[i].reserveId == 0) { + vm.serializeJson(spokeKey, '{}'); + } + string memory spokeObj = vm.serializeString( + spokeKey, + vm.toString(reserves[i].reserveId), + obj + ); + + if (i + 1 == reserves.length || reserves[i + 1].spokeAddress != reserves[i].spokeAddress) { + content = vm.serializeString(sectionKey, vm.toString(reserves[i].spokeAddress), spokeObj); + } + } + vm.writeJson(vm.serializeString('root', 'spokeReserves', content), path); + } + + function _serReserve(Types.SpokeReserveSnapshot memory r) internal returns (string memory) { + string memory k = string.concat(vm.toString(r.spokeAddress), '_', vm.toString(r.reserveId)); + vm.serializeJson(k, '{}'); + vm.serializeString(k, 'symbol', r.symbol); + vm.serializeAddress(k, 'underlying', r.underlying); + vm.serializeAddress(k, 'hub', r.hub); + vm.serializeUint(k, 'assetId', r.assetId); + vm.serializeUint(k, 'decimals', r.decimals); + vm.serializeUint(k, 'collateralRisk', r.collateralRisk); + vm.serializeBool(k, 'paused', r.paused); + vm.serializeBool(k, 'frozen', r.frozen); + vm.serializeBool(k, 'borrowable', r.borrowable); + vm.serializeBool(k, 'receiveSharesEnabled', r.receiveSharesEnabled); + vm.serializeUint(k, 'dynamicConfigKey', r.dynamicConfigKey); + vm.serializeUint(k, 'collateralFactor', r.collateralFactor); + vm.serializeUint(k, 'maxLiquidationBonus', r.maxLiquidationBonus); + vm.serializeUint(k, 'liquidationFee', r.liquidationFee); + vm.serializeAddress(k, 'oracleAddress', r.oracleAddress); + vm.serializeAddress(k, 'priceSource', r.priceSource); + return vm.serializeString(k, 'oraclePrice', vm.toString(r.oraclePrice)); + } + + function _writeSpokeLiqConfigs( + string memory path, + Types.SpokeLiquidationSnapshot[] memory configs + ) internal { + string memory sectionKey = 'spokeLiqConfigs'; + string memory content = '{}'; + vm.serializeJson(sectionKey, '{}'); + + for (uint256 i; i < configs.length; i++) { + string memory k = string.concat('liq_', vm.toString(configs[i].spokeAddress)); + vm.serializeJson(k, '{}'); + vm.serializeString(k, 'targetHealthFactor', vm.toString(configs[i].targetHealthFactor)); + vm.serializeString( + k, + 'healthFactorForMaxBonus', + vm.toString(configs[i].healthFactorForMaxBonus) + ); + vm.serializeUint(k, 'liquidationBonusFactor', configs[i].liquidationBonusFactor); + string memory obj = vm.serializeUint( + k, + 'maxUserReservesLimit', + configs[i].maxUserReservesLimit + ); + content = vm.serializeString(sectionKey, vm.toString(configs[i].spokeAddress), obj); + } + vm.writeJson(vm.serializeString('root', 'spokeLiquidationConfigs', content), path); + } + + function _writeHubAssets(string memory path, Types.HubAssetSnapshot[] memory assets) internal { + string memory sectionKey = 'hubAssets'; + string memory content = '{}'; + vm.serializeJson(sectionKey, '{}'); + + for (uint256 i; i < assets.length; i++) { + string memory obj = _serializeHubAsset(assets[i]); + + string memory hubKey = string.concat('hub_', vm.toString(assets[i].hubAddress)); + if (i == 0 || assets[i].hubAddress != assets[i - 1].hubAddress) { + vm.serializeJson(hubKey, '{}'); + } + string memory hubObj = vm.serializeString(hubKey, vm.toString(assets[i].assetId), obj); + + if (i + 1 == assets.length || assets[i + 1].hubAddress != assets[i].hubAddress) { + content = vm.serializeString(sectionKey, vm.toString(assets[i].hubAddress), hubObj); + } + } + vm.writeJson(vm.serializeString('root', 'hubAssets', content), path); + } + + function _serializeHubAsset(Types.HubAssetSnapshot memory a) internal returns (string memory) { + string memory k = string.concat(vm.toString(a.hubAddress), '_', vm.toString(a.assetId)); + vm.serializeJson(k, '{}'); + vm.serializeString(k, 'symbol', a.symbol); + vm.serializeAddress(k, 'underlying', a.underlying); + vm.serializeUint(k, 'decimals', a.decimals); + vm.serializeUint(k, 'liquidityFee', a.liquidityFee); + vm.serializeAddress(k, 'irStrategy', a.irStrategy); + vm.serializeAddress(k, 'feeReceiver', a.feeReceiver); + vm.serializeAddress(k, 'reinvestmentController', a.reinvestmentController); + vm.serializeUint(k, 'optimalUsageRatio', a.optimalUsageRatio); + vm.serializeUint(k, 'baseDrawnRate', a.baseDrawnRate); + vm.serializeUint(k, 'rateGrowthBeforeOptimal', a.rateGrowthBeforeOptimal); + vm.serializeUint(k, 'rateGrowthAfterOptimal', a.rateGrowthAfterOptimal); + vm.serializeString(k, 'maxDrawnRate', vm.toString(a.maxDrawnRate)); + // Asset state + vm.serializeString(k, 'deficitRay', vm.toString(uint256(a.deficitRay))); + vm.serializeString(k, 'swept', vm.toString(uint256(a.swept))); + vm.serializeString(k, 'premiumShares', vm.toString(uint256(a.premiumShares))); + return vm.serializeString(k, 'premiumOffsetRay', vm.toString(a.premiumOffsetRay)); + } + + function _writeSpokeConfigs( + string memory path, + Types.SpokeConfigSnapshot[] memory caps + ) internal { + string memory sectionKey = 'spokeConfigs'; + string memory content = '{}'; + vm.serializeJson(sectionKey, '{}'); + + for (uint256 i; i < caps.length; i++) { + string memory k = string.concat( + vm.toString(caps[i].hubAddress), + '_', + vm.toString(caps[i].assetId), + '_', + vm.toString(caps[i].spokeAddress) + ); + vm.serializeJson(k, '{}'); + vm.serializeString(k, 'assetSymbol', caps[i].assetSymbol); + vm.serializeUint(k, 'addCap', uint256(caps[i].addCap)); + vm.serializeUint(k, 'drawCap', uint256(caps[i].drawCap)); + vm.serializeUint(k, 'riskPremiumThreshold', caps[i].riskPremiumThreshold); + vm.serializeBool(k, 'active', caps[i].active); + string memory obj = vm.serializeBool(k, 'halted', caps[i].halted); + content = vm.serializeString(sectionKey, k, obj); + } + vm.writeJson(vm.serializeString('root', 'spokeConfigs', content), path); + } +} diff --git a/src/v4-config-engine/AaveV4PayloadEthereum.sol b/src/v4-config-engine/AaveV4PayloadEthereum.sol new file mode 100644 index 00000000..cb1aec7e --- /dev/null +++ b/src/v4-config-engine/AaveV4PayloadEthereum.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {AaveV4Ethereum} from 'aave-address-book/AaveV4Ethereum.sol'; +import {AaveV4Payload} from 'aave-v4/config-engine/AaveV4Payload.sol'; + +/** + * @dev Base smart contract for an Aave V4 governance payload on Ethereum. + * @author Aave Labs + */ +abstract contract AaveV4PayloadEthereum is AaveV4Payload(AaveV4Ethereum.CONFIG_ENGINE) {} diff --git a/tests/ProtocolV4TestBase.t.sol b/tests/ProtocolV4TestBase.t.sol new file mode 100644 index 00000000..eed09c36 --- /dev/null +++ b/tests/ProtocolV4TestBase.t.sol @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import 'forge-std/Test.sol'; +import {ProtocolV4TestBase} from '../src/ProtocolV4TestBase.sol'; +import {ISpoke, ITokenizationSpoke, ISpokeConfigurator} from 'aave-address-book/AaveV4.sol'; +import {AaveV4Ethereum, AaveV4EthereumSpokes, AaveV4EthereumHubs, AaveV4EthereumTokenizationSpokes, AaveV4EthereumPositionManagers} from 'aave-address-book/AaveV4Ethereum.sol'; +import {AaveV4EthereumHubHelpers, AaveV4EthereumSpokeHelpers, AaveV4EthereumTokenizationSpokeHelpers} from 'src/dependencies/v4/AaveV4EthereumHelpers.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; +import {PayloadWithEmit} from './mocks/PayloadWithEmit.sol'; +import {PayloadWithStorage} from './mocks/PayloadWithStorage.sol'; + +contract ProtocolV4TestBaseTest is ProtocolV4TestBase { + uint256 public constant BLOCK_NUMBER = 24829000; + + function setUp() public { + vm.createSelectFork('mainnet', BLOCK_NUMBER); + } + + modifier gasless() { + vm.pauseGasMetering(); + _; + vm.resumeGasMetering(); + } + + function _mockAccessManager() internal { + vm.mockCall( + address(AaveV4Ethereum.ACCESS_MANAGER), + abi.encodeWithSelector(bytes4(keccak256('canCall(address,address,bytes4)'))), + abi.encode(true, uint32(0)) + ); + } + + function _updatePaused(address spoke, uint256 reserveId, bool paused) internal { + _mockAccessManager(); + AaveV4Ethereum.SPOKE_CONFIGURATOR.updatePaused({ + spoke: spoke, + reserveId: reserveId, + paused: paused + }); + vm.clearMockedCalls(); + } + + function _updateFrozen(address spoke, uint256 reserveId, bool frozen) internal { + _mockAccessManager(); + AaveV4Ethereum.SPOKE_CONFIGURATOR.updateFrozen({ + spoke: spoke, + reserveId: reserveId, + frozen: frozen + }); + vm.clearMockedCalls(); + } + + function _cleanupArtifacts(string memory reportName) internal { + string memory beforePath = string.concat('./reports/', reportName, '_before.json'); + string memory afterPath = string.concat('./reports/', reportName, '_after.json'); + if (vm.exists(beforePath)) { + vm.removeFile(beforePath); + } + if (vm.exists(afterPath)) { + vm.removeFile(afterPath); + } + } +} + +contract ProtocolV4TestE2ESingleSpoke is ProtocolV4TestBaseTest { + function test_e2eMainSpoke() public gasless { + e2eTestSpoke({spoke: AaveV4EthereumSpokes.MAIN_SPOKE}); + } +} + +contract ProtocolV4TestE2EDistinctSpokes is ProtocolV4TestBaseTest { + function test_e2eBluechipSpoke() public gasless { + e2eTestSpoke({spoke: AaveV4EthereumSpokes.BLUECHIP_SPOKE}); + } + + function test_e2eEthenaCorrelatedSpoke() public gasless { + e2eTestSpoke({spoke: AaveV4EthereumSpokes.ETHENA_CORRELATED_SPOKE}); + } + + function test_e2eLombardBtcSpoke() public gasless { + e2eTestSpoke({spoke: AaveV4EthereumSpokes.LOMBARD_BTC_SPOKE}); + } +} + +contract ProtocolV4TestE2EAllSpokes is ProtocolV4TestBaseTest { + function test_e2eAllSpokes() public gasless { + e2eTestAllSpokes({ + spokes: AaveV4EthereumSpokeHelpers.getUserSpokes(), + testPositionManagers: true + }); + } +} + +contract ProtocolV4TestE2ETokenizationSpokes is ProtocolV4TestBaseTest { + function test_e2eSingleTokenizationSpoke() public gasless { + e2eTestTokenizationSpoke(AaveV4EthereumTokenizationSpokes.CORE_WETH_TOKENIZATION_SPOKE); + } + + function test_e2eAllTokenizationSpokes() public gasless { + e2eTestAllTokenizationSpokes({ + tokenizationSpokes: AaveV4EthereumTokenizationSpokeHelpers.getTokenizationSpokes() + }); + } +} + +contract ProtocolV4TestPositionManagers is ProtocolV4TestBaseTest { + function test_e2eGatewaysMainSpoke() public gasless { + e2eTestGateways({spoke: AaveV4EthereumSpokes.MAIN_SPOKE}); + } + + function test_e2eRegularPositionManagers() public gasless { + e2eTestRegularPositionManagers({spoke: AaveV4EthereumSpokes.MAIN_SPOKE}); + } + + function test_e2ePositionManagersBluechip() public gasless { + e2eTestPositionManagers({spoke: AaveV4EthereumSpokes.BLUECHIP_SPOKE}); + } +} + +contract ProtocolV4TestPausedFrozenAssets is ProtocolV4TestBaseTest { + function test_pausedAssetReverts() public gasless { + ISpoke spoke = AaveV4EthereumSpokes.MAIN_SPOKE; + Types.ReserveInfo[] memory reserves = _getReserveInfo(spoke); + require(reserves.length > 0, 'No reserves found'); + + // Find first non-paused reserve + uint256 targetIdx; + bool found; + for (uint256 i; i < reserves.length; i++) { + if (!reserves[i].paused) { + targetIdx = i; + found = true; + break; + } + } + require(found, 'No non-paused reserve found'); + + _updatePaused({spoke: address(spoke), reserveId: reserves[targetIdx].reserveId, paused: true}); + + // Update the reserve info to reflect paused state + reserves[targetIdx].paused = true; + + e2eTestPausedAsset({spoke: spoke, pausedAsset: reserves[targetIdx]}); + } + + function test_frozenAssetReverts() public gasless { + ISpoke spoke = AaveV4EthereumSpokes.MAIN_SPOKE; + Types.ReserveInfo[] memory reserves = _getReserveInfo(spoke); + require(reserves.length > 0, 'No reserves found'); + + // Find first non-frozen, non-paused reserve + uint256 targetIdx; + bool found; + for (uint256 i; i < reserves.length; i++) { + if (!reserves[i].frozen && !reserves[i].paused) { + targetIdx = i; + found = true; + break; + } + } + require(found, 'No non-frozen reserve found'); + + _updateFrozen({spoke: address(spoke), reserveId: reserves[targetIdx].reserveId, frozen: true}); + + // Update the reserve info to reflect frozen state + reserves[targetIdx].frozen = true; + + e2eTestFrozenAsset({spoke: spoke, frozenAsset: reserves[targetIdx]}); + } +} + +contract ProtocolV4TestSnapshot is ProtocolV4TestBaseTest { + function test_snapshotState() public { + string memory name = 'v4_snapshot'; + Types.V4Snapshot memory snapshot = createV4Snapshot({ + spokes: AaveV4EthereumSpokeHelpers.getUserSpokes(), + hubs: AaveV4EthereumHubHelpers.getHubs() + }); + writeV4SnapshotJson({name: name, snap: snapshot}); + vm.removeFile(string.concat('./reports/', name, '.json')); + } +} + +contract ProtocolV4TestDefaultTest is ProtocolV4TestBaseTest { + function test_defaultTestWithPayload() public { + string memory name = 'v4_emit_payload'; + defaultTest({ + reportName: name, + spokes: AaveV4EthereumSpokeHelpers.getUserSpokes(), + tokenizationSpokes: AaveV4EthereumTokenizationSpokeHelpers.getTokenizationSpokes(), + payload: address(new PayloadWithEmit()) + }); + _cleanupArtifacts(name); + } + + function test_defaultTestNoE2E() public { + string memory name = 'v4_no_e2e'; + defaultTest({ + reportName: name, + spokes: AaveV4EthereumSpokeHelpers.getUserSpokes(), + tokenizationSpokes: AaveV4EthereumTokenizationSpokeHelpers.getTokenizationSpokes(), + payload: address(new PayloadWithEmit()), + runE2E: false, + testPositionManagers: false + }); + _cleanupArtifacts(name); + } +} + +contract ProtocolV4TestStorageValidation is ProtocolV4TestBaseTest { + function test_noExecutorStorageChange_passes() public { + string memory name = 'v4_storage_pass'; + defaultTest({ + reportName: name, + spokes: AaveV4EthereumSpokeHelpers.getUserSpokes(), + tokenizationSpokes: AaveV4EthereumTokenizationSpokeHelpers.getTokenizationSpokes(), + payload: address(new PayloadWithEmit()), + runE2E: false, + testPositionManagers: false + }); + _cleanupArtifacts(name); + } + + function test_executorStorageChange_reverts() public { + string memory name = 'v4_storage_fail'; + address payload = address(new PayloadWithStorage()); + vm.expectRevert(); + this.defaultTest({ + reportName: name, + spokes: AaveV4EthereumSpokeHelpers.getUserSpokes(), + tokenizationSpokes: AaveV4EthereumTokenizationSpokeHelpers.getTokenizationSpokes(), + payload: payload, + runE2E: false, + testPositionManagers: false + }); + // filesystem writes persist through EVM reverts, so clean up manually + _cleanupArtifacts(name); + } +} + +contract ProtocolV4TestPlausibility is Test, ProtocolV4TestBase { + address internal hubA = makeAddr('hubA'); + address internal hubB = makeAddr('hubB'); + + function test_emptyCapsPasses() public view { + Types.SpokeConfigSnapshot[] memory caps = new Types.SpokeConfigSnapshot[](0); + configChangePlausibilityTest(_snapshot(caps)); + } + + function test_zeroDrawCapSkipped() public view { + Types.SpokeConfigSnapshot[] memory caps = new Types.SpokeConfigSnapshot[](1); + caps[0] = _cap(hubA, 0, 0, 0); + configChangePlausibilityTest(_snapshot(caps)); + } + + function test_singleSpokeDrawLeAddPasses() public view { + Types.SpokeConfigSnapshot[] memory caps = new Types.SpokeConfigSnapshot[](1); + caps[0] = _cap(hubA, 0, 1_000_000, 500_000); + configChangePlausibilityTest(_snapshot(caps)); + } + + function test_singleSpokeDrawGtAddReverts() public { + Types.SpokeConfigSnapshot[] memory caps = new Types.SpokeConfigSnapshot[](1); + caps[0] = _cap(hubA, 0, 500_000, 1_000_000); + vm.expectRevert(bytes('PL_ADD_LT_DRAW')); + this.configChangePlausibilityTest(_snapshot(caps)); + } + + function test_borrowOnlySpokePassesWhenAggregateHolds() public view { + // Spoke A: borrow-only (addCap=0, drawCap=1M) — invalid per-spoke but valid in V4. + // Spoke B: supply-only (addCap=5M, drawCap=0). Aggregate: 1M draw <= 5M add. + Types.SpokeConfigSnapshot[] memory caps = new Types.SpokeConfigSnapshot[](2); + caps[0] = _cap(hubA, 0, 0, 1_000_000); + caps[1] = _cap(hubA, 0, 5_000_000, 0); + configChangePlausibilityTest(_snapshot(caps)); + } + + function test_aggregateDrawGtAggregateAddReverts() public { + // Same (hub, asset), two spokes: 2M+1M=3M add, 2M+2M=4M draw -> 4M > 3M reverts. + Types.SpokeConfigSnapshot[] memory caps = new Types.SpokeConfigSnapshot[](2); + caps[0] = _cap(hubA, 0, 2_000_000, 2_000_000); + caps[1] = _cap(hubA, 0, 1_000_000, 2_000_000); + vm.expectRevert(bytes('PL_ADD_LT_DRAW')); + this.configChangePlausibilityTest(_snapshot(caps)); + } + + function test_distinctAssetGroupsAreIndependent() public { + // (hubA, 0): 1M add, 0 draw -> skipped. (hubA, 1): 1M add, 2M draw -> reverts. + Types.SpokeConfigSnapshot[] memory caps = new Types.SpokeConfigSnapshot[](2); + caps[0] = _cap(hubA, 0, 1_000_000, 0); + caps[1] = _cap(hubA, 1, 1_000_000, 2_000_000); + vm.expectRevert(bytes('PL_ADD_LT_DRAW')); + this.configChangePlausibilityTest(_snapshot(caps)); + } + + function test_distinctHubsAreIndependent() public { + // Same assetId on two hubs aggregate separately. + // hubA asset 0: 1M add, 500k draw -> passes. + // hubB asset 0: 500k add, 1M draw -> reverts. + Types.SpokeConfigSnapshot[] memory caps = new Types.SpokeConfigSnapshot[](2); + caps[0] = _cap(hubA, 0, 1_000_000, 500_000); + caps[1] = _cap(hubB, 0, 500_000, 1_000_000); + vm.expectRevert(bytes('PL_ADD_LT_DRAW')); + this.configChangePlausibilityTest(_snapshot(caps)); + } + + function _cap( + address hub, + uint256 assetId, + uint40 addCap, + uint40 drawCap + ) internal pure returns (Types.SpokeConfigSnapshot memory) { + return + Types.SpokeConfigSnapshot({ + hubAddress: hub, + assetId: assetId, + assetSymbol: 'X', + spokeAddress: address(0), + addCap: addCap, + drawCap: drawCap, + riskPremiumThreshold: 0, + active: true, + halted: false + }); + } + + function _snapshot( + Types.SpokeConfigSnapshot[] memory caps + ) internal pure returns (Types.V4Snapshot memory snap) { + snap.spokeConfigs = caps; + } +} diff --git a/tests/dependencies/v4/SnapshotV4.HubAsset.t.sol b/tests/dependencies/v4/SnapshotV4.HubAsset.t.sol new file mode 100644 index 00000000..883c15e2 --- /dev/null +++ b/tests/dependencies/v4/SnapshotV4.HubAsset.t.sol @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/dependencies/v4/SnapshotV4Base.t.sol'; + +contract SnapshotV4HubAssetTest is SnapshotV4BaseTest { + // First hub asset: USDC, assetId 0. Used as the mutation target. + uint256 internal constant TARGET_IDX = 0; + + /// @dev All hub asset fixtures match the snapshot array. + function test_createV4Snapshot_hubAssets() public view { + assertEq(_createV4Snapshot().hubAssets, _hubAssetFixtures); + } + + /// @dev Swapping the underlying token updates both underlying and the derived symbol. Cannot happen in practice. + function test_delta_underlying_andSymbol() public { + CachedHubAssetFixture memory t = _targetHubAsset(); + IHub.Asset memory asset = _buildAssetFrom(t.input); + MockERC20Symbol newToken = new MockERC20Symbol('NEW_SYM'); + asset.underlying = address(newToken); + hub.setAsset(t.assetId, asset); + Types.V4Snapshot memory snap = _createV4Snapshot(); + assertEq(snap.hubAssets[TARGET_IDX].underlying, address(newToken), 'underlying'); + assertEq(snap.hubAssets[TARGET_IDX].symbol, 'NEW_SYM', 'symbol derives from underlying'); + } + + /// @dev Mutating Asset.decimals propagates. Cannot happen in practice. + function test_delta_decimals() public { + CachedHubAssetFixture memory t = _targetHubAsset(); + IHub.Asset memory asset = _buildAssetFrom(t.input); + asset.decimals = 18; + hub.setAsset(t.assetId, asset); + assertEq( + uint256(_createV4Snapshot().hubAssets[TARGET_IDX].decimals), + uint256(asset.decimals), + 'decimals' + ); + } + + /// @dev Mutating Asset.deficitRay propagates. + function test_delta_deficitRay() public { + CachedHubAssetFixture memory t = _targetHubAsset(); + IHub.Asset memory asset = _buildAssetFrom(t.input); + asset.deficitRay = 999_999; + hub.setAsset(t.assetId, asset); + assertEq( + uint256(_createV4Snapshot().hubAssets[TARGET_IDX].deficitRay), + uint256(asset.deficitRay), + 'deficitRay' + ); + } + + /// @dev Mutating Asset.swept propagates. + function test_delta_swept() public { + CachedHubAssetFixture memory t = _targetHubAsset(); + IHub.Asset memory asset = _buildAssetFrom(t.input); + asset.swept = 12_345; + hub.setAsset(t.assetId, asset); + assertEq( + uint256(_createV4Snapshot().hubAssets[TARGET_IDX].swept), + uint256(asset.swept), + 'swept' + ); + } + + /// @dev Mutating Asset.premiumShares propagates. + function test_delta_premiumShares() public { + CachedHubAssetFixture memory t = _targetHubAsset(); + IHub.Asset memory asset = _buildAssetFrom(t.input); + asset.premiumShares = 55_555; + hub.setAsset(t.assetId, asset); + assertEq( + uint256(_createV4Snapshot().hubAssets[TARGET_IDX].premiumShares), + uint256(asset.premiumShares), + 'premiumShares' + ); + } + + /// @dev Mutating Asset.premiumOffsetRay (signed) propagates. + function test_delta_premiumOffsetRay() public { + CachedHubAssetFixture memory t = _targetHubAsset(); + IHub.Asset memory asset = _buildAssetFrom(t.input); + asset.premiumOffsetRay = int200(-777); + hub.setAsset(t.assetId, asset); + assertEq( + _createV4Snapshot().hubAssets[TARGET_IDX].premiumOffsetRay, + asset.premiumOffsetRay, + 'premiumOffsetRay' + ); + } + + // --- AssetConfig fields --- + + /// @dev Mutating AssetConfig.liquidityFee propagates. + function test_delta_liquidityFee() public { + CachedHubAssetFixture memory t = _targetHubAsset(); + IHub.AssetConfig memory cfg = _buildAssetConfigFrom(t.input); + cfg.liquidityFee = 9_999; + hub.setAssetConfig(t.assetId, cfg); + assertEq( + uint256(_createV4Snapshot().hubAssets[TARGET_IDX].liquidityFee), + uint256(cfg.liquidityFee), + 'liquidityFee' + ); + } + + /// @dev Swapping the IR strategy reroutes IR-derived snapshot fields. + function test_delta_irStrategy() public { + CachedHubAssetFixture memory t = _targetHubAsset(); + IHub.AssetConfig memory cfg = _buildAssetConfigFrom(t.input); + MockIR newIR = new MockIR(); + newIR.setData( + t.assetId, + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 1234, + baseDrawnRate: 567, + rateGrowthBeforeOptimal: 89, + rateGrowthAfterOptimal: 10 + }), + 77_777 + ); + cfg.irStrategy = address(newIR); + hub.setAssetConfig(t.assetId, cfg); + Types.V4Snapshot memory snap = _createV4Snapshot(); + assertEq(snap.hubAssets[TARGET_IDX].irStrategy, address(newIR), 'irStrategy'); + assertEq(snap.hubAssets[TARGET_IDX].optimalUsageRatio, 1234, 'optimalUsageRatio reroute'); + assertEq(snap.hubAssets[TARGET_IDX].maxDrawnRate, 77_777, 'maxDrawnRate reroute'); + } + + /// @dev Mutating AssetConfig.feeReceiver propagates. + function test_delta_feeReceiver() public { + CachedHubAssetFixture memory t = _targetHubAsset(); + IHub.AssetConfig memory cfg = _buildAssetConfigFrom(t.input); + address newReceiver = makeAddr('NEW_FEE_RECEIVER'); + cfg.feeReceiver = newReceiver; + hub.setAssetConfig(t.assetId, cfg); + assertEq(_createV4Snapshot().hubAssets[TARGET_IDX].feeReceiver, newReceiver, 'feeReceiver'); + } + + /// @dev Mutating AssetConfig.reinvestmentController propagates. + function test_delta_reinvestmentController() public { + CachedHubAssetFixture memory t = _targetHubAsset(); + IHub.AssetConfig memory cfg = _buildAssetConfigFrom(t.input); + address newReinv = makeAddr('NEW_REINV'); + cfg.reinvestmentController = newReinv; + hub.setAssetConfig(t.assetId, cfg); + assertEq( + _createV4Snapshot().hubAssets[TARGET_IDX].reinvestmentController, + newReinv, + 'reinvestmentController' + ); + } + + // --- IR-driven fields (mutate IR mock directly) --- + + /// @dev Mutating IR data optimalUsageRatio propagates. + function test_delta_optimalUsageRatio() public { + ir0.setData( + 0, + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 1111, + baseDrawnRate: 100, + rateGrowthBeforeOptimal: 400, + rateGrowthAfterOptimal: 6000 + }), + 30_000 + ); + assertEq( + _createV4Snapshot().hubAssets[TARGET_IDX].optimalUsageRatio, + 1111, + 'optimalUsageRatio' + ); + } + + /// @dev Mutating IR data baseDrawnRate propagates. + function test_delta_baseDrawnRate() public { + ir0.setData( + 0, + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 8000, + baseDrawnRate: 222, + rateGrowthBeforeOptimal: 400, + rateGrowthAfterOptimal: 6000 + }), + 30_000 + ); + assertEq(_createV4Snapshot().hubAssets[TARGET_IDX].baseDrawnRate, 222, 'baseDrawnRate'); + } + + /// @dev Mutating IR data rateGrowthBeforeOptimal propagates. + function test_delta_rateGrowthBeforeOptimal() public { + ir0.setData( + 0, + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 8000, + baseDrawnRate: 100, + rateGrowthBeforeOptimal: 333, + rateGrowthAfterOptimal: 6000 + }), + 30_000 + ); + assertEq( + _createV4Snapshot().hubAssets[TARGET_IDX].rateGrowthBeforeOptimal, + 333, + 'rateGrowthBeforeOptimal' + ); + } + + /// @dev Mutating IR data rateGrowthAfterOptimal propagates. + function test_delta_rateGrowthAfterOptimal() public { + ir0.setData( + 0, + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 8000, + baseDrawnRate: 100, + rateGrowthBeforeOptimal: 400, + rateGrowthAfterOptimal: 4444 + }), + 30_000 + ); + assertEq( + _createV4Snapshot().hubAssets[TARGET_IDX].rateGrowthAfterOptimal, + 4444, + 'rateGrowthAfterOptimal' + ); + } + + /// @dev Mutating IR data maxDrawnRate propagates. + function test_delta_maxDrawnRate() public { + ir0.setData( + 0, + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 8000, + baseDrawnRate: 100, + rateGrowthBeforeOptimal: 400, + rateGrowthAfterOptimal: 6000 + }), + 55_555 + ); + assertEq(_createV4Snapshot().hubAssets[TARGET_IDX].maxDrawnRate, 55_555, 'maxDrawnRate'); + } + + function _targetHubAsset() internal view returns (CachedHubAssetFixture memory) { + return _hubAssetFixtures[TARGET_IDX]; + } + + function _buildAssetFrom( + HubAssetFixture memory f + ) internal pure returns (IHub.Asset memory asset) { + asset.underlying = f.underlying; + asset.decimals = f.decimals; + asset.liquidityFee = f.liquidityFee; + asset.irStrategy = f.irStrategy; + asset.reinvestmentController = f.reinvController; + asset.feeReceiver = f.feeReceiver; + asset.deficitRay = f.deficitRay; + asset.swept = f.swept; + asset.premiumShares = f.premiumShares; + asset.premiumOffsetRay = f.premiumOffsetRay; + } + + function _buildAssetConfigFrom( + HubAssetFixture memory f + ) internal pure returns (IHub.AssetConfig memory) { + return + IHub.AssetConfig({ + feeReceiver: f.feeReceiver, + liquidityFee: f.liquidityFee, + irStrategy: f.irStrategy, + reinvestmentController: f.reinvController + }); + } +} diff --git a/tests/dependencies/v4/SnapshotV4.LiqConfig.t.sol b/tests/dependencies/v4/SnapshotV4.LiqConfig.t.sol new file mode 100644 index 00000000..b83a7abe --- /dev/null +++ b/tests/dependencies/v4/SnapshotV4.LiqConfig.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/dependencies/v4/SnapshotV4Base.t.sol'; + +contract SnapshotV4LiqConfigTest is SnapshotV4BaseTest { + // First liq-config fixture: spokeA. Used as the mutation target. + uint256 internal constant TARGET_IDX = 0; + + /// @dev All liq config fixtures match the snapshot array. + function test_createV4Snapshot_liquidationConfigs() public view { + assertEq(_createV4Snapshot().spokeLiquidationConfigs, _liqConfigFixtures); + } + + /// @dev Mutating targetHealthFactor propagates. + function test_delta_targetHealthFactor() public { + LiqConfigFixture memory t = _targetLiqConfig(); + uint128 newVal = uint128(uint256(t.targetHealthFactor) * 2 + 1); + t.spoke.setLiquidationConfig( + ISpoke.LiquidationConfig({ + targetHealthFactor: newVal, + healthFactorForMaxBonus: t.healthFactorForMaxBonus, + liquidationBonusFactor: t.liquidationBonusFactor + }) + ); + assertEq( + _createV4Snapshot().spokeLiquidationConfigs[TARGET_IDX].targetHealthFactor, + uint256(newVal), + 'targetHealthFactor' + ); + } + + /// @dev Mutating healthFactorForMaxBonus propagates. + function test_delta_healthFactorForMaxBonus() public { + LiqConfigFixture memory t = _targetLiqConfig(); + uint64 newVal = uint64(uint256(t.healthFactorForMaxBonus) - 1); + t.spoke.setLiquidationConfig( + ISpoke.LiquidationConfig({ + targetHealthFactor: t.targetHealthFactor, + healthFactorForMaxBonus: newVal, + liquidationBonusFactor: t.liquidationBonusFactor + }) + ); + assertEq( + _createV4Snapshot().spokeLiquidationConfigs[TARGET_IDX].healthFactorForMaxBonus, + uint256(newVal), + 'healthFactorForMaxBonus' + ); + } + + /// @dev Mutating liquidationBonusFactor propagates. + function test_delta_liquidationBonusFactor() public { + LiqConfigFixture memory t = _targetLiqConfig(); + uint16 newVal = t.liquidationBonusFactor + 50; + t.spoke.setLiquidationConfig( + ISpoke.LiquidationConfig({ + targetHealthFactor: t.targetHealthFactor, + healthFactorForMaxBonus: t.healthFactorForMaxBonus, + liquidationBonusFactor: newVal + }) + ); + assertEq( + uint256(_createV4Snapshot().spokeLiquidationConfigs[TARGET_IDX].liquidationBonusFactor), + uint256(newVal), + 'liquidationBonusFactor' + ); + } + + /// @dev Mutating maxUserReservesLimit propagates. + function test_delta_maxUserReservesLimit() public { + LiqConfigFixture memory t = _targetLiqConfig(); + uint16 newVal = t.maxUserReservesLimit + 1; + t.spoke.setMaxUserReservesLimit(newVal); + assertEq( + uint256(_createV4Snapshot().spokeLiquidationConfigs[TARGET_IDX].maxUserReservesLimit), + uint256(newVal), + 'maxUserReservesLimit' + ); + } + + function _targetLiqConfig() internal view returns (LiqConfigFixture memory) { + return _liqConfigFixtures[TARGET_IDX]; + } +} diff --git a/tests/dependencies/v4/SnapshotV4.Reserve.t.sol b/tests/dependencies/v4/SnapshotV4.Reserve.t.sol new file mode 100644 index 00000000..d6435585 --- /dev/null +++ b/tests/dependencies/v4/SnapshotV4.Reserve.t.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/dependencies/v4/SnapshotV4Base.t.sol'; + +contract SnapshotV4ReserveTest is SnapshotV4BaseTest { + // First reserve in the fixture set: USDC on spokeA, reserveId 0. Used as the + // mutation target for all per-field delta tests below. + uint256 internal constant TARGET_IDX = 0; + + /// @dev All reserve fixtures match the snapshot array. + function test_createV4Snapshot_spokeReserves() public view { + assertEq(_createV4Snapshot().spokeReserves, _reserveFixtures); + } + + // --- ReserveConfig fields --- + + /// @dev Flipping ReserveConfig.paused propagates to the reserve snapshot. + function test_delta_paused() public { + CachedReserveFixture memory t = _targetReserve(); + ISpoke.ReserveConfig memory cfg = _buildReserveConfigFrom(t.input); + cfg.paused = !cfg.paused; + t.input.spoke.setReserveConfig(t.reserveId, cfg); + assertEq(_createV4Snapshot().spokeReserves[TARGET_IDX].paused, cfg.paused, 'paused'); + } + + /// @dev Flipping ReserveConfig.frozen propagates. + function test_delta_frozen() public { + CachedReserveFixture memory t = _targetReserve(); + ISpoke.ReserveConfig memory cfg = _buildReserveConfigFrom(t.input); + cfg.frozen = !cfg.frozen; + t.input.spoke.setReserveConfig(t.reserveId, cfg); + assertEq(_createV4Snapshot().spokeReserves[TARGET_IDX].frozen, cfg.frozen, 'frozen'); + } + + /// @dev Flipping ReserveConfig.borrowable propagates. + function test_delta_borrowable() public { + CachedReserveFixture memory t = _targetReserve(); + ISpoke.ReserveConfig memory cfg = _buildReserveConfigFrom(t.input); + cfg.borrowable = !cfg.borrowable; + t.input.spoke.setReserveConfig(t.reserveId, cfg); + assertEq( + _createV4Snapshot().spokeReserves[TARGET_IDX].borrowable, + cfg.borrowable, + 'borrowable' + ); + } + + /// @dev Flipping ReserveConfig.receiveSharesEnabled propagates. + function test_delta_receiveSharesEnabled() public { + CachedReserveFixture memory t = _targetReserve(); + ISpoke.ReserveConfig memory cfg = _buildReserveConfigFrom(t.input); + cfg.receiveSharesEnabled = !cfg.receiveSharesEnabled; + t.input.spoke.setReserveConfig(t.reserveId, cfg); + assertEq( + _createV4Snapshot().spokeReserves[TARGET_IDX].receiveSharesEnabled, + cfg.receiveSharesEnabled, + 'receiveSharesEnabled' + ); + } + + /// @dev Mutating ReserveConfig.collateralRisk propagates. + function test_delta_collateralRisk() public { + CachedReserveFixture memory t = _targetReserve(); + ISpoke.ReserveConfig memory cfg = _buildReserveConfigFrom(t.input); + cfg.collateralRisk = 9_999; + t.input.spoke.setReserveConfig(t.reserveId, cfg); + assertEq( + uint256(_createV4Snapshot().spokeReserves[TARGET_IDX].collateralRisk), + uint256(cfg.collateralRisk), + 'collateralRisk' + ); + } + + // --- DynamicReserveConfig fields --- + + /// @dev Mutating DynamicReserveConfig.collateralFactor propagates. + function test_delta_collateralFactor() public { + CachedReserveFixture memory t = _targetReserve(); + ISpoke.DynamicReserveConfig memory dyn = _buildDynamicReserveConfigFrom(t.input); + dyn.collateralFactor = 9_500; + t.input.spoke.setDynamicReserveConfig(t.reserveId, t.input.dynamicConfigKey, dyn); + assertEq( + uint256(_createV4Snapshot().spokeReserves[TARGET_IDX].collateralFactor), + uint256(dyn.collateralFactor), + 'collateralFactor' + ); + } + + /// @dev Mutating DynamicReserveConfig.maxLiquidationBonus propagates. + function test_delta_maxLiquidationBonus() public { + CachedReserveFixture memory t = _targetReserve(); + ISpoke.DynamicReserveConfig memory dyn = _buildDynamicReserveConfigFrom(t.input); + dyn.maxLiquidationBonus = 12_345; + t.input.spoke.setDynamicReserveConfig(t.reserveId, t.input.dynamicConfigKey, dyn); + assertEq( + uint256(_createV4Snapshot().spokeReserves[TARGET_IDX].maxLiquidationBonus), + uint256(dyn.maxLiquidationBonus), + 'maxLiquidationBonus' + ); + } + + /// @dev Mutating DynamicReserveConfig.liquidationFee propagates. + function test_delta_liquidationFee() public { + CachedReserveFixture memory t = _targetReserve(); + ISpoke.DynamicReserveConfig memory dyn = _buildDynamicReserveConfigFrom(t.input); + dyn.liquidationFee = 250; + t.input.spoke.setDynamicReserveConfig(t.reserveId, t.input.dynamicConfigKey, dyn); + assertEq( + uint256(_createV4Snapshot().spokeReserves[TARGET_IDX].liquidationFee), + uint256(dyn.liquidationFee), + 'liquidationFee' + ); + } + + // --- Oracle-driven fields --- + + /// @dev Updating the oracle's price for a reserve propagates. + function test_delta_oraclePrice() public { + CachedReserveFixture memory t = _targetReserve(); + uint256 newPrice = t.input.oraclePrice * 2 + 1; + t.input.oracle.setReserve(t.reserveId, t.input.priceSource, newPrice); + assertEq(_createV4Snapshot().spokeReserves[TARGET_IDX].oraclePrice, newPrice, 'oraclePrice'); + } + + /// @dev Updating the oracle's price source for a reserve propagates. + function test_delta_priceSource() public { + CachedReserveFixture memory t = _targetReserve(); + address newSource = makeAddr('NEW_PRICE_SOURCE'); + t.input.oracle.setReserve(t.reserveId, newSource, t.input.oraclePrice); + assertEq(_createV4Snapshot().spokeReserves[TARGET_IDX].priceSource, newSource, 'priceSource'); + } + + /// @dev Swapping the spoke's oracle reroutes `oracleAddress` for its reserves. + function test_delta_oracleAddress() public { + CachedReserveFixture memory t = _targetReserve(); + MockOracle newOracle = new MockOracle(); + newOracle.setReserve(t.reserveId, t.input.priceSource, t.input.oraclePrice); + t.input.spoke.setOracle(address(newOracle)); + assertEq( + _createV4Snapshot().spokeReserves[TARGET_IDX].oracleAddress, + address(newOracle), + 'oracleAddress' + ); + } + + function _targetReserve() internal view returns (CachedReserveFixture memory) { + return _reserveFixtures[TARGET_IDX]; + } + + function _buildReserveConfigFrom( + ReserveFixture memory f + ) internal pure returns (ISpoke.ReserveConfig memory) { + return + ISpoke.ReserveConfig({ + collateralRisk: f.collateralRisk, + paused: f.paused, + frozen: f.frozen, + borrowable: f.borrowable, + receiveSharesEnabled: f.receiveSharesEnabled + }); + } + + function _buildDynamicReserveConfigFrom( + ReserveFixture memory f + ) internal pure returns (ISpoke.DynamicReserveConfig memory) { + return + ISpoke.DynamicReserveConfig({ + collateralFactor: f.collateralFactor, + maxLiquidationBonus: f.maxLiquidationBonus, + liquidationFee: f.liquidationFee + }); + } +} diff --git a/tests/dependencies/v4/SnapshotV4.SpokeConfig.t.sol b/tests/dependencies/v4/SnapshotV4.SpokeConfig.t.sol new file mode 100644 index 00000000..20ae3dba --- /dev/null +++ b/tests/dependencies/v4/SnapshotV4.SpokeConfig.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/dependencies/v4/SnapshotV4Base.t.sol'; + +contract SnapshotV4SpokeConfigTest is SnapshotV4BaseTest { + // First spoke-config fixture: assetId 0 / spokeA. Used as the mutation target. + uint256 internal constant TARGET_IDX = 0; + + /// @dev All spoke config fixtures match the snapshot array. + function test_createV4Snapshot_spokeConfigs() public view { + assertEq(_createV4Snapshot().spokeConfigs, _spokeConfigFixtures); + } + + /// @dev Updating SpokeConfig.addCap propagates. + function test_delta_addCap() public { + SpokeConfigFixture memory t = _targetSpokeConfig(); + IHub.SpokeConfig memory cfg = _newConfigFrom(t); + cfg.addCap = t.addCap + 7_000; + hub.addSpokeConfig(t.assetId, address(t.spoke), cfg); + assertEq( + uint256(_createV4Snapshot().spokeConfigs[TARGET_IDX].addCap), + uint256(cfg.addCap), + 'addCap' + ); + } + + /// @dev Updating SpokeConfig.drawCap propagates. + function test_delta_drawCap() public { + SpokeConfigFixture memory t = _targetSpokeConfig(); + IHub.SpokeConfig memory cfg = _newConfigFrom(t); + cfg.drawCap = t.drawCap + 3_000; + hub.addSpokeConfig(t.assetId, address(t.spoke), cfg); + assertEq( + uint256(_createV4Snapshot().spokeConfigs[TARGET_IDX].drawCap), + uint256(cfg.drawCap), + 'drawCap' + ); + } + + /// @dev Updating SpokeConfig.riskPremiumThreshold propagates. + function test_delta_riskPremiumThreshold() public { + SpokeConfigFixture memory t = _targetSpokeConfig(); + IHub.SpokeConfig memory cfg = _newConfigFrom(t); + cfg.riskPremiumThreshold = 555; + hub.addSpokeConfig(t.assetId, address(t.spoke), cfg); + assertEq( + uint256(_createV4Snapshot().spokeConfigs[TARGET_IDX].riskPremiumThreshold), + uint256(cfg.riskPremiumThreshold), + 'riskPremiumThreshold' + ); + } + + /// @dev Flipping SpokeConfig.active propagates. + function test_delta_active() public { + SpokeConfigFixture memory t = _targetSpokeConfig(); + IHub.SpokeConfig memory cfg = _newConfigFrom(t); + cfg.active = !cfg.active; + hub.addSpokeConfig(t.assetId, address(t.spoke), cfg); + assertEq(_createV4Snapshot().spokeConfigs[TARGET_IDX].active, cfg.active, 'active'); + } + + /// @dev Flipping SpokeConfig.halted propagates. + function test_delta_halted() public { + SpokeConfigFixture memory t = _targetSpokeConfig(); + IHub.SpokeConfig memory cfg = _newConfigFrom(t); + cfg.halted = !cfg.halted; + hub.addSpokeConfig(t.assetId, address(t.spoke), cfg); + assertEq(_createV4Snapshot().spokeConfigs[TARGET_IDX].halted, cfg.halted, 'halted'); + } + + /// @dev Re-calling addSpokeConfig for an existing (assetId, spoke) doesn't duplicate the entry. + function test_delta_reAdd_doesNotDuplicate() public { + Types.V4Snapshot memory snapA = _createV4Snapshot(); + SpokeConfigFixture memory t = _targetSpokeConfig(); + IHub.SpokeConfig memory cfg = _newConfigFrom(t); + cfg.halted = !cfg.halted; + hub.addSpokeConfig(t.assetId, address(t.spoke), cfg); + Types.V4Snapshot memory snapB = _createV4Snapshot(); + assertEq(snapA.spokeConfigs.length, snapB.spokeConfigs.length, 'no duplicate push'); + } + + function _targetSpokeConfig() internal view returns (SpokeConfigFixture memory) { + return _spokeConfigFixtures[TARGET_IDX]; + } + + function _newConfigFrom( + SpokeConfigFixture memory f + ) internal pure returns (IHub.SpokeConfig memory) { + return + IHub.SpokeConfig({ + addCap: f.addCap, + drawCap: f.drawCap, + riskPremiumThreshold: f.riskPremiumThreshold, + active: f.active, + halted: f.halted + }); + } +} diff --git a/tests/dependencies/v4/SnapshotV4Base.t.sol b/tests/dependencies/v4/SnapshotV4Base.t.sol new file mode 100644 index 00000000..1822450f --- /dev/null +++ b/tests/dependencies/v4/SnapshotV4Base.t.sol @@ -0,0 +1,670 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'forge-std/Test.sol'; +import {ISpoke, IHub} from 'aave-address-book/AaveV4.sol'; +import {IHubBase} from 'aave-v4/hub/interfaces/IHubBase.sol'; +import {IAssetInterestRateStrategy} from 'aave-v4/hub/interfaces/IAssetInterestRateStrategy.sol'; +import {SnapshotV4} from 'src/dependencies/v4/SnapshotV4.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; +import {MockSpoke, MockHub, MockOracle, MockIR, MockERC20Symbol} from 'tests/mocks/v4/V4Mocks.sol'; + +abstract contract SnapshotV4BaseTest is Test, SnapshotV4 { + struct ReserveFixture { + MockSpoke spoke; + MockOracle oracle; + address underlying; + address hubAddr; + uint16 assetId; + uint8 decimals; + bool paused; + bool frozen; + bool borrowable; + bool receiveSharesEnabled; + uint24 collateralRisk; + uint32 dynamicConfigKey; + uint16 collateralFactor; + uint32 maxLiquidationBonus; + uint16 liquidationFee; + address priceSource; + uint256 oraclePrice; + } + + struct CachedReserveFixture { + ReserveFixture input; + uint16 reserveId; + } + + struct HubAssetFixture { + address underlying; + uint8 decimals; + uint16 liquidityFee; + address irStrategy; + address feeReceiver; + address reinvController; + uint200 deficitRay; + uint120 swept; + uint120 premiumShares; + int200 premiumOffsetRay; + } + + struct CachedHubAssetFixture { + HubAssetFixture input; + uint256 assetId; + } + + struct LiqConfigFixture { + MockSpoke spoke; + uint128 targetHealthFactor; + uint64 healthFactorForMaxBonus; + uint16 liquidationBonusFactor; + uint16 maxUserReservesLimit; + } + + struct SpokeConfigFixture { + uint256 assetId; + MockSpoke spoke; + uint40 addCap; + uint40 drawCap; + uint24 riskPremiumThreshold; + bool active; + bool halted; + } + + SpokeConfigFixture[] internal _spokeConfigFixtures; + CachedHubAssetFixture[] internal _hubAssetFixtures; + LiqConfigFixture[] internal _liqConfigFixtures; + CachedReserveFixture[] internal _reserveFixtures; + + MockSpoke internal spokeA; + MockSpoke internal spokeB; + MockHub internal hub; + MockOracle internal oracleA; + MockOracle internal oracleB; + MockIR internal ir0; + MockIR internal ir1; + + MockERC20Symbol internal usdc; + MockERC20Symbol internal weth; + MockERC20Symbol internal wbtc; + + address internal priceSource0 = makeAddr('CHAINLINK_USDC'); + address internal priceSource1 = makeAddr('CHAINLINK_WETH'); + address internal priceSource2 = makeAddr('CHAINLINK_WBTC'); + + address internal feeReceiverA = makeAddr('FEE_RECEIVER_A'); + address internal feeReceiverB = makeAddr('FEE_RECEIVER_B'); + address internal reinvA = makeAddr('REINVEST_A'); + address internal reinvB = makeAddr('REINVEST_B'); + + function setUp() public virtual { + _deployMocks(); + _setSpokeOracles(); + _addLiqConfigFixtures(); + _addReserveFixtures(); + _addHubAssetFixtures(); + _configureIRStrategies(); + _addSpokeConfigFixtures(); + } + + function _setSpokeOracles() internal { + spokeA.setOracle(address(oracleA)); + spokeB.setOracle(address(oracleB)); + } + + function _addLiqConfigFixtures() internal { + _addLiqConfig( + LiqConfigFixture({ + spoke: spokeA, + targetHealthFactor: 1.05e18, + healthFactorForMaxBonus: 0.95e18, + liquidationBonusFactor: 100, + maxUserReservesLimit: 8 + }) + ); + _addLiqConfig( + LiqConfigFixture({ + spoke: spokeB, + targetHealthFactor: 1.10e18, + healthFactorForMaxBonus: 0.97e18, + liquidationBonusFactor: 200, + maxUserReservesLimit: 12 + }) + ); + } + + function _addLiqConfig(LiqConfigFixture memory f) internal { + f.spoke.setMaxUserReservesLimit(f.maxUserReservesLimit); + f.spoke.setLiquidationConfig( + ISpoke.LiquidationConfig({ + targetHealthFactor: f.targetHealthFactor, + healthFactorForMaxBonus: f.healthFactorForMaxBonus, + liquidationBonusFactor: f.liquidationBonusFactor + }) + ); + _liqConfigFixtures.push(f); + } + + /// @notice Assert a `SpokeLiquidationSnapshot` matches the stored fixture inputs. + function assertEq( + Types.SpokeLiquidationSnapshot memory snap, + LiqConfigFixture memory expected, + uint256 idx + ) internal pure { + string memory pfx = string.concat('liqConfig[', vm.toString(idx), '] '); + assertEq(snap.spokeAddress, address(expected.spoke), string.concat(pfx, 'spoke')); + assertEq( + snap.targetHealthFactor, + uint256(expected.targetHealthFactor), + string.concat(pfx, 'targetHealthFactor') + ); + assertEq( + snap.healthFactorForMaxBonus, + uint256(expected.healthFactorForMaxBonus), + string.concat(pfx, 'healthFactorForMaxBonus') + ); + assertEq( + uint256(snap.liquidationBonusFactor), + uint256(expected.liquidationBonusFactor), + string.concat(pfx, 'liquidationBonusFactor') + ); + assertEq( + uint256(snap.maxUserReservesLimit), + uint256(expected.maxUserReservesLimit), + string.concat(pfx, 'maxUserReservesLimit') + ); + } + + function _deployMocks() internal { + usdc = new MockERC20Symbol('USDC'); + weth = new MockERC20Symbol('WETH'); + wbtc = new MockERC20Symbol('WBTC'); + + oracleA = new MockOracle(); + oracleB = new MockOracle(); + + ir0 = new MockIR(); + ir1 = new MockIR(); + + hub = new MockHub(); + spokeA = new MockSpoke(); + spokeB = new MockSpoke(); + } + + function _addHubAssetFixtures() internal { + _addHubAsset( + HubAssetFixture({ + underlying: address(usdc), + decimals: 6, + liquidityFee: 10, + irStrategy: address(ir0), + feeReceiver: feeReceiverA, + reinvController: reinvA, + deficitRay: 11, + swept: 22, + premiumShares: 33, + premiumOffsetRay: int200(44) + }) + ); + _addHubAsset( + HubAssetFixture({ + underlying: address(weth), + decimals: 18, + liquidityFee: 20, + irStrategy: address(ir1), + feeReceiver: feeReceiverB, + reinvController: reinvB, + deficitRay: 55, + swept: 66, + premiumShares: 77, + premiumOffsetRay: int200(-88) + }) + ); + // No IR strategy on this one — exercises the `irStrategy == address(0)` branch. + _addHubAsset( + HubAssetFixture({ + underlying: address(wbtc), + decimals: 8, + liquidityFee: 30, + irStrategy: address(0), + feeReceiver: feeReceiverA, + reinvController: address(0), + deficitRay: 99, + swept: 100, + premiumShares: 101, + premiumOffsetRay: int200(102) + }) + ); + } + + function _configureIRStrategies() internal { + ir0.setData( + 0, + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 8000, + baseDrawnRate: 100, + rateGrowthBeforeOptimal: 400, + rateGrowthAfterOptimal: 6000 + }), + 30_000 + ); + ir1.setData( + 1, + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 7000, + baseDrawnRate: 200, + rateGrowthBeforeOptimal: 500, + rateGrowthAfterOptimal: 7000 + }), + 50_000 + ); + } + + function _addSpokeConfigFixtures() internal { + _addSpokeConfig( + SpokeConfigFixture({ + assetId: 0, + spoke: spokeA, + addCap: 1_000_000, + drawCap: 500_000, + riskPremiumThreshold: 100, + active: true, + halted: false + }) + ); + _addSpokeConfig( + SpokeConfigFixture({ + assetId: 0, + spoke: spokeB, + addCap: 2_000_000, + drawCap: 1_500_000, + riskPremiumThreshold: 200, + active: true, + halted: true + }) + ); + _addSpokeConfig( + SpokeConfigFixture({ + assetId: 1, + spoke: spokeA, + addCap: 3_000_000, + drawCap: 2_500_000, + riskPremiumThreshold: 300, + active: false, + halted: false + }) + ); + _addSpokeConfig( + SpokeConfigFixture({ + assetId: 2, + spoke: spokeB, + addCap: 4_000_000, + drawCap: 3_500_000, + riskPremiumThreshold: 400, + active: true, + halted: false + }) + ); + } + + function _addSpokeConfig(SpokeConfigFixture memory f) internal { + hub.addSpokeConfig( + f.assetId, + address(f.spoke), + IHub.SpokeConfig({ + addCap: f.addCap, + drawCap: f.drawCap, + riskPremiumThreshold: f.riskPremiumThreshold, + active: f.active, + halted: f.halted + }) + ); + _spokeConfigFixtures.push(f); + } + + /// @notice Assert a `SpokeConfigSnapshot` matches the stored fixture inputs. + /// `assetSymbol` is resolved from the asset's underlying token, mirroring + /// `SnapshotV4._snapshotCapsForHub`. + function assertEq( + Types.SpokeConfigSnapshot memory snap, + SpokeConfigFixture memory expected, + uint256 idx + ) internal view { + string memory pfx = string.concat('spokeConfig[', vm.toString(idx), '] '); + assertEq(snap.hubAddress, address(hub), string.concat(pfx, 'hub')); + assertEq(snap.assetId, expected.assetId, string.concat(pfx, 'assetId')); + assertEq(snap.spokeAddress, address(expected.spoke), string.concat(pfx, 'spoke')); + (address underlying, ) = hub.getAssetUnderlyingAndDecimals(expected.assetId); + assertEq( + snap.assetSymbol, + MockERC20Symbol(underlying).symbol(), + string.concat(pfx, 'assetSymbol') + ); + assertEq(uint256(snap.addCap), uint256(expected.addCap), string.concat(pfx, 'addCap')); + assertEq(uint256(snap.drawCap), uint256(expected.drawCap), string.concat(pfx, 'drawCap')); + assertEq( + uint256(snap.riskPremiumThreshold), + uint256(expected.riskPremiumThreshold), + string.concat(pfx, 'riskPremiumThreshold') + ); + assertEq(snap.active, expected.active, string.concat(pfx, 'active')); + assertEq(snap.halted, expected.halted, string.concat(pfx, 'halted')); + } + + function _createV4Snapshot() internal view returns (Types.V4Snapshot memory) { + ISpoke[] memory spokes = new ISpoke[](2); + spokes[0] = ISpoke(address(spokeA)); + spokes[1] = ISpoke(address(spokeB)); + IHub[] memory hubs = new IHub[](1); + hubs[0] = IHub(address(hub)); + return createV4Snapshot(spokes, hubs); + } + + function _addReserveFixtures() internal { + _addReserve( + ReserveFixture({ + spoke: spokeA, + oracle: oracleA, + underlying: address(usdc), + hubAddr: address(hub), + assetId: 0, + decimals: 6, + paused: false, + frozen: false, + borrowable: true, + receiveSharesEnabled: true, + collateralRisk: 1000, + dynamicConfigKey: 1, + collateralFactor: 7500, + maxLiquidationBonus: 10500, + liquidationFee: 100, + priceSource: priceSource0, + oraclePrice: 1e8 + }) + ); + _addReserve( + ReserveFixture({ + spoke: spokeA, + oracle: oracleA, + underlying: address(weth), + hubAddr: address(hub), + assetId: 1, + decimals: 18, + paused: false, + frozen: true, + borrowable: false, + receiveSharesEnabled: false, + collateralRisk: 2500, + dynamicConfigKey: 2, + collateralFactor: 8000, + maxLiquidationBonus: 11000, + liquidationFee: 150, + priceSource: priceSource1, + oraclePrice: 2_000e8 + }) + ); + + _addReserve( + ReserveFixture({ + spoke: spokeB, + oracle: oracleB, + underlying: address(wbtc), + hubAddr: address(hub), + assetId: 2, + decimals: 8, + paused: true, + frozen: false, + borrowable: true, + receiveSharesEnabled: true, + collateralRisk: 3000, + dynamicConfigKey: 3, + collateralFactor: 7000, + maxLiquidationBonus: 11500, + liquidationFee: 200, + priceSource: priceSource2, + oraclePrice: 60_000e8 + }) + ); + } + + function _addReserve(ReserveFixture memory f) internal { + ISpoke.Reserve memory reserve; + reserve.underlying = f.underlying; + reserve.hub = IHubBase(f.hubAddr); + reserve.assetId = f.assetId; + reserve.decimals = f.decimals; + reserve.collateralRisk = f.collateralRisk; + reserve.dynamicConfigKey = f.dynamicConfigKey; + + ISpoke.ReserveConfig memory config = ISpoke.ReserveConfig({ + collateralRisk: f.collateralRisk, + paused: f.paused, + frozen: f.frozen, + borrowable: f.borrowable, + receiveSharesEnabled: f.receiveSharesEnabled + }); + + ISpoke.DynamicReserveConfig memory dyn = ISpoke.DynamicReserveConfig({ + collateralFactor: f.collateralFactor, + maxLiquidationBonus: f.maxLiquidationBonus, + liquidationFee: f.liquidationFee + }); + + uint256 reserveId = f.spoke.addReserve(reserve, config, dyn); + f.oracle.setReserve(reserveId, f.priceSource, f.oraclePrice); + _reserveFixtures.push(CachedReserveFixture({input: f, reserveId: uint16(reserveId)})); + } + + /// @notice Assert a `SpokeReserveSnapshot` matches the stored fixture inputs. + function assertEq( + Types.SpokeReserveSnapshot memory snap, + CachedReserveFixture memory expected, + uint256 idx + ) internal view { + string memory pfx = string.concat('reserve[', vm.toString(idx), '] '); + assertEq(snap.spokeAddress, address(expected.input.spoke), string.concat(pfx, 'spoke')); + assertEq(snap.reserveId, expected.reserveId, string.concat(pfx, 'reserveId')); + assertEq(snap.underlying, expected.input.underlying, string.concat(pfx, 'underlying')); + assertEq( + snap.symbol, + MockERC20Symbol(expected.input.underlying).symbol(), + string.concat(pfx, 'symbol') + ); + assertEq(snap.hub, expected.input.hubAddr, string.concat(pfx, 'hub')); + assertEq(uint256(snap.assetId), uint256(expected.input.assetId), string.concat(pfx, 'assetId')); + assertEq( + uint256(snap.decimals), + uint256(expected.input.decimals), + string.concat(pfx, 'decimals') + ); + assertEq( + uint256(snap.collateralRisk), + uint256(expected.input.collateralRisk), + string.concat(pfx, 'collateralRisk') + ); + assertEq(snap.paused, expected.input.paused, string.concat(pfx, 'paused')); + assertEq(snap.frozen, expected.input.frozen, string.concat(pfx, 'frozen')); + assertEq(snap.borrowable, expected.input.borrowable, string.concat(pfx, 'borrowable')); + assertEq( + snap.receiveSharesEnabled, + expected.input.receiveSharesEnabled, + string.concat(pfx, 'receiveSharesEnabled') + ); + assertEq( + uint256(snap.dynamicConfigKey), + uint256(expected.input.dynamicConfigKey), + string.concat(pfx, 'dynamicConfigKey') + ); + assertEq( + uint256(snap.collateralFactor), + uint256(expected.input.collateralFactor), + string.concat(pfx, 'collateralFactor') + ); + assertEq( + uint256(snap.maxLiquidationBonus), + uint256(expected.input.maxLiquidationBonus), + string.concat(pfx, 'maxLiquidationBonus') + ); + assertEq( + uint256(snap.liquidationFee), + uint256(expected.input.liquidationFee), + string.concat(pfx, 'liquidationFee') + ); + assertEq( + snap.oracleAddress, + address(expected.input.oracle), + string.concat(pfx, 'oracleAddress') + ); + assertEq(snap.priceSource, expected.input.priceSource, string.concat(pfx, 'priceSource')); + assertEq(snap.oraclePrice, expected.input.oraclePrice, string.concat(pfx, 'oraclePrice')); + } + + /// @notice Assert a `HubAssetSnapshot` matches the stored fixture inputs. + /// IR-strategy fields are zero when `irStrategy == address(0)`; otherwise queried + /// from the IR mock at assert time, mirroring how `SnapshotV4` resolves them. + function assertEq( + Types.HubAssetSnapshot memory snap, + CachedHubAssetFixture memory expected, + uint256 idx + ) internal view { + string memory pfx = string.concat('hubAsset[', vm.toString(idx), '] '); + assertEq(snap.hubAddress, address(hub), string.concat(pfx, 'hub')); + assertEq(snap.assetId, expected.assetId, string.concat(pfx, 'assetId')); + assertEq(snap.underlying, expected.input.underlying, string.concat(pfx, 'underlying')); + assertEq( + snap.symbol, + MockERC20Symbol(expected.input.underlying).symbol(), + string.concat(pfx, 'symbol') + ); + assertEq( + uint256(snap.decimals), + uint256(expected.input.decimals), + string.concat(pfx, 'decimals') + ); + assertEq( + uint256(snap.liquidityFee), + uint256(expected.input.liquidityFee), + string.concat(pfx, 'liquidityFee') + ); + assertEq(snap.irStrategy, expected.input.irStrategy, string.concat(pfx, 'irStrategy')); + assertEq(snap.feeReceiver, expected.input.feeReceiver, string.concat(pfx, 'feeReceiver')); + assertEq( + snap.reinvestmentController, + expected.input.reinvController, + string.concat(pfx, 'reinvController') + ); + + // IR strategy fields — zero when address(0), otherwise queried from the mock. + uint256 expOptimalUR; + uint256 expBaseRate; + uint256 expGrowthBefore; + uint256 expGrowthAfter; + uint256 expMaxDrawnRate; + if (expected.input.irStrategy != address(0)) { + IAssetInterestRateStrategy ir = IAssetInterestRateStrategy(expected.input.irStrategy); + IAssetInterestRateStrategy.InterestRateData memory data = ir.getInterestRateData( + expected.assetId + ); + expOptimalUR = data.optimalUsageRatio; + expBaseRate = data.baseDrawnRate; + expGrowthBefore = data.rateGrowthBeforeOptimal; + expGrowthAfter = data.rateGrowthAfterOptimal; + expMaxDrawnRate = ir.getMaxDrawnRate(expected.assetId); + } + assertEq(snap.optimalUsageRatio, expOptimalUR, string.concat(pfx, 'optimalUsageRatio')); + assertEq(snap.baseDrawnRate, expBaseRate, string.concat(pfx, 'baseDrawnRate')); + assertEq( + snap.rateGrowthBeforeOptimal, + expGrowthBefore, + string.concat(pfx, 'rateGrowthBeforeOptimal') + ); + assertEq( + snap.rateGrowthAfterOptimal, + expGrowthAfter, + string.concat(pfx, 'rateGrowthAfterOptimal') + ); + assertEq(snap.maxDrawnRate, expMaxDrawnRate, string.concat(pfx, 'maxDrawnRate')); + + // Asset state — written directly from the fixture into the mock, so equality is exact. + assertEq( + uint256(snap.deficitRay), + uint256(expected.input.deficitRay), + string.concat(pfx, 'deficitRay') + ); + assertEq(uint256(snap.swept), uint256(expected.input.swept), string.concat(pfx, 'swept')); + assertEq( + uint256(snap.premiumShares), + uint256(expected.input.premiumShares), + string.concat(pfx, 'premiumShares') + ); + assertEq( + snap.premiumOffsetRay, + expected.input.premiumOffsetRay, + string.concat(pfx, 'premiumOffsetRay') + ); + } + + function _addHubAsset(HubAssetFixture memory f) internal { + IHub.Asset memory asset; + asset.underlying = f.underlying; + asset.decimals = f.decimals; + asset.liquidityFee = f.liquidityFee; + asset.irStrategy = f.irStrategy; + asset.reinvestmentController = f.reinvController; + asset.feeReceiver = f.feeReceiver; + asset.deficitRay = f.deficitRay; + asset.swept = f.swept; + asset.premiumShares = f.premiumShares; + asset.premiumOffsetRay = f.premiumOffsetRay; + + IHub.AssetConfig memory config = IHub.AssetConfig({ + feeReceiver: f.feeReceiver, + liquidityFee: f.liquidityFee, + irStrategy: f.irStrategy, + reinvestmentController: f.reinvController + }); + + uint256 assetId = hub.addAsset(asset, config); + _hubAssetFixtures.push(CachedHubAssetFixture({input: f, assetId: assetId})); + } + + function assertEq( + Types.SpokeReserveSnapshot[] memory snaps, + CachedReserveFixture[] memory expected + ) internal view { + assertEq(snaps.length, expected.length, 'spokeReserves length'); + for (uint256 i; i < expected.length; i++) { + assertEq(snaps[i], expected[i], i); + } + } + + function assertEq( + Types.HubAssetSnapshot[] memory snaps, + CachedHubAssetFixture[] memory expected + ) internal view { + assertEq(snaps.length, expected.length, 'hubAssets length'); + for (uint256 i; i < expected.length; i++) { + assertEq(snaps[i], expected[i], i); + } + } + + function assertEq( + Types.SpokeLiquidationSnapshot[] memory snaps, + LiqConfigFixture[] memory expected + ) internal pure { + assertEq(snaps.length, expected.length, 'liqConfigs length'); + for (uint256 i; i < expected.length; i++) { + assertEq(snaps[i], expected[i], i); + } + } + + function assertEq( + Types.SpokeConfigSnapshot[] memory snaps, + SpokeConfigFixture[] memory expected + ) internal view { + assertEq(snaps.length, expected.length, 'spokeConfigs length'); + for (uint256 i; i < expected.length; i++) { + assertEq(snaps[i], expected[i], i); + } + } +} diff --git a/tests/dependencies/v4/SnapshotV4Combined.t.sol b/tests/dependencies/v4/SnapshotV4Combined.t.sol new file mode 100644 index 00000000..2e02dc57 --- /dev/null +++ b/tests/dependencies/v4/SnapshotV4Combined.t.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/dependencies/v4/SnapshotV4Base.t.sol'; + +contract SnapshotV4CombinedTest is SnapshotV4BaseTest { + // Override the base setUp: deploy mocks only and let each test selectively + // call `_addFixtures()` for partial-config and delta scenarios. + function setUp() public override { + _deployMocks(); + _setSpokeOracles(); + } + + /// @dev Empty spokes and hubs produce an all-empty snapshot. + function test_createV4Snapshot_emptyInputs() public view { + ISpoke[] memory spokes = new ISpoke[](0); + IHub[] memory hubs = new IHub[](0); + Types.V4Snapshot memory snap = createV4Snapshot(spokes, hubs); + assertEq(snap.spokeReserves.length, 0, 'empty reserves'); + assertEq(snap.spokeLiquidationConfigs.length, 0, 'empty liq'); + assertEq(snap.hubAssets.length, 0, 'empty hubAssets'); + assertEq(snap.spokeConfigs.length, 0, 'empty caps'); + } + + /// @dev Adding only reserves leaves hub assets and spoke configs empty. + function test_partial_reservesOnly() public { + _addReserveFixtures(); + Types.V4Snapshot memory snap = _createV4Snapshot(); + assertEq(snap.spokeReserves, _reserveFixtures); + assertEq(snap.hubAssets.length, 0, 'hub assets should be empty'); + assertEq(snap.spokeConfigs.length, 0, 'spoke caps should be empty'); + } + + /// @dev Adding only hub assets leaves reserves and spoke configs empty. + function test_partial_hubAssetsOnly() public { + _addHubAssetFixtures(); + _configureIRStrategies(); + Types.V4Snapshot memory snap = _createV4Snapshot(); + assertEq(snap.hubAssets, _hubAssetFixtures); + assertEq(snap.spokeReserves.length, 0, 'reserves should be empty'); + assertEq(snap.spokeConfigs.length, 0, 'spoke caps should be empty'); + } + + /// @dev Hub assets without registered spoke configs produce empty spokeConfigs. + function test_partial_hubAssetsNoSpokeConfigs() public { + _addHubAssetFixtures(); + _configureIRStrategies(); + Types.V4Snapshot memory snap = _createV4Snapshot(); + assertEq(snap.hubAssets.length, _hubAssetFixtures.length, 'hub assets populated'); + assertEq(snap.spokeConfigs.length, 0, 'no spoke configs registered'); + } + + /// @dev Adding only liq configs leaves other sections empty. + function test_partial_liqConfigsOnly() public { + _addLiqConfigFixtures(); + Types.V4Snapshot memory snap = _createV4Snapshot(); + assertEq(snap.spokeLiquidationConfigs, _liqConfigFixtures); + assertEq(snap.spokeReserves.length, 0, 'reserves should be empty'); + assertEq(snap.hubAssets.length, 0, 'hub assets should be empty'); + assertEq(snap.spokeConfigs.length, 0, 'spoke caps should be empty'); + } + + /// @dev Liq configs are emitted per-spoke regardless of fixture state (zero-default). + function test_partial_liqConfigsSpokeDriven() public view { + Types.V4Snapshot memory snap = _createV4Snapshot(); + assertEq(snap.spokeLiquidationConfigs.length, 2, '2 spokes -> 2 liq configs'); + assertEq( + snap.spokeLiquidationConfigs[0].targetHealthFactor, + 0, + 'default targetHealthFactor is zero' + ); + assertEq( + snap.spokeLiquidationConfigs[0].liquidationBonusFactor, + 0, + 'default liquidationBonusFactor is zero' + ); + } + + /// @dev Mutating one oracle price moves only that reserve's price; other reserves stable. + function test_delta_oraclePrice() public { + _addAllFixtures(); + + Types.V4Snapshot memory snapA = _createV4Snapshot(); + + CachedReserveFixture memory target = _reserveFixtures[0]; + uint256 newPrice = target.input.oraclePrice * 2 + 1; + target.input.oracle.setReserve(target.reserveId, target.input.priceSource, newPrice); + + Types.V4Snapshot memory snapB = _createV4Snapshot(); + + assertEq(snapA.spokeReserves.length, snapB.spokeReserves.length, 'reserves length moved'); + assertEq(snapB.spokeReserves[0].oraclePrice, newPrice, 'price did not update'); + assertTrue( + snapA.spokeReserves[0].oraclePrice != snapB.spokeReserves[0].oraclePrice, + 'price unchanged' + ); + for (uint256 i = 1; i < snapB.spokeReserves.length; i++) { + assertEq( + snapA.spokeReserves[i].oraclePrice, + snapB.spokeReserves[i].oraclePrice, + 'unrelated price moved' + ); + } + assertEq( + snapA.spokeReserves[0].underlying, + snapB.spokeReserves[0].underlying, + 'underlying moved' + ); + assertEq(snapA.spokeReserves[0].decimals, snapB.spokeReserves[0].decimals, 'decimals moved'); + } + + /// @dev Re-calling addSpokeConfig to flip `halted` moves only that field; array length stable. + function test_delta_spokeConfigFlag() public { + _addAllFixtures(); + + Types.V4Snapshot memory snapA = _createV4Snapshot(); + + SpokeConfigFixture memory orig = _spokeConfigFixtures[0]; + hub.addSpokeConfig( + orig.assetId, + address(orig.spoke), + IHub.SpokeConfig({ + addCap: orig.addCap, + drawCap: orig.drawCap, + riskPremiumThreshold: orig.riskPremiumThreshold, + active: orig.active, + halted: !orig.halted + }) + ); + + Types.V4Snapshot memory snapB = _createV4Snapshot(); + + assertEq(snapA.spokeConfigs.length, snapB.spokeConfigs.length, 'spokeConfigs length moved'); + assertEq(snapB.spokeConfigs[0].halted, !orig.halted, 'halted did not flip'); + assertTrue(snapA.spokeConfigs[0].halted != snapB.spokeConfigs[0].halted, 'halted unchanged'); + assertEq(snapA.spokeConfigs[0].addCap, snapB.spokeConfigs[0].addCap, 'addCap moved'); + assertEq(snapA.spokeConfigs[0].drawCap, snapB.spokeConfigs[0].drawCap, 'drawCap moved'); + assertEq(snapA.spokeConfigs[0].active, snapB.spokeConfigs[0].active, 'active moved'); + } + + /// @dev Mutating IR data moves only the corresponding hub asset's IR fields. + function test_delta_irData() public { + _addAllFixtures(); + + Types.V4Snapshot memory snapA = _createV4Snapshot(); + + uint256 newMaxDrawnRate = 99_999; + ir0.setData( + 0, + IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 8000, + baseDrawnRate: 100, + rateGrowthBeforeOptimal: 400, + rateGrowthAfterOptimal: 6000 + }), + newMaxDrawnRate + ); + + Types.V4Snapshot memory snapB = _createV4Snapshot(); + + assertEq(snapA.hubAssets.length, snapB.hubAssets.length, 'hubAssets length moved'); + assertEq(snapB.hubAssets[0].maxDrawnRate, newMaxDrawnRate, 'maxDrawnRate did not update'); + assertTrue( + snapA.hubAssets[0].maxDrawnRate != snapB.hubAssets[0].maxDrawnRate, + 'maxDrawnRate unchanged' + ); + assertEq( + snapA.hubAssets[1].maxDrawnRate, + snapB.hubAssets[1].maxDrawnRate, + 'unrelated IR moved' + ); + assertEq(snapA.hubAssets[0].underlying, snapB.hubAssets[0].underlying, 'underlying moved'); + assertEq( + snapA.hubAssets[0].liquidityFee, + snapB.hubAssets[0].liquidityFee, + 'liquidityFee moved' + ); + } + + /// @dev Snapshot aggregates assets and configs across multiple hubs in pass order. + function test_multiHub_aggregates() public { + _addAllFixtures(); + + MockHub hub2 = new MockHub(); + { + IHub.Asset memory asset; + asset.underlying = address(usdc); + asset.decimals = 6; + asset.liquidityFee = 7; + asset.irStrategy = address(0); + asset.reinvestmentController = address(0); + asset.feeReceiver = feeReceiverA; + IHub.AssetConfig memory config = IHub.AssetConfig({ + feeReceiver: feeReceiverA, + liquidityFee: 7, + irStrategy: address(0), + reinvestmentController: address(0) + }); + hub2.addAsset(asset, config); + } + hub2.addSpokeConfig( + 0, + address(spokeA), + IHub.SpokeConfig({ + addCap: 7_000_000, + drawCap: 6_000_000, + riskPremiumThreshold: 700, + active: true, + halted: false + }) + ); + + ISpoke[] memory spokes = new ISpoke[](2); + spokes[0] = ISpoke(address(spokeA)); + spokes[1] = ISpoke(address(spokeB)); + IHub[] memory hubs = new IHub[](2); + hubs[0] = IHub(address(hub)); + hubs[1] = IHub(address(hub2)); + Types.V4Snapshot memory snap = createV4Snapshot(spokes, hubs); + + assertEq(snap.hubAssets.length, _hubAssetFixtures.length + 1, 'hubAssets aggregated'); + assertEq(snap.spokeConfigs.length, _spokeConfigFixtures.length + 1, 'spokeConfigs aggregated'); + + assertEq(snap.hubAssets[0].hubAddress, address(hub), 'first asset from hub1'); + assertEq( + snap.hubAssets[snap.hubAssets.length - 1].hubAddress, + address(hub2), + 'last asset from hub2' + ); + assertEq(snap.spokeConfigs[0].hubAddress, address(hub), 'first cap from hub1'); + assertEq( + snap.spokeConfigs[snap.spokeConfigs.length - 1].hubAddress, + address(hub2), + 'last cap from hub2' + ); + + Types.SpokeConfigSnapshot memory hub2Cap = snap.spokeConfigs[snap.spokeConfigs.length - 1]; + assertEq(hub2Cap.assetId, 0, 'hub2 cap assetId'); + assertEq(hub2Cap.spokeAddress, address(spokeA), 'hub2 cap spoke'); + assertEq(uint256(hub2Cap.addCap), 7_000_000, 'hub2 addCap'); + assertEq(uint256(hub2Cap.drawCap), 6_000_000, 'hub2 drawCap'); + } + + function _addAllFixtures() internal { + _addLiqConfigFixtures(); + _addReserveFixtures(); + _addHubAssetFixtures(); + _configureIRStrategies(); + _addSpokeConfigFixtures(); + } +} diff --git a/tests/dependencies/v4/V4DiffWriter.t.sol b/tests/dependencies/v4/V4DiffWriter.t.sol new file mode 100644 index 00000000..e0fe2198 --- /dev/null +++ b/tests/dependencies/v4/V4DiffWriter.t.sol @@ -0,0 +1,444 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'forge-std/Test.sol'; +import {SnapshotV4} from 'src/dependencies/v4/SnapshotV4.sol'; +import {V4DiffWriter} from 'src/dependencies/v4/V4DiffWriter.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; +import {V4DiffWriterHarness} from 'tests/mocks/v4/V4DiffWriterHarness.sol'; + +abstract contract V4DiffWriterTestBase is Test { + address internal spokeA; + address internal spokeB; + address internal hubX; + address internal hubY; + address internal underlyingUsdc; + address internal underlyingWeth; + address internal oracleAddr; + address internal priceSource; + address internal irStrategy; + address internal feeReceiver; + address internal reinvController; + + function _setUpAddresses() internal { + spokeA = makeAddr('spokeA'); + spokeB = makeAddr('spokeB'); + hubX = makeAddr('hubX'); + hubY = makeAddr('hubY'); + underlyingUsdc = makeAddr('underlyingUsdc'); + underlyingWeth = makeAddr('underlyingWeth'); + oracleAddr = makeAddr('oracleAddr'); + priceSource = makeAddr('priceSource'); + irStrategy = makeAddr('irStrategy'); + feeReceiver = makeAddr('feeReceiver'); + reinvController = makeAddr('reinvController'); + } + + function _makeReserve( + address spoke, + uint256 reserveId, + string memory symbol, + uint16 collateralFactor + ) internal view returns (Types.SpokeReserveSnapshot memory) { + return + Types.SpokeReserveSnapshot({ + spokeAddress: spoke, + reserveId: reserveId, + underlying: underlyingUsdc, + symbol: symbol, + hub: hubX, + assetId: uint16(reserveId), + decimals: 6, + collateralRisk: 1000, + paused: false, + frozen: false, + borrowable: true, + receiveSharesEnabled: true, + dynamicConfigKey: 1, + collateralFactor: collateralFactor, + maxLiquidationBonus: 10500, + liquidationFee: 100, + oracleAddress: oracleAddr, + priceSource: priceSource, + oraclePrice: 1e8 + }); + } + + function _makeHubAsset( + address hub, + uint256 assetId, + string memory symbol, + uint16 liquidityFee + ) internal view returns (Types.HubAssetSnapshot memory) { + return + Types.HubAssetSnapshot({ + hubAddress: hub, + assetId: assetId, + underlying: underlyingUsdc, + symbol: symbol, + decimals: 6, + liquidityFee: liquidityFee, + irStrategy: irStrategy, + feeReceiver: feeReceiver, + reinvestmentController: reinvController, + optimalUsageRatio: 8000, + baseDrawnRate: 100, + rateGrowthBeforeOptimal: 400, + rateGrowthAfterOptimal: 6000, + maxDrawnRate: 30_000, + deficitRay: 0, + swept: 0, + premiumShares: 0, + premiumOffsetRay: int200(0) + }); + } +} + +/// Inherits SnapshotV4 so we can execute `writeV4SnapshotJson` and `diffV4Snapshots` +contract V4DiffWriterTest is V4DiffWriterTestBase, SnapshotV4 { + string internal constant REPORT = 'v4diff_writer_test'; + + function setUp() public { + _setUpAddresses(); + vm.chainId(1); + } + + function test_writeSnapshotJson_persistsAllSections() public { + Types.V4Snapshot memory snap = _baseSnapshot(); + V4DiffWriter.writeSnapshotJson('v4diff_writer_single', snap); + + string memory json = vm.readFile('./reports/v4diff_writer_single.json'); + + // Top-level sections are present + assertTrue(vm.contains(json, '"spokeReserves"'), 'spokeReserves missing'); + assertTrue(vm.contains(json, '"spokeLiquidationConfigs"'), 'spokeLiqConfigs missing'); + assertTrue(vm.contains(json, '"hubAssets"'), 'hubAssets missing'); + assertTrue(vm.contains(json, '"spokeConfigs"'), 'spokeConfigs missing'); + + // Per-entity values + assertTrue(vm.contains(json, '"symbol": "USDC"'), 'reserve symbol'); + assertTrue(vm.contains(json, '"collateralFactor": 7500'), 'CF value'); + assertTrue(vm.contains(json, '"paused": false'), 'paused value'); + assertTrue(vm.contains(json, '"liquidityFee": 10'), 'liqFee value'); + assertTrue(vm.contains(json, '"addCap": 1000000'), 'addCap value'); + assertTrue(vm.contains(json, '"liquidationBonusFactor": 100'), 'liqBonusFactor value'); + + vm.removeFile('./reports/v4diff_writer_single.json'); + } + + function test_diffV4Snapshots_emitsChanges() public { + // Build the two snapshots independently so the "before" arrays aren't mutated by the + // "after" assignments, ie array references on copy. + Types.V4Snapshot memory before = _baseSnapshot(); + Types.V4Snapshot memory afterSnap = _baseSnapshot(); + afterSnap.spokeReserves[0].collateralFactor = 8000; + afterSnap.spokeReserves[0].paused = true; + afterSnap.hubAssets[0].liquidityFee = 50; + afterSnap.spokeConfigs[0].addCap = 2_000_000; + afterSnap.spokeConfigs[0].halted = true; + afterSnap.spokeLiquidationConfigs[0].liquidationBonusFactor = 200; + + writeV4SnapshotJson(string.concat(REPORT, '_before'), before); + writeV4SnapshotJson(string.concat(REPORT, '_after'), afterSnap); + + // ---- JSON contents: before has old values, after has new values ---- + string memory beforeJson = vm.readFile(string.concat('./reports/', REPORT, '_before.json')); + string memory afterJson = vm.readFile(string.concat('./reports/', REPORT, '_after.json')); + + assertTrue(vm.contains(beforeJson, '"collateralFactor": 7500'), 'before CF'); + assertTrue(vm.contains(afterJson, '"collateralFactor": 8000'), 'after CF'); + + assertTrue(vm.contains(beforeJson, '"paused": false'), 'before paused'); + assertTrue(vm.contains(afterJson, '"paused": true'), 'after paused'); + + assertTrue(vm.contains(beforeJson, '"liquidityFee": 10'), 'before liqFee'); + assertTrue(vm.contains(afterJson, '"liquidityFee": 50'), 'after liqFee'); + + assertTrue(vm.contains(beforeJson, '"addCap": 1000000'), 'before addCap'); + assertTrue(vm.contains(afterJson, '"addCap": 2000000'), 'after addCap'); + + assertTrue(vm.contains(beforeJson, '"halted": false'), 'before halted'); + assertTrue(vm.contains(afterJson, '"halted": true'), 'after halted'); + + assertTrue(vm.contains(beforeJson, '"liquidationBonusFactor": 100'), 'before liqBonus'); + assertTrue(vm.contains(afterJson, '"liquidationBonusFactor": 200'), 'after liqBonus'); + + // ---- Run the TypeScript CLI to render the markdown diff ---- + diffV4Snapshots(REPORT); + + string memory md = vm.readFile( + string.concat('./diffs/', REPORT, '_before_', REPORT, '_after.md') + ); + + // Spoke reserve section: BPS fields are formatted as "W.FF % [bps]" + assertTrue(vm.contains(md, '## Spoke Reserve Changes'), 'spoke reserve section'); + assertTrue(vm.contains(md, 'collateralFactor'), 'CF row'); + assertTrue(vm.contains(md, '75.00 % [7500]'), 'CF before formatted'); + assertTrue(vm.contains(md, '80.00 % [8000]'), 'CF after formatted'); + assertTrue(vm.contains(md, 'paused'), 'paused row'); + + // Hub asset section + assertTrue(vm.contains(md, '## Hub Asset Changes'), 'hub asset section'); + assertTrue(vm.contains(md, 'liquidityFee'), 'liqFee row'); + assertTrue(vm.contains(md, '0.10 % [10]'), 'liqFee before'); + assertTrue(vm.contains(md, '0.50 % [50]'), 'liqFee after'); + + // Spoke cap section — uint40s rendered with thousand separators + assertTrue(vm.contains(md, '## Hub Spoke Config Changes'), 'spoke config section'); + assertTrue(vm.contains(md, 'addCap'), 'addCap row'); + assertTrue(vm.contains(md, '1,000,000'), 'addCap before'); + assertTrue(vm.contains(md, '2,000,000'), 'addCap after'); + assertTrue(vm.contains(md, 'halted'), 'halted row'); + + // Spoke liquidation section + assertTrue(vm.contains(md, '## Spoke Liquidation Config Changes'), 'liq config section'); + assertTrue(vm.contains(md, 'liquidationBonusFactor'), 'liqBonus row'); + assertTrue(vm.contains(md, '1.00 % [100]'), 'liqBonus before'); + assertTrue(vm.contains(md, '2.00 % [200]'), 'liqBonus after'); + + // No noise from unchanged sections is necessary — but the raw diff block always closes the doc + assertTrue(vm.contains(md, '## Raw diff'), 'raw diff trailer'); + + _cleanup(); + } + + function _baseSnapshot() internal view returns (Types.V4Snapshot memory snap) { + snap.spokeReserves = new Types.SpokeReserveSnapshot[](1); + snap.spokeReserves[0] = Types.SpokeReserveSnapshot({ + spokeAddress: spokeA, + reserveId: 0, + underlying: underlyingUsdc, + symbol: 'USDC', + hub: hubX, + assetId: 0, + decimals: 6, + collateralRisk: 1000, + paused: false, + frozen: false, + borrowable: true, + receiveSharesEnabled: true, + dynamicConfigKey: 1, + collateralFactor: 7500, + maxLiquidationBonus: 10500, + liquidationFee: 100, + oracleAddress: oracleAddr, + priceSource: priceSource, + oraclePrice: 1e8 + }); + + snap.spokeLiquidationConfigs = new Types.SpokeLiquidationSnapshot[](1); + snap.spokeLiquidationConfigs[0] = Types.SpokeLiquidationSnapshot({ + spokeAddress: spokeA, + targetHealthFactor: 1.05e18, + healthFactorForMaxBonus: 0.95e18, + liquidationBonusFactor: 100, + maxUserReservesLimit: 8 + }); + + snap.hubAssets = new Types.HubAssetSnapshot[](1); + snap.hubAssets[0] = Types.HubAssetSnapshot({ + hubAddress: hubX, + assetId: 0, + underlying: underlyingUsdc, + symbol: 'USDC', + decimals: 6, + liquidityFee: 10, + irStrategy: irStrategy, + feeReceiver: feeReceiver, + reinvestmentController: reinvController, + optimalUsageRatio: 8000, + baseDrawnRate: 100, + rateGrowthBeforeOptimal: 400, + rateGrowthAfterOptimal: 6000, + maxDrawnRate: 30_000, + deficitRay: 0, + swept: 0, + premiumShares: 0, + premiumOffsetRay: int200(0) + }); + + snap.spokeConfigs = new Types.SpokeConfigSnapshot[](1); + snap.spokeConfigs[0] = Types.SpokeConfigSnapshot({ + hubAddress: hubX, + assetId: 0, + assetSymbol: 'USDC', + spokeAddress: spokeA, + addCap: 1_000_000, + drawCap: 500_000, + riskPremiumThreshold: 100, + active: true, + halted: false + }); + } + + function _cleanup() internal { + string memory beforePath = string.concat('./reports/', REPORT, '_before.json'); + string memory afterPath = string.concat('./reports/', REPORT, '_after.json'); + string memory diffPath = string.concat('./diffs/', REPORT, '_before_', REPORT, '_after.md'); + if (vm.exists(beforePath)) vm.removeFile(beforePath); + if (vm.exists(afterPath)) vm.removeFile(afterPath); + if (vm.exists(diffPath)) vm.removeFile(diffPath); + } +} + +/// Individual V4DiffWriter helpers for individual tests +contract V4DiffWriterHarnessTest is V4DiffWriterTestBase { + V4DiffWriterHarness internal harness; + + function setUp() public { + _setUpAddresses(); + harness = new V4DiffWriterHarness(); + vm.chainId(1); + } + + function test_serReserve_includesAllFields() public { + Types.SpokeReserveSnapshot memory r = _makeReserve(spokeA, 0, 'USDC', 7500); + string memory json = harness.serReserve(r); + + // vm.serialize* returns compact JSON (no whitespace after colons), + // unlike the pretty-printed file output. + assertTrue(vm.contains(json, '"symbol":"USDC"'), 'symbol'); + assertTrue(vm.contains(json, '"decimals":6'), 'decimals'); + assertTrue(vm.contains(json, '"collateralRisk":1000'), 'collateralRisk'); + assertTrue(vm.contains(json, '"paused":false'), 'paused'); + assertTrue(vm.contains(json, '"frozen":false'), 'frozen'); + assertTrue(vm.contains(json, '"borrowable":true'), 'borrowable'); + assertTrue(vm.contains(json, '"receiveSharesEnabled":true'), 'receiveSharesEnabled'); + assertTrue(vm.contains(json, '"dynamicConfigKey":1'), 'dynamicConfigKey'); + assertTrue(vm.contains(json, '"collateralFactor":7500'), 'collateralFactor'); + assertTrue(vm.contains(json, '"maxLiquidationBonus":10500'), 'maxLiquidationBonus'); + assertTrue(vm.contains(json, '"liquidationFee":100'), 'liquidationFee'); + assertTrue(vm.contains(json, '"oraclePrice":"100000000"'), 'oraclePrice'); + } + + function test_writeSpokeReserves_groupsBySpokeAddress() public { + Types.SpokeReserveSnapshot[] memory reserves = new Types.SpokeReserveSnapshot[](3); + reserves[0] = _makeReserve(spokeA, 0, 'USDC', 7500); + reserves[1] = _makeReserve(spokeA, 1, 'WETH', 8000); + reserves[2] = _makeReserve(spokeB, 0, 'USDC', 7000); + + string memory path = './reports/harness_spoke_reserves.json'; + harness.writeSpokeReserves(path, reserves); + + string memory json = vm.readFile(path); + assertTrue(vm.contains(json, '"spokeReserves"'), 'section'); + assertTrue(vm.contains(json, '"collateralFactor": 7500'), 'CF reserve 0'); + assertTrue(vm.contains(json, '"collateralFactor": 8000'), 'CF reserve 1'); + assertTrue(vm.contains(json, '"collateralFactor": 7000'), 'CF reserve 2'); + assertTrue(vm.contains(json, vm.toString(spokeA)), 'spoke A key'); + assertTrue(vm.contains(json, vm.toString(spokeB)), 'spoke B key'); + assertTrue(vm.contains(json, '"symbol": "USDC"'), 'USDC symbol'); + assertTrue(vm.contains(json, '"symbol": "WETH"'), 'WETH symbol'); + + vm.removeFile(path); + } + + function test_writeSpokeLiqConfigs_writesAllConfigs() public { + Types.SpokeLiquidationSnapshot[] memory configs = new Types.SpokeLiquidationSnapshot[](2); + configs[0] = Types.SpokeLiquidationSnapshot({ + spokeAddress: spokeA, + targetHealthFactor: 1.05e18, + healthFactorForMaxBonus: 0.95e18, + liquidationBonusFactor: 100, + maxUserReservesLimit: 8 + }); + configs[1] = Types.SpokeLiquidationSnapshot({ + spokeAddress: spokeB, + targetHealthFactor: 1.10e18, + healthFactorForMaxBonus: 0.90e18, + liquidationBonusFactor: 250, + maxUserReservesLimit: 12 + }); + + string memory path = './reports/harness_spoke_liq.json'; + harness.writeSpokeLiqConfigs(path, configs); + + string memory json = vm.readFile(path); + assertTrue(vm.contains(json, '"spokeLiquidationConfigs"'), 'section'); + assertTrue(vm.contains(json, '"liquidationBonusFactor": 100'), 'first liqBonus'); + assertTrue(vm.contains(json, '"liquidationBonusFactor": 250'), 'second liqBonus'); + assertTrue(vm.contains(json, '"maxUserReservesLimit": 8'), 'first limit'); + assertTrue(vm.contains(json, '"maxUserReservesLimit": 12'), 'second limit'); + assertTrue(vm.contains(json, '"targetHealthFactor": "1050000000000000000"'), 'first HF'); + + vm.removeFile(path); + } + + function test_serializeHubAsset_includesAllFields() public { + Types.HubAssetSnapshot memory a = _makeHubAsset(hubX, 0, 'USDC', 10); + string memory json = harness.serializeHubAsset(a); + + assertTrue(vm.contains(json, '"symbol":"USDC"'), 'symbol'); + assertTrue(vm.contains(json, '"decimals":6'), 'decimals'); + assertTrue(vm.contains(json, '"liquidityFee":10'), 'liquidityFee'); + assertTrue(vm.contains(json, '"optimalUsageRatio":8000'), 'optimalUsageRatio'); + assertTrue(vm.contains(json, '"baseDrawnRate":100'), 'baseDrawnRate'); + assertTrue(vm.contains(json, '"rateGrowthBeforeOptimal":400'), 'rateGrowthBeforeOptimal'); + assertTrue(vm.contains(json, '"rateGrowthAfterOptimal":6000'), 'rateGrowthAfterOptimal'); + assertTrue(vm.contains(json, '"maxDrawnRate":"30000"'), 'maxDrawnRate'); + assertTrue(vm.contains(json, '"deficitRay":"0"'), 'deficitRay'); + assertTrue(vm.contains(json, '"premiumOffsetRay":"0"'), 'premiumOffsetRay'); + } + + function test_writeHubAssets_groupsByHubAddress() public { + Types.HubAssetSnapshot[] memory assets = new Types.HubAssetSnapshot[](3); + assets[0] = _makeHubAsset(hubX, 0, 'USDC', 10); + assets[1] = _makeHubAsset(hubX, 1, 'WETH', 20); + assets[2] = _makeHubAsset(hubY, 0, 'USDC', 30); + + string memory path = './reports/harness_hub_assets.json'; + harness.writeHubAssets(path, assets); + + string memory json = vm.readFile(path); + assertTrue(vm.contains(json, '"hubAssets"'), 'section'); + assertTrue(vm.contains(json, '"liquidityFee": 10'), 'asset 0 fee'); + assertTrue(vm.contains(json, '"liquidityFee": 20'), 'asset 1 fee'); + assertTrue(vm.contains(json, '"liquidityFee": 30'), 'asset 2 fee'); + assertTrue(vm.contains(json, vm.toString(hubX)), 'hub X key'); + assertTrue(vm.contains(json, vm.toString(hubY)), 'hub Y key'); + + vm.removeFile(path); + } + + function test_writeSpokeConfigs_writesAllConfigs() public { + Types.SpokeConfigSnapshot[] memory caps = new Types.SpokeConfigSnapshot[](2); + caps[0] = Types.SpokeConfigSnapshot({ + hubAddress: hubX, + assetId: 0, + assetSymbol: 'USDC', + spokeAddress: spokeA, + addCap: 1_000_000, + drawCap: 500_000, + riskPremiumThreshold: 100, + active: true, + halted: false + }); + caps[1] = Types.SpokeConfigSnapshot({ + hubAddress: hubX, + assetId: 0, + assetSymbol: 'USDC', + spokeAddress: spokeB, + addCap: 2_000_000, + drawCap: 800_000, + riskPremiumThreshold: 200, + active: false, + halted: true + }); + + string memory path = './reports/harness_spoke_caps.json'; + harness.writeSpokeConfigs(path, caps); + + string memory json = vm.readFile(path); + assertTrue(vm.contains(json, '"spokeConfigs"'), 'section'); + assertTrue(vm.contains(json, '"addCap": 1000000'), 'first addCap'); + assertTrue(vm.contains(json, '"addCap": 2000000'), 'second addCap'); + assertTrue(vm.contains(json, '"drawCap": 500000'), 'first drawCap'); + assertTrue(vm.contains(json, '"drawCap": 800000'), 'second drawCap'); + assertTrue(vm.contains(json, '"active": true'), 'first active'); + assertTrue(vm.contains(json, '"active": false'), 'second active'); + assertTrue(vm.contains(json, '"halted": false'), 'first halted'); + assertTrue(vm.contains(json, '"halted": true'), 'second halted'); + + vm.removeFile(path); + } +} diff --git a/tests/mocks/v4/V4DiffWriterHarness.sol b/tests/mocks/v4/V4DiffWriterHarness.sol new file mode 100644 index 00000000..5a5d166d --- /dev/null +++ b/tests/mocks/v4/V4DiffWriterHarness.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {V4DiffWriter} from 'src/dependencies/v4/V4DiffWriter.sol'; +import {Types} from 'src/dependencies/v4/Types.sol'; + +/// @title V4DiffWriterHarness +/// @notice Harness contract that exposes internal helpers as external entrypoints +contract V4DiffWriterHarness { + function writeSnapshotJson(string memory reportName, Types.V4Snapshot memory snapshot) external { + V4DiffWriter.writeSnapshotJson(reportName, snapshot); + } + + function writeSpokeReserves( + string memory path, + Types.SpokeReserveSnapshot[] memory reserves + ) external { + V4DiffWriter._writeSpokeReserves(path, reserves); + } + + function serReserve(Types.SpokeReserveSnapshot memory r) external returns (string memory) { + return V4DiffWriter._serReserve(r); + } + + function writeSpokeLiqConfigs( + string memory path, + Types.SpokeLiquidationSnapshot[] memory configs + ) external { + V4DiffWriter._writeSpokeLiqConfigs(path, configs); + } + + function writeHubAssets(string memory path, Types.HubAssetSnapshot[] memory assets) external { + V4DiffWriter._writeHubAssets(path, assets); + } + + function serializeHubAsset(Types.HubAssetSnapshot memory a) external returns (string memory) { + return V4DiffWriter._serializeHubAsset(a); + } + + function writeSpokeConfigs( + string memory path, + Types.SpokeConfigSnapshot[] memory configs + ) external { + V4DiffWriter._writeSpokeConfigs(path, configs); + } +} diff --git a/tests/mocks/v4/V4Mocks.sol b/tests/mocks/v4/V4Mocks.sol new file mode 100644 index 00000000..5e52d6c5 --- /dev/null +++ b/tests/mocks/v4/V4Mocks.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {ISpoke, IHub} from 'aave-address-book/AaveV4.sol'; +import {IAssetInterestRateStrategy} from 'aave-v4/hub/interfaces/IAssetInterestRateStrategy.sol'; + +/// @notice ERC20 fixture that only exposes `symbol()`, the single call SnapshotV4 +/// makes on the underlying token. +contract MockERC20Symbol { + string public symbol; + + constructor(string memory _symbol) { + symbol = _symbol; + } +} + +/// @notice Minimal IAaveOracle mock. Only the two functions SnapshotV4 reads +/// (`getReserveSource`, `getReservePrice`) are implemented. +contract MockOracle { + mapping(uint256 => address) private _sources; + mapping(uint256 => uint256) private _prices; + + function setReserve(uint256 reserveId, address source, uint256 price) external { + _sources[reserveId] = source; + _prices[reserveId] = price; + } + + function getReserveSource(uint256 reserveId) external view returns (address) { + return _sources[reserveId]; + } + + function getReservePrice(uint256 reserveId) external view returns (uint256) { + return _prices[reserveId]; + } +} + +/// @notice Minimal IAssetInterestRateStrategy mock. +contract MockIR { + mapping(uint256 => IAssetInterestRateStrategy.InterestRateData) private _data; + mapping(uint256 => uint256) private _maxRates; + + function setData( + uint256 assetId, + IAssetInterestRateStrategy.InterestRateData memory data, + uint256 maxDrawnRate + ) external { + _data[assetId] = data; + _maxRates[assetId] = maxDrawnRate; + } + + function getInterestRateData( + uint256 assetId + ) external view returns (IAssetInterestRateStrategy.InterestRateData memory) { + return _data[assetId]; + } + + function getMaxDrawnRate(uint256 assetId) external view returns (uint256) { + return _maxRates[assetId]; + } +} + +/// @notice Minimal ISpoke mock covering the reads SnapshotV4 performs on a spoke. +contract MockSpoke { + address public ORACLE; + uint16 public MAX_USER_RESERVES_LIMIT; + ISpoke.LiquidationConfig private _liqConfig; + + ISpoke.Reserve[] private _reserves; + mapping(uint256 => ISpoke.ReserveConfig) private _reserveConfigs; + mapping(uint256 => mapping(uint32 => ISpoke.DynamicReserveConfig)) private _dynConfigs; + + function setOracle(address oracle) external { + ORACLE = oracle; + } + + function setMaxUserReservesLimit(uint16 limit) external { + MAX_USER_RESERVES_LIMIT = limit; + } + + function setLiquidationConfig(ISpoke.LiquidationConfig memory cfg) external { + _liqConfig = cfg; + } + + function addReserve( + ISpoke.Reserve memory reserve, + ISpoke.ReserveConfig memory config, + ISpoke.DynamicReserveConfig memory dyn + ) external returns (uint256 reserveId) { + reserveId = _reserves.length; + _reserves.push(reserve); + _reserveConfigs[reserveId] = config; + _dynConfigs[reserveId][reserve.dynamicConfigKey] = dyn; + } + + // Per-field mutators for already-added reserves, used by snapshot delta tests. + function setReserveConfig(uint256 reserveId, ISpoke.ReserveConfig memory config) external { + _reserveConfigs[reserveId] = config; + } + + function setDynamicReserveConfig( + uint256 reserveId, + uint32 dynamicConfigKey, + ISpoke.DynamicReserveConfig memory dyn + ) external { + _dynConfigs[reserveId][dynamicConfigKey] = dyn; + } + + function getReserveCount() external view returns (uint256) { + return _reserves.length; + } + + function getReserve(uint256 reserveId) external view returns (ISpoke.Reserve memory) { + return _reserves[reserveId]; + } + + function getReserveConfig(uint256 reserveId) external view returns (ISpoke.ReserveConfig memory) { + return _reserveConfigs[reserveId]; + } + + function getDynamicReserveConfig( + uint256 reserveId, + uint32 dynamicConfigKey + ) external view returns (ISpoke.DynamicReserveConfig memory) { + return _dynConfigs[reserveId][dynamicConfigKey]; + } + + function getLiquidationConfig() external view returns (ISpoke.LiquidationConfig memory) { + return _liqConfig; + } +} + +/// @notice Minimal IHub mock covering the reads SnapshotV4 performs on a hub. +contract MockHub { + IHub.Asset[] private _assets; + mapping(uint256 => IHub.AssetConfig) private _assetConfigs; + mapping(uint256 => address[]) private _spokesByAsset; + mapping(uint256 => mapping(address => IHub.SpokeConfig)) private _spokeConfigs; + mapping(uint256 => mapping(address => bool)) private _spokeRegistered; + + function addAsset( + IHub.Asset memory asset, + IHub.AssetConfig memory config + ) external returns (uint256 assetId) { + assetId = _assets.length; + _assets.push(asset); + _assetConfigs[assetId] = config; + } + + // Per-asset mutators for already-added assets, used by snapshot delta tests. + function setAsset(uint256 assetId, IHub.Asset memory asset) external { + _assets[assetId] = asset; + } + + function setAssetConfig(uint256 assetId, IHub.AssetConfig memory config) external { + _assetConfigs[assetId] = config; + } + + function addSpokeConfig(uint256 assetId, address spoke, IHub.SpokeConfig memory config) external { + if (!_spokeRegistered[assetId][spoke]) { + _spokesByAsset[assetId].push(spoke); + _spokeRegistered[assetId][spoke] = true; + } + _spokeConfigs[assetId][spoke] = config; + } + + function getAssetCount() external view returns (uint256) { + return _assets.length; + } + + function getAsset(uint256 assetId) external view returns (IHub.Asset memory) { + return _assets[assetId]; + } + + function getAssetConfig(uint256 assetId) external view returns (IHub.AssetConfig memory) { + return _assetConfigs[assetId]; + } + + function getAssetUnderlyingAndDecimals(uint256 assetId) external view returns (address, uint8) { + return (_assets[assetId].underlying, _assets[assetId].decimals); + } + + function getSpokeCount(uint256 assetId) external view returns (uint256) { + return _spokesByAsset[assetId].length; + } + + function getSpokeAddress(uint256 assetId, uint256 index) external view returns (address) { + return _spokesByAsset[assetId][index]; + } + + function getSpokeConfig( + uint256 assetId, + address spoke + ) external view returns (IHub.SpokeConfig memory) { + return _spokeConfigs[assetId][spoke]; + } +}