Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions src/api/routes/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { LimitParam, OffsetParam } from '../schemas/params';
import { InvalidRequestError, InvalidRequestErrorType, NotFoundError } from '../../errors';
import { ClarityAbi } from '@stacks/transactions';
import { SmartContractSchema } from '../schemas/entities/smart-contracts';
import { TransactionEventSchema } from '../schemas/entities/transaction-events';
import { SmartContractLogTransactionEvent } from '../schemas/entities/transaction-events';
import { ContractEventListResponseSchema } from '../schemas/responses/responses';

export const ContractRoutes: FastifyPluginAsync<
Record<never, never>,
Expand Down Expand Up @@ -114,33 +115,50 @@ export const ContractRoutes: FastifyPluginAsync<
querystring: Type.Object({
limit: LimitParam(ResourceType.Contract, 'Limit', 'max number of events to fetch'),
offset: OffsetParam(),
cursor: Type.Optional(
Type.String({
description: 'Cursor for pagination',
})
),
}),
response: {
200: Type.Object(
{
limit: Type.Integer(),
offset: Type.Integer(),
results: Type.Array(TransactionEventSchema),
},
{ description: 'List of events' }
),
200: ContractEventListResponseSchema,
},
},
},
async (req, reply) => {
const { contract_id } = req.params;
const limit = getPagingQueryLimit(ResourceType.Contract, req.query.limit);
const offset = parsePagingQueryInput(req.query.offset ?? 0);
const cursor = req.query.cursor;

// Validate cursor format if provided
if (cursor && !cursor.match(/^\d+-\d+-\d+$/)) {
throw new InvalidRequestError(
'Invalid cursor format. Expected format: blockHeight-txIndex-eventIndex',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a block height, the cursor should use the index block hash so it is resistent to re-orgs. If you look at Matt's approach to cursors in the other endpoints, he uses this approach and also tests with different re-org scenarios.

This will change your queries a bit but you'll just need to transform from the index block hash into the block height before using your current filters.

InvalidRequestErrorType.invalid_param
);
}
const eventsQuery = await fastify.db.getSmartContractEvents({
contractId: contract_id,
limit,
offset,
cursor,
});
if (!eventsQuery.found) {
throw new NotFoundError(`cannot find events for contract by ID}`);
}
const parsedEvents = eventsQuery.result.map(event => parseDbEvent(event));
await reply.send({ limit, offset, results: parsedEvents });
const parsedEvents = eventsQuery.result.map((event: any) => parseDbEvent(event));
const response = {
limit,
offset,
total: eventsQuery.total || 0,
results: parsedEvents as SmartContractLogTransactionEvent[],
next_cursor: eventsQuery.nextCursor || null,
prev_cursor: null, // TODO: Implement prev_cursor as well
cursor: cursor || null,
};
await reply.send(response);
}
);

Expand Down
2 changes: 1 addition & 1 deletion src/api/schemas/entities/transaction-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const AbstractTransactionEventSchema = Type.Object(
);
type AbstractTransactionEvent = Static<typeof AbstractTransactionEventSchema>;

