From be85d3251741b6e8f0a95bf2c0243d9904cb7f10 Mon Sep 17 00:00:00 2001 From: Enzo Cioppettini Date: Wed, 6 Dec 2023 20:19:18 -0300 Subject: [PATCH 1/6] add epoch boundary tracking for cardano delegation primitive --- .../paima-funnel/src/cde/cardanoPool.ts | 9 ++- .../paima-funnel/src/funnels/carp/funnel.ts | 45 +++++++++++++- .../engine/paima-sm/src/cde-cardano-pool.ts | 15 ++++- packages/engine/paima-sm/src/index.ts | 22 +++++++ packages/engine/paima-sm/src/types.ts | 6 ++ packages/node-sdk/paima-db/migrations/up.sql | 8 ++- packages/node-sdk/paima-db/src/index.ts | 2 + .../node-sdk/paima-db/src/paima-tables.ts | 25 +++++++- .../src/sql/cardano-last-epoch.queries.ts | 61 +++++++++++++++++++ .../paima-db/src/sql/cardano-last-epoch.sql | 13 ++++ .../cde-cardano-pool-delegation.queries.ts | 49 +++++++++++++-- .../src/sql/cde-cardano-pool-delegation.sql | 26 ++++++-- .../src/cde-access-internals.ts | 16 ++++- .../paima-utils-backend/src/cde-access.ts | 2 +- .../paima-sdk/paima-utils/src/constants.ts | 4 ++ 15 files changed, 280 insertions(+), 23 deletions(-) create mode 100644 packages/node-sdk/paima-db/src/sql/cardano-last-epoch.queries.ts create mode 100644 packages/node-sdk/paima-db/src/sql/cardano-last-epoch.sql diff --git a/packages/engine/paima-funnel/src/cde/cardanoPool.ts b/packages/engine/paima-funnel/src/cde/cardanoPool.ts index 8156eadf9..36f476783 100644 --- a/packages/engine/paima-funnel/src/cde/cardanoPool.ts +++ b/packages/engine/paima-funnel/src/cde/cardanoPool.ts @@ -6,6 +6,7 @@ import type { import { ChainDataExtensionDatumType, DEFAULT_FUNNEL_TIMEOUT, timeout } from '@paima/utils'; import { Routes, query } from '@dcspark/carp-client/client/src'; import type { DelegationForPoolResponse } from '@dcspark/carp-client/shared/models/DelegationForPool'; +import { absoluteSlotToEpoch } from '../funnels/carp/funnel.js'; export default async function getCdeData( url: string, @@ -22,13 +23,16 @@ export default async function getCdeData( DEFAULT_FUNNEL_TIMEOUT ); - return events.map(e => eventToCdeDatum(e, extension, getBlockNumber(e.slot))); + return events.map(e => + eventToCdeDatum(e, extension, getBlockNumber(e.slot), absoluteSlotToEpoch(e.slot)) + ); } function eventToCdeDatum( event: DelegationForPoolResponse[0], extension: ChainDataExtensionCardanoDelegation, - blockNumber: number + blockNumber: number, + epoch: number ): CdeCardanoPoolDatum { return { cdeId: extension.cdeId, @@ -37,6 +41,7 @@ function eventToCdeDatum( payload: { address: event.credential, pool: event.pool || undefined, + epoch, }, scheduledPrefix: extension.scheduledPrefix, }; diff --git a/packages/engine/paima-funnel/src/funnels/carp/funnel.ts b/packages/engine/paima-funnel/src/funnels/carp/funnel.ts index 0a6b6d727..9ff904d85 100644 --- a/packages/engine/paima-funnel/src/funnels/carp/funnel.ts +++ b/packages/engine/paima-funnel/src/funnels/carp/funnel.ts @@ -8,11 +8,10 @@ import { Network, timeout, } from '@paima/utils'; -import type { ChainDataExtensionCardanoProjectedNFT } from '@paima/sm'; +import type { ChainDataExtensionCardanoProjectedNFT, InternalEvent } from '@paima/sm'; import { type ChainData, type ChainDataExtension, - type ChainDataExtensionCardanoDelegation, type ChainDataExtensionDatum, type PresyncChainData, } from '@paima/sm'; @@ -25,7 +24,7 @@ import getCdePoolData from '../../cde/cardanoPool.js'; import getCdeProjectedNFTData from '../../cde/cardanoProjectedNFT.js'; import { query } from '@dcspark/carp-client/client/src/index'; import { Routes } from '@dcspark/carp-client/shared/routes'; -import { FUNNEL_PRESYNC_FINISHED } from '@paima/utils/src/constants'; +import { FUNNEL_PRESYNC_FINISHED, InternalEventType } from '@paima/utils/src/constants'; import { CarpFunnelCacheEntry } from '../FunnelCache.js'; const delayForWaitingForFinalityLoop = 1000; @@ -45,6 +44,33 @@ function knownShelleyTime(): { timestamp: number; absoluteSlot: number } { } } +function shelleyEra(): { firstSlot: number; startEpoch: number; slotDuration: number } { + switch (ENV.CARDANO_NETWORK) { + case 'preview': + return { firstSlot: 0, startEpoch: 0, slotDuration: 86400 }; + case 'preprod': + return { firstSlot: 86400, startEpoch: 4, slotDuration: 432000 }; + case 'mainnet': + return { firstSlot: 4492800, startEpoch: 208, slotDuration: 432000 }; + default: + throw new Error('unknown cardano network'); + } +} + +export function absoluteSlotToEpoch(slot: number): number { + const era = shelleyEra(); + const slotRelativeToEra = slot - era.firstSlot; + + if (slotRelativeToEra >= 0) { + return era.startEpoch + Math.floor(slotRelativeToEra / era.slotDuration); + } else { + // this shouldn't really happen in practice, unless for some reason the + // indexed EVM blocks are older than the start of the shelley era (which + // does not apply to the presync). + throw new Error('slot number is not in the current era'); + } +} + /* Maps an EVM timestamp to a unique absolute slot in Cardano. @@ -131,6 +157,19 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { this.bufferedData = null; + for (const data of composed) { + // TODO: this can be optimized, since if the epoch doesn't change we don't actually need to do anything + if (!data.internalEvents) { + data.internalEvents = [] as InternalEvent[]; + data.internalEvents?.push({ + type: InternalEventType.CardanoBestEpoch, + epoch: absoluteSlotToEpoch( + timestampToAbsoluteSlot(data.timestamp, this.confirmationDepth) + ), + }); + } + } + return composed; } diff --git a/packages/engine/paima-sm/src/cde-cardano-pool.ts b/packages/engine/paima-sm/src/cde-cardano-pool.ts index 4edbd7465..f3d66774c 100644 --- a/packages/engine/paima-sm/src/cde-cardano-pool.ts +++ b/packages/engine/paima-sm/src/cde-cardano-pool.ts @@ -1,5 +1,5 @@ import { ENV } from '@paima/utils'; -import { createScheduledData, cdeCardanoPoolInsertData } from '@paima/db'; +import { createScheduledData, cdeCardanoPoolInsertData, removeOldEntries } from '@paima/db'; import type { SQLUpdate } from '@paima/db'; import type { CdeCardanoPoolDatum } from './types.js'; @@ -16,7 +16,18 @@ export default async function processDatum(cdeDatum: CdeCardanoPoolDatum): Promi createScheduledData(scheduledInputData, scheduledBlockHeight), [ cdeCardanoPoolInsertData, - { cde_id: cdeId, address: cdeDatum.payload.address, pool: cdeDatum.payload.pool }, + { + cde_id: cdeId, + address: cdeDatum.payload.address, + pool: cdeDatum.payload.pool, + epoch: cdeDatum.payload.epoch, + }, + ], + [ + removeOldEntries, + { + address: cdeDatum.payload.address, + }, ], ]; return updateList; diff --git a/packages/engine/paima-sm/src/index.ts b/packages/engine/paima-sm/src/index.ts index 3f3681291..2950bce61 100644 --- a/packages/engine/paima-sm/src/index.ts +++ b/packages/engine/paima-sm/src/index.ts @@ -5,6 +5,7 @@ import { ChainDataExtensionDatumType, doLog, ENV, + InternalEventType, Network, SCHEDULED_DATA_ADDRESS, } from '@paima/utils'; @@ -24,6 +25,7 @@ import { getLatestProcessedCdeBlockheight, getCardanoLatestProcessedCdeSlot, markCardanoCdeSlotProcessed, + updateCardanoEpoch, } from '@paima/db'; import Prando from '@paima/prando'; @@ -36,6 +38,7 @@ import type { ChainDataExtensionDatum, GameStateTransitionFunction, GameStateMachineInitializer, + InternalEvent, } from './types.js'; export * from './types.js'; export type * from './types.js'; @@ -168,6 +171,8 @@ const SM: GameStateMachineInitializer = { dbTx ); + await processInternalEvents(latestChainData.internalEvents, dbTx); + const checkpointName = `game_sm_start`; await dbTx.query(`SAVEPOINT ${checkpointName}`); try { @@ -359,4 +364,21 @@ async function processUserInputs( return latestChainData.submittedData.length; } +async function processInternalEvents( + events: InternalEvent[] | undefined, + dbTx: PoolClient +): Promise { + if (!events) { + return; + } + + for (const event of events) { + switch (event.type) { + case InternalEventType.CardanoBestEpoch: + await updateCardanoEpoch.run({ epoch: event.epoch }, dbTx); + break; + } + } +} + export default SM; diff --git a/packages/engine/paima-sm/src/types.ts b/packages/engine/paima-sm/src/types.ts index 5a90de939..77dc6cc03 100644 --- a/packages/engine/paima-sm/src/types.ts +++ b/packages/engine/paima-sm/src/types.ts @@ -13,6 +13,7 @@ import type { OldERC6551RegistryContract, ERC6551RegistryContract, Network, + InternalEventType, } from '@paima/utils'; import { Type } from '@sinclair/typebox'; import type { Static } from '@sinclair/typebox'; @@ -25,8 +26,12 @@ export interface ChainData { blockNumber: number; submittedData: SubmittedData[]; extensionDatums?: ChainDataExtensionDatum[]; + internalEvents?: InternalEvent[]; } +export type InternalEvent = CardanoEpochEvent; +export type CardanoEpochEvent = { type: InternalEventType.CardanoBestEpoch; epoch: number }; + export interface PresyncChainData { network: Network; blockNumber: number; @@ -69,6 +74,7 @@ interface CdeDatumErc6551RegistryPayload { interface CdeDatumCardanoPoolPayload { address: string; pool: string | undefined; + epoch: number; } interface CdeDatumCardanoProjectedNFTPayload { diff --git a/packages/node-sdk/paima-db/migrations/up.sql b/packages/node-sdk/paima-db/migrations/up.sql index 37a16edc9..5e4ea257e 100644 --- a/packages/node-sdk/paima-db/migrations/up.sql +++ b/packages/node-sdk/paima-db/migrations/up.sql @@ -89,9 +89,15 @@ CREATE TABLE emulated_block_heights ( CREATE TABLE cde_cardano_pool_delegation ( cde_id INTEGER NOT NULL, + epoch INTEGER NOT NULL, address TEXT NOT NULL, pool TEXT, - PRIMARY KEY (cde_id, address) + PRIMARY KEY (cde_id, epoch, address) +); + +CREATE TABLE cardano_last_epoch ( + id INTEGER PRIMARY KEY, + epoch INTEGER NOT NULL ); CREATE TABLE cde_cardano_projected_nft ( diff --git a/packages/node-sdk/paima-db/src/index.ts b/packages/node-sdk/paima-db/src/index.ts index 590c9f1bc..197a62f3f 100644 --- a/packages/node-sdk/paima-db/src/index.ts +++ b/packages/node-sdk/paima-db/src/index.ts @@ -35,6 +35,8 @@ export * from './sql/cde-cardano-projected-nft.queries.js'; export type * from './sql/cde-cardano-projected-nft.queries.js'; export * from './sql/cde-tracking-cardano.queries.js'; export type * from './sql/cde-tracking-cardano.queries.js'; +export * from './sql/cardano-last-epoch.queries.js'; +export type * from './sql/cardano-last-epoch.queries.js'; export { tx, diff --git a/packages/node-sdk/paima-db/src/paima-tables.ts b/packages/node-sdk/paima-db/src/paima-tables.ts index e6779616d..c2ab6dc1d 100644 --- a/packages/node-sdk/paima-db/src/paima-tables.ts +++ b/packages/node-sdk/paima-db/src/paima-tables.ts @@ -259,17 +259,19 @@ const TABLE_DATA_CDE_ERC6551_REGISTRY: TableData = { const QUERY_CREATE_TABLE_CDE_CARDANO_POOL = ` CREATE TABLE cde_cardano_pool_delegation ( cde_id INTEGER NOT NULL, + epoch INTEGER NOT NULL, address TEXT NOT NULL, pool TEXT, - PRIMARY KEY (cde_id, address) + PRIMARY KEY (cde_id, epoch, address) ); `; const TABLE_DATA_CDE_CARDANO_POOL: TableData = { tableName: 'cde_cardano_pool_delegation', - primaryKeyColumns: ['cde_id', 'address'], + primaryKeyColumns: ['cde_id', 'epoch', 'address'], columnData: packTuples([ ['cde_id', 'integer', 'NO', ''], + ['epoch', 'integer', 'NO', ''], ['address', 'text', 'NO', ''], ['pool', 'text', 'YES', ''], ]), @@ -318,6 +320,24 @@ const TABLE_DATA_CDE_CARDANO_PROJECTED_NFT: TableData = { creationQuery: QUERY_CREATE_TABLE_CDE_CARDANO_PROJECTED_NFT, }; +const QUERY_CREATE_TABLE_CARDANO_LAST_EPOCH = ` +CREATE TABLE cardano_last_epoch ( + id INTEGER PRIMARY KEY, + epoch INTEGER NOT NULL +); +`; + +const TABLE_DATA_CARDANO_LAST_EPOCH: TableData = { + tableName: 'cardano_last_epoch', + primaryKeyColumns: ['id'], + columnData: packTuples([ + ['id', 'integer', 'NO', ''], + ['epoch', 'integer', 'NO', ''], + ]), + serialColumns: [], + creationQuery: QUERY_CREATE_TABLE_CARDANO_LAST_EPOCH, +}; + const QUERY_CREATE_TABLE_EMULATED = ` CREATE TABLE emulated_block_heights ( deployment_chain_block_height INTEGER PRIMARY KEY, @@ -354,4 +374,5 @@ export const TABLES: TableData[] = [ TABLE_DATA_CDE_CARDANO_POOL, TABLE_DATA_CDE_CARDANO_PROJECTED_NFT, TABLE_DATA_EMULATED, + TABLE_DATA_CARDANO_LAST_EPOCH, ]; diff --git a/packages/node-sdk/paima-db/src/sql/cardano-last-epoch.queries.ts b/packages/node-sdk/paima-db/src/sql/cardano-last-epoch.queries.ts new file mode 100644 index 000000000..f40af9ad7 --- /dev/null +++ b/packages/node-sdk/paima-db/src/sql/cardano-last-epoch.queries.ts @@ -0,0 +1,61 @@ +/** Types generated for queries found in "src/sql/cardano-last-epoch.sql" */ +import { PreparedQuery } from '@pgtyped/runtime'; + +/** 'UpdateCardanoEpoch' parameters type */ +export interface IUpdateCardanoEpochParams { + epoch: number; +} + +/** 'UpdateCardanoEpoch' return type */ +export type IUpdateCardanoEpochResult = void; + +/** 'UpdateCardanoEpoch' query type */ +export interface IUpdateCardanoEpochQuery { + params: IUpdateCardanoEpochParams; + result: IUpdateCardanoEpochResult; +} + +const updateCardanoEpochIR: any = {"usedParamSet":{"epoch":true},"params":[{"name":"epoch","required":true,"transform":{"type":"scalar"},"locs":[{"a":72,"b":78},{"a":122,"b":128}]}],"statement":"INSERT INTO cardano_last_epoch(\n id,\n epoch\n) VALUES (\n 0,\n :epoch!\n) \nON CONFLICT (id) DO\nUPDATE SET epoch = :epoch!"}; + +/** + * Query generated from SQL: + * ``` + * INSERT INTO cardano_last_epoch( + * id, + * epoch + * ) VALUES ( + * 0, + * :epoch! + * ) + * ON CONFLICT (id) DO + * UPDATE SET epoch = :epoch! + * ``` + */ +export const updateCardanoEpoch = new PreparedQuery(updateCardanoEpochIR); + + +/** 'GetCardanoEpoch' parameters type */ +export type IGetCardanoEpochParams = void; + +/** 'GetCardanoEpoch' return type */ +export interface IGetCardanoEpochResult { + epoch: number; +} + +/** 'GetCardanoEpoch' query type */ +export interface IGetCardanoEpochQuery { + params: IGetCardanoEpochParams; + result: IGetCardanoEpochResult; +} + +const getCardanoEpochIR: any = {"usedParamSet":{},"params":[],"statement":"SELECT epoch from cardano_last_epoch LIMIT 1"}; + +/** + * Query generated from SQL: + * ``` + * SELECT epoch from cardano_last_epoch LIMIT 1 + * ``` + */ +export const getCardanoEpoch = new PreparedQuery(getCardanoEpochIR); + + diff --git a/packages/node-sdk/paima-db/src/sql/cardano-last-epoch.sql b/packages/node-sdk/paima-db/src/sql/cardano-last-epoch.sql new file mode 100644 index 000000000..cde0df923 --- /dev/null +++ b/packages/node-sdk/paima-db/src/sql/cardano-last-epoch.sql @@ -0,0 +1,13 @@ +/* @name updateCardanoEpoch */ +INSERT INTO cardano_last_epoch( + id, + epoch +) VALUES ( + 0, + :epoch! +) +ON CONFLICT (id) DO +UPDATE SET epoch = :epoch!; + +/* @name getCardanoEpoch */ +SELECT epoch from cardano_last_epoch LIMIT 1; \ No newline at end of file diff --git a/packages/node-sdk/paima-db/src/sql/cde-cardano-pool-delegation.queries.ts b/packages/node-sdk/paima-db/src/sql/cde-cardano-pool-delegation.queries.ts index aca7374af..f921be0c1 100644 --- a/packages/node-sdk/paima-db/src/sql/cde-cardano-pool-delegation.queries.ts +++ b/packages/node-sdk/paima-db/src/sql/cde-cardano-pool-delegation.queries.ts @@ -10,6 +10,7 @@ export interface ICdeCardanoPoolGetAddressDelegationParams { export interface ICdeCardanoPoolGetAddressDelegationResult { address: string; cde_id: number; + epoch: number; pool: string | null; } @@ -19,13 +20,14 @@ export interface ICdeCardanoPoolGetAddressDelegationQuery { result: ICdeCardanoPoolGetAddressDelegationResult; } -const cdeCardanoPoolGetAddressDelegationIR: any = {"usedParamSet":{"address":true},"params":[{"name":"address","required":true,"transform":{"type":"scalar"},"locs":[{"a":59,"b":67}]}],"statement":"SELECT * FROM cde_cardano_pool_delegation \nWHERE address = :address!"}; +const cdeCardanoPoolGetAddressDelegationIR: any = {"usedParamSet":{"address":true},"params":[{"name":"address","required":true,"transform":{"type":"scalar"},"locs":[{"a":59,"b":67}]}],"statement":"SELECT * FROM cde_cardano_pool_delegation \nWHERE address = :address!\nORDER BY epoch"}; /** * Query generated from SQL: * ``` * SELECT * FROM cde_cardano_pool_delegation * WHERE address = :address! + * ORDER BY epoch * ``` */ export const cdeCardanoPoolGetAddressDelegation = new PreparedQuery(cdeCardanoPoolGetAddressDelegationIR); @@ -35,6 +37,7 @@ export const cdeCardanoPoolGetAddressDelegation = new PreparedQuery(cdeCardanoPoolInsertDataIR); +/** 'RemoveOldEntries' parameters type */ +export interface IRemoveOldEntriesParams { + address: string; +} + +/** 'RemoveOldEntries' return type */ +export type IRemoveOldEntriesResult = void; + +/** 'RemoveOldEntries' query type */ +export interface IRemoveOldEntriesQuery { + params: IRemoveOldEntriesParams; + result: IRemoveOldEntriesResult; +} + +const removeOldEntriesIR: any = {"usedParamSet":{"address":true},"params":[{"name":"address","required":true,"transform":{"type":"scalar"},"locs":[{"a":179,"b":187},{"a":238,"b":246}]}],"statement":"DELETE FROM cde_cardano_pool_delegation\nWHERE (cde_id, epoch, address) NOT IN (\n SELECT\n cde_id, epoch, address\n FROM cde_cardano_pool_delegation\n WHERE address = :address!\n ORDER BY epoch DESC\n\tLIMIT 2\n)\nAND address = :address!"}; + +/** + * Query generated from SQL: + * ``` + * DELETE FROM cde_cardano_pool_delegation + * WHERE (cde_id, epoch, address) NOT IN ( + * SELECT + * cde_id, epoch, address + * FROM cde_cardano_pool_delegation + * WHERE address = :address! + * ORDER BY epoch DESC + * LIMIT 2 + * ) + * AND address = :address! + * ``` + */ +export const removeOldEntries = new PreparedQuery(removeOldEntriesIR); + + diff --git a/packages/node-sdk/paima-db/src/sql/cde-cardano-pool-delegation.sql b/packages/node-sdk/paima-db/src/sql/cde-cardano-pool-delegation.sql index ff1fba4b5..3481df76b 100644 --- a/packages/node-sdk/paima-db/src/sql/cde-cardano-pool-delegation.sql +++ b/packages/node-sdk/paima-db/src/sql/cde-cardano-pool-delegation.sql @@ -1,15 +1,31 @@ /* @name cdeCardanoPoolGetAddressDelegation */ SELECT * FROM cde_cardano_pool_delegation -WHERE address = :address!; +WHERE address = :address! +ORDER BY epoch; /* @name cdeCardanoPoolInsertData */ INSERT INTO cde_cardano_pool_delegation( cde_id, address, - pool + pool, + epoch ) VALUES ( :cde_id!, :address!, - :pool! -) ON CONFLICT (cde_id, address) DO - UPDATE SET pool = :pool!; \ No newline at end of file + :pool!, + :epoch! +) ON CONFLICT (cde_id, epoch, address) DO + UPDATE SET pool = :pool!; + + +/* @name removeOldEntries */ +DELETE FROM cde_cardano_pool_delegation +WHERE (cde_id, epoch, address) NOT IN ( + SELECT + cde_id, epoch, address + FROM cde_cardano_pool_delegation + WHERE address = :address! + ORDER BY epoch DESC + LIMIT 2 +) +AND address = :address!; \ No newline at end of file diff --git a/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts b/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts index d956559b6..7e3eaf315 100644 --- a/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts +++ b/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts @@ -15,6 +15,7 @@ import { cdeCardanoPoolGetAddressDelegation, cdeCardanoGetProjectedNft, type ICdeCardanoGetProjectedNftResult, + getCardanoEpoch, } from '@paima/db'; import type { OwnedNftsResponse, GenericCdeDataUnit, TokenIdPair } from './types.js'; @@ -184,13 +185,24 @@ export async function internalGetAllOwnedErc6551Accounts( export async function internalGetCardanoAddressDelegation( readonlyDBConn: Pool, address: string -): Promise { +): Promise<{ events: { pool: string | null; epoch: number }[]; currentEpoch: number } | null> { const results = await cdeCardanoPoolGetAddressDelegation.run({ address }, readonlyDBConn); if (results.length === 0) { return null; } - return results[0].pool; + const currentEpoch = await getCardanoEpoch.run(undefined, readonlyDBConn); + + if (currentEpoch.length === 0) { + throw new Error('Current epoch table not initialized'); + } + + return { + currentEpoch: currentEpoch[0].epoch, + events: results.map(r => { + return { pool: r.pool, epoch: r.epoch }; + }), + }; } export async function internalGetCardanoProjectedNft( diff --git a/packages/node-sdk/paima-utils-backend/src/cde-access.ts b/packages/node-sdk/paima-utils-backend/src/cde-access.ts index 400a68f64..72576089d 100644 --- a/packages/node-sdk/paima-utils-backend/src/cde-access.ts +++ b/packages/node-sdk/paima-utils-backend/src/cde-access.ts @@ -166,7 +166,7 @@ export async function getAllOwnedErc6551Accounts( export async function getCardanoAddressDelegation( readonlyDBConn: Pool, address: string -): Promise { +): Promise<{ events: { pool: string | null; epoch: number }[]; currentEpoch: number } | null> { return await internalGetCardanoAddressDelegation(readonlyDBConn, address); } diff --git a/packages/paima-sdk/paima-utils/src/constants.ts b/packages/paima-sdk/paima-utils/src/constants.ts index 4b99e272e..b57e412ba 100644 --- a/packages/paima-sdk/paima-utils/src/constants.ts +++ b/packages/paima-sdk/paima-utils/src/constants.ts @@ -39,3 +39,7 @@ export const enum Network { } export const FUNNEL_PRESYNC_FINISHED = 'finished'; + +export const enum InternalEventType { + CardanoBestEpoch, +} From cdf3f33d0adce32b35bb473f28ae7585fe730a90 Mon Sep 17 00:00:00 2001 From: Enzo Cioppettini Date: Tue, 12 Dec 2023 00:08:51 -0300 Subject: [PATCH 2/6] skip redundant epoch change events --- .../paima-funnel/src/funnels/carp/funnel.ts | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/engine/paima-funnel/src/funnels/carp/funnel.ts b/packages/engine/paima-funnel/src/funnels/carp/funnel.ts index 9ff904d85..22908e8f0 100644 --- a/packages/engine/paima-funnel/src/funnels/carp/funnel.ts +++ b/packages/engine/paima-funnel/src/funnels/carp/funnel.ts @@ -44,14 +44,14 @@ function knownShelleyTime(): { timestamp: number; absoluteSlot: number } { } } -function shelleyEra(): { firstSlot: number; startEpoch: number; slotDuration: number } { +function shelleyEra(): { firstSlot: number; startEpoch: number; slotsPerEpoch: number } { switch (ENV.CARDANO_NETWORK) { case 'preview': - return { firstSlot: 0, startEpoch: 0, slotDuration: 86400 }; + return { firstSlot: 0, startEpoch: 0, slotsPerEpoch: 86400 }; case 'preprod': - return { firstSlot: 86400, startEpoch: 4, slotDuration: 432000 }; + return { firstSlot: 86400, startEpoch: 4, slotsPerEpoch: 432000 }; case 'mainnet': - return { firstSlot: 4492800, startEpoch: 208, slotDuration: 432000 }; + return { firstSlot: 4492800, startEpoch: 208, slotsPerEpoch: 432000 }; default: throw new Error('unknown cardano network'); } @@ -62,7 +62,7 @@ export function absoluteSlotToEpoch(slot: number): number { const slotRelativeToEra = slot - era.firstSlot; if (slotRelativeToEra >= 0) { - return era.startEpoch + Math.floor(slotRelativeToEra / era.slotDuration); + return era.startEpoch + Math.floor(slotRelativeToEra / era.slotsPerEpoch); } else { // this shouldn't really happen in practice, unless for some reason the // indexed EVM blocks are older than the start of the shelley era (which @@ -157,16 +157,24 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { this.bufferedData = null; + let prevEpoch; + for (const data of composed) { - // TODO: this can be optimized, since if the epoch doesn't change we don't actually need to do anything if (!data.internalEvents) { data.internalEvents = [] as InternalEvent[]; - data.internalEvents?.push({ - type: InternalEventType.CardanoBestEpoch, - epoch: absoluteSlotToEpoch( - timestampToAbsoluteSlot(data.timestamp, this.confirmationDepth) - ), - }); + + const epoch = absoluteSlotToEpoch( + timestampToAbsoluteSlot(data.timestamp, this.confirmationDepth) + ); + + if (!prevEpoch || epoch !== prevEpoch) { + data.internalEvents?.push({ + type: InternalEventType.CardanoBestEpoch, + epoch: epoch, + }); + } + + prevEpoch = epoch; } } From a53cb256809063308f0be5057d4923bde2b83d77 Mon Sep 17 00:00:00 2001 From: Enzo Cioppettini Date: Tue, 12 Dec 2023 00:09:36 -0300 Subject: [PATCH 3/6] return two delegation entries only when current is current epoch --- .../src/cde-access-internals.ts | 20 +++++++++++++------ .../paima-utils-backend/src/cde-access.ts | 5 +++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts b/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts index 7e3eaf315..b45a4b4af 100644 --- a/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts +++ b/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts @@ -197,12 +197,20 @@ export async function internalGetCardanoAddressDelegation( throw new Error('Current epoch table not initialized'); } - return { - currentEpoch: currentEpoch[0].epoch, - events: results.map(r => { - return { pool: r.pool, epoch: r.epoch }; - }), - }; + if (currentEpoch[0].epoch === results[results.length - 1].epoch) { + return { + currentEpoch: currentEpoch[0].epoch, + events: results.map(r => { + return { pool: r.pool, epoch: r.epoch }; + }), + }; + } else { + const result = results[results.length - 1]; + return { + currentEpoch: currentEpoch[0].epoch, + events: [{ pool: result.pool, epoch: result.epoch }], + }; + } } export async function internalGetCardanoProjectedNft( diff --git a/packages/node-sdk/paima-utils-backend/src/cde-access.ts b/packages/node-sdk/paima-utils-backend/src/cde-access.ts index 72576089d..1484b0efe 100644 --- a/packages/node-sdk/paima-utils-backend/src/cde-access.ts +++ b/packages/node-sdk/paima-utils-backend/src/cde-access.ts @@ -162,6 +162,11 @@ export async function getAllOwnedErc6551Accounts( /** * Fetch the pool this address is delegating to, if any. + * + * If the last delegation indexed for this address happened during the current + * epoch, this returns both the current delegation and the previous entry. + * + * Otherwise, this will just return a single entry. */ export async function getCardanoAddressDelegation( readonlyDBConn: Pool, From be845e4126e4f061d0da2f9d9f890e472f83c0c4 Mon Sep 17 00:00:00 2001 From: Enzo Cioppettini Date: Tue, 12 Dec 2023 12:25:58 -0300 Subject: [PATCH 4/6] carp funnel: add last epoch to the cache --- .../paima-funnel/src/funnels/FunnelCache.ts | 9 ++++++++- .../paima-funnel/src/funnels/carp/funnel.ts | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/engine/paima-funnel/src/funnels/FunnelCache.ts b/packages/engine/paima-funnel/src/funnels/FunnelCache.ts index 1c85b8f5d..7d41e5cc3 100644 --- a/packages/engine/paima-funnel/src/funnels/FunnelCache.ts +++ b/packages/engine/paima-funnel/src/funnels/FunnelCache.ts @@ -76,6 +76,7 @@ export class RpcCacheEntry implements FunnelCacheEntry { export type CarpFunnelCacheEntryState = { startingSlot: number; lastPoint: { blockHeight: number; timestamp: number } | undefined; + epoch: number | undefined; }; export class CarpFunnelCacheEntry implements FunnelCacheEntry { @@ -83,7 +84,7 @@ export class CarpFunnelCacheEntry implements FunnelCacheEntry { public static readonly SYMBOL = Symbol('CarpFunnelStartingSlot'); public updateStartingSlot(startingSlot: number): void { - this.state = { startingSlot, lastPoint: this.state?.lastPoint }; + this.state = { startingSlot, lastPoint: this.state?.lastPoint, epoch: this.state?.epoch }; } public updateLastPoint(blockHeight: number, timestamp: number): void { @@ -92,6 +93,12 @@ export class CarpFunnelCacheEntry implements FunnelCacheEntry { } } + public updateEpoch(epoch: number): void { + if (this.state) { + this.state.epoch = epoch; + } + } + public initialized(): boolean { return !!this.state; } diff --git a/packages/engine/paima-funnel/src/funnels/carp/funnel.ts b/packages/engine/paima-funnel/src/funnels/carp/funnel.ts index 22908e8f0..b917a02b4 100644 --- a/packages/engine/paima-funnel/src/funnels/carp/funnel.ts +++ b/packages/engine/paima-funnel/src/funnels/carp/funnel.ts @@ -26,6 +26,7 @@ import { query } from '@dcspark/carp-client/client/src/index'; import { Routes } from '@dcspark/carp-client/shared/routes'; import { FUNNEL_PRESYNC_FINISHED, InternalEventType } from '@paima/utils/src/constants'; import { CarpFunnelCacheEntry } from '../FunnelCache.js'; +import { getCardanoEpoch } from '@paima/db'; const delayForWaitingForFinalityLoop = 1000; @@ -155,10 +156,6 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { const composed = composeChainData(this.bufferedData, grouped); - this.bufferedData = null; - - let prevEpoch; - for (const data of composed) { if (!data.internalEvents) { data.internalEvents = [] as InternalEvent[]; @@ -167,6 +164,8 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { timestampToAbsoluteSlot(data.timestamp, this.confirmationDepth) ); + const prevEpoch = this.cache.getState().epoch; + if (!prevEpoch || epoch !== prevEpoch) { data.internalEvents?.push({ type: InternalEventType.CardanoBestEpoch, @@ -174,10 +173,12 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { }); } - prevEpoch = epoch; + this.cache.updateEpoch(epoch); } } + this.bufferedData = null; + return composed; } @@ -272,6 +273,12 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { ) ); + const epoch = await getCardanoEpoch.run(undefined, dbTx); + + if (epoch.length === 1) { + newEntry.updateEpoch(epoch[0].epoch); + } + return newEntry; })(); From ecff3f65bc565245a178ebb419c88ab492272bd3 Mon Sep 17 00:00:00 2001 From: Enzo Cioppettini Date: Tue, 12 Dec 2023 16:02:29 -0300 Subject: [PATCH 5/6] refactor the time computation config --- .../paima-funnel/src/cde/cardanoPool.ts | 4 +- .../paima-funnel/src/funnels/carp/funnel.ts | 78 +++++++++++-------- 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/packages/engine/paima-funnel/src/cde/cardanoPool.ts b/packages/engine/paima-funnel/src/cde/cardanoPool.ts index 36f476783..0197dffa4 100644 --- a/packages/engine/paima-funnel/src/cde/cardanoPool.ts +++ b/packages/engine/paima-funnel/src/cde/cardanoPool.ts @@ -6,14 +6,14 @@ import type { import { ChainDataExtensionDatumType, DEFAULT_FUNNEL_TIMEOUT, timeout } from '@paima/utils'; import { Routes, query } from '@dcspark/carp-client/client/src'; import type { DelegationForPoolResponse } from '@dcspark/carp-client/shared/models/DelegationForPool'; -import { absoluteSlotToEpoch } from '../funnels/carp/funnel.js'; export default async function getCdeData( url: string, extension: ChainDataExtensionCardanoDelegation, fromAbsoluteSlot: number, toAbsoluteSlot: number, - getBlockNumber: (slot: number) => number + getBlockNumber: (slot: number) => number, + absoluteSlotToEpoch: (slot: number) => number ): Promise { const events = await timeout( query(url, Routes.delegationForPool, { diff --git a/packages/engine/paima-funnel/src/funnels/carp/funnel.ts b/packages/engine/paima-funnel/src/funnels/carp/funnel.ts index b917a02b4..39cb03508 100644 --- a/packages/engine/paima-funnel/src/funnels/carp/funnel.ts +++ b/packages/engine/paima-funnel/src/funnels/carp/funnel.ts @@ -30,36 +30,42 @@ import { getCardanoEpoch } from '@paima/db'; const delayForWaitingForFinalityLoop = 1000; -// This returns the unix timestamp of the first block in the Shelley era of the -// configured network, and the slot of the corresponding block. -function knownShelleyTime(): { timestamp: number; absoluteSlot: number } { +type Era = { + firstSlot: number; + startEpoch: number; + slotsPerEpoch: number; + timestamp: number; +}; + +function shelleyEra(): Era { switch (ENV.CARDANO_NETWORK) { case 'preview': - return { timestamp: 1666656000, absoluteSlot: 0 }; + return { + firstSlot: 0, + startEpoch: 0, + slotsPerEpoch: 86400, + timestamp: 1666656000, + }; case 'preprod': - return { timestamp: 1655769600, absoluteSlot: 86400 }; + return { + firstSlot: 86400, + startEpoch: 4, + slotsPerEpoch: 432000, + timestamp: 1655769600, + }; case 'mainnet': - return { timestamp: 1596059091, absoluteSlot: 4492800 }; + return { + firstSlot: 4492800, + startEpoch: 208, + slotsPerEpoch: 432000, + timestamp: 1596059091, + }; default: throw new Error('unknown cardano network'); } } -function shelleyEra(): { firstSlot: number; startEpoch: number; slotsPerEpoch: number } { - switch (ENV.CARDANO_NETWORK) { - case 'preview': - return { firstSlot: 0, startEpoch: 0, slotsPerEpoch: 86400 }; - case 'preprod': - return { firstSlot: 86400, startEpoch: 4, slotsPerEpoch: 432000 }; - case 'mainnet': - return { firstSlot: 4492800, startEpoch: 208, slotsPerEpoch: 432000 }; - default: - throw new Error('unknown cardano network'); - } -} - -export function absoluteSlotToEpoch(slot: number): number { - const era = shelleyEra(); +function absoluteSlotToEpoch(era: Era, slot: number): number { const slotRelativeToEra = slot - era.firstSlot; if (slotRelativeToEra >= 0) { @@ -87,14 +93,12 @@ Note: The state pairing only matters after the presync stage is done, so as long as the timestamp of the block specified in START_BLOCKHEIGHT happens after the first Shelley block, we don't need to consider the previous Cardano era (if any). */ -function timestampToAbsoluteSlot(timestamp: number, confirmationDepth: number): number { +function timestampToAbsoluteSlot(era: Era, timestamp: number, confirmationDepth: number): number { const cardanoAvgBlockPeriod = 20; // map timestamps with a delta, since we are waiting for blocks. const confirmationTimeDelta = cardanoAvgBlockPeriod * confirmationDepth; - const era = knownShelleyTime(); - - return timestamp - confirmationTimeDelta - era.timestamp + era.absoluteSlot; + return timestamp - confirmationTimeDelta - era.timestamp + era.firstSlot; } export class CarpFunnel extends BaseFunnel implements ChainFunnel { @@ -112,9 +116,11 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { this.readPresyncData.bind(this); this.getDbTx.bind(this); this.bufferedData = null; + this.era = shelleyEra(); } private bufferedData: ChainData[] | null; + private era: Era; public override async readData(blockHeight: number): Promise { if (!this.bufferedData || this.bufferedData[0].blockNumber != blockHeight) { @@ -151,7 +157,8 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { this.sharedData.extensions, lastTimestamp, this.cache, - this.confirmationDepth + this.confirmationDepth, + this.era ); const composed = composeChainData(this.bufferedData, grouped); @@ -161,7 +168,8 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { data.internalEvents = [] as InternalEvent[]; const epoch = absoluteSlotToEpoch( - timestampToAbsoluteSlot(data.timestamp, this.confirmationDepth) + this.era, + timestampToAbsoluteSlot(this.era, data.timestamp, this.confirmationDepth) ); const prevEpoch = this.cache.getState().epoch; @@ -207,7 +215,8 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { Math.min(arg.to, this.cache.getState().startingSlot - 1), slot => { return slot; - } + }, + slot => absoluteSlotToEpoch(this.era, slot) ); return data; } else { @@ -268,6 +277,7 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { newEntry.updateStartingSlot( timestampToAbsoluteSlot( + shelleyEra(), (await sharedData.web3.eth.getBlock(startingBlockHeight)).timestamp as number, confirmationDepth ) @@ -299,14 +309,15 @@ async function readDataInternal( extensions: ChainDataExtension[], lastTimestamp: number, cache: CarpFunnelCacheEntry, - confirmationDepth: number + confirmationDepth: number, + era: Era ): Promise { // the lower range is exclusive - const min = timestampToAbsoluteSlot(lastTimestamp, confirmationDepth); + const min = timestampToAbsoluteSlot(era, lastTimestamp, confirmationDepth); // the upper range is inclusive const maxElement = data[data.length - 1]; - const max = timestampToAbsoluteSlot(maxElement.timestamp, confirmationDepth); + const max = timestampToAbsoluteSlot(era, maxElement.timestamp, confirmationDepth); cache.updateLastPoint(maxElement.blockNumber, maxElement.timestamp); @@ -331,7 +342,7 @@ async function readDataInternal( const blockNumbers = data.reduce( (dict, data) => { - dict[timestampToAbsoluteSlot(data.timestamp, confirmationDepth)] = data.blockNumber; + dict[timestampToAbsoluteSlot(era, data.timestamp, confirmationDepth)] = data.blockNumber; return dict; }, {} as { [slot: number]: number } @@ -362,7 +373,8 @@ async function readDataInternal( extension, min, Math.min(max, extension.stopSlot || max), - mapSlotToBlockNumber + mapSlotToBlockNumber, + slot => absoluteSlotToEpoch(era, slot) ); return poolData; From 0c4df9cf8ac5d942bcd48a1fe77a813a26469162 Mon Sep 17 00:00:00 2001 From: Enzo Cioppettini Date: Fri, 22 Dec 2023 14:28:33 -0300 Subject: [PATCH 6/6] address review comments --- packages/engine/paima-funnel/src/funnels/carp/funnel.ts | 9 ++++++--- .../paima-utils-backend/src/cde-access-internals.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/engine/paima-funnel/src/funnels/carp/funnel.ts b/packages/engine/paima-funnel/src/funnels/carp/funnel.ts index 39cb03508..c3cfee533 100644 --- a/packages/engine/paima-funnel/src/funnels/carp/funnel.ts +++ b/packages/engine/paima-funnel/src/funnels/carp/funnel.ts @@ -175,13 +175,16 @@ export class CarpFunnel extends BaseFunnel implements ChainFunnel { const prevEpoch = this.cache.getState().epoch; if (!prevEpoch || epoch !== prevEpoch) { - data.internalEvents?.push({ + data.internalEvents.push({ type: InternalEventType.CardanoBestEpoch, epoch: epoch, }); - } - this.cache.updateEpoch(epoch); + // The execution of the event that we just pushed should set the + // `cardano_last_epoch` table to `epoch`. This cache entry mirrors the + // value of that table, so we need to update it here too. + this.cache.updateEpoch(epoch); + } } } diff --git a/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts b/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts index b45a4b4af..fe8b1fde1 100644 --- a/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts +++ b/packages/node-sdk/paima-utils-backend/src/cde-access-internals.ts @@ -182,6 +182,14 @@ export async function internalGetAllOwnedErc6551Accounts( return results.map(row => row.account_created); } +/** + * If the most recent delegation is the current epoch, we need to return the + * list of recent delegations so the app can know what delegation the user had + * beforehand since delegations only matters once they cross an epoch boundary + * + * If the most recent delegation isn't from the current epoch, we know it's the + * one that is active now + */ export async function internalGetCardanoAddressDelegation( readonlyDBConn: Pool, address: string