diff --git a/README.md b/README.md index 3da8b7d..1edebb4 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,40 @@ pnpm dev Visit http://localhost:8080 to see the GraphQL Playground, local password is `testing`. +### Tokenized strategy lifecycle + +`YearnV3Strategy.NewTokenizedStrategy(address,address,string)` is indexed with a +wildcard subscription on each configured chain. Initialization runs through +delegatecall and emits from the new strategy address, so discovery must work +before that address appears in a vault's `StrategyChanged` event. + +The emitter must match the event's `strategy` parameter. Matching events register +the strategy address for every configured `YearnV3Strategy` event, including +reports, shutdowns, and management or emergency-admin changes. Those events remain +restricted to discovered addresses. Existing vault and splitter discovery paths +still apply. + +`V3TokenizedStrategyDeployed` records the emitter as `strategyAddress`, the event's +`strategy`, `asset`, and `apiVersion`, and full block/transaction/log provenance. +Its nullable `transactionTo` is the top-level deployment transaction destination; +it is useful factory provenance but is not itself proof that the destination is a +canonical Yearn factory. The initialization event supplies no factory parameter, +and direct contract creation has no transaction destination. + +The initialization event also supplies no management address. Consumers that need +initial-management attribution must read `management()` from archive state at the +deployment block. Subsequent `UpdatePendingManagement`, `UpdateManagement`, and +`UpdateEmergencyAdmin` events are retained in `raw_events` as observed control +changes. `V3StrategyShutdown` records the strategy emitter and the same standard +provenance fields. None of these events is an endorsement or verification of a +strategy's implementation. + +Run the lifecycle routing regression tests with +`corepack pnpm exec vitest run test/tokenized-strategy.test.ts`. +Deploying this schema/config change requires the normal database reset and +backfill; verify deployment, report, management, and shutdown coverage at a fixed +block. + ### Environment Variables Copy `.env.example` to `.env` and fill in values as needed: diff --git a/config.yaml b/config.yaml index 44d477f..4c7406a 100644 --- a/config.yaml +++ b/config.yaml @@ -113,6 +113,7 @@ contracts: - event: "NewTokenizedStrategy(address indexed strategy, address indexed asset, string apiVersion)" - event: "StrategyShutdown()" - event: "Transfer(address indexed from, address indexed to, uint256 value)" + - event: "UpdateEmergencyAdmin(address indexed newEmergencyAdmin)" - event: "UpdateKeeper(address indexed newKeeper)" - event: "UpdateManagement(address indexed newManagement)" - event: "UpdatePendingManagement(address indexed newPendingManagement)" @@ -278,6 +279,13 @@ contracts: handler: src/EventHandlers.ts events: - event: "NewDebtAllocator(address indexed allocator, address indexed governance)" + field_selection: + transaction_fields: + - hash + - from + - transactionIndex + - to + - input - name: AssignedDebtAllocator handler: src/EventHandlers.ts events: @@ -413,7 +421,7 @@ chains: - name: YearnV3Accountant address: [] - name: YearnV3Strategy - # No static addresses: strategies are discovered from vault/role-manager events. + # Discovered from strategy initialization and existing vault/splitter events. address: [] - name: YearnV3SplitterFactory address: diff --git a/schema.graphql b/schema.graphql index 676c7b0..84fc9eb 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1016,6 +1016,36 @@ type V3StrategyReported { performanceFees: BigInt! } +type V3TokenizedStrategyDeployed { + id: ID! + strategyAddress: String! @index + chainId: Int! @index + blockNumber: Int! @index + blockTimestamp: Int! @index + blockHash: String! + transactionHash: String! @index + transactionIndex: Int! + transactionFrom: String @index + transactionTo: String @index + logIndex: Int! + strategy: String! @index + asset: String! @index + apiVersion: String! +} + +type V3StrategyShutdown { + id: ID! + strategyAddress: String! @index + chainId: Int! @index + blockNumber: Int! @index + blockTimestamp: Int! @index + blockHash: String! + transactionHash: String! @index + transactionIndex: Int! + transactionFrom: String @index + logIndex: Int! +} + type V3SplitterNewSplitter { id: ID! factoryAddress: String! @index diff --git a/src/EventHandlers.ts b/src/EventHandlers.ts index c33ab03..d6f6e09 100644 --- a/src/EventHandlers.ts +++ b/src/EventHandlers.ts @@ -79,6 +79,8 @@ import type { V3RoleManagerRemovedVault, V3SplitterNewSplitter, V3StrategyReported, + V3StrategyShutdown, + V3TokenizedStrategyDeployed, V3VaultFactoryNewVault, V3YieldSplitterNewYieldSplitter, VeyfiGaugeRegistered, @@ -1960,6 +1962,47 @@ indexer.onEvent({ contract: "YearnV3Strategy", event: "Reported" }, async ({ eve context.V3StrategyReported.set(entity); }); +indexer.onEvent({ contract: "YearnV3Strategy", event: "StrategyShutdown" }, async ({ event, context }) => { + const entity: V3StrategyShutdown = { + ...eventCore(event), + strategyAddress: getAddress(event.srcAddress), + }; + context.V3StrategyShutdown.set(entity); +}); + +// Keep control events routable in the generated test indexer. Production +// persistence is supplied by Envio's raw_events recorder. +indexer.onEvent({ contract: "YearnV3Strategy", event: "UpdatePendingManagement" }, async () => {}); +indexer.onEvent({ contract: "YearnV3Strategy", event: "UpdateManagement" }, async () => {}); +indexer.onEvent({ contract: "YearnV3Strategy", event: "UpdateEmergencyAdmin" }, async () => {}); + +// Initialization is emitted by the strategy itself, before its address is known. +indexer.contractRegister( + { contract: "YearnV3Strategy", event: "NewTokenizedStrategy", wildcard: true }, + async ({ event, context }) => { + const strategyAddress = getAddress(event.srcAddress); + if (strategyAddress !== getAddress(event.params.strategy)) return; + context.chain.YearnV3Strategy.add(strategyAddress); + }, +); + +indexer.onEvent( + { contract: "YearnV3Strategy", event: "NewTokenizedStrategy", wildcard: true }, + async ({ event, context }) => { + const strategyAddress = getAddress(event.srcAddress); + if (strategyAddress !== getAddress(event.params.strategy)) return; + const entity: V3TokenizedStrategyDeployed = { + ...eventCore(event), + transactionTo: addr(event.transaction.to), + strategyAddress, + strategy: getAddress(event.params.strategy), + asset: getAddress(event.params.asset), + apiVersion: event.params.apiVersion, + }; + context.V3TokenizedStrategyDeployed.set(entity); + }, +); + indexer.onEvent({ contract: "YearnV3SplitterFactory", event: "NewSplitter" }, async ({ event, context }) => { const entity: V3SplitterNewSplitter = { id: eventId(event), diff --git a/test/tokenized-strategy.test.ts b/test/tokenized-strategy.test.ts new file mode 100644 index 0000000..50cfe2d --- /dev/null +++ b/test/tokenized-strategy.test.ts @@ -0,0 +1,202 @@ +import { createTestIndexer } from "envio"; +import { getAddress } from "viem"; +import { describe, expect, it } from "vitest"; + +const rawStrategy = "0x1234567890abcdef1234567890abcdef12345678"; +const rawAsset = "0xabcdef1234567890abcdef1234567890abcdef12"; +const strategy = getAddress(rawStrategy); +const asset = getAddress(rawAsset); +const sender = getAddress("0x1111111111111111111111111111111111111111"); +const factory = getAddress("0xE9E8C89c8Fc7E8b8F23425688eb68987231178e5"); +const vault = "0x2222222222222222222222222222222222222222"; +const brain = getAddress("0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52"); +const block = { + number: 30_000_000, + timestamp: 1_788_000_000, + hash: `0x${"ab".repeat(32)}`, +}; +const transaction = { + hash: `0x${"cd".repeat(32)}`, + transactionIndex: 3, + from: sender, + to: factory, +}; +const provenance = { + blockNumber: block.number, + blockTimestamp: block.timestamp, + blockHash: block.hash, + transactionHash: transaction.hash, + transactionIndex: transaction.transactionIndex, + transactionFrom: sender, +}; +const deployment = { + contract: "YearnV3Strategy", + event: "NewTokenizedStrategy", + srcAddress: rawStrategy, + params: { strategy, asset: rawAsset, apiVersion: "3.0.4" }, + block, + transaction, + logIndex: 0, +} as const; +const report = { + contract: "YearnV3Strategy", + event: "Reported", + srcAddress: strategy, + params: { profit: 100n, loss: 2n, protocolFees: 3n, performanceFees: 4n }, + block, + transaction, + logIndex: 1, +} as const; +const shutdown = { + contract: "YearnV3Strategy", + event: "StrategyShutdown", + srcAddress: strategy, + block: { ...block, number: block.number + 1 }, + transaction, + logIndex: 2, +} as const; +const pendingManagement = { + contract: "YearnV3Strategy", + event: "UpdatePendingManagement", + srcAddress: strategy, + params: { newPendingManagement: brain }, + block, + transaction, + logIndex: 3, +} as const; +const management = { + contract: "YearnV3Strategy", + event: "UpdateManagement", + srcAddress: strategy, + params: { newManagement: brain }, + block, + transaction, + logIndex: 4, +} as const; +const emergencyAdmin = { + contract: "YearnV3Strategy", + event: "UpdateEmergencyAdmin", + srcAddress: strategy, + params: { newEmergencyAdmin: brain }, + block, + transaction, + logIndex: 5, +} as const; + +describe("tokenized strategy lifecycle routing", () => { + it.each(createTestIndexer().chainIds)( + "discovers a standalone strategy and tracks its lifecycle on chain %i", + async (chainId) => { + const indexer = createTestIndexer(); + await indexer.process({ + chains: { [chainId]: { simulate: [deployment, report, shutdown] } }, + }); + + expect(await indexer.V3TokenizedStrategyDeployed.getAll()).toEqual([{ + ...provenance, + transactionTo: factory, + id: `${chainId}_${block.number}_0`, + chainId, + logIndex: 0, + strategyAddress: strategy, + strategy, + asset, + apiVersion: "3.0.4", + }]); + expect(await indexer.V3StrategyReported.getAll()).toEqual([{ + ...provenance, + id: `${chainId}_${block.number}_1`, + chainId, + logIndex: 1, + strategyAddress: strategy, + ...report.params, + }]); + expect(await indexer.V3StrategyShutdown.getAll()).toEqual([{ + ...provenance, + id: `${chainId}_${block.number + 1}_2`, + chainId, + blockNumber: block.number + 1, + logIndex: 2, + strategyAddress: strategy, + }]); + }, + ); + + it("routes control changes after wildcard strategy discovery", async () => { + const indexer = createTestIndexer(); + await expect(indexer.process({ + chains: { + 1: { + simulate: [deployment, pendingManagement, management, emergencyAdmin], + }, + }, + })).resolves.toBeDefined(); + + expect(await indexer.V3TokenizedStrategyDeployed.getAll()).toHaveLength(1); + }); + + it("preserves factory-to-vault strategy discovery, reporting, and shutdowns", async () => { + const indexer = createTestIndexer(); + await indexer.process({ + chains: { + 1: { + simulate: [ + { + contract: "YearnV3VaultFactory", + event: "NewVault", + srcAddress: factory, + params: { vault_address: vault, asset }, + block, + logIndex: 0, + }, + { + contract: "YearnV3Vault", + event: "StrategyChanged", + srcAddress: vault, + params: { strategy, change_type: 1n }, + block, + logIndex: 1, + }, + { ...report, logIndex: 2 }, + shutdown, + ], + }, + }, + }); + + expect(await indexer.V3VaultFactoryNewVault.getAll()).toHaveLength(1); + expect(await indexer.StrategyChanged.getAll()).toHaveLength(1); + expect(await indexer.V3TokenizedStrategyDeployed.getAll()).toEqual([]); + expect(await indexer.V3StrategyReported.getAll()).toHaveLength(1); + expect(await indexer.V3StrategyShutdown.getAll()).toHaveLength(1); + }); + + it("does not record or register a strategy named by a different emitter", async () => { + const indexer = createTestIndexer(); + await expect(indexer.process({ + chains: { + 1: { + simulate: [ + { ...deployment, srcAddress: sender }, + shutdown, + { ...shutdown, srcAddress: sender, logIndex: 3 }, + ], + }, + }, + })).rejects.toThrow("never reached a handler"); + expect(await indexer.V3TokenizedStrategyDeployed.getAll()).toEqual([]); + expect(await indexer.V3StrategyShutdown.getAll()).toEqual([]); + }); + + it.each([report, shutdown, pendingManagement, management, emergencyAdmin])( + "keeps $event restricted to discovered strategies", + async (event) => { + const indexer = createTestIndexer(); + await expect(indexer.process({ + chains: { 1: { simulate: [event] } }, + })).rejects.toThrow("never reached a handler"); + expect(await indexer.V3StrategyReported.getAll()).toEqual([]); + expect(await indexer.V3StrategyShutdown.getAll()).toEqual([]); + }, + ); +});