const SmartContractLogTransactionEventSchema = Type.Intersect(
export const SmartContractLogTransactionEventSchema = Type.Intersect(
[
AbstractTransactionEventSchema,
Type.Object({
Expand Down
11 changes: 9 additions & 2 deletions src/api/schemas/responses/responses.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { Static, Type } from '@sinclair/typebox';
import { Nullable, OptionalNullable, PaginatedCursorResponse, PaginatedResponse } from '../util';
import { OptionalNullable, PaginatedCursorResponse, PaginatedResponse } from '../util';
import { MempoolStatsSchema } from '../entities/mempool-transactions';
import { MempoolTransactionSchema, TransactionSchema } from '../entities/transactions';
import { MicroblockSchema } from '../entities/microblock';
import {
AddressTransactionWithTransfersSchema,
InboundStxTransferSchema,
} from '../entities/addresses';
import { TransactionEventSchema } from '../entities/transaction-events';
import {
SmartContractLogTransactionEventSchema,
TransactionEventSchema,
} from '../entities/transaction-events';
import {
BurnchainRewardSchema,
BurnchainRewardSlotHolderSchema,
Expand Down Expand Up @@ -184,5 +187,9 @@ export type RunFaucetResponse = Static<typeof RunFaucetResponseSchema>;
export const BlockListV2ResponseSchema = PaginatedCursorResponse(NakamotoBlockSchema);
export type BlockListV2Response = Static<typeof BlockListV2ResponseSchema>;

export const ContractEventListResponseSchema = PaginatedCursorResponse(
SmartContractLogTransactionEventSchema
);

export const BlockSignerSignatureResponseSchema = PaginatedResponse(SignerSignatureSchema);
export type BlockSignerSignatureResponse = Static<typeof BlockSignerSignatureResponseSchema>;
7 changes: 7 additions & 0 deletions src/datastore/common.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FoundOrNot } from 'src/helpers';
import { Block } from '../api/schemas/entities/block';
import { SyntheticPoxEventName } from '../pox-helpers';
import { PgBytea, PgJsonb, PgNumeric } from '@hirosystems/api-toolkit';
Expand Down Expand Up @@ -552,6 +553,12 @@ export interface DbSmartContractEvent extends DbEventBase {
value: string;
}

export type DbCursorPaginatedFoundOrNot<T> = FoundOrNot<T> & {
nextCursor?: string | null;
prevCursor?: string | null;
total: number;
};

export interface DbStxLockEvent extends DbEventBase {
event_type: DbEventTypeId.StxLock;
locked_amount: bigint;
Expand Down
92 changes: 80 additions & 12 deletions src/datastore/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
DbSearchResult,
DbSmartContract,
DbSmartContractEvent,
DbCursorPaginatedFoundOrNot,
DbStxBalance,
DbStxEvent,
DbStxLockEvent,
Expand Down Expand Up @@ -101,6 +102,28 @@ import { parseBlockParam } from '../api/routes/v2/schemas';

export const MIGRATIONS_DIR = path.join(REPO_DIR, 'migrations');

// Cursor utilities for smart contract events
function createEventCursor(blockHeight: number, txIndex: number, eventIndex: number): string {
return `${blockHeight}-${txIndex}-${eventIndex}`;
}

function parseEventCursor(
cursor: string
): { blockHeight: number; txIndex: number; eventIndex: number } | null {
const parts = cursor.split('-');
if (parts.length !== 3) return null;
const blockHeight = parseInt(parts[0]);
const txIndex = parseInt(parts[1]);
const eventIndex = parseInt(parts[2]);

// Validate that parsing was successful
if (isNaN(blockHeight) || isNaN(txIndex) || isNaN(eventIndex)) {
return null;
}

return { blockHeight, txIndex, eventIndex };
}

/**
* This is the main interface between the API and the Postgres database. It contains all methods that
* query the DB in search for blockchain data to be returned via endpoints or WebSockets/Socket.IO.
Expand Down Expand Up @@ -2089,15 +2112,30 @@ export class PgStore extends BasePgStore {
});
}

async getSmartContractEvents({
contractId,
limit,
offset,
}: {
async getSmartContractEvents(args: {
contractId: string;
limit: number;
offset: number;
}): Promise<FoundOrNot<DbSmartContractEvent[]>> {
offset?: number;
cursor?: string;
}): Promise<DbCursorPaginatedFoundOrNot<DbSmartContractEvent[]>> {
const contractId = args.contractId;
const limit = args.limit;
const offset = args.offset ?? 0;
const cursor = args.cursor ?? null;

// Parse cursor if provided
const parsedCursor = cursor ? parseEventCursor(cursor) : null;

// Get total count first
const totalCountResult = await this.sql<{ count: string }[]>`
SELECT COUNT(*) as count
FROM contract_logs
WHERE contract_identifier = ${contractId}
AND canonical = true
AND microblock_canonical = true
`;
const totalCount = parseInt(totalCountResult[0]?.count || '0');

const logResults = await this.sql<
{
event_index: number;
Expand All @@ -2112,12 +2150,36 @@ export class PgStore extends BasePgStore {
SELECT
event_index, tx_id, tx_index, block_height, contract_identifier, topic, value
FROM contract_logs
WHERE canonical = true AND microblock_canonical = true AND contract_identifier = ${contractId}
WHERE canonical = true
AND microblock_canonical = true
AND contract_identifier = ${contractId}
${
parsedCursor
? this
.sql`AND (block_height, tx_index, event_index) < (${parsedCursor.blockHeight}, ${parsedCursor.txIndex}, ${parsedCursor.eventIndex})`
: this.sql``
}
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
LIMIT ${limit}
OFFSET ${offset}
LIMIT ${limit + 1}
${cursor ? this.sql`` : this.sql`OFFSET ${offset}`}
`;
const result = logResults.map(result => {

// Check if there are more results (for next cursor)
const hasMore = logResults.length > limit;
const results = hasMore ? logResults.slice(0, limit) : logResults;

// Generate next cursor from the last result
const nextCursor =
hasMore && results.length > 0
? createEventCursor(
results[results.length - 1].block_height,
results[results.length - 1].tx_index,
results[results.length - 1].event_index
)
: null;

// Map to DbSmartContractEvent format
const mappedResults = results.map(result => {
const event: DbSmartContractEvent = {
event_index: result.event_index,
tx_id: result.tx_id,
Expand All @@ -2131,7 +2193,13 @@ export class PgStore extends BasePgStore {
};
return event;
});
return { found: true, result };

return {
found: true,
result: mappedResults,
nextCursor,
total: totalCount,
};
}

async getSmartContractByTrait(args: {
Expand Down
Loading