204 lines
6.5 KiB
TypeScript
204 lines
6.5 KiB
TypeScript
import winston from "winston";
|
|
import { RunPollingJob } from "../RunPollingJob";
|
|
import { GetSolanaTransactions } from "./GetSolanaTransactions";
|
|
import { MetadataRepository, SolanaSlotRepository, StatRepository } from "../../repositories";
|
|
import { solana } from "../../entities";
|
|
|
|
export class PollSolanaTransactions extends RunPollingJob {
|
|
private cfg: PollSolanaTransactionsConfig;
|
|
private metadataRepo: MetadataRepository<PollSolanaTransactionsMetadata>;
|
|
private slotRepository: SolanaSlotRepository;
|
|
private statsRepo: StatRepository;
|
|
private getSolanaTransactions: GetSolanaTransactions;
|
|
private latestSlot?: number;
|
|
private slotCursor?: number;
|
|
private lastRange?: Range;
|
|
protected logger: winston.Logger;
|
|
|
|
constructor(
|
|
metadataRepo: MetadataRepository<any>,
|
|
slotRepo: SolanaSlotRepository,
|
|
statsRepo: StatRepository,
|
|
cfg: PollSolanaTransactionsConfig
|
|
) {
|
|
super(cfg.interval ?? 1_000, cfg.id, statsRepo);
|
|
|
|
this.metadataRepo = metadataRepo;
|
|
this.slotRepository = slotRepo;
|
|
this.getSolanaTransactions = new GetSolanaTransactions(slotRepo);
|
|
this.statsRepo = statsRepo;
|
|
this.cfg = cfg;
|
|
this.logger = winston.child({ module: "PollSolanaTransactions", label: this.cfg.id });
|
|
}
|
|
|
|
protected async preHook(): Promise<void> {
|
|
const metadata = await this.metadataRepo.get(this.cfg.id);
|
|
if (metadata) {
|
|
this.slotCursor = metadata.lastSlot;
|
|
}
|
|
}
|
|
|
|
async hasNext(): Promise<boolean> {
|
|
if (this.cfg.toSlot && this.slotCursor && this.slotCursor >= this.cfg.toSlot) {
|
|
this.logger.info(
|
|
`[hasNext] Finished processing all slots from ${this.cfg.fromSlot} to ${this.cfg.toSlot}`
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
protected async get(): Promise<solana.Transaction[]> {
|
|
this.report();
|
|
this.latestSlot = await this.slotRepository.getLatestSlot(this.cfg.commitment);
|
|
const range = this.getSlotRange(this.latestSlot);
|
|
|
|
if (range.fromSlot > this.latestSlot) {
|
|
this.logger.info(
|
|
`[get] Next range is after latest slot [fromSlot: ${range.fromSlot} - latestSlot: ${this.latestSlot}], waiting...`
|
|
);
|
|
return [];
|
|
}
|
|
|
|
let toBlock = await this.findValidBlock(range.toSlot, (slot) => slot - 1);
|
|
let fromBlock = await this.findValidBlock(range.fromSlot, (slot) => slot + 1);
|
|
|
|
if (!fromBlock.blockTime || !toBlock.blockTime || fromBlock.blockTime > toBlock.blockTime) {
|
|
// TODO: validate if this is correct
|
|
throw new Error(
|
|
`Invalid slot range: fromSlotBlockTime=${fromBlock.blockTime} toSlotBlockTime=${toBlock.blockTime}`
|
|
);
|
|
}
|
|
|
|
// signatures for address goes back from current sig
|
|
const afterSignature = fromBlock.transactions[0]?.transaction.signatures[0];
|
|
let beforeSignature: string | undefined =
|
|
toBlock.transactions[toBlock.transactions.length - 1]?.transaction.signatures[0];
|
|
if (!afterSignature || !beforeSignature) {
|
|
throw new Error(
|
|
`No signature presents in transactions. After: ${afterSignature}. Before: ${beforeSignature} [slots: ${range.fromSlot} - ${range.toSlot}]`
|
|
);
|
|
}
|
|
|
|
const results: solana.Transaction[] = await this.getSolanaTransactions.execute(
|
|
this.cfg.programId,
|
|
{ fromBlock, toBlock },
|
|
{
|
|
commitment: this.cfg.commitment,
|
|
signaturesLimit: this.cfg.signaturesLimit,
|
|
}
|
|
);
|
|
|
|
this.lastRange = range;
|
|
return results;
|
|
}
|
|
|
|
protected async persist(): Promise<void> {
|
|
this.slotCursor = this.lastRange?.toSlot;
|
|
if (this.slotCursor) {
|
|
await this.metadataRepo.save(this.cfg.id, { lastSlot: this.slotCursor });
|
|
}
|
|
}
|
|
|
|
private getSlotRange(latestSlot: number): Range {
|
|
let fromSlot = this.slotCursor ? this.slotCursor + 1 : this.cfg.fromSlot ?? latestSlot;
|
|
// cfg.fromSlot is present and is greater than current slot height, then we allow to skip slots.
|
|
if (this.slotCursor && this.cfg.fromSlot && this.cfg.fromSlot > this.slotCursor) {
|
|
fromSlot = this.cfg.fromSlot;
|
|
}
|
|
|
|
let toSlot = Math.min(fromSlot + this.cfg.slotBatchSize, latestSlot);
|
|
// limit toSlot to configured toSlot
|
|
if (this.cfg.toSlot && toSlot > this.cfg.toSlot) {
|
|
toSlot = this.cfg.toSlot;
|
|
}
|
|
|
|
if (fromSlot > toSlot) {
|
|
throw new Error(
|
|
`Invalid slot range: fromSlot=${fromSlot} toSlot=${toSlot}. Might be cause we are up to date.`
|
|
);
|
|
}
|
|
|
|
return { fromSlot, toSlot };
|
|
}
|
|
|
|
private report(): void {
|
|
const labels = {
|
|
job: this.cfg.id,
|
|
chain: "solana",
|
|
commitment: this.cfg.commitment,
|
|
};
|
|
this.statsRepo.count("job_execution", labels);
|
|
this.statsRepo.measure("polling_cursor", BigInt(this.latestSlot ?? 0), {
|
|
...labels,
|
|
type: "max",
|
|
});
|
|
this.statsRepo.measure("polling_cursor", BigInt(this.slotCursor ?? 0n), {
|
|
...labels,
|
|
type: "current",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Recursively find a valid block for the given slot.
|
|
* @param slot - the starting slot
|
|
* @param nextSlot - the function to get the next slot (to go up or down for example)
|
|
* @returns a block if found, otherwise throws an error
|
|
*/
|
|
private async findValidBlock(
|
|
slot: number,
|
|
nextSlot: (slot: number) => number
|
|
): Promise<solana.Block> {
|
|
const blockResult = await this.slotRepository.getBlock(slot, this.cfg.commitment);
|
|
if (blockResult.isOk()) {
|
|
const block = blockResult.getValue();
|
|
if (block.transactions.length > 0) {
|
|
return block;
|
|
}
|
|
const next = nextSlot(slot);
|
|
this.logger.warn(
|
|
`[findValidBlock] No transactions found for slot ${slot}, trying next slot ${next}`
|
|
);
|
|
return this.findValidBlock(next, nextSlot);
|
|
}
|
|
|
|
if (blockResult.getError().skippedSlot() || blockResult.getError().noBlockOrBlockTime()) {
|
|
const next = nextSlot(slot);
|
|
this.logger.warn(
|
|
`[findValidBlock] No block found for slot ${slot}, trying next slot ${next}`
|
|
);
|
|
return this.findValidBlock(next, nextSlot);
|
|
}
|
|
|
|
throw blockResult.getError();
|
|
}
|
|
}
|
|
|
|
export class PollSolanaTransactionsConfig {
|
|
id: string;
|
|
commitment: string;
|
|
programId: string;
|
|
fromSlot?: number;
|
|
toSlot?: number;
|
|
slotBatchSize: number = 1_000;
|
|
signaturesLimit: number = 500;
|
|
interval?: number = 5_000;
|
|
|
|
constructor(id: string, programId: string, commitment?: string, slotBatchSize?: number) {
|
|
this.id = id;
|
|
this.commitment = commitment ?? "finalized";
|
|
this.programId = programId;
|
|
this.slotBatchSize = slotBatchSize ?? 10_000;
|
|
}
|
|
}
|
|
|
|
export type PollSolanaTransactionsMetadata = {
|
|
lastSlot: number;
|
|
};
|
|
|
|
type Range = {
|
|
fromSlot: number;
|
|
toSlot: number;
|
|
};
|