From d00f60127a207c6d25bb22025945f41306892074 Mon Sep 17 00:00:00 2001 From: akcharlie24 Date: Fri, 13 Feb 2026 16:28:59 +0530 Subject: [PATCH 1/5] feat: added config for HandleFees Event --- indexers/drizzle/schema.ts | 1 + .../utils/configs/investment_flows_erc4626.ts | 61 ++++++++++++++++++- prisma/drizzle/schema.ts | 1 + .../migration.sql | 2 + prisma/schema.prisma | 1 + 5 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260213080836_added_pool_info_ekubo_v2/migration.sql diff --git a/indexers/drizzle/schema.ts b/indexers/drizzle/schema.ts index e33c278..c570139 100644 --- a/indexers/drizzle/schema.ts +++ b/indexers/drizzle/schema.ts @@ -53,6 +53,7 @@ export const position_fees_collected = pgTable('position_fees_collected', { amount0: text('amount0').notNull(), amount1: text('amount1').notNull(), vault_address: text('vault_address').notNull(), + pool_info: text('pool_info'), timestamp: integer('timestamp').notNull(), cursor: bigint('_cursor', { mode: 'bigint' }) }, (position_fees_collected) => ({ diff --git a/indexers/utils/configs/investment_flows_erc4626.ts b/indexers/utils/configs/investment_flows_erc4626.ts index 0fb03a3..1f134ae 100644 --- a/indexers/utils/configs/investment_flows_erc4626.ts +++ b/indexers/utils/configs/investment_flows_erc4626.ts @@ -1,12 +1,12 @@ -import { ContractAddr, EkuboCLVaultStrategies, UniversalStrategies, VesuRebalanceStrategies } from "@strkfarm/sdk"; +import { ContractAddr, EkuboCLVaultStrategies, EkuboCLVaultV2Strategies, UniversalStrategies, VesuRebalanceStrategies } from "@strkfarm/sdk"; import { standariseAddress } from "../../../src/utils"; import { AdditionalField, ContractConfig, EventConfig } from "../config"; import { onEventEkuboVault } from "../ekubo_vault"; import { eventKey } from "../common_transform"; +import { uint256 } from "starknet"; export const EKUBO_VAULT_CONTRACTS: ContractConfig[] = [ ...EkuboCLVaultStrategies - .filter((strat) => strat.curator?.name.toLowerCase().includes('re7')) .map((ekuboStrat) => ({ address: standariseAddress(ekuboStrat.address.address), asset: '', // not applicable for this dual asset vault @@ -14,6 +14,15 @@ export const EKUBO_VAULT_CONTRACTS: ContractConfig[] = [ })) ]; +export const EKUBO_VAULT_CONTRACTS_V2: ContractConfig[] = [ + ...EkuboCLVaultV2Strategies + .map((ekuboV2Strat) => ({ + address: standariseAddress(ekuboV2Strat.address.address), + asset: '', // not applicable for this dual asset vault + name: ekuboV2Strat.name, + })) +]; + const ERC4626_VAULT_CONTRACTS: ContractConfig[] = [ // vesu rebalance strategies ...VesuRebalanceStrategies.map((vesuStrat) => ({ @@ -147,6 +156,52 @@ export const CONFIG_INVESTMENT_FLOWS_ERC4626: EventConfig[] = [ return standariseAddress(event.address); }, }, + { + name: "pool_info", + source: "custom", + sqlType: "text", + customLogic: (event) => { + // V1 events have 10 data elements, V2 events have 22 + // After u256 parsing: token0(1) + token0_origin_bal(2) + amount0(2) + + // token1(1) + token1_origin_bal(2) + amount1(2) = 10 felts + // V2 adds ManagedPool struct: pool_key(7) + bounds(4) + nft_id(1) = 12 more felts + if (event.data.length <= 10) { + return null; // V1 contract - no pool_info + } + + try { + // Parse ManagedPool struct from event.data[10..21] + // PoolKey: token0, token1, fee(u128 = 2 felts), tick_spacing(u128 = 2 felts), extension + // Bounds: lower(i129 = 2 felts), upper(i129 = 2 felts) + // nft_id: u64 (1 felt) + const poolInfo = { + pool_key: { + token0: standariseAddress(`0x${BigInt(event.data[10]).toString(16).padStart(64, "0")}`), + token1: standariseAddress(`0x${BigInt(event.data[11]).toString(16).padStart(64, "0")}`), + fee: uint256.uint256ToBN({ low: event.data[12], high: event.data[13] }).toString(), + tick_spacing: uint256.uint256ToBN({ low: event.data[14], high: event.data[15] }).toString(), + extension: standariseAddress(`0x${BigInt(event.data[16]).toString(16).padStart(64, "0")}`), + }, + bounds: { + lower: { + mag: BigInt(event.data[17]).toString(), + sign: BigInt(event.data[18]).toString() === "1", + }, + upper: { + mag: BigInt(event.data[19]).toString(), + sign: BigInt(event.data[20]).toString() === "1", + }, + }, + nft_id: BigInt(event.data[21]).toString(), + }; + + return JSON.stringify(poolInfo); + } catch (error) { + console.error("Error parsing pool_info:", error); + return null; + } + }, + }, ], }, -]; \ No newline at end of file +]; diff --git a/prisma/drizzle/schema.ts b/prisma/drizzle/schema.ts index aca79fe..cb4fde1 100644 --- a/prisma/drizzle/schema.ts +++ b/prisma/drizzle/schema.ts @@ -53,6 +53,7 @@ export const position_fees_collected = pgTable('position_fees_collected', { amount0: text('amount0').notNull(), amount1: text('amount1').notNull(), vault_address: text('vault_address').notNull(), + pool_info: text('pool_info'), timestamp: integer('timestamp').notNull(), cursor: bigint('_cursor', { mode: 'bigint' }) }, (position_fees_collected) => ({ diff --git a/prisma/migrations/20260213080836_added_pool_info_ekubo_v2/migration.sql b/prisma/migrations/20260213080836_added_pool_info_ekubo_v2/migration.sql new file mode 100644 index 0000000..620b303 --- /dev/null +++ b/prisma/migrations/20260213080836_added_pool_info_ekubo_v2/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."position_fees_collected" ADD COLUMN "pool_info" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa201e2..31f8c1b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -86,6 +86,7 @@ model position_fees_collected { amount1 String vault_address String + pool_info String? // JSON snapshot of ManagedPool (V2 contracts only) timestamp Int cursor BigInt? @map("_cursor") From 2e8e138e47c217a4550e0618df213f29787fd51d Mon Sep 17 00:00:00 2001 From: akcharlie24 Date: Sun, 15 Feb 2026 16:05:26 +0530 Subject: [PATCH 2/5] feat: added Deposit/Withdraw for ekubo-v2 --- indexers/drizzle/schema.ts | 120 +++++++---- .../utils/configs/investment_flows_erc4626.ts | 196 ++++++++++++++++++ indexers/utils/ekubo_vault_v2.ts | 73 +++++++ prisma/drizzle/schema.ts | 25 +++ .../migration.sql | 81 ++++++++ prisma/schema.prisma | 32 +++ prisma/seed.ts | 9 +- .../customResolvers/ekuboVaultFlows.ts | 99 ++++++--- 8 files changed, 567 insertions(+), 68 deletions(-) create mode 100644 indexers/utils/ekubo_vault_v2.ts create mode 100644 prisma/migrations/20260215102707_add_ekubo_v2_investment_flows/migration.sql diff --git a/indexers/drizzle/schema.ts b/indexers/drizzle/schema.ts index c570139..fc926ab 100644 --- a/indexers/drizzle/schema.ts +++ b/indexers/drizzle/schema.ts @@ -89,14 +89,29 @@ export const position_updated = pgTable('position_updated', { .on(position_updated.block_number, position_updated.tx_index, position_updated.event_index) })); -export const strategy_metadata = pgTable('strategy_metadata', { +export const ekubo_v2_investment_flows = pgTable('ekubo_v2_investment_flows', { id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), - strategy_address: text('strategy_address').notNull().unique(), - strategy_name: text('strategy_name').notNull(), - quote_asset: text('quote_asset').notNull() -}, (strategy_metadata) => ({ - 'strategy_metadata_id': uniqueIndex('strategy_metadata_id') - .on(strategy_metadata.strategy_address) + block_number: integer('block_number').notNull(), + tx_index: integer('tx_index').notNull(), + event_index: integer('event_index').notNull(), + tx_hash: text('tx_hash').notNull(), + sender: text('sender').notNull(), + owner: text('owner').notNull(), + receiver: text('receiver').notNull(), + shares: text('shares').notNull(), + amount0: text('amount0').notNull(), + amount1: text('amount1').notNull(), + token0: text('token0').notNull(), + token1: text('token1').notNull(), + vault_address: text('vault_address').notNull(), + user_address: text('user_address').notNull(), + type: text('type').notNull(), + timestamp: integer('timestamp').notNull(), + cursor: bigint('_cursor', { mode: 'bigint' }), + quote_amount: decimal('quote_amount', { precision: 65, scale: 30 }).notNull() +}, (ekubo_v2_investment_flows) => ({ + 'event_id': uniqueIndex('event_id') + .on(ekubo_v2_investment_flows.block_number, ekubo_v2_investment_flows.tx_index, ekubo_v2_investment_flows.event_index) })); export const raw_price_events = pgTable('raw_price_events', { @@ -116,31 +131,6 @@ export const raw_price_events = pgTable('raw_price_events', { .on(raw_price_events.block_number, raw_price_events.tx_index, raw_price_events.event_index) })); -export const prices = pgTable('prices', { - id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), - asset: text('asset').notNull(), - price: doublePrecision('price').notNull(), - timestamp: integer('timestamp').notNull(), - block_number: integer('block_number').notNull(), - cursor: bigint('_cursor', { mode: 'bigint' }) -}, (prices) => ({ - 'price_id': uniqueIndex('price_id') - .on(prices.asset, prices.timestamp) -})); - -export const token_metadata = pgTable('token_metadata', { - id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), - address: text('address').notNull().unique(), - name: text('name').notNull(), - symbol: text('symbol').notNull(), - decimals: integer('decimals').notNull(), - pragma_pair_id: text('pragma_pair_id').notNull(), - pragma_decimals: integer('pragma_decimals').notNull() -}, (token_metadata) => ({ - 'token_metadata_id': uniqueIndex('token_metadata_id') - .on(token_metadata.address) -})); - export const svk_alt_redemptions_subscribed = pgTable('svk_alt_redemptions_subscribed', { id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), block_number: integer('block_number').notNull(), @@ -148,8 +138,8 @@ export const svk_alt_redemptions_subscribed = pgTable('svk_alt_redemptions_subsc event_index: integer('event_index').notNull(), tx_hash: text('tx_hash').notNull(), contract_address: text('contract_address').notNull(), - new_nft_id: text('new_nft_id').notNull(), - old_nft_id: text('old_nft_id').notNull(), + new_nft_id: integer('new_nft_id').notNull(), + old_nft_id: integer('old_nft_id').notNull(), receiver: text('receiver').notNull(), timestamp: integer('timestamp').notNull(), cursor: bigint('_cursor', { mode: 'bigint' }) @@ -165,10 +155,10 @@ export const svk_alt_redemptions_claimed = pgTable('svk_alt_redemptions_claimed' event_index: integer('event_index').notNull(), tx_hash: text('tx_hash').notNull(), contract_address: text('contract_address').notNull(), - new_nft_id: text('new_nft_id').notNull(), - old_nft_id: text('old_nft_id').notNull(), + new_nft_id: integer('new_nft_id').notNull(), + old_nft_id: integer('old_nft_id').notNull(), receivable: text('receivable').notNull(), - swap_id: text('swap_id').notNull(), + swap_id: integer('swap_id').notNull(), timestamp: integer('timestamp').notNull(), cursor: bigint('_cursor', { mode: 'bigint' }) }, (svk_alt_redemptions_claimed) => ({ @@ -183,8 +173,8 @@ export const svk_alt_redemptions_unsubscribed = pgTable('svk_alt_redemptions_uns event_index: integer('event_index').notNull(), tx_hash: text('tx_hash').notNull(), contract_address: text('contract_address').notNull(), - new_nft_id: text('new_nft_id').notNull(), - old_nft_id: text('old_nft_id').notNull(), + new_nft_id: integer('new_nft_id').notNull(), + old_nft_id: integer('old_nft_id').notNull(), owner: text('owner').notNull(), is_old_nft_returned: boolean('is_old_nft_returned').notNull(), is_original_assets_returned: boolean('is_original_assets_returned').notNull(), @@ -196,6 +186,46 @@ export const svk_alt_redemptions_unsubscribed = pgTable('svk_alt_redemptions_uns .on(svk_alt_redemptions_unsubscribed.block_number, svk_alt_redemptions_unsubscribed.tx_index, svk_alt_redemptions_unsubscribed.event_index) })); +export const strategy_metadata = pgTable('strategy_metadata', { + id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), + strategy_address: text('strategy_address').notNull().unique(), + strategy_name: text('strategy_name').notNull(), + quote_asset: text('quote_asset').notNull() +}, (strategy_metadata) => ({ + 'strategy_metadata_id': uniqueIndex('strategy_metadata_id') + .on(strategy_metadata.strategy_address) +})); + +export const prices = pgTable('prices', { + id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), + asset: text('asset').notNull(), + price: doublePrecision('price').notNull(), + timestamp: integer('timestamp').notNull(), + block_number: integer('block_number').notNull(), + cursor: bigint('_cursor', { mode: 'bigint' }) +}, (prices) => ({ + 'price_id': uniqueIndex('price_id') + .on(prices.asset, prices.timestamp) +})); + +export const token_metadata = pgTable('token_metadata', { + id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), + address: text('address').notNull().unique(), + name: text('name').notNull(), + symbol: text('symbol').notNull(), + decimals: integer('decimals').notNull(), + pragma_pair_id: text('pragma_pair_id').notNull(), + pragma_decimals: integer('pragma_decimals').notNull() +}, (token_metadata) => ({ + 'token_metadata_id': uniqueIndex('token_metadata_id') + .on(token_metadata.address) +})); + +export const lst_price_sync_progress = pgTable('lst_price_sync_progress', { + id: text('id').notNull().primaryKey().default("lst_price_sync"), + last_processed_block: integer('last_processed_block') +}); + export const svk_alt_redemptions = pgTable('svk_alt_redemptions', { id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), block_number: integer('block_number').notNull(), @@ -219,4 +249,16 @@ export const svk_alt_redemptions = pgTable('svk_alt_redemptions', { }, (svk_alt_redemptions) => ({ 'redemption_unique': uniqueIndex('redemption_unique') .on(svk_alt_redemptions.contract_address, svk_alt_redemptions.old_nft_id) +})); + +export const strategy_apy = pgTable('strategy_apy', { + id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), + strategy_id: text('strategy_id').notNull(), + strategy_address: text('strategy_address').notNull(), + net_apy: doublePrecision('net_apy'), + timestamp: integer('timestamp').notNull(), + block_number: integer('block_number') +}, (strategy_apy) => ({ + 'strategy_apy_unique': uniqueIndex('strategy_apy_unique') + .on(strategy_apy.strategy_id, strategy_apy.timestamp) })); \ No newline at end of file diff --git a/indexers/utils/configs/investment_flows_erc4626.ts b/indexers/utils/configs/investment_flows_erc4626.ts index 1f134ae..0553ac5 100644 --- a/indexers/utils/configs/investment_flows_erc4626.ts +++ b/indexers/utils/configs/investment_flows_erc4626.ts @@ -2,6 +2,7 @@ import { ContractAddr, EkuboCLVaultStrategies, EkuboCLVaultV2Strategies, Univers import { standariseAddress } from "../../../src/utils"; import { AdditionalField, ContractConfig, EventConfig } from "../config"; import { onEventEkuboVault } from "../ekubo_vault"; +import { onEventEkuboVaultV2 } from "../ekubo_vault_v2"; import { eventKey } from "../common_transform"; import { uint256 } from "starknet"; @@ -134,6 +135,201 @@ export const CONFIG_INVESTMENT_FLOWS_ERC4626: EventConfig[] = [ }) ] }, + { + tableName: "ekubo_v2_investment_flows", + includeReceipt: true, // REQUIRED to get all events in tx + contracts: EKUBO_VAULT_CONTRACTS_V2, + onEvent: onEventEkuboVaultV2, // NEW callback for V2 + defaultKeys: [[eventKey("Deposit")]], + keyFields: [ + { name: "sender", type: "ContractAddress", sqlType: "text" }, + { name: "owner", type: "ContractAddress", sqlType: "text" }, + ], + dataFields: [ + { name: "shares", type: "u256", sqlType: "numeric(78,0)" }, + { name: "amount0", type: "u256", sqlType: "numeric(78,0)" }, + { name: "amount1", type: "u256", sqlType: "numeric(78,0)" }, + ], + additionalFields: [ + { + name: "contract", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.address), + }, + { + name: "receiver", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.keys[2]), // owner + }, + { + name: "user_address", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.keys[2]), // owner + }, + { + name: "type", + source: "custom", + sqlType: "text", + customLogic: () => "deposit", + }, + ], + }, + { + tableName: "ekubo_v2_investment_flows", + includeReceipt: true, + contracts: EKUBO_VAULT_CONTRACTS_V2, + onEvent: onEventEkuboVaultV2, + defaultKeys: [[eventKey("Withdraw")]], + keyFields: [ + { name: "sender", type: "ContractAddress", sqlType: "text" }, + { name: "receiver", type: "ContractAddress", sqlType: "text" }, + { name: "owner", type: "ContractAddress", sqlType: "text" }, + ], + dataFields: [ + { name: "shares", type: "u256", sqlType: "numeric(78,0)" }, + { name: "amount0", type: "u256", sqlType: "numeric(78,0)" }, + { name: "amount1", type: "u256", sqlType: "numeric(78,0)" }, + ], + additionalFields: [ + { + name: "contract", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.address), + }, + { + name: "user_address", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.keys[3]), // owner (3rd key) + }, + { + name: "type", + source: "custom", + sqlType: "text", + customLogic: () => "withdraw", + }, + ], + }, + { + tableName: "investment_flows", + includeReceipt: false, + contracts: EKUBO_VAULT_CONTRACTS_V2, + onEvent: undefined, // No callback for V2 in investment_flows + defaultKeys: [[eventKey("Deposit")]], + keyFields: [ + { name: "sender", type: "ContractAddress", sqlType: "text" }, + { name: "owner", type: "ContractAddress", sqlType: "text" }, + ], + dataFields: [ + { name: "shares", type: "u256", sqlType: "numeric(78,0)" }, + { name: "amount0", type: "u256", sqlType: "skip" }, + { name: "amount1", type: "u256", sqlType: "skip" }, + ], + additionalFields: [ + { + name: "receiver", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.keys[2]), // owner + }, + { + name: "amount", + source: "custom", + sqlType: "text", + customLogic: () => "", // Empty for V2 + }, + { + name: "asset", + source: "custom", + sqlType: "text", + customLogic: () => "", // Empty for V2 + }, + { + name: "contract", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.address), + }, + { + name: "epoch", + source: "custom", + sqlType: "numeric(20,0)", + customLogic: () => 0, + }, + { + name: "request_id", + source: "custom", + sqlType: "numeric(20,0)", + customLogic: () => 0, + }, + { + name: "type", + source: "custom", + sqlType: "text", + customLogic: () => "deposit", + }, + ], + }, + // V2 Ekubo Vault Withdraw for investment_flows (backward compatibility, empty amount) + { + tableName: "investment_flows", + includeReceipt: false, + contracts: EKUBO_VAULT_CONTRACTS_V2, + onEvent: undefined, + defaultKeys: [[eventKey("Withdraw")]], + keyFields: [ + { name: "sender", type: "ContractAddress", sqlType: "text" }, + { name: "receiver", type: "ContractAddress", sqlType: "text" }, + { name: "owner", type: "ContractAddress", sqlType: "text" }, + ], + dataFields: [ + { name: "shares", type: "u256", sqlType: "numeric(78,0)" }, + { name: "amount0", type: "u256", sqlType: "skip" }, + { name: "amount1", type: "u256", sqlType: "skip" }, + ], + additionalFields: [ + { + name: "amount", + source: "custom", + sqlType: "text", + customLogic: () => "", // Empty for V2 + }, + { + name: "asset", + source: "custom", + sqlType: "text", + customLogic: () => "", // Empty for V2 + }, + { + name: "contract", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.address), + }, + { + name: "epoch", + source: "custom", + sqlType: "numeric(20,0)", + customLogic: () => 0, + }, + { + name: "request_id", + source: "custom", + sqlType: "numeric(20,0)", + customLogic: () => 0, + }, + { + name: "type", + source: "custom", + sqlType: "text", + customLogic: () => "withdraw", + }, + ], + }, { tableName: "position_fees_collected", contracts: EKUBO_VAULT_CONTRACTS, diff --git a/indexers/utils/ekubo_vault_v2.ts b/indexers/utils/ekubo_vault_v2.ts new file mode 100644 index 0000000..633e2cb --- /dev/null +++ b/indexers/utils/ekubo_vault_v2.ts @@ -0,0 +1,73 @@ +import { useDrizzleStorage } from "@apibara/plugin-drizzle"; +import { Block, Event } from "@apibara/starknet"; +import { num } from "starknet"; + +import { OnEvent } from "./config"; +import { eventKey } from "./common_transform"; +import { standariseAddress } from "."; +import * as schema from "../drizzle/schema"; +import { EkuboCLVaultV2Strategies } from "@strkfarm/sdk"; + +export const onEventEkuboVaultV2: OnEvent = async ( + event: Event, + processedRecord: Record, + allEvents: readonly Event[], + block: Block +): Promise => { + const { db } = useDrizzleStorage(); + if (!allEvents.length) { + throw new Error("Expected allEvents for ekubo_vault_v2"); + } + + // if event is not from V2 Ekubo pools, return + if (!EkuboCLVaultV2Strategies.some((strat) => strat.address.eqString(event.address))) { + return; + } + + // Select events before the current event + // and sort them by eventIndexInTransaction in descending order + const filteredEvents = allEvents + .filter((e) => e.eventIndexInTransaction < event.eventIndexInTransaction) + .sort((a, b) => b.eventIndexInTransaction - a.eventIndexInTransaction); + + // First EkuboPositionUpdated event (from vault, not Ekubo core) + const positionUpdateEvent = filteredEvents.find( + (e) => + standariseAddress(e.keys[0]) == + standariseAddress(num.getDecimalString(eventKey("PositionUpdated"))) + ); + + if (!positionUpdateEvent) { + throw new Error("Expected EkuboPositionUpdated event for V2 vault"); + } + + // EkuboPositionUpdated data structure: + // nft_id (u64 = 1 felt), pool_key (PoolKey), bounds (Bounds), amount0_delta (i129), amount1_delta (i129), liquidity_delta (i129) + // PoolKey: token0, token1, fee (u128 = 2 felts), tick_spacing (u128 = 2 felts), extension + // nft_id is data[0] + // PoolKey starts at data[1] + const token0 = standariseAddress(`0x${BigInt(positionUpdateEvent.data[1]).toString(16).padStart(64, "0")}`); + const token1 = standariseAddress(`0x${BigInt(positionUpdateEvent.data[2]).toString(16).padStart(64, "0")}`); + + const record: any = { + block_number: Number(block.header.blockNumber), + tx_index: event.transactionIndex, + event_index: event.eventIndex, + tx_hash: event.transactionHash, + sender: processedRecord.sender, + owner: processedRecord.owner, + receiver: processedRecord.receiver || processedRecord.owner, // receiver only in withdraw + shares: processedRecord.shares, + amount0: processedRecord.amount0, + amount1: processedRecord.amount1, + token0, + token1, + vault_address: processedRecord.contract, + user_address: processedRecord.user_address, + type: processedRecord.type, + timestamp: Math.round(block.header.timestamp.getTime() / 1000), + cursor: BigInt(block.header.blockNumber).toString(), + }; + + await db.insert(schema.ekubo_v2_investment_flows).values([record]).execute(); +}; diff --git a/prisma/drizzle/schema.ts b/prisma/drizzle/schema.ts index cb4fde1..fc926ab 100644 --- a/prisma/drizzle/schema.ts +++ b/prisma/drizzle/schema.ts @@ -89,6 +89,31 @@ export const position_updated = pgTable('position_updated', { .on(position_updated.block_number, position_updated.tx_index, position_updated.event_index) })); +export const ekubo_v2_investment_flows = pgTable('ekubo_v2_investment_flows', { + id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), + block_number: integer('block_number').notNull(), + tx_index: integer('tx_index').notNull(), + event_index: integer('event_index').notNull(), + tx_hash: text('tx_hash').notNull(), + sender: text('sender').notNull(), + owner: text('owner').notNull(), + receiver: text('receiver').notNull(), + shares: text('shares').notNull(), + amount0: text('amount0').notNull(), + amount1: text('amount1').notNull(), + token0: text('token0').notNull(), + token1: text('token1').notNull(), + vault_address: text('vault_address').notNull(), + user_address: text('user_address').notNull(), + type: text('type').notNull(), + timestamp: integer('timestamp').notNull(), + cursor: bigint('_cursor', { mode: 'bigint' }), + quote_amount: decimal('quote_amount', { precision: 65, scale: 30 }).notNull() +}, (ekubo_v2_investment_flows) => ({ + 'event_id': uniqueIndex('event_id') + .on(ekubo_v2_investment_flows.block_number, ekubo_v2_investment_flows.tx_index, ekubo_v2_investment_flows.event_index) +})); + export const raw_price_events = pgTable('raw_price_events', { id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), block_number: integer('block_number').notNull(), diff --git a/prisma/migrations/20260215102707_add_ekubo_v2_investment_flows/migration.sql b/prisma/migrations/20260215102707_add_ekubo_v2_investment_flows/migration.sql new file mode 100644 index 0000000..e13fea7 --- /dev/null +++ b/prisma/migrations/20260215102707_add_ekubo_v2_investment_flows/migration.sql @@ -0,0 +1,81 @@ +-- CreateTable +CREATE TABLE "public"."ekubo_v2_investment_flows" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "block_number" INTEGER NOT NULL, + "tx_index" INTEGER NOT NULL DEFAULT 0, + "event_index" INTEGER NOT NULL DEFAULT 0, + "tx_hash" TEXT NOT NULL, + "sender" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "receiver" TEXT NOT NULL, + "shares" TEXT NOT NULL, + "amount0" TEXT NOT NULL, + "amount1" TEXT NOT NULL, + "token0" TEXT NOT NULL, + "token1" TEXT NOT NULL, + "vault_address" TEXT NOT NULL, + "user_address" TEXT NOT NULL, + "type" TEXT NOT NULL, + "timestamp" INTEGER NOT NULL, + "_cursor" BIGINT, + "quote_amount" DECIMAL(65,30) NOT NULL DEFAULT 0, + + CONSTRAINT "ekubo_v2_investment_flows_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ekubo_v2_investment_flows_block_number_tx_index_event_index_key" ON "public"."ekubo_v2_investment_flows"("block_number", "tx_index", "event_index"); + +-- Function to calculate quote_amount on ekubo_v2_investment_flows insert/update +CREATE OR REPLACE FUNCTION calculate_ekubo_v2_quote_amount() +RETURNS TRIGGER AS $$ +DECLARE + quote_asset_address TEXT; + token0_quote_amount DECIMAL(65,30); + token1_quote_amount DECIMAL(65,30); + total_quote_amount DECIMAL(65,30); + token0_address TEXT; + token1_address TEXT; +BEGIN + -- Get quote_asset from strategy_metadata using the vault_address + SELECT sm.quote_asset INTO quote_asset_address + FROM "public"."strategy_metadata" sm + WHERE sm.strategy_address = NEW.vault_address; + + -- If no strategy metadata found, raise exception + IF quote_asset_address IS NULL THEN + RAISE EXCEPTION 'No strategy metadata found for vault address %', NEW.vault_address; + RETURN NULL; + END IF; + + -- Normalize token addresses (v2 STRK -> v1 STRK for pricing) + token0_address = NEW.token0; + IF token0_address = '0x28d709c875c0ceac3dce7065bec5328186dc89fe254527084d1689910954b0a' THEN + token0_address = '0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d'; + END IF; + + token1_address = NEW.token1; + IF token1_address = '0x28d709c875c0ceac3dce7065bec5328186dc89fe254527084d1689910954b0a' THEN + token1_address = '0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d'; + END IF; + + -- Calculate quote amounts for both tokens using existing helper function + token0_quote_amount := calculate_quote_amount(token0_address, quote_asset_address, NEW.amount0::DECIMAL, NEW.timestamp); + token1_quote_amount := calculate_quote_amount(token1_address, quote_asset_address, NEW.amount1::DECIMAL, NEW.timestamp); + + -- Calculate total quote amount (sum of both tokens) + total_quote_amount := token0_quote_amount + token1_quote_amount; + + -- Set the calculated quote_amount + NEW.quote_amount := total_quote_amount; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger for ekubo_v2_investment_flows insert/update +CREATE TRIGGER ekubo_v2_investment_flows_calculate_quote_amount_trigger + BEFORE INSERT OR UPDATE ON "public"."ekubo_v2_investment_flows" + FOR EACH ROW + EXECUTE FUNCTION calculate_ekubo_v2_quote_amount(); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 31f8c1b..17c16a0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -130,6 +130,38 @@ model position_updated { @@schema("public") } +model ekubo_v2_investment_flows { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + block_number Int + tx_index Int @default(0) + event_index Int @default(0) + tx_hash String + + sender String + owner String + receiver String + shares String + amount0 String + amount1 String + + token0 String + token1 String + + vault_address String + user_address String + type String // deposit | withdraw + + timestamp Int + cursor BigInt? @map("_cursor") + + // The represent the value of investment in the quote asset (from strategy_metadata) + // updated by trigger on this table using the prices table as info + quote_amount Decimal @default(0) + + @@unique([block_number, tx_index, event_index], name: "event_id") + @@schema("public") +} + // // Shared schemas // diff --git a/prisma/seed.ts b/prisma/seed.ts index 6a84d4b..4239319 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,5 +1,5 @@ import { PrismaClient, token_metadata } from "@prisma/client"; -import { EkuboCLVaultStrategies } from "@strkfarm/sdk"; +import { EkuboCLVaultStrategies, EkuboCLVaultV2Strategies } from "@strkfarm/sdk"; import { UniversalStrategies } from "@strkfarm/sdk"; import { VesuRebalanceStrategies } from "@strkfarm/sdk"; import { Global } from "@strkfarm/sdk"; @@ -86,6 +86,13 @@ async function seedStrategyMetadata() { strategy_name: strategy.name, quote_asset: strategy.additionalInfo.quoteAsset.address.address, })), + ...EkuboCLVaultV2Strategies + .filter((str) => str.curator?.name.toLowerCase().includes('re7')) + .map((strategy) => ({ + strategy_address: strategy.address.address, + strategy_name: strategy.name, + quote_asset: strategy.additionalInfo.quoteAsset.address.address, + })), ...VesuRebalanceStrategies.map((strategy) => ({ strategy_address: strategy.address.address, strategy_name: strategy.name, diff --git a/src/graphql/customResolvers/ekuboVaultFlows.ts b/src/graphql/customResolvers/ekuboVaultFlows.ts index 3d6baf3..4cca324 100644 --- a/src/graphql/customResolvers/ekuboVaultFlows.ts +++ b/src/graphql/customResolvers/ekuboVaultFlows.ts @@ -1,6 +1,7 @@ import { Resolver, Query, Arg, ObjectType, Field } from "type-graphql"; import { PrismaClient } from "@prisma/client"; import { standariseAddress } from "@/utils"; +import { EkuboCLVaultStrategies, EkuboCLVaultV2Strategies } from "@strkfarm/sdk"; const prisma = new PrismaClient(); @@ -53,38 +54,80 @@ export class EkuboVaultFlowsResolver { const contract = standariseAddress(vault_contract); const user = standariseAddress(user_address); - // get deposit/withdraw events for this vault and user - const flows = await prisma.position_updated.findMany({ - where: { - vault_address: contract, - user_address: user, - }, - orderBy: [ - { block_number: "desc" }, - { tx_index: "asc" }, - { event_index: "asc" }, - ], - }); - - if (!flows.length) return []; + // Determine if vault is V1 or V2 + const isV1 = EkuboCLVaultStrategies.some((strat) => + strat.address.eqString(contract) + ); + const isV2 = EkuboCLVaultV2Strategies.some((strat) => + strat.address.eqString(contract) + ); + + if (!isV1 && !isV2) { + throw new Error(`Unknown vault contract: ${contract}`); + } const results: EkuboVaultFlow[] = []; - for (const f of flows) { - results.push({ - type: BigInt(f.amount0) > 0n ? "deposit" : "withdraw", - tx_hash: f.tx_hash, - block_number: f.block_number, - tx_index: f.tx_index, - event_index: f.event_index, - token0: f.token0, - token1: f.token1, - amount0: f.amount0, - amount1: f.amount1, - liquidity_delta: f.liquidity_delta, - timestamp: f.timestamp, - quote_amount: f.quote_amount.toNumber() || 0, + if (isV1) { + // Query position_updated for V1 vaults + const flows = await prisma.position_updated.findMany({ + where: { + vault_address: contract, + user_address: user, + }, + orderBy: [ + { block_number: "desc" }, + { tx_index: "asc" }, + { event_index: "asc" }, + ], }); + + for (const f of flows) { + results.push({ + type: BigInt(f.amount0) > 0n ? "deposit" : "withdraw", + tx_hash: f.tx_hash, + block_number: f.block_number, + tx_index: f.tx_index, + event_index: f.event_index, + token0: f.token0, + token1: f.token1, + amount0: f.amount0, + amount1: f.amount1, + liquidity_delta: f.liquidity_delta, + timestamp: f.timestamp, + quote_amount: f.quote_amount.toNumber() || 0, + }); + } + } else { + // Query ekubo_v2_investment_flows for V2 vaults + const flows = await prisma.ekubo_v2_investment_flows.findMany({ + where: { + vault_address: contract, + user_address: user, + }, + orderBy: [ + { block_number: "desc" }, + { tx_index: "asc" }, + { event_index: "asc" }, + ], + }); + + for (const f of flows) { + results.push({ + type: f.type, // Already set in the record + tx_hash: f.tx_hash, + block_number: f.block_number, + tx_index: f.tx_index, + event_index: f.event_index, + token0: f.token0, + token1: f.token1, + amount0: f.amount0, + amount1: f.amount1, + liquidity_delta: "0", // Not tracked in V2 + timestamp: f.timestamp, + quote_amount: f.quote_amount.toNumber() || 0, + }); + } } return results; From acf19a3b144cfd00a49b9c0b9d248cfd36ff3203 Mon Sep 17 00:00:00 2001 From: akcharlie24 Date: Sun, 15 Feb 2026 16:39:35 +0530 Subject: [PATCH 3/5] fix: removed re7 filters --- prisma/seed.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index 4239319..bdf01f1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -80,14 +80,12 @@ async function seedStrategyMetadata() { quote_asset: strategy.depositTokens[0].address.address, })), ...EkuboCLVaultStrategies - .filter((str) => str.curator?.name.toLowerCase().includes('re7')) .map((strategy) => ({ strategy_address: strategy.address.address, strategy_name: strategy.name, quote_asset: strategy.additionalInfo.quoteAsset.address.address, })), ...EkuboCLVaultV2Strategies - .filter((str) => str.curator?.name.toLowerCase().includes('re7')) .map((strategy) => ({ strategy_address: strategy.address.address, strategy_name: strategy.name, @@ -117,4 +115,4 @@ async function seed() { if (require.main === module) { seed(); // seedStrategyMetadata(); -} \ No newline at end of file +} From 3a140afaf0821a5f628ea7bcc3b16135eb60b34a Mon Sep 17 00:00:00 2001 From: akcharlie24 Date: Wed, 18 Feb 2026 13:09:40 +0530 Subject: [PATCH 4/5] feat: adds role based indexing to the indexer --- indexers/drizzle/schema.ts | 43 ++- indexers/utils/config.ts | 2 + indexers/utils/configs/active_permissions.ts | 269 ++++++++++++++++++ prisma/drizzle/schema.ts | 40 +++ .../migration.sql | 137 +++++++++ prisma/schema.prisma | 48 ++++ 6 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 indexers/utils/configs/active_permissions.ts create mode 100644 prisma/migrations/20260217122444_add_active_permissions/migration.sql diff --git a/indexers/drizzle/schema.ts b/indexers/drizzle/schema.ts index fc926ab..d214bf0 100644 --- a/indexers/drizzle/schema.ts +++ b/indexers/drizzle/schema.ts @@ -261,4 +261,45 @@ export const strategy_apy = pgTable('strategy_apy', { }, (strategy_apy) => ({ 'strategy_apy_unique': uniqueIndex('strategy_apy_unique') .on(strategy_apy.strategy_id, strategy_apy.timestamp) -})); \ No newline at end of file +})); + +export const role_events = pgTable('role_events', { + id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), + block_number: integer('block_number').notNull(), + tx_index: integer('tx_index').notNull(), + event_index: integer('event_index').notNull(), + tx_hash: text('tx_hash').notNull(), + event_type: text('event_type').notNull(), + contract_address: text('contract_address').notNull(), + role: text('role').notNull(), + role_name: text('role_name').notNull(), + account: text('account'), + sender: text('sender'), + previous_admin_role: text('previous_admin_role'), + previous_admin_role_name: text('previous_admin_role_name'), + new_admin_role: text('new_admin_role'), + new_admin_role_name: text('new_admin_role_name'), + timestamp: integer('timestamp').notNull(), + cursor: bigint('_cursor', { mode: 'bigint' }) +}, (role_events) => ({ + 'event_id': uniqueIndex('event_id') + .on(role_events.block_number, role_events.tx_index, role_events.event_index) +})); + +export const contract_roles = pgTable('contract_roles', { + id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), + contract_address: text('contract_address').notNull(), + role_id: text('role_id').notNull(), + role_name: text('role_name').notNull(), + account: text('account').notNull(), + role_admin_id: text('role_admin_id').notNull().default("0x0000000000000000000000000000000000000000000000000000000000000000"), + role_admin_name: text('role_admin_name').notNull().default("DEFAULT_ADMIN_ROLE"), + granted_at_block: integer('granted_at_block').notNull(), + granted_at_timestamp: integer('granted_at_timestamp').notNull(), + last_modified_block: integer('last_modified_block').notNull(), + last_modified_timestamp: integer('last_modified_timestamp').notNull() +}, (contract_roles) => ({ + 'contract_roles_unique': uniqueIndex('contract_roles_unique') + .on(contract_roles.contract_address, contract_roles.role_id, contract_roles.account) +})); + diff --git a/indexers/utils/config.ts b/indexers/utils/config.ts index 4db85ec..7f35c69 100644 --- a/indexers/utils/config.ts +++ b/indexers/utils/config.ts @@ -9,6 +9,7 @@ import { onEventEkuboVault } from "./ekubo_vault"; import { CONFIG_INVESTMENT_FLOWS_ERC4626, EKUBO_VAULT_CONTRACTS } from "./configs/investment_flows_erc4626"; import { CONFIG_INVESTMENT_FLOWS_STARKNET_VAULT_KIT } from "./configs/investment_flows_starknet_vault_kit"; import { CONFIG_PRAGMA_PRICE } from "./configs/pragma_price"; +import { CONFIG_ACTIVE_PERMISSIONS } from "./configs/active_permissions"; export interface EventField { name: string; @@ -68,6 +69,7 @@ export const CONFIG: EventConfig[] = [ ...CONFIG_PRAGMA_PRICE, ...CONFIG_INVESTMENT_FLOWS_ERC4626, ...CONFIG_INVESTMENT_FLOWS_STARKNET_VAULT_KIT, + ...CONFIG_ACTIVE_PERMISSIONS, { tableName: "harvests", diff --git a/indexers/utils/configs/active_permissions.ts b/indexers/utils/configs/active_permissions.ts new file mode 100644 index 0000000..c9e3e1b --- /dev/null +++ b/indexers/utils/configs/active_permissions.ts @@ -0,0 +1,269 @@ +import { UniversalStrategies, HyperLSTStrategies } from "@strkfarm/sdk"; +import { standariseAddress } from "../../../src/utils"; +import { ContractConfig, EventConfig } from "../config"; +import { eventKey } from "../common_transform"; +// Ekubo Access Control Contracts +const EKUBO_ACCESS_CONTROL_CONTRACTS: ContractConfig[] = [ + { + address: standariseAddress("0x0636a3f51cc37f5729e4da4b1de6a8549a28f3c0d5bf3b17f150971e451ff9c2"), + asset: "", // Not applicable for access control contracts + name: "Ekubo Access Control 1", + }, + { + address: standariseAddress("0x00707bf89863473548fb2844c9f3f96d83fe2394453259035a5791e4b1490642"), + asset: "", + name: "Ekubo Access Control 2", + }, +]; + +// Universal Strategies - Extract vault and manager addresses dynamically from SDK +const UNIVERSAL_STRATEGY_CONTRACTS: ContractConfig[] = [ + // Vault addresses + ...UniversalStrategies.map((strategy) => ({ + address: standariseAddress(strategy.address.address), + asset: "", + name: `${strategy.name} Vault`, + })), + // Manager addresses + ...UniversalStrategies.map((strategy) => ({ + address: standariseAddress(strategy.additionalInfo.manager.address), + asset: "", + name: `${strategy.name} Manager`, + })), +]; + +// HyperLST Strategies - Extract vault and manager addresses dynamically from SDK +const HYPERLST_STRATEGY_CONTRACTS: ContractConfig[] = [ + // Vault addresses + ...HyperLSTStrategies.map((strategy) => ({ + address: standariseAddress(strategy.address.address), + asset: "", + name: `${strategy.name} Vault`, + })), + // Manager addresses + ...HyperLSTStrategies.map((strategy) => ({ + address: standariseAddress(strategy.additionalInfo.manager.address), + asset: "", + name: `${strategy.name} Manager`, + })), +]; + +// Combine all access control contracts +const ALL_ACCESS_CONTROL_CONTRACTS: ContractConfig[] = [ + ...EKUBO_ACCESS_CONTROL_CONTRACTS, + ...UNIVERSAL_STRATEGY_CONTRACTS, + ...HYPERLST_STRATEGY_CONTRACTS, +]; + +// Lookup map of known role selector hashes to human-readable names. +// Keys are standarised (leading zeros stripped) to match what standariseAddress() produces. +// Roles in Cairo are computed via selector!("ROLE_NAME") which produces a keccak256 +// hash — they cannot be decoded back to strings, so a static map is required. +const KNOWN_ROLE_NAMES: Record = { + // DEFAULT_ADMIN_ROLE = 0 + "0x0": "DEFAULT_ADMIN_ROLE", + // selector!("GOVERNOR") + "0x26838efa4183e08fe3607359d1259272af9d4716f65e1a7b5921f78fd5a3c6a": "GOVERNOR", + // selector!("RELAYER") + "0x34f864e5201b0fde9b5ee3e4cf96384802b0ffdfcf7f9de4699ce21a30afc4f": "RELAYER", + // selector!("EMERGENCY_ACTOR") + "0x34cde74b9063efe5d744d0e7c535eca81c0e48ad60da32f9963463ceb475c4b": "EMERGENCY_ACTOR", +}; + +// Helper function to decode role name from felt252. +// Normalises the input via standariseAddress first so map keys always match. +function decodeRoleName(roleHex: string): string { + const normalised = standariseAddress(roleHex); + return KNOWN_ROLE_NAMES[normalised] ?? `UNKNOWN_ROLE_${normalised.substring(0, 10)}`; +} + +export const CONFIG_ACTIVE_PERMISSIONS: EventConfig[] = [ + // RoleGranted Event + { + tableName: "role_events", + contracts: ALL_ACCESS_CONTROL_CONTRACTS, + defaultKeys: [[eventKey("RoleGranted")]], + keyFields: [], + dataFields: [ + { name: "role", type: "text", sqlType: "text" }, + { name: "account", type: "ContractAddress", sqlType: "text" }, + { name: "sender", type: "ContractAddress", sqlType: "text" }, + ], + additionalFields: [ + { + name: "event_type", + source: "custom", + sqlType: "text", + customLogic: () => "RoleGranted", + }, + { + name: "contract_address", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.address), + }, + { + name: "role_name", + source: "custom", + sqlType: "text", + customLogic: (event) => { + // role is the first data field + const roleHex = standariseAddress(event.data[0]); + return decodeRoleName(roleHex); + }, + }, + { + name: "previous_admin_role", + source: "custom", + sqlType: "text", + customLogic: () => null, + }, + { + name: "previous_admin_role_name", + source: "custom", + sqlType: "text", + customLogic: () => null, + }, + { + name: "new_admin_role", + source: "custom", + sqlType: "text", + customLogic: () => null, + }, + { + name: "new_admin_role_name", + source: "custom", + sqlType: "text", + customLogic: () => null, + }, + ], + }, + // RoleRevoked Event + { + tableName: "role_events", + contracts: ALL_ACCESS_CONTROL_CONTRACTS, + defaultKeys: [[eventKey("RoleRevoked")]], + keyFields: [], + dataFields: [ + { name: "role", type: "text", sqlType: "text" }, + { name: "account", type: "ContractAddress", sqlType: "text" }, + { name: "sender", type: "ContractAddress", sqlType: "text" }, + ], + additionalFields: [ + { + name: "event_type", + source: "custom", + sqlType: "text", + customLogic: () => "RoleRevoked", + }, + { + name: "contract_address", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.address), + }, + { + name: "role_name", + source: "custom", + sqlType: "text", + customLogic: (event) => { + // role is the first data field + const roleHex = standariseAddress(event.data[0]); + return decodeRoleName(roleHex); + }, + }, + { + name: "previous_admin_role", + source: "custom", + sqlType: "text", + customLogic: () => null, + }, + { + name: "previous_admin_role_name", + source: "custom", + sqlType: "text", + customLogic: () => null, + }, + { + name: "new_admin_role", + source: "custom", + sqlType: "text", + customLogic: () => null, + }, + { + name: "new_admin_role_name", + source: "custom", + sqlType: "text", + customLogic: () => null, + }, + ], + }, + // RoleAdminChanged Event + { + tableName: "role_events", + contracts: ALL_ACCESS_CONTROL_CONTRACTS, + defaultKeys: [[eventKey("RoleAdminChanged")]], + keyFields: [], + dataFields: [ + { name: "role", type: "text", sqlType: "text" }, + { name: "previous_admin_role", type: "text", sqlType: "text" }, + { name: "new_admin_role", type: "text", sqlType: "text" }, + ], + additionalFields: [ + { + name: "event_type", + source: "custom", + sqlType: "text", + customLogic: () => "RoleAdminChanged", + }, + { + name: "contract_address", + source: "custom", + sqlType: "text", + customLogic: (event) => standariseAddress(event.address), + }, + { + name: "role_name", + source: "custom", + sqlType: "text", + customLogic: (event) => { + // role is the first data field + const roleHex = standariseAddress(event.data[0]); + return decodeRoleName(roleHex); + }, + }, + { + name: "previous_admin_role_name", + source: "custom", + sqlType: "text", + customLogic: (event) => { + // previous_admin_role is the second data field + const roleHex = standariseAddress(event.data[1]); + return decodeRoleName(roleHex); + }, + }, + { + name: "new_admin_role_name", + source: "custom", + sqlType: "text", + customLogic: (event) => { + // new_admin_role is the third data field + const roleHex = standariseAddress(event.data[2]); + return decodeRoleName(roleHex); + }, + }, + { + name: "account", + source: "custom", + sqlType: "text", + customLogic: () => null, + }, + { + name: "sender", + source: "custom", + sqlType: "text", + customLogic: () => null, + }, + ], + }, +]; diff --git a/prisma/drizzle/schema.ts b/prisma/drizzle/schema.ts index fc926ab..bb91204 100644 --- a/prisma/drizzle/schema.ts +++ b/prisma/drizzle/schema.ts @@ -261,4 +261,44 @@ export const strategy_apy = pgTable('strategy_apy', { }, (strategy_apy) => ({ 'strategy_apy_unique': uniqueIndex('strategy_apy_unique') .on(strategy_apy.strategy_id, strategy_apy.timestamp) +})); + +export const role_events = pgTable('role_events', { + id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), + block_number: integer('block_number').notNull(), + tx_index: integer('tx_index').notNull(), + event_index: integer('event_index').notNull(), + tx_hash: text('tx_hash').notNull(), + event_type: text('event_type').notNull(), + contract_address: text('contract_address').notNull(), + role: text('role').notNull(), + role_name: text('role_name').notNull(), + account: text('account'), + sender: text('sender'), + previous_admin_role: text('previous_admin_role'), + previous_admin_role_name: text('previous_admin_role_name'), + new_admin_role: text('new_admin_role'), + new_admin_role_name: text('new_admin_role_name'), + timestamp: integer('timestamp').notNull(), + cursor: bigint('_cursor', { mode: 'bigint' }) +}, (role_events) => ({ + 'event_id': uniqueIndex('event_id') + .on(role_events.block_number, role_events.tx_index, role_events.event_index) +})); + +export const contract_roles = pgTable('contract_roles', { + id: text('id').notNull().primaryKey().default(sql`gen_random_uuid()`), + contract_address: text('contract_address').notNull(), + role_id: text('role_id').notNull(), + role_name: text('role_name').notNull(), + account: text('account').notNull(), + role_admin_id: text('role_admin_id').notNull().default("0x0000000000000000000000000000000000000000000000000000000000000000"), + role_admin_name: text('role_admin_name').notNull().default("DEFAULT_ADMIN_ROLE"), + granted_at_block: integer('granted_at_block').notNull(), + granted_at_timestamp: integer('granted_at_timestamp').notNull(), + last_modified_block: integer('last_modified_block').notNull(), + last_modified_timestamp: integer('last_modified_timestamp').notNull() +}, (contract_roles) => ({ + 'contract_roles_unique': uniqueIndex('contract_roles_unique') + .on(contract_roles.contract_address, contract_roles.role_id, contract_roles.account) })); \ No newline at end of file diff --git a/prisma/migrations/20260217122444_add_active_permissions/migration.sql b/prisma/migrations/20260217122444_add_active_permissions/migration.sql new file mode 100644 index 0000000..7147a3a --- /dev/null +++ b/prisma/migrations/20260217122444_add_active_permissions/migration.sql @@ -0,0 +1,137 @@ +-- CreateTable: Raw events table for role events +CREATE TABLE "public"."role_events" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "block_number" INTEGER NOT NULL, + "tx_index" INTEGER NOT NULL DEFAULT 0, + "event_index" INTEGER NOT NULL DEFAULT 0, + "tx_hash" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "contract_address" TEXT NOT NULL, + "role" TEXT NOT NULL, + "role_name" TEXT NOT NULL, + "account" TEXT, + "sender" TEXT, + "previous_admin_role" TEXT, + "previous_admin_role_name" TEXT, + "new_admin_role" TEXT, + "new_admin_role_name" TEXT, + "timestamp" INTEGER NOT NULL, + "_cursor" BIGINT, + + CONSTRAINT "role_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: State table maintaining current contract roles +CREATE TABLE "public"."contract_roles" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "contract_address" TEXT NOT NULL, + "role_id" TEXT NOT NULL, + "role_name" TEXT NOT NULL, + "account" TEXT NOT NULL, + "role_admin_id" TEXT NOT NULL DEFAULT '0x0000000000000000000000000000000000000000000000000000000000000000', + "role_admin_name" TEXT NOT NULL DEFAULT 'DEFAULT_ADMIN_ROLE', + "granted_at_block" INTEGER NOT NULL, + "granted_at_timestamp" INTEGER NOT NULL, + "last_modified_block" INTEGER NOT NULL, + "last_modified_timestamp" INTEGER NOT NULL, + + CONSTRAINT "contract_roles_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "role_events_block_number_tx_index_event_index_key" ON "public"."role_events"("block_number", "tx_index", "event_index"); + +-- CreateIndex +CREATE INDEX "role_events_contract_address_role_idx" ON "public"."role_events"("contract_address", "role"); + +-- CreateIndex +CREATE INDEX "role_events_contract_address_account_idx" ON "public"."role_events"("contract_address", "account"); + +-- CreateIndex +CREATE UNIQUE INDEX "contract_roles_contract_address_role_id_account_key" ON "public"."contract_roles"("contract_address", "role_id", "account"); + +-- CreateIndex +CREATE INDEX "contract_roles_contract_address_idx" ON "public"."contract_roles"("contract_address"); + +-- CreateIndex +CREATE INDEX "contract_roles_account_idx" ON "public"."contract_roles"("account"); + +-- CreateIndex +CREATE INDEX "contract_roles_role_admin_id_idx" ON "public"."contract_roles"("role_admin_id"); + +-- ============================================================================ +-- MAIN TRIGGER FUNCTION TO ROUTE EVENTS +-- ============================================================================ + +CREATE OR REPLACE FUNCTION handle_role_event() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.event_type = 'RoleGranted' THEN + -- Insert or update contract_roles table + -- Role names are already decoded during indexing, just copy them! + INSERT INTO "public"."contract_roles" ( + contract_address, role_id, role_name, account, + role_admin_id, role_admin_name, + granted_at_block, granted_at_timestamp, + last_modified_block, last_modified_timestamp + ) + VALUES ( + NEW.contract_address, + NEW.role, + NEW.role_name, -- Already decoded during indexing! + NEW.account, + '0x0000000000000000000000000000000000000000000000000000000000000000', + 'DEFAULT_ADMIN_ROLE', + NEW.block_number, + NEW.timestamp, + NEW.block_number, + NEW.timestamp + ) + ON CONFLICT (contract_address, role_id, account) + DO UPDATE SET + last_modified_block = EXCLUDED.last_modified_block, + last_modified_timestamp = EXCLUDED.last_modified_timestamp; + + ELSIF NEW.event_type = 'RoleRevoked' THEN + -- Delete from contract_roles table + DELETE FROM "public"."contract_roles" + WHERE contract_address = NEW.contract_address + AND role_id = NEW.role + AND account = NEW.account; + + ELSIF NEW.event_type = 'RoleAdminChanged' THEN + -- Update all roles with this role_id to have new admin + -- Admin role names are already decoded during indexing! + UPDATE "public"."contract_roles" + SET + role_admin_id = NEW.new_admin_role, + role_admin_name = NEW.new_admin_role_name, -- Already decoded during indexing! + last_modified_block = NEW.block_number, + last_modified_timestamp = NEW.timestamp + WHERE contract_address = NEW.contract_address + AND role_id = NEW.role; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- CREATE TRIGGERS +-- ============================================================================ + +-- Trigger for role events +CREATE TRIGGER role_events_insert_trigger + AFTER INSERT ON "public"."role_events" + FOR EACH ROW + EXECUTE FUNCTION handle_role_event(); + +CREATE TRIGGER role_events_update_trigger + AFTER UPDATE ON "public"."role_events" + FOR EACH ROW + EXECUTE FUNCTION handle_role_event(); + +CREATE TRIGGER role_events_delete_trigger + AFTER DELETE ON "public"."role_events" + FOR EACH ROW + EXECUTE FUNCTION handle_role_event(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17c16a0..a558d73 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -341,3 +341,51 @@ model strategy_apy { @@index([timestamp], name: "strategy_apy_timestamp_idx") @@schema("public") } + +// Raw events table for role-based access control events +model role_events { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + block_number Int + tx_index Int @default(0) + event_index Int @default(0) + tx_hash String + + event_type String // 'RoleGranted', 'RoleRevoked', 'RoleAdminChanged' + contract_address String + role String + role_name String // Decoded role name (computed during indexing) + account String? + sender String? + previous_admin_role String? + previous_admin_role_name String? // Decoded previous admin role name + new_admin_role String? + new_admin_role_name String? // Decoded new admin role name + timestamp Int + cursor BigInt? @map("_cursor") + + @@unique([block_number, tx_index, event_index], name: "event_id") + @@index([contract_address, role]) + @@index([contract_address, account]) + @@schema("public") +} + +// State table maintaining current role assignments +model contract_roles { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + contract_address String + role_id String + role_name String + account String + role_admin_id String @default("0x0000000000000000000000000000000000000000000000000000000000000000") + role_admin_name String @default("DEFAULT_ADMIN_ROLE") + granted_at_block Int + granted_at_timestamp Int + last_modified_block Int + last_modified_timestamp Int + + @@unique([contract_address, role_id, account], name: "contract_roles_unique") + @@index([contract_address]) + @@index([account]) + @@index([role_admin_id]) + @@schema("public") +} From 31a8cec96e6373bc78b98d8c125c5aa48ed2b05d Mon Sep 17 00:00:00 2001 From: akcharlie24 Date: Wed, 18 Feb 2026 18:04:33 +0530 Subject: [PATCH 5/5] feat: added FindManyContract_rolesResolver to graph api --- src/graphql/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/graphql/index.ts b/src/graphql/index.ts index b61bb4e..62e3ed4 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -12,6 +12,7 @@ import { FindFirstSvk_alt_redemptionsResolver, FindManySvk_alt_redemptionsResolver, FindManyStrategy_apyResolver, + FindManyContract_rolesResolver, } from "@generated/type-graphql"; import { buildSchema, Resolver, Query, Arg } from 'type-graphql'; import { startStandaloneServer } from "@apollo/server/standalone"; @@ -56,7 +57,8 @@ async function main() { FindFirstInvestment_flowsResolver, FindFirstSvk_alt_redemptionsResolver, FindManySvk_alt_redemptionsResolver, - FindManyStrategy_apyResolver + FindManyStrategy_apyResolver, + FindManyContract_rolesResolver, ], validate: false, });