From af11f38022c2d09153ff913d1c2b9e732ed44d51 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Mon, 24 Nov 2025 22:26:07 +0530 Subject: [PATCH 01/19] added PendingL1TransactionStorage for persisting mina transactions in db. --- packages/persistance/prisma/schema.prisma | 12 +++++++ .../src/PrismaDatabaseConnection.ts | 5 +++ packages/persistance/src/index.ts | 1 + .../mappers/PendingL1TransactionMapper.ts | 33 ++++++++++++++++++ .../src/storage/StorageDependencyFactory.ts | 2 ++ .../PendingL1TransactionStorage.ts | 34 +++++++++++++++++++ 6 files changed, 87 insertions(+) create mode 100644 packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts create mode 100644 packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts diff --git a/packages/persistance/prisma/schema.prisma b/packages/persistance/prisma/schema.prisma index d0ed1472e..0b18eb5f0 100644 --- a/packages/persistance/prisma/schema.prisma +++ b/packages/persistance/prisma/schema.prisma @@ -148,3 +148,15 @@ model IncomingMessageBatch { messages IncomingMessageBatchTransaction[] } + +model PendingL1Transaction { + sender String + nonce Int + attempts Int + status String @db.VarChar(32) + transaction Json @db.Json + lastError String? + sentAt DateTime? + + @@id([sender, nonce]) +} diff --git a/packages/persistance/src/PrismaDatabaseConnection.ts b/packages/persistance/src/PrismaDatabaseConnection.ts index 84440ca1c..5e0453962 100644 --- a/packages/persistance/src/PrismaDatabaseConnection.ts +++ b/packages/persistance/src/PrismaDatabaseConnection.ts @@ -13,6 +13,7 @@ import { PrismaBlockStorage } from "./services/prisma/PrismaBlockStorage"; import { PrismaSettlementStorage } from "./services/prisma/PrismaSettlementStorage"; import { PrismaMessageStorage } from "./services/prisma/PrismaMessageStorage"; import { PrismaTransactionStorage } from "./services/prisma/PrismaTransactionStorage"; +import { PrismaPendingL1TransactionStorage } from "./services/prisma/PrismaPendingL1TransactionStorage"; export interface PrismaDatabaseConfig { // Either object-based config or connection string @@ -82,6 +83,9 @@ export class PrismaDatabaseConnection transactionStorage: { useClass: PrismaTransactionStorage, }, + pendingL1TransactionStorage: { + useClass: PrismaPendingL1TransactionStorage, + }, }; } @@ -97,6 +101,7 @@ export class PrismaDatabaseConnection "IncomingMessageBatch", "IncomingMessageBatchTransaction", "LinkedLeaf", + "PendingL1Transaction", ]; await this.prismaClient.$transaction( diff --git a/packages/persistance/src/index.ts b/packages/persistance/src/index.ts index 788de3d57..88b5e7df1 100644 --- a/packages/persistance/src/index.ts +++ b/packages/persistance/src/index.ts @@ -7,6 +7,7 @@ export * from "./services/prisma/PrismaBatchStore"; export * from "./services/prisma/PrismaSettlementStorage"; export * from "./services/prisma/PrismaMessageStorage"; export * from "./services/prisma/PrismaTransactionStorage"; +export * from "./services/prisma/PrismaPendingL1TransactionStorage"; export * from "./services/prisma/mappers/BatchMapper"; export * from "./services/prisma/mappers/BlockMapper"; export * from "./services/prisma/mappers/FieldMapper"; diff --git a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts new file mode 100644 index 000000000..314978b89 --- /dev/null +++ b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts @@ -0,0 +1,33 @@ +import { + PendingL1Transaction, + Prisma, +} from "@prisma/client"; +import { PendingL1TransactionRecord, PendingL1TransactionStatus } from "@proto-kit/sequencer"; + +export class PendingL1TransactionMapper { + public mapOut(input: PendingL1Transaction): PendingL1TransactionRecord { + return { + sender: input.sender, + nonce: input.nonce, + attempts: input.attempts, + status: input.status as PendingL1TransactionStatus, + transactionJson: JSON.stringify(input.transaction), + lastError: input.lastError ?? undefined, + sentAt: input.sentAt ? new Date(input.sentAt) : undefined, + }; + } + + public mapIn( + input: PendingL1TransactionRecord + ): Prisma.PendingL1TransactionCreateInput { + return { + sender: input.sender, + nonce: input.nonce, + attempts: input.attempts, + status: input.status, + transaction: JSON.parse(input.transactionJson), + lastError: input.lastError ?? null, + sentAt: input.sentAt ? input.sentAt.toJSON() : null, + }; + } +} diff --git a/packages/sequencer/src/storage/StorageDependencyFactory.ts b/packages/sequencer/src/storage/StorageDependencyFactory.ts index e53d3b0c9..c7c13319a 100644 --- a/packages/sequencer/src/storage/StorageDependencyFactory.ts +++ b/packages/sequencer/src/storage/StorageDependencyFactory.ts @@ -11,6 +11,7 @@ import { AsyncMerkleTreeStore } from "../state/async/AsyncMerkleTreeStore"; import { BatchStorage } from "./repositories/BatchStorage"; import { BlockQueue, BlockStorage } from "./repositories/BlockStorage"; import { MessageStorage } from "./repositories/MessageStorage"; +import { PendingL1TransactionStorage } from "./repositories/PendingL1TransactionStorage"; import { SettlementStorage } from "./repositories/SettlementStorage"; import { TransactionStorage } from "./repositories/TransactionStorage"; @@ -28,6 +29,7 @@ export interface StorageDependencyMinimumDependencies extends DependencyRecord { messageStorage: DependencyDeclaration; settlementStorage: DependencyDeclaration; transactionStorage: DependencyDeclaration; + pendingL1TransactionStorage: DependencyDeclaration; } export interface StorageDependencyFactory extends DependencyFactory { diff --git a/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts b/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts new file mode 100644 index 000000000..f90e88409 --- /dev/null +++ b/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts @@ -0,0 +1,34 @@ +export type PendingL1TransactionStatus = + | "queued" + | "sent" + | "included" + | "failed"; + +export interface PendingL1TransactionRecord { + sender: string; + nonce: number; + attempts: number; + status: PendingL1TransactionStatus; + transactionJson: string; + lastError?: string; + sentAt?: Date; +} + +export interface PendingL1TransactionStorage { + queue(record: Omit): Promise; + + update( + sender: string, + nonce: number, + updates: Partial> + ): Promise; + + delete(sender: string, nonce: number): Promise; + + findBySenderAndNonce( + sender: string, + nonce: number + ): Promise; + + findByStatuses(statuses: PendingL1TransactionStatus[]): Promise; +} From 183520d344c995dacf1673343b41cf7f47139b63 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Mon, 24 Nov 2025 22:35:31 +0530 Subject: [PATCH 02/19] implementing PendingL1TransactionStorage --- .../PrismaPendingL1TransactionStorage.ts | 103 ++++++++++++++++++ .../InMemoryPendingL1TransactionStorage.ts | 59 ++++++++++ 2 files changed, 162 insertions(+) create mode 100644 packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts create mode 100644 packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts diff --git a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts new file mode 100644 index 000000000..c63383067 --- /dev/null +++ b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts @@ -0,0 +1,103 @@ +import { inject, injectable } from "tsyringe"; +import { Prisma } from "@prisma/client"; +import { + PendingL1TransactionRecord, + PendingL1TransactionStatus, + PendingL1TransactionStorage, +} from "@proto-kit/sequencer"; + +import type { PrismaConnection } from "../../PrismaDatabaseConnection"; +import { PendingL1TransactionMapper } from "./mappers/PendingL1TransactionMapper"; + +@injectable() +export class PrismaPendingL1TransactionStorage + implements PendingL1TransactionStorage +{ + private readonly mapper = new PendingL1TransactionMapper(); + + public constructor( + @inject("Database") private readonly connection: PrismaConnection + ) {} + + public async queue(record: Omit): Promise { + const { prismaClient } = this.connection; + const status:PendingL1TransactionStatus = "queued"; + await prismaClient.pendingL1Transaction.create({ + data: { + sender: record.sender, + nonce: record.nonce, + attempts: record.attempts, + status: status, + transaction: JSON.parse(record.transactionJson), + lastError: record.lastError ?? null, + sentAt: record.sentAt, + }, + }); + } + + public async update( + sender: string, + nonce: number, + updates: Partial> + ): Promise { + const { prismaClient } = this.connection; + await prismaClient.pendingL1Transaction.update({ + where: { + sender_nonce: { + sender, + nonce, + }, + }, + data: { + ...(updates.attempts !== undefined && { attempts: updates.attempts }), + ...(updates.status !== undefined && { status: updates.status }), + ...(updates.transactionJson !== undefined && { transaction: JSON.parse(updates.transactionJson) }), + ...(updates.lastError !== undefined && { lastError: updates.lastError }), + ...(updates.sentAt !== undefined && { sentAt: updates.sentAt }), + }, + }); + } + + public async delete(sender: string, nonce: number): Promise { + const { prismaClient } = this.connection; + await prismaClient.pendingL1Transaction.delete({ + where: { + sender_nonce: { + sender, + nonce, + }, + }, + }); + } + + public async findBySenderAndNonce( + sender: string, + nonce: number + ): Promise { + const { prismaClient } = this.connection; + const record = await prismaClient.pendingL1Transaction.findUnique({ + where: { + sender_nonce: { + sender, + nonce, + }, + }, + }); + if (!record) { + return undefined; + } + return this.mapper.mapOut(record); + } + + public async findByStatuses(statuses: PendingL1TransactionStatus[]): Promise { + const { prismaClient } = this.connection; + const rows = await prismaClient.pendingL1Transaction.findMany({ + where: { + status: { + in: statuses, + }, + }, + }); + return rows.map((record) => this.mapper.mapOut(record)); + } +} diff --git a/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts b/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts new file mode 100644 index 000000000..88ad204d1 --- /dev/null +++ b/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts @@ -0,0 +1,59 @@ +import { + PendingL1TransactionRecord, + PendingL1TransactionStatus, + PendingL1TransactionStorage, +} from "../repositories/PendingL1TransactionStorage"; + +export class InMemoryPendingL1TransactionStorage + implements PendingL1TransactionStorage +{ + // Key: sender:nonce + private store = new Map(); + + private getKey(sender: string, nonce: number): string { + return `${sender}:${nonce}`; + } + + public async queue(record: Omit): Promise { + const key = this.getKey(record.sender, record.nonce); + this.store.set(key, { + ...record, + status: "queued", + }); + } + + public async update( + sender: string, + nonce: number, + updates: Partial> + ): Promise { + const key = this.getKey(sender, nonce); + const existing = this.store.get(key); + if (existing === undefined) { + return; + } + this.store.set(key, { + ...existing, + ...updates, + }); + } + + public async delete(sender: string, nonce: number): Promise { + const key = this.getKey(sender, nonce); + this.store.delete(key); + } + + public async findBySenderAndNonce( + sender: string, + nonce: number + ): Promise { + const key = this.getKey(sender, nonce); + return this.store.get(key); + } + + public async findByStatuses(statuses: PendingL1TransactionStatus[]): Promise { + return Array.from(this.store.values()).filter((record) => + statuses.includes(record.status) + ); + } +} From f3be0385c367a00434cde59c9de7d8dd2447c379 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Tue, 25 Nov 2025 04:56:09 +0530 Subject: [PATCH 03/19] implementing L1TransactionRetryStrategy --- packages/sequencer/src/index.ts | 5 +- .../src/settlement/SettlementModule.ts | 16 ++++ .../DefaultL1TransactionRetryStrategy.ts | 90 +++++++++++++++++++ .../L1TransactionRetryStrategy.ts | 15 ++++ .../src/storage/inmemory/InMemoryDatabase.ts | 4 + 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts create mode 100644 packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts diff --git a/packages/sequencer/src/index.ts b/packages/sequencer/src/index.ts index 15b423ea7..c8b7ce8b6 100644 --- a/packages/sequencer/src/index.ts +++ b/packages/sequencer/src/index.ts @@ -11,7 +11,6 @@ export * from "./sequencer/builder/Closeable"; export * from "./worker/flow/Flow"; export * from "./worker/flow/Task"; export * from "./worker/flow/JSONTaskSerializer"; -// export * from "./worker/queue/BullQueue"; export * from "./worker/queue/TaskQueue"; export * from "./worker/queue/LocalTaskQueue"; export * from "./worker/queue/ListenerList"; @@ -71,6 +70,7 @@ export * from "./storage/repositories/BlockStorage"; export * from "./storage/repositories/SettlementStorage"; export * from "./storage/repositories/MessageStorage"; export * from "./storage/repositories/TransactionStorage"; +export * from "./storage/repositories/PendingL1TransactionStorage"; export * from "./storage/inmemory/InMemoryDatabase"; export * from "./storage/inmemory/InMemoryAsyncMerkleTreeStore"; export * from "./storage/inmemory/InMemoryBlockStorage"; @@ -78,6 +78,7 @@ export * from "./storage/inmemory/InMemoryBatchStorage"; export * from "./storage/inmemory/InMemorySettlementStorage"; export * from "./storage/inmemory/InMemoryMessageStorage"; export * from "./storage/inmemory/InMemoryTransactionStorage"; +export * from "./storage/inmemory/InMemoryPendingL1TransactionStorage"; export * from "./storage/StorageDependencyFactory"; export * from "./storage/Database"; export * from "./storage/DatabasePruneModule"; @@ -107,6 +108,8 @@ export * from "./settlement/permissions/BaseLayerContractPermissions"; export * from "./settlement/permissions/ProvenSettlementPermissions"; export * from "./settlement/permissions/SignedSettlementPermissions"; export * from "./settlement/tasks/SettlementProvingTask"; +export * from "./settlement/transactions/L1TransactionRetryStrategy"; +export * from "./settlement/transactions/DefaultL1TransactionRetryStrategy"; export * from "./settlement/transactions/MinaTransactionSender"; export * from "./settlement/transactions/MinaTransactionSimulator"; export * from "./settlement/transactions/MinaSimulationService"; diff --git a/packages/sequencer/src/settlement/SettlementModule.ts b/packages/sequencer/src/settlement/SettlementModule.ts index 597da53b3..72a349e41 100644 --- a/packages/sequencer/src/settlement/SettlementModule.ts +++ b/packages/sequencer/src/settlement/SettlementModule.ts @@ -14,6 +14,7 @@ import { fetchAccount, Field, Mina, + PrivateKey, PublicKey, TokenContract, TokenId, @@ -46,6 +47,18 @@ import { SignedSettlementPermissions } from "./permissions/SignedSettlementPermi import { SettlementUtils } from "./utils/SettlementUtils"; import { BridgingModule } from "./BridgingModule"; import { MinaSigner } from "./MinaSigner"; +import { DefaultL1TransactionRetryStrategy } from "./transactions/DefaultL1TransactionRetryStrategy"; + +export type SettlementModuleConfig = { + feepayer: PrivateKey; +} & { + // TODO Add possibility to only configure public keys (for proven operation) + keys?: { + settlement: PrivateKey; + dispatch: PrivateKey; + minaBridge: PrivateKey; + }; +}; export type SettlementModuleEvents = { "settlement-submitted": [Batch]; @@ -88,6 +101,9 @@ export class SettlementModule BridgingModule: { useClass: BridgingModule, }, + L1TransactionRetryStrategy: { + useClass: DefaultL1TransactionRetryStrategy, + }, }; } diff --git a/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts b/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts new file mode 100644 index 000000000..890ab25fb --- /dev/null +++ b/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts @@ -0,0 +1,90 @@ +import { noop } from "@proto-kit/common"; +import { Transaction, UInt64 } from "o1js"; +import { inject } from "tsyringe"; + +import { + sequencerModule, + SequencerModule, +} from "../../sequencer/builder/SequencerModule"; +import { PendingL1TransactionRecord } from "../../storage/repositories/PendingL1TransactionStorage"; +import { FeeStrategy } from "../../protocol/baselayer/fees/FeeStrategy"; + +import { L1TransactionRetryStrategy } from "./L1TransactionRetryStrategy"; + +export type TransactionRetryConfig = { + maxAttempts?: number; + feeMultiplier?: number; + maxFee?: number; + retryDelayMs?: number; +}; + +const DEFAULT_RETRY_CONFIG: Required = { + maxAttempts: 3, + feeMultiplier: 1.1, + maxFee: 10 * 1e9, + retryDelayMs: 60 * 1000, // 1 minute +}; + +@sequencerModule() +export class DefaultL1TransactionRetryStrategy + extends SequencerModule + implements L1TransactionRetryStrategy +{ + public constructor( + @inject("FeeStrategy") private readonly feeStrategy: FeeStrategy + ) { + super(); + } + + private get retryConfig(): Required { + return { + maxAttempts: this.config.maxAttempts ?? DEFAULT_RETRY_CONFIG.maxAttempts, + feeMultiplier: + this.config.feeMultiplier ?? DEFAULT_RETRY_CONFIG.feeMultiplier, + maxFee: this.config.maxFee ?? DEFAULT_RETRY_CONFIG.maxFee, + retryDelayMs: + this.config.retryDelayMs ?? DEFAULT_RETRY_CONFIG.retryDelayMs, + }; + } + + public async start(): Promise { + noop(); + } + + public async shouldRetry( + record: PendingL1TransactionRecord + ): Promise { + if (record.attempts >= this.retryConfig.maxAttempts) { + return false; + } + return true; + } + + public async prepareRetryTransaction( + record: PendingL1TransactionRecord + ): Promise> { + const tx = Transaction.fromJSON(JSON.parse(record.transactionJson)); + const currentFee = tx.transaction.feePayer.body.fee; + const newFee = UInt64.from(this.bumpFee(Number(currentFee.toBigInt()))); + tx.setFee(newFee); + // Delay if needed + await new Promise((resolve) => + setTimeout( + resolve, + Math.max( + 0, + this.retryConfig.retryDelayMs - + (Date.now() - (record.sentAt?.getTime() ?? 0)) + ) + ) + ); + return tx; + } + + private bumpFee(currentFee: number): number { + const { feeMultiplier, maxFee } = this.retryConfig; + const baseFee = this.feeStrategy.getFee(); + const bumped = Number(currentFee) * feeMultiplier; + return Math.floor(Math.min(Math.max(bumped, baseFee), maxFee)); + } +} diff --git a/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts b/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts new file mode 100644 index 000000000..19eb5c047 --- /dev/null +++ b/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts @@ -0,0 +1,15 @@ +import { + PendingL1TransactionRecord, +} from "../../storage/repositories/PendingL1TransactionStorage"; +import { Transaction } from "o1js"; + +export interface L1TransactionRetryStrategy { + shouldRetry( + record: PendingL1TransactionRecord + ): Promise; + + prepareRetryTransaction( + record: PendingL1TransactionRecord, + ): Promise>; +} + diff --git a/packages/sequencer/src/storage/inmemory/InMemoryDatabase.ts b/packages/sequencer/src/storage/inmemory/InMemoryDatabase.ts index 5406d539f..7e1e2d323 100644 --- a/packages/sequencer/src/storage/inmemory/InMemoryDatabase.ts +++ b/packages/sequencer/src/storage/inmemory/InMemoryDatabase.ts @@ -16,6 +16,7 @@ import { InMemoryMessageStorage } from "./InMemoryMessageStorage"; import { InMemorySettlementStorage } from "./InMemorySettlementStorage"; import { InMemoryTransactionStorage } from "./InMemoryTransactionStorage"; import { InMemoryAsyncMerkleTreeStore } from "./InMemoryAsyncMerkleTreeStore"; +import { InMemoryPendingL1TransactionStorage } from "./InMemoryPendingL1TransactionStorage"; @sequencerModule() @closeable() @@ -55,6 +56,9 @@ export class InMemoryDatabase extends SequencerModule implements Database { transactionStorage: { useClass: InMemoryTransactionStorage, }, + pendingL1TransactionStorage: { + useClass: InMemoryPendingL1TransactionStorage, + }, }; } From f67aaeef29cd9eaeb0fd197055c2cbba876be493 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Tue, 25 Nov 2025 04:56:28 +0530 Subject: [PATCH 04/19] added MinaSigner interface --- .../src/settlement/transactions/MinaSigner.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/sequencer/src/settlement/transactions/MinaSigner.ts diff --git a/packages/sequencer/src/settlement/transactions/MinaSigner.ts b/packages/sequencer/src/settlement/transactions/MinaSigner.ts new file mode 100644 index 000000000..b9a45e559 --- /dev/null +++ b/packages/sequencer/src/settlement/transactions/MinaSigner.ts @@ -0,0 +1,11 @@ +import { Field, Signature, Transaction, PublicKey } from "o1js"; + +export interface MinaSigner { + getContractKeys(): PublicKey[]; + + sign(signatureData: Field[]): Signature; + + signTransaction( + tx: Transaction + ): Transaction; +} \ No newline at end of file From a1cd5336f1fc41ddc09c749d42f64c0ac76acf3f Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Tue, 25 Nov 2025 05:07:30 +0530 Subject: [PATCH 05/19] updated MinaTransactionSender to use L1TransactionRetryStrategy and PendingL1TransactionStorage --- .../transactions/MinaTransactionSender.ts | 263 ++++++++++++------ 1 file changed, 174 insertions(+), 89 deletions(-) diff --git a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts index a594d85c0..ebf9c7062 100644 --- a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts +++ b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts @@ -1,24 +1,26 @@ import { fetchAccount, Mina, PublicKey, Transaction } from "o1js"; import { inject, injectable } from "tsyringe"; import { - EventEmitter, EventsRecord, - EventListenable, log, ReplayingSingleUseEventEmitter, filterNonUndefined, } from "@proto-kit/common"; import type { MinaBaseLayer } from "../../protocol/baselayer/MinaBaseLayer"; +import { + PendingL1TransactionRecord, + PendingL1TransactionStorage, +} from "../../storage/repositories/PendingL1TransactionStorage"; import { FlowCreator } from "../../worker/flow/Flow"; import { SettlementProvingTask, TransactionTaskResult, } from "../tasks/SettlementProvingTask"; +import { MinaSigner } from "./MinaSigner"; import { MinaTransactionSimulator } from "./MinaTransactionSimulator"; - -type SenderKey = string; +import { L1TransactionRetryStrategy } from "./L1TransactionRetryStrategy"; export interface TxEvents extends EventsRecord { sent: [{ hash: string }]; @@ -31,97 +33,48 @@ export type TxSendResult = @injectable() export class MinaTransactionSender { - private txStatusEmitters: Record> = {}; - - // TODO Persist all of that - private txQueue: Record = {}; - - private txIdCursor: number = 0; - - private cache: { tx: Transaction; id: number }[] = []; + private activeEmitters = new Map< + string, + ReplayingSingleUseEventEmitter + >(); public constructor( private readonly creator: FlowCreator, private readonly provingTask: SettlementProvingTask, private readonly simulator: MinaTransactionSimulator, - @inject("BaseLayer") private readonly baseLayer: MinaBaseLayer - ) {} + @inject("BaseLayer") private readonly baseLayer: MinaBaseLayer, + @inject("PendingL1TransactionStorage") + private readonly pendingStorage: PendingL1TransactionStorage, + @inject("L1TransactionRetryStrategy") + private readonly retryStrategy: L1TransactionRetryStrategy, + @inject("MinaSigner") private readonly signer: MinaSigner + ) { + void this.startPolling(); + } + + private getEmitterKey(sender: string, nonce: number): string { + return `${sender}:${nonce}`; + } public async getNextNonce(sender: PublicKey): Promise { const account = await this.simulator.getAccount(sender); return parseInt(account.nonce.toString(), 10); } - private async trySendCached({ - tx, - id, - }: { - tx: Transaction; - id: number; - }): Promise { - const feePayer = tx.transaction.feePayer.body; - const sender = feePayer.publicKey.toBase58(); - const senderQueue = this.txQueue[sender]; - - const sendable = senderQueue.at(0) === Number(feePayer.nonce.toString()); - if (sendable) { - const txId = await tx.send(); - - const statusEmitter = this.txStatusEmitters[id]; - log.info(`Sent L1 transaction ${txId.hash}`); - statusEmitter.emit("sent", { hash: txId.hash }); - - txId.wait().then( - (included) => { - log.info(`L1 transaction ${included.hash} has been included`); - statusEmitter.emit("included", { hash: included.hash }); - }, - (error) => { - log.info("Waiting on L1 transaction threw and error", error); - statusEmitter.emit("rejected", error); - } - ); - - senderQueue.pop(); - return txId; - } - return undefined; - } - - private async resolveCached(): Promise { - const indizesToRemove: number[] = []; - for (let i = 0; i < this.cache.length; i++) { - // eslint-disable-next-line no-await-in-loop - const result = await this.trySendCached(this.cache[i]); - if (result !== undefined) { - indizesToRemove.push(i); - } - } - this.cache = this.cache.filter( - (ignored, index) => !indizesToRemove.includes(index) - ); - return indizesToRemove.length; + private serializeTransaction(tx: Transaction): string { + return JSON.stringify(tx.toJSON()); } - private async sendOrQueue( - tx: Transaction - ): Promise> { - // eslint-disable-next-line no-plusplus - const id = this.txIdCursor++; - this.cache.push({ tx, id }); - const eventEmitter = new ReplayingSingleUseEventEmitter(); - this.txStatusEmitters[id] = eventEmitter; - - let removedLastIteration = 0; - do { - // eslint-disable-next-line no-await-in-loop - removedLastIteration = await this.resolveCached(); - } while (removedLastIteration > 0); - - // This altered return type only exposes listening-related functions and erases the rest - return eventEmitter; + private deserializeTransaction(json: string): Transaction { + return Mina.Transaction.fromJSON(JSON.parse(json)); } + /** + * Tf there is a transaction with a lower nonce thats not included yet, this transaction will be queued instead. + * @param transaction - The transaction to prove and send. + * @param waitOnStatus + * @returns + */ public async proveAndSendTransaction< Wait extends "sent" | "included" | "none", >( @@ -129,16 +82,20 @@ export class MinaTransactionSender { waitOnStatus: Wait ): Promise> { const { publicKey, nonce } = transaction.transaction.feePayer.body; + const sender = publicKey.toBase58(); + const nonceNum = Number(nonce.toString()); + + // Setup emitter before queueing + const emitterKey = this.getEmitterKey(sender, nonceNum); + const emitter = new ReplayingSingleUseEventEmitter(); + this.activeEmitters.set(emitterKey, emitter); log.debug( - `Proving tx from sender ${publicKey.toBase58()} nonce ${nonce.toString()}` + `Proving tx from sender ${sender} nonce ${nonce.toString()}` ); - // Add Transaction to sender's queue - (this.txQueue[publicKey.toBase58()] ??= []).push(Number(nonce.toString())); - const flow = this.creator.createFlow( - `tx-${publicKey.toBase58()}-${nonce.toString()}`, + `tx-${sender}-${nonce.toString()}`, {} ); @@ -184,18 +141,31 @@ export class MinaTransactionSender { log.trace(result.transaction.toPretty()); - const txStatus = await this.sendOrQueue(result.transaction); + // const signedTx = this.signer.signTransaction(result.transaction); + + // Queue the transaction + await this.pendingStorage.queue({ + sender, + nonce: nonceNum, + attempts: 0, + transactionJson: this.serializeTransaction(result.transaction), + sentAt: new Date(), + }); if (waitOnStatus !== "none") { const waitInstruction: "sent" | "included" = waitOnStatus; const hash = await new Promise>( (resolve, reject) => { - txStatus.on(waitInstruction, (txSendResult) => { - log.info(`Tx ${txSendResult.hash} included`); + emitter.on(waitInstruction, (txSendResult) => { + log.info(`Tx ${txSendResult.hash} ${waitInstruction}`); resolve(txSendResult); + if (waitInstruction === "included") { + this.activeEmitters.delete(emitterKey); + } }); - txStatus.on("rejected", (error) => { + emitter.on("rejected", (error) => { reject(error); + this.activeEmitters.delete(emitterKey); }); } ); @@ -204,7 +174,122 @@ export class MinaTransactionSender { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return hash as TxSendResult; } + + // If waitOnStatus is none, delete the emitter. + this.activeEmitters.delete(emitterKey); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return undefined as TxSendResult; } + + private async startPolling() { + // Polling loop + while (true) { + try { + await this.processPendingTransactions(); + await new Promise((r) => setTimeout(r, 5000)); // 5s interval + } catch (e) { + log.error("Error in MinaTransactionSender polling loop", e); + await new Promise((r) => setTimeout(r, 10000)); + } + } + } + + private async processPendingTransactions() { + // Find all pending transactions, state: queued | sent + const pendingTransactions = await this.pendingStorage.findByStatuses(["queued", "sent"]); + + const bySender: Record = {}; + for (const tx of pendingTransactions) { + (bySender[tx.sender] ??= []).push(tx); + } + + for (const sender of Object.keys(bySender)) { + // Sort in ascending order of nonce + const txs = bySender[sender].sort((a, b) => a.nonce - b.nonce); + if (txs.length === 0) continue; + + // Send the first queued transaction, transactions stays in queued state until the previous transaction is included or rejected + const txToSend = txs[0]; + if (txToSend.status === "queued") { + await this.sendTransaction(txToSend); + } else if (txToSend.status === "sent" && !this.activeEmitters.has(this.getEmitterKey(txToSend.sender, txToSend.nonce))) { + // If the transaction is sent and the emitter is not active, [TODO] check L1 for inclusion and retry if needed + await this.sendTransaction(txToSend); + } + } + } + + private async sendTransaction(record: PendingL1TransactionRecord) { + const tx = this.deserializeTransaction(record.transactionJson); + const emitterKey = this.getEmitterKey(record.sender, record.nonce); + const emitter = this.activeEmitters.get(emitterKey); + + try { + const pendingTx = await tx.send(); + // Update DB + await this.pendingStorage.update(record.sender, record.nonce, { + status: "sent", + attempts: record.attempts + 1, + sentAt: new Date(), + transactionJson: this.serializeTransaction(tx), + }); + + log.info(`Sent L1 transaction ${pendingTx.hash} for nonce ${record.nonce} (Attempt ${record.attempts + 1})`); + emitter?.emit("sent", { hash: pendingTx.hash }); + + // Wait for inclusion + pendingTx.wait().then( + async (included) => { + log.info(`Transaction ${included.hash} included`); + emitter?.emit("included", { hash: included.hash }); + await this.pendingStorage.update(record.sender, record.nonce, { status: "included" }); + this.activeEmitters.delete(emitterKey); + }, + async (error) => { + log.info(`Transaction ${pendingTx.hash} failed/rejected`, error); + // retry the transaction + await this.retryTransaction(record); + } + ); + } catch (error) { + log.error(`Failed to send transaction ${record.sender}:${record.nonce}`, error); + await this.pendingStorage.update(record.sender, record.nonce, { + status: "failed", + lastError: error instanceof Error ? error.message : String(error), + }); + } + } + + private async retryTransaction(record: PendingL1TransactionRecord) { + const shouldRetry = await this.retryStrategy.shouldRetry(record); + if (!shouldRetry) { + const emitterKey = this.getEmitterKey(record.sender, record.nonce); + const emitter = this.activeEmitters.get(emitterKey); + if (emitter) { + emitter.emit("rejected", new Error(`Max attempts reached for ${record.sender}:${record.nonce}`)); + this.activeEmitters.delete(emitterKey); + } + return; + } + // Prepare retry + try { + const retryTx = await this.retryStrategy.prepareRetryTransaction(record); + const signedRetryTx = this.signer.signTransaction(retryTx); + // Send the retry transaction + await this.sendTransaction({...record, transactionJson: this.serializeTransaction(signedRetryTx), attempts: record.attempts + 1}); + } catch (error) { + log.error(`Failed to prepare retry for ${record.sender}:${record.nonce}`, error); + await this.pendingStorage.update(record.sender, record.nonce, { + status: "failed", + lastError: error instanceof Error ? error.message : String(error), + }); + const emitterKey = this.getEmitterKey(record.sender, record.nonce); + const emitter = this.activeEmitters.get(emitterKey); + if (emitter) { + emitter.emit("rejected", error); + } + this.activeEmitters.delete(emitterKey); + } + } } From 40abbfdb53b8b8678dd836c98fb299d133875655 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Tue, 25 Nov 2025 05:11:35 +0530 Subject: [PATCH 06/19] fixing tests --- package-lock.json | 54 +++++++++++++++++++ package.json | 1 + .../transactions/LocalMinaSigner.ts | 33 ++++++++++++ packages/sequencer/test/TestingSequencer.ts | 3 ++ .../test/integration/BlockProduction-test.ts | 1 + .../integration/BlockProductionSize.test.ts | 1 + .../test/integration/Mempool.test.ts | 1 + .../sequencer/test/integration/Proven.test.ts | 1 + .../integration/StorageIntegration.test.ts | 1 + .../atomic-block-production.test.ts | 1 + .../sequencer/test/settlement/Settlement.ts | 4 ++ 11 files changed, 101 insertions(+) create mode 100644 packages/sequencer/src/settlement/transactions/LocalMinaSigner.ts diff --git a/package-lock.json b/package-lock.json index 761e3033b..238383c60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "packages/*" ], "dependencies": { + "clean": "^4.0.2", "react-json-view-lite": "^1.4.0" }, "devDependencies": { @@ -7497,6 +7498,18 @@ "validator": "^13.9.0" } }, + "node_modules/clean": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/clean/-/clean-4.0.2.tgz", + "integrity": "sha512-2LGVh4dNtI16L4UzqDHO6Hbl74YjG1vWvEUU78dgLO4kuyqJZFMNMPBx+EGtYKTFb14e24p+gWXgkabqxc1EUw==", + "license": "MIT", + "dependencies": { + "async": "^0.9.0", + "minimist": "^1.1.0", + "mix2": "^1.0.0", + "skema": "^1.0.0" + } + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -7516,6 +7529,12 @@ "node": ">=6" } }, + "node_modules/clean/node_modules/async": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "integrity": "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw==", + "license": "MIT" + }, "node_modules/cli-boxes": { "version": "3.0.0", "license": "MIT", @@ -16877,6 +16896,15 @@ "node": ">=12" } }, + "node_modules/make-array": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/make-array/-/make-array-0.1.2.tgz", + "integrity": "sha512-bcFmxgZ+OTaMYJp/w6eifElKTcfum7Gi5H7vQ8KzAf9X6swdxkVuilCaG3ZjXr/qJsQT4JJ2Rq9SDYScWEdu9Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "license": "MIT", @@ -17849,6 +17877,15 @@ "dev": true, "license": "ISC" }, + "node_modules/mix2": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/mix2/-/mix2-1.0.5.tgz", + "integrity": "sha512-ybWz7nY+WHBBIyliND5eYaJKzkoa+qXRYNTmVqAxSLlFtL/umT2iv+pmyTu1oU7WNkrirwheqR8d9EaKVz0e5g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mkdirp": { "version": "1.0.4", "license": "MIT", @@ -21762,6 +21799,23 @@ "version": "1.0.5", "license": "MIT" }, + "node_modules/skema": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz", + "integrity": "sha512-5LWfF2RSW2B3xfOaY6j49X8aNwsnj9cRVrM5QMF7it+cZvpv5ufiOUT13ps2U52sIbAzs11bdRP6mi5qyg75VQ==", + "license": "MIT", + "dependencies": { + "async": "^0.9.0", + "make-array": "^0.1.2", + "mix2": "^1.0.0" + } + }, + "node_modules/skema/node_modules/async": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "integrity": "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw==", + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "license": "MIT", diff --git a/package.json b/package.json index 7ffba608c..ff39f5177 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ ] }, "dependencies": { + "clean": "^4.0.2", "react-json-view-lite": "^1.4.0" } } diff --git a/packages/sequencer/src/settlement/transactions/LocalMinaSigner.ts b/packages/sequencer/src/settlement/transactions/LocalMinaSigner.ts new file mode 100644 index 000000000..096747f89 --- /dev/null +++ b/packages/sequencer/src/settlement/transactions/LocalMinaSigner.ts @@ -0,0 +1,33 @@ +import { Field, PrivateKey, PublicKey, Signature, Transaction } from "o1js"; +import { + sequencerModule, + SequencerModule, +} from "../../sequencer/builder/SequencerModule"; +import { MinaSigner } from "./MinaSigner"; +import { noop } from "@proto-kit/common"; + +export type LocalMinaSignerConfig = { + signers: PrivateKey[]; +}; + +@sequencerModule() +export class LocalMinaSigner + extends SequencerModule + implements MinaSigner +{ + public getContractKeys(): PublicKey[] { + return this.config.signers.map((signer) => signer.toPublicKey()); + } + + public sign(signatureData: Field[]): Signature { + return Signature.create(this.config.signers[0], signatureData); + } + + public signTransaction(tx: Transaction): Transaction { + return tx.sign([...this.config.signers]); + } + + public async start(): Promise { + noop(); + } +} diff --git a/packages/sequencer/test/TestingSequencer.ts b/packages/sequencer/test/TestingSequencer.ts index 41715c40a..8f9f9f9fe 100644 --- a/packages/sequencer/test/TestingSequencer.ts +++ b/packages/sequencer/test/TestingSequencer.ts @@ -15,6 +15,7 @@ import { SequencerStartupModule, } from "../src"; import { ConstantFeeStrategy } from "../src/protocol/baselayer/fees/ConstantFeeStrategy"; +import { DefaultL1TransactionRetryStrategy } from "../src/settlement/transactions/DefaultL1TransactionRetryStrategy"; export interface DefaultTestingSequencerModules extends SequencerModulesRecord { Database: typeof InMemoryDatabase; @@ -27,6 +28,7 @@ export interface DefaultTestingSequencerModules extends SequencerModulesRecord { TaskQueue: typeof LocalTaskQueue; FeeStrategy: typeof ConstantFeeStrategy; SequencerStartupModule: typeof SequencerStartupModule; + L1TransactionRetryStrategy: typeof DefaultL1TransactionRetryStrategy; } export function testingSequencerModules< @@ -52,6 +54,7 @@ export function testingSequencerModules< TaskQueue: LocalTaskQueue, FeeStrategy: ConstantFeeStrategy, SequencerStartupModule, + L1TransactionRetryStrategy: DefaultL1TransactionRetryStrategy, } satisfies DefaultTestingSequencerModules; return { diff --git a/packages/sequencer/test/integration/BlockProduction-test.ts b/packages/sequencer/test/integration/BlockProduction-test.ts index 3189f4798..2629298c5 100644 --- a/packages/sequencer/test/integration/BlockProduction-test.ts +++ b/packages/sequencer/test/integration/BlockProduction-test.ts @@ -150,6 +150,7 @@ export function testBlockProduction< TaskQueue: {}, FeeStrategy: {}, SequencerStartupModule: {}, + L1TransactionRetryStrategy: {}, }, Runtime: { Balance: {}, diff --git a/packages/sequencer/test/integration/BlockProductionSize.test.ts b/packages/sequencer/test/integration/BlockProductionSize.test.ts index ad263f018..302ff2b0d 100644 --- a/packages/sequencer/test/integration/BlockProductionSize.test.ts +++ b/packages/sequencer/test/integration/BlockProductionSize.test.ts @@ -80,6 +80,7 @@ describe("block limit", () => { LocalTaskWorkerModule: VanillaTaskWorkerModules.defaultConfig(), BaseLayer: {}, TaskQueue: {}, + L1TransactionRetryStrategy: {}, FeeStrategy: {}, SequencerStartupModule: {}, }, diff --git a/packages/sequencer/test/integration/Mempool.test.ts b/packages/sequencer/test/integration/Mempool.test.ts index 2609ef78f..beca99a31 100644 --- a/packages/sequencer/test/integration/Mempool.test.ts +++ b/packages/sequencer/test/integration/Mempool.test.ts @@ -107,6 +107,7 @@ describe.each([["InMemory", InMemoryDatabase]])( BaseLayer: {}, TaskQueue: {}, SequencerStartupModule: {}, + L1TransactionRetryStrategy: {}, }, Protocol: { AccountState: {}, diff --git a/packages/sequencer/test/integration/Proven.test.ts b/packages/sequencer/test/integration/Proven.test.ts index 6de1a5517..3a9abfb64 100644 --- a/packages/sequencer/test/integration/Proven.test.ts +++ b/packages/sequencer/test/integration/Proven.test.ts @@ -98,6 +98,7 @@ describe.skip("Proven", () => { TaskQueue: {}, FeeStrategy: {}, SequencerStartupModule: {}, + L1TransactionRetryStrategy: {}, BaseLayer: { network: { type: "local", diff --git a/packages/sequencer/test/integration/StorageIntegration.test.ts b/packages/sequencer/test/integration/StorageIntegration.test.ts index b26a5008b..99382807b 100644 --- a/packages/sequencer/test/integration/StorageIntegration.test.ts +++ b/packages/sequencer/test/integration/StorageIntegration.test.ts @@ -106,6 +106,7 @@ describe.each([["InMemory", InMemoryDatabase]])( TaskQueue: {}, FeeStrategy: {}, SequencerStartupModule: {}, + L1TransactionRetryStrategy: {}, }, Protocol: { AccountState: {}, diff --git a/packages/sequencer/test/protocol/production/sequencing/atomic-block-production.test.ts b/packages/sequencer/test/protocol/production/sequencing/atomic-block-production.test.ts index a9b156023..83bc33b84 100644 --- a/packages/sequencer/test/protocol/production/sequencing/atomic-block-production.test.ts +++ b/packages/sequencer/test/protocol/production/sequencing/atomic-block-production.test.ts @@ -58,6 +58,7 @@ describe("atomic block production", () => { TaskQueue: {}, FeeStrategy: {}, SequencerStartupModule: {}, + L1TransactionRetryStrategy: {}, }, Runtime: { Balance: {}, diff --git a/packages/sequencer/test/settlement/Settlement.ts b/packages/sequencer/test/settlement/Settlement.ts index cfc97c045..1950b20df 100644 --- a/packages/sequencer/test/settlement/Settlement.ts +++ b/packages/sequencer/test/settlement/Settlement.ts @@ -6,6 +6,8 @@ import { } from "@proto-kit/common"; import { VanillaProtocolModules } from "@proto-kit/library"; import { Runtime } from "@proto-kit/module"; +import { DefaultL1TransactionRetryStrategy } from "../../src/settlement/transactions/DefaultL1TransactionRetryStrategy"; +import { LocalMinaSigner } from "../../src/settlement/transactions/LocalMinaSigner"; import { BlockProverPublicInput, BridgeContract, @@ -130,6 +132,7 @@ export const settlementTestFn = ( BaseLayer: MinaBaseLayer, SettlementModule: SettlementModule, SettlementSigner: InMemoryMinaSigner, + L1TransactionRetryStrategy: DefaultL1TransactionRetryStrategy }, { SettlementProvingTask, @@ -185,6 +188,7 @@ export const settlementTestFn = ( FeeStrategy: {}, SettlementModule: {}, SequencerStartupModule: {}, + L1TransactionRetryStrategy: {}, TaskQueue: { simulatedDuration: 0, From cdb5e7c8538f68dcc1c9dbb3284170eca63c0a1f Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Tue, 25 Nov 2025 18:33:59 +0530 Subject: [PATCH 07/19] refactoring, removed feeStrategy dependencies from bridgingModule, settlementModule --- .../sequencer/src/settlement/BridgingModule.ts | 4 +--- .../src/settlement/SettlementModule.ts | 15 ++++++--------- .../transactions/MinaTransactionSender.ts | 18 ++++++++++++------ .../sequencer/test/settlement/Settlement.ts | 11 ++--------- 4 files changed, 21 insertions(+), 27 deletions(-) diff --git a/packages/sequencer/src/settlement/BridgingModule.ts b/packages/sequencer/src/settlement/BridgingModule.ts index 90bf94282..8bc94c18c 100644 --- a/packages/sequencer/src/settlement/BridgingModule.ts +++ b/packages/sequencer/src/settlement/BridgingModule.ts @@ -42,7 +42,6 @@ import { FungibleToken } from "mina-fungible-token"; // eslint-disable-next-line import/no-extraneous-dependencies import groupBy from "lodash/groupBy"; -import { FeeStrategy } from "../protocol/baselayer/fees/FeeStrategy"; import type { MinaBaseLayer } from "../protocol/baselayer/MinaBaseLayer"; import { AsyncLinkedLeafStore } from "../state/async/AsyncLinkedLeafStore"; import { CachedLinkedLeafStore } from "../state/lmt/CachedLinkedLeafStore"; @@ -96,6 +95,7 @@ export class BridgingModule { private readonly linkedLeafStore: AsyncLinkedLeafStore, @inject("FeeStrategy") private readonly feeStrategy: FeeStrategy, + @inject("AreProofsEnabled") areProofsEnabled: AreProofsEnabled, @inject("BaseLayer") private readonly baseLayer: MinaBaseLayer, @inject("SettlementSigner") private readonly signer: MinaSigner, @inject("TransactionSender") @@ -386,7 +386,6 @@ export class BridgingModule { sender: feepayer, // eslint-disable-next-line no-plusplus nonce: nonce++, - fee: this.feeStrategy.getFee(), memo: "pull state root", }, async () => { @@ -504,7 +503,6 @@ export class BridgingModule { sender: feepayer, // eslint-disable-next-line no-plusplus nonce: nonce++, - fee: this.feeStrategy.getFee(), memo: "roll up actions", }, async () => { diff --git a/packages/sequencer/src/settlement/SettlementModule.ts b/packages/sequencer/src/settlement/SettlementModule.ts index 72a349e41..05779069a 100644 --- a/packages/sequencer/src/settlement/SettlementModule.ts +++ b/packages/sequencer/src/settlement/SettlementModule.ts @@ -25,6 +25,7 @@ import { EventEmittingComponent, log, DependencyFactory, + AreProofsEnabled, } from "@proto-kit/common"; // eslint-disable-next-line import/no-extraneous-dependencies import truncate from "lodash/truncate"; @@ -37,7 +38,6 @@ import type { MinaBaseLayer } from "../protocol/baselayer/MinaBaseLayer"; import { Batch, SettleableBatch } from "../storage/model/Batch"; import { BlockProofSerializer } from "../protocol/production/tasks/serializers/BlockProofSerializer"; import { Settlement } from "../storage/model/Settlement"; -import { FeeStrategy } from "../protocol/baselayer/fees/FeeStrategy"; import { SettlementStartupModule } from "../sequencer/SettlementStartupModule"; import { SettlementStorage } from "../storage/repositories/SettlementStorage"; @@ -90,6 +90,7 @@ export class SettlementModule @inject("SettlementSigner") private readonly signer: MinaSigner, @inject("FeeStrategy") private readonly feeStrategy: FeeStrategy, + @inject("AreProofsEnabled") areProofsEnabled: AreProofsEnabled, private readonly settlementStartupModule: SettlementStartupModule ) { super(); @@ -182,8 +183,7 @@ export class SettlementModule { sender: feepayer, nonce: options?.nonce, - fee: this.feeStrategy.getFee(), - memo: "Protokit settle", + memo: "Protokit settle" }, async () => { await settlementContract.settle( @@ -252,8 +252,7 @@ export class SettlementModule { sender: feepayer, nonce, - fee: this.feeStrategy.getFee(), - memo: "Protokit settlement deploy", + memo: "Protokit settlement deploy" }, async () => { AccountUpdate.fundNewAccount(feepayer, 2); @@ -294,8 +293,7 @@ export class SettlementModule { sender: feepayer, nonce: nonce + 1, - fee: this.feeStrategy.getFee(), - memo: "Deploy MINA bridge", + memo: "Protokit settlement init" }, async () => { AccountUpdate.fundNewAccount(feepayer, 1); @@ -339,8 +337,7 @@ export class SettlementModule { sender: feepayer, nonce: nonce, - memo: `Deploy token bridge for ${truncate(tokenId.toString(), { length: 6 })}`, - fee: this.feeStrategy.getFee(), + memo: `Deploy token bridge for ${truncate(tokenId.toString(), { length: 6 })}` }, async () => { AccountUpdate.fundNewAccount(feepayer, 1); diff --git a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts index ebf9c7062..4e125bd71 100644 --- a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts +++ b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts @@ -1,4 +1,4 @@ -import { fetchAccount, Mina, PublicKey, Transaction } from "o1js"; +import { fetchAccount, Mina, PublicKey, Transaction, UInt64 } from "o1js"; import { inject, injectable } from "tsyringe"; import { EventsRecord, @@ -21,6 +21,7 @@ import { MinaSigner } from "./MinaSigner"; import { MinaTransactionSimulator } from "./MinaTransactionSimulator"; import { L1TransactionRetryStrategy } from "./L1TransactionRetryStrategy"; +import { FeeStrategy } from "../../protocol/baselayer/fees/FeeStrategy"; export interface TxEvents extends EventsRecord { sent: [{ hash: string }]; @@ -47,7 +48,8 @@ export class MinaTransactionSender { private readonly pendingStorage: PendingL1TransactionStorage, @inject("L1TransactionRetryStrategy") private readonly retryStrategy: L1TransactionRetryStrategy, - @inject("MinaSigner") private readonly signer: MinaSigner + @inject("MinaSigner") private readonly signer: MinaSigner, + @inject("FeeStrategy") private readonly feeStrategy: FeeStrategy ) { void this.startPolling(); } @@ -84,6 +86,10 @@ export class MinaTransactionSender { const { publicKey, nonce } = transaction.transaction.feePayer.body; const sender = publicKey.toBase58(); const nonceNum = Number(nonce.toString()); + // Set Fee [TODO] uncomment after the singer is implemented properly + // const unsignedTx = await transaction.setFee(UInt64.from(this.feeStrategy.getFee())); + // const signedTx = this.signer.signTransaction(unsignedTx); + const signedTx = transaction; // Setup emitter before queueing const emitterKey = this.getEmitterKey(sender, nonceNum); @@ -100,15 +106,15 @@ export class MinaTransactionSender { ); const accounts = await Promise.all( - transaction.transaction.accountUpdates.map( + signedTx.transaction.accountUpdates.map( async (au) => await fetchAccount({ publicKey: au.publicKey, tokenId: au.tokenId }) ) ); // Load accounts - await this.simulator.getAccounts(transaction); - await this.simulator.applyTransaction(transaction); + await this.simulator.getAccounts(signedTx); + await this.simulator.applyTransaction(signedTx); log.trace("Applied transaction to local simulated ledger"); @@ -120,7 +126,7 @@ export class MinaTransactionSender { await flow.pushTask( this.provingTask, { - transaction, + transaction: signedTx as Transaction, chainState: { graphql, accounts: accounts diff --git a/packages/sequencer/test/settlement/Settlement.ts b/packages/sequencer/test/settlement/Settlement.ts index 1950b20df..9c180e7ef 100644 --- a/packages/sequencer/test/settlement/Settlement.ts +++ b/packages/sequencer/test/settlement/Settlement.ts @@ -61,7 +61,6 @@ import { import { BlockProofSerializer } from "../../src/protocol/production/tasks/serializers/BlockProofSerializer"; import { testingSequencerModules } from "../TestingSequencer"; import { createTransaction } from "../integration/utils"; -import { FeeStrategy } from "../../src/protocol/baselayer/fees/FeeStrategy"; import { BridgingModule } from "../../src/settlement/BridgingModule"; import { FungibleTokenContractModule } from "../../src/settlement/utils/FungibleTokenContractModule"; import { FungibleTokenAdminContractModule } from "../../src/settlement/utils/FungibleTokenAdminContractModule"; @@ -109,7 +108,6 @@ export const settlementTestFn = ( let blockQueue: BlockQueue; let userPublicKey: PublicKey; - let feeStrategy: FeeStrategy; let blockSerializer: BlockProofSerializer; @@ -284,7 +282,6 @@ export const settlementTestFn = ( "BlockTrigger" ); blockQueue = appChain.sequencer.resolve("BlockQueue") as BlockQueue; - feeStrategy = appChain.sequencer.resolve("FeeStrategy") as FeeStrategy; blockSerializer = appChain.sequencer.dependencyContainer.resolve(BlockProofSerializer); @@ -363,8 +360,7 @@ export const settlementTestFn = ( { sender: sequencerKey.toPublicKey(), memo: "Deploy custom token", - nonce: nonceCounter++, - fee: feeStrategy.getFee(), + nonce: nonceCounter++ }, async () => { AccountUpdate.fundNewAccount(sequencerKey.toPublicKey(), 3); @@ -432,7 +428,6 @@ export const settlementTestFn = ( sender: sequencerKey.toPublicKey(), memo: "Mint custom token", nonce: nonceCounter++, - fee: feeStrategy.getFee(), }, async () => { AccountUpdate.fundNewAccount(sequencerKey.toPublicKey(), 1); @@ -747,12 +742,10 @@ export const settlementTestFn = ( const amount = BigInt(1e9 * 10); - const fee = feeStrategy.getFee(); const tx = await Mina.transaction( { sender: userKey.toPublicKey(), nonce: user0Nonce++, - fee, memo: "Redeem withdrawal", }, async () => { @@ -795,7 +788,7 @@ export const settlementTestFn = ( ).balance.toBigInt(); // tx fee - const minaFees = BigInt(fee); + const minaFees = BigInt(tx.transaction.feePayer.body.fee.toString()); expect((balanceAfter - balanceBefore).toString()).toBe( (amount - (tokenConfig === undefined ? minaFees : 0n)).toString() From c856dfbb224b48d1536f212e9cedf063167b7008 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Tue, 16 Dec 2025 14:23:27 +0530 Subject: [PATCH 08/19] using SettlementSigner, and removing LocalMinaSigner --- .../src/settlement/BridgingModule.ts | 3 -- .../src/settlement/SettlementModule.ts | 15 --------- .../DefaultL1TransactionRetryStrategy.ts | 2 +- .../L1TransactionRetryStrategy.ts | 2 +- .../transactions/LocalMinaSigner.ts | 33 ------------------- .../src/settlement/transactions/MinaSigner.ts | 11 ------- .../transactions/MinaTransactionSender.ts | 8 ++--- .../sequencer/test/settlement/Settlement.ts | 1 - 8 files changed, 6 insertions(+), 69 deletions(-) delete mode 100644 packages/sequencer/src/settlement/transactions/LocalMinaSigner.ts delete mode 100644 packages/sequencer/src/settlement/transactions/MinaSigner.ts diff --git a/packages/sequencer/src/settlement/BridgingModule.ts b/packages/sequencer/src/settlement/BridgingModule.ts index 8bc94c18c..32d35b25a 100644 --- a/packages/sequencer/src/settlement/BridgingModule.ts +++ b/packages/sequencer/src/settlement/BridgingModule.ts @@ -93,9 +93,6 @@ export class BridgingModule { private readonly outgoingMessageCollector: OutgoingMessageCollector, @inject("AsyncLinkedLeafStore") private readonly linkedLeafStore: AsyncLinkedLeafStore, - @inject("FeeStrategy") - private readonly feeStrategy: FeeStrategy, - @inject("AreProofsEnabled") areProofsEnabled: AreProofsEnabled, @inject("BaseLayer") private readonly baseLayer: MinaBaseLayer, @inject("SettlementSigner") private readonly signer: MinaSigner, @inject("TransactionSender") diff --git a/packages/sequencer/src/settlement/SettlementModule.ts b/packages/sequencer/src/settlement/SettlementModule.ts index 05779069a..6c29a20d6 100644 --- a/packages/sequencer/src/settlement/SettlementModule.ts +++ b/packages/sequencer/src/settlement/SettlementModule.ts @@ -25,7 +25,6 @@ import { EventEmittingComponent, log, DependencyFactory, - AreProofsEnabled, } from "@proto-kit/common"; // eslint-disable-next-line import/no-extraneous-dependencies import truncate from "lodash/truncate"; @@ -49,17 +48,6 @@ import { BridgingModule } from "./BridgingModule"; import { MinaSigner } from "./MinaSigner"; import { DefaultL1TransactionRetryStrategy } from "./transactions/DefaultL1TransactionRetryStrategy"; -export type SettlementModuleConfig = { - feepayer: PrivateKey; -} & { - // TODO Add possibility to only configure public keys (for proven operation) - keys?: { - settlement: PrivateKey; - dispatch: PrivateKey; - minaBridge: PrivateKey; - }; -}; - export type SettlementModuleEvents = { "settlement-submitted": [Batch]; }; @@ -88,9 +76,6 @@ export class SettlementModule @inject("TransactionSender") private readonly transactionSender: MinaTransactionSender, @inject("SettlementSigner") private readonly signer: MinaSigner, - @inject("FeeStrategy") - private readonly feeStrategy: FeeStrategy, - @inject("AreProofsEnabled") areProofsEnabled: AreProofsEnabled, private readonly settlementStartupModule: SettlementStartupModule ) { super(); diff --git a/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts b/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts index 890ab25fb..a9c42d7df 100644 --- a/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts +++ b/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts @@ -62,7 +62,7 @@ export class DefaultL1TransactionRetryStrategy public async prepareRetryTransaction( record: PendingL1TransactionRecord - ): Promise> { + ): Promise> { const tx = Transaction.fromJSON(JSON.parse(record.transactionJson)); const currentFee = tx.transaction.feePayer.body.fee; const newFee = UInt64.from(this.bumpFee(Number(currentFee.toBigInt()))); diff --git a/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts b/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts index 19eb5c047..d85372277 100644 --- a/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts +++ b/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts @@ -10,6 +10,6 @@ export interface L1TransactionRetryStrategy { prepareRetryTransaction( record: PendingL1TransactionRecord, - ): Promise>; + ): Promise>; } diff --git a/packages/sequencer/src/settlement/transactions/LocalMinaSigner.ts b/packages/sequencer/src/settlement/transactions/LocalMinaSigner.ts deleted file mode 100644 index 096747f89..000000000 --- a/packages/sequencer/src/settlement/transactions/LocalMinaSigner.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Field, PrivateKey, PublicKey, Signature, Transaction } from "o1js"; -import { - sequencerModule, - SequencerModule, -} from "../../sequencer/builder/SequencerModule"; -import { MinaSigner } from "./MinaSigner"; -import { noop } from "@proto-kit/common"; - -export type LocalMinaSignerConfig = { - signers: PrivateKey[]; -}; - -@sequencerModule() -export class LocalMinaSigner - extends SequencerModule - implements MinaSigner -{ - public getContractKeys(): PublicKey[] { - return this.config.signers.map((signer) => signer.toPublicKey()); - } - - public sign(signatureData: Field[]): Signature { - return Signature.create(this.config.signers[0], signatureData); - } - - public signTransaction(tx: Transaction): Transaction { - return tx.sign([...this.config.signers]); - } - - public async start(): Promise { - noop(); - } -} diff --git a/packages/sequencer/src/settlement/transactions/MinaSigner.ts b/packages/sequencer/src/settlement/transactions/MinaSigner.ts deleted file mode 100644 index b9a45e559..000000000 --- a/packages/sequencer/src/settlement/transactions/MinaSigner.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Field, Signature, Transaction, PublicKey } from "o1js"; - -export interface MinaSigner { - getContractKeys(): PublicKey[]; - - sign(signatureData: Field[]): Signature; - - signTransaction( - tx: Transaction - ): Transaction; -} \ No newline at end of file diff --git a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts index 4e125bd71..2839f4492 100644 --- a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts +++ b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts @@ -1,4 +1,4 @@ -import { fetchAccount, Mina, PublicKey, Transaction, UInt64 } from "o1js"; +import { fetchAccount, Mina, PublicKey, Transaction } from "o1js"; import { inject, injectable } from "tsyringe"; import { EventsRecord, @@ -17,11 +17,11 @@ import { SettlementProvingTask, TransactionTaskResult, } from "../tasks/SettlementProvingTask"; -import { MinaSigner } from "./MinaSigner"; import { MinaTransactionSimulator } from "./MinaTransactionSimulator"; import { L1TransactionRetryStrategy } from "./L1TransactionRetryStrategy"; import { FeeStrategy } from "../../protocol/baselayer/fees/FeeStrategy"; +import { MinaSigner } from "../MinaSigner"; export interface TxEvents extends EventsRecord { sent: [{ hash: string }]; @@ -48,7 +48,7 @@ export class MinaTransactionSender { private readonly pendingStorage: PendingL1TransactionStorage, @inject("L1TransactionRetryStrategy") private readonly retryStrategy: L1TransactionRetryStrategy, - @inject("MinaSigner") private readonly signer: MinaSigner, + @inject("SettlementSigner") private readonly signer: MinaSigner, @inject("FeeStrategy") private readonly feeStrategy: FeeStrategy ) { void this.startPolling(); @@ -281,7 +281,7 @@ export class MinaTransactionSender { // Prepare retry try { const retryTx = await this.retryStrategy.prepareRetryTransaction(record); - const signedRetryTx = this.signer.signTransaction(retryTx); + const signedRetryTx = this.signer.signTx(retryTx); // Send the retry transaction await this.sendTransaction({...record, transactionJson: this.serializeTransaction(signedRetryTx), attempts: record.attempts + 1}); } catch (error) { diff --git a/packages/sequencer/test/settlement/Settlement.ts b/packages/sequencer/test/settlement/Settlement.ts index 9c180e7ef..6ee233ac5 100644 --- a/packages/sequencer/test/settlement/Settlement.ts +++ b/packages/sequencer/test/settlement/Settlement.ts @@ -7,7 +7,6 @@ import { import { VanillaProtocolModules } from "@proto-kit/library"; import { Runtime } from "@proto-kit/module"; import { DefaultL1TransactionRetryStrategy } from "../../src/settlement/transactions/DefaultL1TransactionRetryStrategy"; -import { LocalMinaSigner } from "../../src/settlement/transactions/LocalMinaSigner"; import { BlockProverPublicInput, BridgeContract, From 226be1f4b5b364563987c8782c6055f1d94b7efe Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Tue, 16 Dec 2025 16:36:39 +0530 Subject: [PATCH 09/19] storing transaction obj in PendingL1TransactionRecord instead of json string --- .../prisma/PrismaPendingL1TransactionStorage.ts | 4 ++-- .../prisma/mappers/PendingL1TransactionMapper.ts | 5 +++-- .../DefaultL1TransactionRetryStrategy.ts | 4 ++-- .../transactions/MinaTransactionSender.ts | 16 ++++------------ .../repositories/PendingL1TransactionStorage.ts | 4 +++- 5 files changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts index c63383067..21c7d6e06 100644 --- a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts +++ b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts @@ -28,7 +28,7 @@ export class PrismaPendingL1TransactionStorage nonce: record.nonce, attempts: record.attempts, status: status, - transaction: JSON.parse(record.transactionJson), + transaction: record.transaction.toJSON(), lastError: record.lastError ?? null, sentAt: record.sentAt, }, @@ -51,7 +51,7 @@ export class PrismaPendingL1TransactionStorage data: { ...(updates.attempts !== undefined && { attempts: updates.attempts }), ...(updates.status !== undefined && { status: updates.status }), - ...(updates.transactionJson !== undefined && { transaction: JSON.parse(updates.transactionJson) }), + ...(updates.transaction !== undefined && { transaction: updates.transaction.toJSON() }), ...(updates.lastError !== undefined && { lastError: updates.lastError }), ...(updates.sentAt !== undefined && { sentAt: updates.sentAt }), }, diff --git a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts index 314978b89..b56d04d0e 100644 --- a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts +++ b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts @@ -3,6 +3,7 @@ import { Prisma, } from "@prisma/client"; import { PendingL1TransactionRecord, PendingL1TransactionStatus } from "@proto-kit/sequencer"; +import { Mina } from "o1js"; export class PendingL1TransactionMapper { public mapOut(input: PendingL1Transaction): PendingL1TransactionRecord { @@ -11,7 +12,7 @@ export class PendingL1TransactionMapper { nonce: input.nonce, attempts: input.attempts, status: input.status as PendingL1TransactionStatus, - transactionJson: JSON.stringify(input.transaction), + transaction: Mina.Transaction.fromJSON(input.transaction as string), lastError: input.lastError ?? undefined, sentAt: input.sentAt ? new Date(input.sentAt) : undefined, }; @@ -25,7 +26,7 @@ export class PendingL1TransactionMapper { nonce: input.nonce, attempts: input.attempts, status: input.status, - transaction: JSON.parse(input.transactionJson), + transaction: input.transaction.toJSON(), lastError: input.lastError ?? null, sentAt: input.sentAt ? input.sentAt.toJSON() : null, }; diff --git a/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts b/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts index a9c42d7df..53310165e 100644 --- a/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts +++ b/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts @@ -63,7 +63,7 @@ export class DefaultL1TransactionRetryStrategy public async prepareRetryTransaction( record: PendingL1TransactionRecord ): Promise> { - const tx = Transaction.fromJSON(JSON.parse(record.transactionJson)); + const tx = record.transaction; const currentFee = tx.transaction.feePayer.body.fee; const newFee = UInt64.from(this.bumpFee(Number(currentFee.toBigInt()))); tx.setFee(newFee); @@ -78,7 +78,7 @@ export class DefaultL1TransactionRetryStrategy ) ) ); - return tx; + return tx as Transaction; } private bumpFee(currentFee: number): number { diff --git a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts index 2839f4492..6e77b3d1f 100644 --- a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts +++ b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts @@ -63,14 +63,6 @@ export class MinaTransactionSender { return parseInt(account.nonce.toString(), 10); } - private serializeTransaction(tx: Transaction): string { - return JSON.stringify(tx.toJSON()); - } - - private deserializeTransaction(json: string): Transaction { - return Mina.Transaction.fromJSON(JSON.parse(json)); - } - /** * Tf there is a transaction with a lower nonce thats not included yet, this transaction will be queued instead. * @param transaction - The transaction to prove and send. @@ -154,7 +146,7 @@ export class MinaTransactionSender { sender, nonce: nonceNum, attempts: 0, - transactionJson: this.serializeTransaction(result.transaction), + transaction: result.transaction, sentAt: new Date(), }); @@ -227,7 +219,7 @@ export class MinaTransactionSender { } private async sendTransaction(record: PendingL1TransactionRecord) { - const tx = this.deserializeTransaction(record.transactionJson); + const tx = record.transaction; const emitterKey = this.getEmitterKey(record.sender, record.nonce); const emitter = this.activeEmitters.get(emitterKey); @@ -238,7 +230,7 @@ export class MinaTransactionSender { status: "sent", attempts: record.attempts + 1, sentAt: new Date(), - transactionJson: this.serializeTransaction(tx), + transaction: tx, }); log.info(`Sent L1 transaction ${pendingTx.hash} for nonce ${record.nonce} (Attempt ${record.attempts + 1})`); @@ -283,7 +275,7 @@ export class MinaTransactionSender { const retryTx = await this.retryStrategy.prepareRetryTransaction(record); const signedRetryTx = this.signer.signTx(retryTx); // Send the retry transaction - await this.sendTransaction({...record, transactionJson: this.serializeTransaction(signedRetryTx), attempts: record.attempts + 1}); + await this.sendTransaction({...record, transaction: signedRetryTx, attempts: record.attempts + 1}); } catch (error) { log.error(`Failed to prepare retry for ${record.sender}:${record.nonce}`, error); await this.pendingStorage.update(record.sender, record.nonce, { diff --git a/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts b/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts index f90e88409..65b950512 100644 --- a/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts +++ b/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts @@ -1,3 +1,5 @@ +import { Transaction } from "o1js"; + export type PendingL1TransactionStatus = | "queued" | "sent" @@ -9,7 +11,7 @@ export interface PendingL1TransactionRecord { nonce: number; attempts: number; status: PendingL1TransactionStatus; - transactionJson: string; + transaction: Transaction; lastError?: string; sentAt?: Date; } From 2b18a0d73e265ec8e15827551837b20066b07662 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Thu, 18 Dec 2025 14:23:06 +0530 Subject: [PATCH 10/19] making MinaTransactionSender closeable --- .../PrismaPendingL1TransactionStorage.ts | 10 +------ .../transactions/MinaTransactionSender.ts | 26 +++++++++++++------ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts index 21c7d6e06..3ddd72976 100644 --- a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts +++ b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts @@ -23,15 +23,7 @@ export class PrismaPendingL1TransactionStorage const { prismaClient } = this.connection; const status:PendingL1TransactionStatus = "queued"; await prismaClient.pendingL1Transaction.create({ - data: { - sender: record.sender, - nonce: record.nonce, - attempts: record.attempts, - status: status, - transaction: record.transaction.toJSON(), - lastError: record.lastError ?? null, - sentAt: record.sentAt, - }, + data: this.mapper.mapIn({...record, status}), }); } diff --git a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts index 6e77b3d1f..5798954f8 100644 --- a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts +++ b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts @@ -1,4 +1,4 @@ -import { fetchAccount, Mina, PublicKey, Transaction } from "o1js"; +import { Bool, fetchAccount, Mina, PublicKey, Transaction } from "o1js"; import { inject, injectable } from "tsyringe"; import { EventsRecord, @@ -22,6 +22,7 @@ import { MinaTransactionSimulator } from "./MinaTransactionSimulator"; import { L1TransactionRetryStrategy } from "./L1TransactionRetryStrategy"; import { FeeStrategy } from "../../protocol/baselayer/fees/FeeStrategy"; import { MinaSigner } from "../MinaSigner"; +import { closeable, Closeable } from "../../sequencer/builder/Closeable"; export interface TxEvents extends EventsRecord { sent: [{ hash: string }]; @@ -33,12 +34,15 @@ export type TxSendResult = Input extends "none" ? void : { hash: string }; @injectable() -export class MinaTransactionSender { +@closeable() +export class MinaTransactionSender implements Closeable { private activeEmitters = new Map< string, ReplayingSingleUseEventEmitter >(); + private interval?: any; + public constructor( private readonly creator: FlowCreator, private readonly provingTask: SettlementProvingTask, @@ -51,7 +55,7 @@ export class MinaTransactionSender { @inject("SettlementSigner") private readonly signer: MinaSigner, @inject("FeeStrategy") private readonly feeStrategy: FeeStrategy ) { - void this.startPolling(); + this.startPolling(); } private getEmitterKey(sender: string, nonce: number): string { @@ -180,16 +184,22 @@ export class MinaTransactionSender { return undefined as TxSendResult; } - private async startPolling() { - // Polling loop - while (true) { + private startPolling() { + const intervalMs = 5000; + + this.interval = setInterval(async () => { try { await this.processPendingTransactions(); - await new Promise((r) => setTimeout(r, 5000)); // 5s interval } catch (e) { log.error("Error in MinaTransactionSender polling loop", e); - await new Promise((r) => setTimeout(r, 10000)); } + }, intervalMs); + } + + public async close(): Promise { + if (this.interval !== undefined) { + clearInterval(this.interval); + this.interval = undefined; } } From 2fe04641c65e51d4b914c4fbb68165ee9f0a42bf Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Thu, 18 Dec 2025 19:01:40 +0530 Subject: [PATCH 11/19] Refactor PendingL1Transaction and Settlement models to use transactionId instead of transactionHash, PendingL1TransactionStorage to use id as the primary identifier. --- packages/persistance/prisma/schema.prisma | 12 +++---- .../PrismaPendingL1TransactionStorage.ts | 34 ++++++------------- .../prisma/PrismaSettlementStorage.ts | 2 +- .../services/prisma/mappers/BatchMapper.ts | 2 +- .../mappers/PendingL1TransactionMapper.ts | 2 ++ .../prisma/mappers/SettlementMapper.ts | 4 +-- .../src/settlement/SettlementModule.ts | 5 ++- .../transactions/MinaTransactionSender.ts | 22 ++++++------ .../InMemoryPendingL1TransactionStorage.ts | 32 +++++++---------- .../sequencer/src/storage/model/Settlement.ts | 2 +- .../PendingL1TransactionStorage.ts | 16 +++------ 11 files changed, 53 insertions(+), 80 deletions(-) diff --git a/packages/persistance/prisma/schema.prisma b/packages/persistance/prisma/schema.prisma index 0b18eb5f0..fea7a3633 100644 --- a/packages/persistance/prisma/schema.prisma +++ b/packages/persistance/prisma/schema.prisma @@ -105,8 +105,8 @@ model Batch { blocks Block[] - settlementTransactionHash String? - settlement Settlement? @relation(fields: [settlementTransactionHash], references: [transactionHash]) + settlementTransactionId String? + settlement Settlement? @relation(fields: [settlementTransactionId], references: [transactionId]) } model BlockResult { @@ -123,8 +123,7 @@ model BlockResult { } model Settlement { - // transaction String - transactionHash String @id + transactionId String @id promisedMessagesHash String batches Batch[] @@ -150,13 +149,14 @@ model IncomingMessageBatch { } model PendingL1Transaction { + id String @id @default(cuid()) sender String nonce Int attempts Int status String @db.VarChar(32) transaction Json @db.Json lastError String? - sentAt DateTime? + sentAt DateTime? - @@id([sender, nonce]) + @@unique([sender, nonce]) } diff --git a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts index 3ddd72976..0e9af7ffb 100644 --- a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts +++ b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts @@ -19,27 +19,22 @@ export class PrismaPendingL1TransactionStorage @inject("Database") private readonly connection: PrismaConnection ) {} - public async queue(record: Omit): Promise { + public async queue(record: Omit): Promise { const { prismaClient } = this.connection; const status:PendingL1TransactionStatus = "queued"; - await prismaClient.pendingL1Transaction.create({ + const txnRecord = await prismaClient.pendingL1Transaction.create({ data: this.mapper.mapIn({...record, status}), }); + return txnRecord.id; } public async update( - sender: string, - nonce: number, - updates: Partial> + id: string, + updates: Partial> ): Promise { const { prismaClient } = this.connection; await prismaClient.pendingL1Transaction.update({ - where: { - sender_nonce: { - sender, - nonce, - }, - }, + where: { id }, data: { ...(updates.attempts !== undefined && { attempts: updates.attempts }), ...(updates.status !== undefined && { status: updates.status }), @@ -50,29 +45,20 @@ export class PrismaPendingL1TransactionStorage }); } - public async delete(sender: string, nonce: number): Promise { + public async delete(id: string): Promise { const { prismaClient } = this.connection; await prismaClient.pendingL1Transaction.delete({ where: { - sender_nonce: { - sender, - nonce, - }, + id, }, }); } - public async findBySenderAndNonce( - sender: string, - nonce: number - ): Promise { + public async findById(id: string): Promise { const { prismaClient } = this.connection; const record = await prismaClient.pendingL1Transaction.findUnique({ where: { - sender_nonce: { - sender, - nonce, - }, + id, }, }); if (!record) { diff --git a/packages/persistance/src/services/prisma/PrismaSettlementStorage.ts b/packages/persistance/src/services/prisma/PrismaSettlementStorage.ts index e6b5addcb..47c5f3c31 100644 --- a/packages/persistance/src/services/prisma/PrismaSettlementStorage.ts +++ b/packages/persistance/src/services/prisma/PrismaSettlementStorage.ts @@ -18,7 +18,7 @@ export class PrismaSettlementStorage implements SettlementStorage { const batch = await prismaClient.batch.findFirst({ where: { - settlementTransactionHash: { + settlementTransactionId: { not: null, }, }, diff --git a/packages/persistance/src/services/prisma/mappers/BatchMapper.ts b/packages/persistance/src/services/prisma/mappers/BatchMapper.ts index 490c6cda4..e69f40fcb 100644 --- a/packages/persistance/src/services/prisma/mappers/BatchMapper.ts +++ b/packages/persistance/src/services/prisma/mappers/BatchMapper.ts @@ -21,7 +21,7 @@ export class BatchMapper const batch: PrismaBatch = { proof: input.proof, height: input.height, - settlementTransactionHash: null, + settlementTransactionId: null, }; return [batch, []]; } diff --git a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts index b56d04d0e..3b6b9314d 100644 --- a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts +++ b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts @@ -8,6 +8,7 @@ import { Mina } from "o1js"; export class PendingL1TransactionMapper { public mapOut(input: PendingL1Transaction): PendingL1TransactionRecord { return { + id: input.id, sender: input.sender, nonce: input.nonce, attempts: input.attempts, @@ -22,6 +23,7 @@ export class PendingL1TransactionMapper { input: PendingL1TransactionRecord ): Prisma.PendingL1TransactionCreateInput { return { + id: input.id, sender: input.sender, nonce: input.nonce, attempts: input.attempts, diff --git a/packages/persistance/src/services/prisma/mappers/SettlementMapper.ts b/packages/persistance/src/services/prisma/mappers/SettlementMapper.ts index 5f923e34f..33979555c 100644 --- a/packages/persistance/src/services/prisma/mappers/SettlementMapper.ts +++ b/packages/persistance/src/services/prisma/mappers/SettlementMapper.ts @@ -12,7 +12,7 @@ export class SettlementMapper const [settlement, batches] = input; return { batches, - transactionHash: settlement.transactionHash, + transactionId: settlement.transactionId, promisedMessagesHash: settlement.promisedMessagesHash, }; } @@ -21,7 +21,7 @@ export class SettlementMapper return [ { promisedMessagesHash: input.promisedMessagesHash, - transactionHash: input.transactionHash, + transactionId: input.transactionId, }, input.batches, ]; diff --git a/packages/sequencer/src/settlement/SettlementModule.ts b/packages/sequencer/src/settlement/SettlementModule.ts index 6c29a20d6..bfd817fe7 100644 --- a/packages/sequencer/src/settlement/SettlementModule.ts +++ b/packages/sequencer/src/settlement/SettlementModule.ts @@ -187,15 +187,14 @@ export class SettlementModule signingWithSignatureCheck: [...this.signer.getContractAddresses()], }); - const { hash: transactionHash } = - await this.transactionSender.proveAndSendTransaction(tx, "included"); + const { transactionId: txId } = await this.transactionSender.proveAndSendTransaction(tx, "included"); log.info("Settlement transaction sent and included"); const settlement = { batches: [batch.height], promisedMessagesHash: latestSequenceStateHash.toString(), - transactionHash, + transactionId: txId, }; await this.settlementStorage.pushSettlement(settlement); diff --git a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts index 5798954f8..87bca1b00 100644 --- a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts +++ b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts @@ -31,7 +31,7 @@ export interface TxEvents extends EventsRecord { } export type TxSendResult = - Input extends "none" ? void : { hash: string }; + Input extends "none" ? void : { transactionId: string }; @injectable() @closeable() @@ -146,7 +146,7 @@ export class MinaTransactionSender implements Closeable { // const signedTx = this.signer.signTransaction(result.transaction); // Queue the transaction - await this.pendingStorage.queue({ + const txnId = await this.pendingStorage.queue({ sender, nonce: nonceNum, attempts: 0, @@ -156,11 +156,11 @@ export class MinaTransactionSender implements Closeable { if (waitOnStatus !== "none") { const waitInstruction: "sent" | "included" = waitOnStatus; - const hash = await new Promise>( + const {transactionId: txId} = await new Promise>( (resolve, reject) => { emitter.on(waitInstruction, (txSendResult) => { - log.info(`Tx ${txSendResult.hash} ${waitInstruction}`); - resolve(txSendResult); + log.info(`Tx ${txnId} ${waitInstruction}`); + resolve({ transactionId: txnId }); if (waitInstruction === "included") { this.activeEmitters.delete(emitterKey); } @@ -174,7 +174,7 @@ export class MinaTransactionSender implements Closeable { // Yeah that's not super clean, but couldn't figure out a better way tbh // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return hash as TxSendResult; + return {transactionId: txId} as TxSendResult; } // If waitOnStatus is none, delete the emitter. @@ -236,7 +236,7 @@ export class MinaTransactionSender implements Closeable { try { const pendingTx = await tx.send(); // Update DB - await this.pendingStorage.update(record.sender, record.nonce, { + await this.pendingStorage.update(record.id, { status: "sent", attempts: record.attempts + 1, sentAt: new Date(), @@ -244,14 +244,14 @@ export class MinaTransactionSender implements Closeable { }); log.info(`Sent L1 transaction ${pendingTx.hash} for nonce ${record.nonce} (Attempt ${record.attempts + 1})`); - emitter?.emit("sent", { hash: pendingTx.hash }); + emitter?.emit("sent", { hash: pendingTx.hash}); // Wait for inclusion pendingTx.wait().then( async (included) => { log.info(`Transaction ${included.hash} included`); emitter?.emit("included", { hash: included.hash }); - await this.pendingStorage.update(record.sender, record.nonce, { status: "included" }); + await this.pendingStorage.update(record.id, { status: "included" }); this.activeEmitters.delete(emitterKey); }, async (error) => { @@ -262,7 +262,7 @@ export class MinaTransactionSender implements Closeable { ); } catch (error) { log.error(`Failed to send transaction ${record.sender}:${record.nonce}`, error); - await this.pendingStorage.update(record.sender, record.nonce, { + await this.pendingStorage.update(record.id, { status: "failed", lastError: error instanceof Error ? error.message : String(error), }); @@ -288,7 +288,7 @@ export class MinaTransactionSender implements Closeable { await this.sendTransaction({...record, transaction: signedRetryTx, attempts: record.attempts + 1}); } catch (error) { log.error(`Failed to prepare retry for ${record.sender}:${record.nonce}`, error); - await this.pendingStorage.update(record.sender, record.nonce, { + await this.pendingStorage.update(record.id, { status: "failed", lastError: error instanceof Error ? error.message : String(error), }); diff --git a/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts b/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts index 88ad204d1..73fc5bf98 100644 --- a/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts +++ b/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts @@ -10,45 +10,37 @@ export class InMemoryPendingL1TransactionStorage // Key: sender:nonce private store = new Map(); - private getKey(sender: string, nonce: number): string { - return `${sender}:${nonce}`; - } - public async queue(record: Omit): Promise { - const key = this.getKey(record.sender, record.nonce); + public async queue(record: Omit): Promise { + const key = Math.random().toString(36).substring(2, 15); this.store.set(key, { ...record, + id: key, status: "queued", }); + return key; } public async update( - sender: string, - nonce: number, - updates: Partial> + id: string, + updates: Partial> ): Promise { - const key = this.getKey(sender, nonce); - const existing = this.store.get(key); + const existing = this.store.get(id); if (existing === undefined) { return; } - this.store.set(key, { + this.store.set(id, { ...existing, ...updates, }); } - public async delete(sender: string, nonce: number): Promise { - const key = this.getKey(sender, nonce); - this.store.delete(key); + public async delete(id: string): Promise { + this.store.delete(id); } - public async findBySenderAndNonce( - sender: string, - nonce: number - ): Promise { - const key = this.getKey(sender, nonce); - return this.store.get(key); + public async findById(id: string): Promise { + return this.store.get(id); } public async findByStatuses(statuses: PendingL1TransactionStatus[]): Promise { diff --git a/packages/sequencer/src/storage/model/Settlement.ts b/packages/sequencer/src/storage/model/Settlement.ts index f3b91ab73..ea8c807f0 100644 --- a/packages/sequencer/src/storage/model/Settlement.ts +++ b/packages/sequencer/src/storage/model/Settlement.ts @@ -1,5 +1,5 @@ export interface Settlement { - transactionHash: string; + transactionId: string; promisedMessagesHash: string; batches: number[]; } diff --git a/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts b/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts index 65b950512..882fc4e70 100644 --- a/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts +++ b/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts @@ -7,6 +7,7 @@ export type PendingL1TransactionStatus = | "failed"; export interface PendingL1TransactionRecord { + id: string; sender: string; nonce: number; attempts: number; @@ -17,20 +18,13 @@ export interface PendingL1TransactionRecord { } export interface PendingL1TransactionStorage { - queue(record: Omit): Promise; + queue(record: Omit): Promise; - update( - sender: string, - nonce: number, - updates: Partial> - ): Promise; + update(id: string, updates: Partial>): Promise; - delete(sender: string, nonce: number): Promise; + delete(id: string): Promise; - findBySenderAndNonce( - sender: string, - nonce: number - ): Promise; + findById(id: string): Promise; findByStatuses(statuses: PendingL1TransactionStatus[]): Promise; } From 5a02ce095d579a7cf2f203e0c43b87858f5ba5e8 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Fri, 19 Dec 2025 19:30:54 +0530 Subject: [PATCH 12/19] added a queued wait status in MinaTransactionSender --- .../src/settlement/transactions/MinaTransactionSender.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts index 87bca1b00..67cce7f7c 100644 --- a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts +++ b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts @@ -30,7 +30,7 @@ export interface TxEvents extends EventsRecord { rejected: [any]; } -export type TxSendResult = +export type TxSendResult = Input extends "none" ? void : { transactionId: string }; @injectable() @@ -74,7 +74,7 @@ export class MinaTransactionSender implements Closeable { * @returns */ public async proveAndSendTransaction< - Wait extends "sent" | "included" | "none", + Wait extends "sent" | "included" | "queued" | "none", >( transaction: Transaction, waitOnStatus: Wait @@ -153,6 +153,9 @@ export class MinaTransactionSender implements Closeable { transaction: result.transaction, sentAt: new Date(), }); + if (waitOnStatus === "queued") { + return {transactionId: txnId} as TxSendResult; + } if (waitOnStatus !== "none") { const waitInstruction: "sent" | "included" = waitOnStatus; From 843d91b0423d92a6ccd809cc41da602d14991f3a Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Fri, 19 Dec 2025 20:17:31 +0530 Subject: [PATCH 13/19] adding foreign key relation between Settlement and PendingL1Transaction --- package-lock.json | 56 +---------------------- package.json | 3 +- packages/persistance/prisma/schema.prisma | 3 ++ 3 files changed, 5 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 238383c60..72747ccfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "packages/*" ], "dependencies": { - "clean": "^4.0.2", "react-json-view-lite": "^1.4.0" }, "devDependencies": { @@ -44,7 +43,7 @@ "ts-jest": "^29.0.5", "typedoc": "^0.26.11", "typedoc-plugin-frontmatter": "1.0.0", - "typedoc-plugin-inline-sources": "1.2.0", + "typedoc-plugin-inline-sources": "^1.2.0", "typedoc-plugin-markdown": "4.2.10", "typescript": "5.1" } @@ -7498,18 +7497,6 @@ "validator": "^13.9.0" } }, - "node_modules/clean": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/clean/-/clean-4.0.2.tgz", - "integrity": "sha512-2LGVh4dNtI16L4UzqDHO6Hbl74YjG1vWvEUU78dgLO4kuyqJZFMNMPBx+EGtYKTFb14e24p+gWXgkabqxc1EUw==", - "license": "MIT", - "dependencies": { - "async": "^0.9.0", - "minimist": "^1.1.0", - "mix2": "^1.0.0", - "skema": "^1.0.0" - } - }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -7529,12 +7516,6 @@ "node": ">=6" } }, - "node_modules/clean/node_modules/async": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", - "integrity": "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw==", - "license": "MIT" - }, "node_modules/cli-boxes": { "version": "3.0.0", "license": "MIT", @@ -16896,15 +16877,6 @@ "node": ">=12" } }, - "node_modules/make-array": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/make-array/-/make-array-0.1.2.tgz", - "integrity": "sha512-bcFmxgZ+OTaMYJp/w6eifElKTcfum7Gi5H7vQ8KzAf9X6swdxkVuilCaG3ZjXr/qJsQT4JJ2Rq9SDYScWEdu9Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/make-dir": { "version": "4.0.0", "license": "MIT", @@ -17877,15 +17849,6 @@ "dev": true, "license": "ISC" }, - "node_modules/mix2": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/mix2/-/mix2-1.0.5.tgz", - "integrity": "sha512-ybWz7nY+WHBBIyliND5eYaJKzkoa+qXRYNTmVqAxSLlFtL/umT2iv+pmyTu1oU7WNkrirwheqR8d9EaKVz0e5g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mkdirp": { "version": "1.0.4", "license": "MIT", @@ -21799,23 +21762,6 @@ "version": "1.0.5", "license": "MIT" }, - "node_modules/skema": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz", - "integrity": "sha512-5LWfF2RSW2B3xfOaY6j49X8aNwsnj9cRVrM5QMF7it+cZvpv5ufiOUT13ps2U52sIbAzs11bdRP6mi5qyg75VQ==", - "license": "MIT", - "dependencies": { - "async": "^0.9.0", - "make-array": "^0.1.2", - "mix2": "^1.0.0" - } - }, - "node_modules/skema/node_modules/async": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", - "integrity": "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw==", - "license": "MIT" - }, "node_modules/slash": { "version": "3.0.0", "license": "MIT", diff --git a/package.json b/package.json index ff39f5177..b2e673a26 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "ts-jest": "^29.0.5", "typedoc": "^0.26.11", "typedoc-plugin-frontmatter": "1.0.0", - "typedoc-plugin-inline-sources": "1.2.0", + "typedoc-plugin-inline-sources": "^1.2.0", "typedoc-plugin-markdown": "4.2.10", "typescript": "5.1", "istanbul-merge": "^2.0.0" @@ -74,7 +74,6 @@ ] }, "dependencies": { - "clean": "^4.0.2", "react-json-view-lite": "^1.4.0" } } diff --git a/packages/persistance/prisma/schema.prisma b/packages/persistance/prisma/schema.prisma index fea7a3633..30089b853 100644 --- a/packages/persistance/prisma/schema.prisma +++ b/packages/persistance/prisma/schema.prisma @@ -127,6 +127,7 @@ model Settlement { promisedMessagesHash String batches Batch[] + pendingL1Transaction PendingL1Transaction? @relation(fields: [transactionId], references: [id]) } model IncomingMessageBatchTransaction { @@ -158,5 +159,7 @@ model PendingL1Transaction { lastError String? sentAt DateTime? + settlement Settlement? + @@unique([sender, nonce]) } From e4ec80b925b50d7aaa401a98c856ff36597ef108 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Fri, 19 Dec 2025 21:05:33 +0530 Subject: [PATCH 14/19] fixing lint errors --- .../GeneratedResolverFactoryGraphqlModule.ts | 3 +- packages/persistance/prisma/schema.prisma | 18 +-- .../PrismaPendingL1TransactionStorage.ts | 26 ++-- .../mappers/PendingL1TransactionMapper.ts | 8 +- .../src/settlement/SettlementModule.ts | 12 +- .../DefaultL1TransactionRetryStrategy.ts | 27 ++-- .../L1TransactionRetryStrategy.ts | 16 +-- .../transactions/MinaTransactionSender.ts | 121 +++++++++++------- .../InMemoryPendingL1TransactionStorage.ts | 13 +- .../PendingL1TransactionStorage.ts | 13 +- .../sequencer/test/settlement/Settlement.ts | 7 +- 11 files changed, 154 insertions(+), 110 deletions(-) diff --git a/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts b/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts index 4f991d012..c3094795f 100644 --- a/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts +++ b/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts @@ -5,7 +5,6 @@ import { } from "@proto-kit/api"; import { NonEmptyArray, createMethodMiddlewareDecorator } from "type-graphql"; import { inject } from "tsyringe"; -// eslint-disable-next-line import/no-extraneous-dependencies import { PrismaClient } from "@prisma/client-indexer"; import { @@ -90,6 +89,7 @@ export class GeneratedResolverFactoryGraphqlModule extends ResolverFactoryGraphq public async initializePrismaClient() { // setup the prisma client and feed it to the server, // since this is necessary for the returned resolvers to work + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const prismaClient = new PrismaClient({ // datasourceUrl: 'postgresql://admin:password@localhost:5433/protokit-indexer?schema=public' }); @@ -100,6 +100,7 @@ export class GeneratedResolverFactoryGraphqlModule extends ResolverFactoryGraphq public async resolvers(): Promise> { this.graphqlServer.setContext({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment prisma: await this.initializePrismaClient(), }); diff --git a/packages/persistance/prisma/schema.prisma b/packages/persistance/prisma/schema.prisma index 30089b853..e76180c18 100644 --- a/packages/persistance/prisma/schema.prisma +++ b/packages/persistance/prisma/schema.prisma @@ -126,7 +126,7 @@ model Settlement { transactionId String @id promisedMessagesHash String - batches Batch[] + batches Batch[] pendingL1Transaction PendingL1Transaction? @relation(fields: [transactionId], references: [id]) } @@ -150,14 +150,14 @@ model IncomingMessageBatch { } model PendingL1Transaction { - id String @id @default(cuid()) - sender String - nonce Int - attempts Int - status String @db.VarChar(32) - transaction Json @db.Json - lastError String? - sentAt DateTime? + id String @id @default(cuid()) + sender String + nonce Int + attempts Int + status String @db.VarChar(32) + transaction Json @db.Json + lastError String? + sentAt DateTime? settlement Settlement? diff --git a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts index 0e9af7ffb..2a4b45fcf 100644 --- a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts +++ b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts @@ -1,5 +1,4 @@ import { inject, injectable } from "tsyringe"; -import { Prisma } from "@prisma/client"; import { PendingL1TransactionRecord, PendingL1TransactionStatus, @@ -7,6 +6,7 @@ import { } from "@proto-kit/sequencer"; import type { PrismaConnection } from "../../PrismaDatabaseConnection"; + import { PendingL1TransactionMapper } from "./mappers/PendingL1TransactionMapper"; @injectable() @@ -19,11 +19,13 @@ export class PrismaPendingL1TransactionStorage @inject("Database") private readonly connection: PrismaConnection ) {} - public async queue(record: Omit): Promise { + public async queue( + record: Omit + ): Promise { const { prismaClient } = this.connection; - const status:PendingL1TransactionStatus = "queued"; + const status: PendingL1TransactionStatus = "queued"; const txnRecord = await prismaClient.pendingL1Transaction.create({ - data: this.mapper.mapIn({...record, status}), + data: this.mapper.mapIn({ ...record, status }), }); return txnRecord.id; } @@ -38,8 +40,12 @@ export class PrismaPendingL1TransactionStorage data: { ...(updates.attempts !== undefined && { attempts: updates.attempts }), ...(updates.status !== undefined && { status: updates.status }), - ...(updates.transaction !== undefined && { transaction: updates.transaction.toJSON() }), - ...(updates.lastError !== undefined && { lastError: updates.lastError }), + ...(updates.transaction !== undefined && { + transaction: updates.transaction.toJSON(), + }), + ...(updates.lastError !== undefined && { + lastError: updates.lastError, + }), ...(updates.sentAt !== undefined && { sentAt: updates.sentAt }), }, }); @@ -54,7 +60,9 @@ export class PrismaPendingL1TransactionStorage }); } - public async findById(id: string): Promise { + public async findById( + id: string + ): Promise { const { prismaClient } = this.connection; const record = await prismaClient.pendingL1Transaction.findUnique({ where: { @@ -67,7 +75,9 @@ export class PrismaPendingL1TransactionStorage return this.mapper.mapOut(record); } - public async findByStatuses(statuses: PendingL1TransactionStatus[]): Promise { + public async findByStatuses( + statuses: PendingL1TransactionStatus[] + ): Promise { const { prismaClient } = this.connection; const rows = await prismaClient.pendingL1Transaction.findMany({ where: { diff --git a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts index 3b6b9314d..8355bf672 100644 --- a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts +++ b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts @@ -1,8 +1,8 @@ +import { PendingL1Transaction, Prisma } from "@prisma/client"; import { - PendingL1Transaction, - Prisma, -} from "@prisma/client"; -import { PendingL1TransactionRecord, PendingL1TransactionStatus } from "@proto-kit/sequencer"; + PendingL1TransactionRecord, + PendingL1TransactionStatus, +} from "@proto-kit/sequencer"; import { Mina } from "o1js"; export class PendingL1TransactionMapper { diff --git a/packages/sequencer/src/settlement/SettlementModule.ts b/packages/sequencer/src/settlement/SettlementModule.ts index bfd817fe7..2d2211f68 100644 --- a/packages/sequencer/src/settlement/SettlementModule.ts +++ b/packages/sequencer/src/settlement/SettlementModule.ts @@ -14,7 +14,6 @@ import { fetchAccount, Field, Mina, - PrivateKey, PublicKey, TokenContract, TokenId, @@ -168,7 +167,7 @@ export class SettlementModule { sender: feepayer, nonce: options?.nonce, - memo: "Protokit settle" + memo: "Protokit settle", }, async () => { await settlementContract.settle( @@ -187,7 +186,8 @@ export class SettlementModule signingWithSignatureCheck: [...this.signer.getContractAddresses()], }); - const { transactionId: txId } = await this.transactionSender.proveAndSendTransaction(tx, "included"); + const { transactionId: txId } = + await this.transactionSender.proveAndSendTransaction(tx, "included"); log.info("Settlement transaction sent and included"); @@ -236,7 +236,7 @@ export class SettlementModule { sender: feepayer, nonce, - memo: "Protokit settlement deploy" + memo: "Protokit settlement deploy", }, async () => { AccountUpdate.fundNewAccount(feepayer, 2); @@ -277,7 +277,7 @@ export class SettlementModule { sender: feepayer, nonce: nonce + 1, - memo: "Protokit settlement init" + memo: "Protokit settlement init", }, async () => { AccountUpdate.fundNewAccount(feepayer, 1); @@ -321,7 +321,7 @@ export class SettlementModule { sender: feepayer, nonce: nonce, - memo: `Deploy token bridge for ${truncate(tokenId.toString(), { length: 6 })}` + memo: `Deploy token bridge for ${truncate(tokenId.toString(), { length: 6 })}`, }, async () => { AccountUpdate.fundNewAccount(feepayer, 1); diff --git a/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts b/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts index 53310165e..0a42bad9f 100644 --- a/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts +++ b/packages/sequencer/src/settlement/transactions/DefaultL1TransactionRetryStrategy.ts @@ -1,4 +1,4 @@ -import { noop } from "@proto-kit/common"; +import { noop, sleep } from "@proto-kit/common"; import { Transaction, UInt64 } from "o1js"; import { inject } from "tsyringe"; @@ -54,31 +54,26 @@ export class DefaultL1TransactionRetryStrategy public async shouldRetry( record: PendingL1TransactionRecord ): Promise { - if (record.attempts >= this.retryConfig.maxAttempts) { - return false; - } - return true; + return record.attempts < this.retryConfig.maxAttempts; } public async prepareRetryTransaction( record: PendingL1TransactionRecord - ): Promise> { + ): Promise> { const tx = record.transaction; const currentFee = tx.transaction.feePayer.body.fee; const newFee = UInt64.from(this.bumpFee(Number(currentFee.toBigInt()))); - tx.setFee(newFee); + await tx.setFee(newFee); // Delay if needed - await new Promise((resolve) => - setTimeout( - resolve, - Math.max( - 0, - this.retryConfig.retryDelayMs - - (Date.now() - (record.sentAt?.getTime() ?? 0)) - ) + await sleep( + Math.max( + 0, + this.retryConfig.retryDelayMs - + (Date.now() - (record.sentAt?.getTime() ?? 0)) ) ); - return tx as Transaction; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return tx as Transaction; } private bumpFee(currentFee: number): number { diff --git a/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts b/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts index d85372277..0f950276a 100644 --- a/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts +++ b/packages/sequencer/src/settlement/transactions/L1TransactionRetryStrategy.ts @@ -1,15 +1,11 @@ -import { - PendingL1TransactionRecord, -} from "../../storage/repositories/PendingL1TransactionStorage"; import { Transaction } from "o1js"; +import { PendingL1TransactionRecord } from "../../storage/repositories/PendingL1TransactionStorage"; + export interface L1TransactionRetryStrategy { - shouldRetry( - record: PendingL1TransactionRecord - ): Promise; - + shouldRetry(record: PendingL1TransactionRecord): Promise; + prepareRetryTransaction( - record: PendingL1TransactionRecord, - ): Promise>; + record: PendingL1TransactionRecord + ): Promise>; } - diff --git a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts index 67cce7f7c..5b1257cbc 100644 --- a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts +++ b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts @@ -1,4 +1,4 @@ -import { Bool, fetchAccount, Mina, PublicKey, Transaction } from "o1js"; +import { fetchAccount, PublicKey, Transaction } from "o1js"; import { inject, injectable } from "tsyringe"; import { EventsRecord, @@ -17,21 +17,22 @@ import { SettlementProvingTask, TransactionTaskResult, } from "../tasks/SettlementProvingTask"; - -import { MinaTransactionSimulator } from "./MinaTransactionSimulator"; -import { L1TransactionRetryStrategy } from "./L1TransactionRetryStrategy"; import { FeeStrategy } from "../../protocol/baselayer/fees/FeeStrategy"; import { MinaSigner } from "../MinaSigner"; import { closeable, Closeable } from "../../sequencer/builder/Closeable"; +import { L1TransactionRetryStrategy } from "./L1TransactionRetryStrategy"; +import { MinaTransactionSimulator } from "./MinaTransactionSimulator"; + export interface TxEvents extends EventsRecord { sent: [{ hash: string }]; included: [{ hash: string }]; rejected: [any]; } -export type TxSendResult = - Input extends "none" ? void : { transactionId: string }; +export type TxSendResult< + Input extends "sent" | "included" | "queued" | "none", +> = Input extends "none" ? void : { transactionId: string }; @injectable() @closeable() @@ -68,10 +69,11 @@ export class MinaTransactionSender implements Closeable { } /** - * Tf there is a transaction with a lower nonce thats not included yet, this transaction will be queued instead. + * If there is a transaction with a lower nonce thats not included yet, + * this transaction will be queued instead. * @param transaction - The transaction to prove and send. - * @param waitOnStatus - * @returns + * @param waitOnStatus + * @returns */ public async proveAndSendTransaction< Wait extends "sent" | "included" | "queued" | "none", @@ -92,9 +94,7 @@ export class MinaTransactionSender implements Closeable { const emitter = new ReplayingSingleUseEventEmitter(); this.activeEmitters.set(emitterKey, emitter); - log.debug( - `Proving tx from sender ${sender} nonce ${nonce.toString()}` - ); + log.debug(`Proving tx from sender ${sender} nonce ${nonce.toString()}`); const flow = this.creator.createFlow( `tx-${sender}-${nonce.toString()}`, @@ -122,7 +122,7 @@ export class MinaTransactionSender implements Closeable { await flow.pushTask( this.provingTask, { - transaction: signedTx as Transaction, + transaction: signedTx, chainState: { graphql, accounts: accounts @@ -143,8 +143,6 @@ export class MinaTransactionSender implements Closeable { log.trace(result.transaction.toPretty()); - // const signedTx = this.signer.signTransaction(result.transaction); - // Queue the transaction const txnId = await this.pendingStorage.queue({ sender, @@ -154,32 +152,33 @@ export class MinaTransactionSender implements Closeable { sentAt: new Date(), }); if (waitOnStatus === "queued") { - return {transactionId: txnId} as TxSendResult; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return { transactionId: txnId } as TxSendResult; } if (waitOnStatus !== "none") { const waitInstruction: "sent" | "included" = waitOnStatus; - const {transactionId: txId} = await new Promise>( - (resolve, reject) => { - emitter.on(waitInstruction, (txSendResult) => { - log.info(`Tx ${txnId} ${waitInstruction}`); - resolve({ transactionId: txnId }); - if (waitInstruction === "included") { - this.activeEmitters.delete(emitterKey); - } - }); - emitter.on("rejected", (error) => { - reject(error); + const { transactionId: txId } = await new Promise< + TxSendResult<"sent" | "included"> + >((resolve, reject) => { + emitter.on(waitInstruction, (txSendResult) => { + log.info(`Tx ${txnId} ${waitInstruction}`); + resolve({ transactionId: txnId }); + if (waitInstruction === "included") { this.activeEmitters.delete(emitterKey); - }); - } - ); + } + }); + emitter.on("rejected", (error) => { + reject(error); + this.activeEmitters.delete(emitterKey); + }); + }); // Yeah that's not super clean, but couldn't figure out a better way tbh // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return {transactionId: txId} as TxSendResult; + return { transactionId: txId } as TxSendResult; } - + // If waitOnStatus is none, delete the emitter. this.activeEmitters.delete(emitterKey); @@ -201,6 +200,7 @@ export class MinaTransactionSender implements Closeable { public async close(): Promise { if (this.interval !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument clearInterval(this.interval); this.interval = undefined; } @@ -208,7 +208,10 @@ export class MinaTransactionSender implements Closeable { private async processPendingTransactions() { // Find all pending transactions, state: queued | sent - const pendingTransactions = await this.pendingStorage.findByStatuses(["queued", "sent"]); + const pendingTransactions = await this.pendingStorage.findByStatuses([ + "queued", + "sent", + ]); const bySender: Record = {}; for (const tx of pendingTransactions) { @@ -218,16 +221,26 @@ export class MinaTransactionSender implements Closeable { for (const sender of Object.keys(bySender)) { // Sort in ascending order of nonce const txs = bySender[sender].sort((a, b) => a.nonce - b.nonce); + // eslint-disable-next-line no-continue if (txs.length === 0) continue; - // Send the first queued transaction, transactions stays in queued state until the previous transaction is included or rejected + // Send the first queued transaction, + // transactions stays in queued state until the previous transaction is included or rejected const txToSend = txs[0]; if (txToSend.status === "queued") { - await this.sendTransaction(txToSend); - } else if (txToSend.status === "sent" && !this.activeEmitters.has(this.getEmitterKey(txToSend.sender, txToSend.nonce))) { - // If the transaction is sent and the emitter is not active, [TODO] check L1 for inclusion and retry if needed + // eslint-disable-next-line no-await-in-loop await this.sendTransaction(txToSend); } + // If the transaction is sent and the emitter is not active, + // [TODO] check L1 for inclusion and retry if needed + // else if ( + // txToSend.status === "sent" && + // !this.activeEmitters.has( + // this.getEmitterKey(txToSend.sender, txToSend.nonce) + // ) + // ) { + // await this.sendTransaction(txToSend); + // } } } @@ -242,12 +255,14 @@ export class MinaTransactionSender implements Closeable { await this.pendingStorage.update(record.id, { status: "sent", attempts: record.attempts + 1, - sentAt: new Date(), + sentAt: new Date(), transaction: tx, }); - log.info(`Sent L1 transaction ${pendingTx.hash} for nonce ${record.nonce} (Attempt ${record.attempts + 1})`); - emitter?.emit("sent", { hash: pendingTx.hash}); + log.info( + `Sent L1 transaction ${pendingTx.hash} for nonce ${record.nonce} (Attempt ${record.attempts + 1})` + ); + emitter?.emit("sent", { hash: pendingTx.hash }); // Wait for inclusion pendingTx.wait().then( @@ -264,7 +279,10 @@ export class MinaTransactionSender implements Closeable { } ); } catch (error) { - log.error(`Failed to send transaction ${record.sender}:${record.nonce}`, error); + log.error( + `Failed to send transaction ${record.sender}:${record.nonce}`, + error + ); await this.pendingStorage.update(record.id, { status: "failed", lastError: error instanceof Error ? error.message : String(error), @@ -278,7 +296,10 @@ export class MinaTransactionSender implements Closeable { const emitterKey = this.getEmitterKey(record.sender, record.nonce); const emitter = this.activeEmitters.get(emitterKey); if (emitter) { - emitter.emit("rejected", new Error(`Max attempts reached for ${record.sender}:${record.nonce}`)); + emitter.emit( + "rejected", + new Error(`Max attempts reached for ${record.sender}:${record.nonce}`) + ); this.activeEmitters.delete(emitterKey); } return; @@ -286,11 +307,21 @@ export class MinaTransactionSender implements Closeable { // Prepare retry try { const retryTx = await this.retryStrategy.prepareRetryTransaction(record); - const signedRetryTx = this.signer.signTx(retryTx); + const signedRetryTx = this.signer.signTx( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + retryTx as Transaction + ); // Send the retry transaction - await this.sendTransaction({...record, transaction: signedRetryTx, attempts: record.attempts + 1}); + await this.sendTransaction({ + ...record, + transaction: signedRetryTx, + attempts: record.attempts + 1, + }); } catch (error) { - log.error(`Failed to prepare retry for ${record.sender}:${record.nonce}`, error); + log.error( + `Failed to prepare retry for ${record.sender}:${record.nonce}`, + error + ); await this.pendingStorage.update(record.id, { status: "failed", lastError: error instanceof Error ? error.message : String(error), diff --git a/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts b/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts index 73fc5bf98..8e77ac79b 100644 --- a/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts +++ b/packages/sequencer/src/storage/inmemory/InMemoryPendingL1TransactionStorage.ts @@ -10,8 +10,9 @@ export class InMemoryPendingL1TransactionStorage // Key: sender:nonce private store = new Map(); - - public async queue(record: Omit): Promise { + public async queue( + record: Omit + ): Promise { const key = Math.random().toString(36).substring(2, 15); this.store.set(key, { ...record, @@ -39,11 +40,15 @@ export class InMemoryPendingL1TransactionStorage this.store.delete(id); } - public async findById(id: string): Promise { + public async findById( + id: string + ): Promise { return this.store.get(id); } - public async findByStatuses(statuses: PendingL1TransactionStatus[]): Promise { + public async findByStatuses( + statuses: PendingL1TransactionStatus[] + ): Promise { return Array.from(this.store.values()).filter((record) => statuses.includes(record.status) ); diff --git a/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts b/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts index 882fc4e70..455f0085c 100644 --- a/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts +++ b/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts @@ -18,13 +18,20 @@ export interface PendingL1TransactionRecord { } export interface PendingL1TransactionStorage { - queue(record: Omit): Promise; + queue( + record: Omit + ): Promise; - update(id: string, updates: Partial>): Promise; + update( + id: string, + updates: Partial> + ): Promise; delete(id: string): Promise; findById(id: string): Promise; - findByStatuses(statuses: PendingL1TransactionStatus[]): Promise; + findByStatuses( + statuses: PendingL1TransactionStatus[] + ): Promise; } diff --git a/packages/sequencer/test/settlement/Settlement.ts b/packages/sequencer/test/settlement/Settlement.ts index 6ee233ac5..01485fd11 100644 --- a/packages/sequencer/test/settlement/Settlement.ts +++ b/packages/sequencer/test/settlement/Settlement.ts @@ -6,7 +6,6 @@ import { } from "@proto-kit/common"; import { VanillaProtocolModules } from "@proto-kit/library"; import { Runtime } from "@proto-kit/module"; -import { DefaultL1TransactionRetryStrategy } from "../../src/settlement/transactions/DefaultL1TransactionRetryStrategy"; import { BlockProverPublicInput, BridgeContract, @@ -64,6 +63,7 @@ import { BridgingModule } from "../../src/settlement/BridgingModule"; import { FungibleTokenContractModule } from "../../src/settlement/utils/FungibleTokenContractModule"; import { FungibleTokenAdminContractModule } from "../../src/settlement/utils/FungibleTokenAdminContractModule"; import { MinaNetworkUtils } from "../../src/protocol/baselayer/network-utils/MinaNetworkUtils"; +import { DefaultL1TransactionRetryStrategy } from "../../src/settlement/transactions/DefaultL1TransactionRetryStrategy"; import { Balances, BalancesKey } from "./mocks/Balances"; import { WithdrawalMessageProcessor, Withdrawals } from "./mocks/Withdrawals"; @@ -107,7 +107,6 @@ export const settlementTestFn = ( let blockQueue: BlockQueue; let userPublicKey: PublicKey; - let blockSerializer: BlockProofSerializer; const bridgedTokenId = @@ -129,7 +128,7 @@ export const settlementTestFn = ( BaseLayer: MinaBaseLayer, SettlementModule: SettlementModule, SettlementSigner: InMemoryMinaSigner, - L1TransactionRetryStrategy: DefaultL1TransactionRetryStrategy + L1TransactionRetryStrategy: DefaultL1TransactionRetryStrategy, }, { SettlementProvingTask, @@ -359,7 +358,7 @@ export const settlementTestFn = ( { sender: sequencerKey.toPublicKey(), memo: "Deploy custom token", - nonce: nonceCounter++ + nonce: nonceCounter++, }, async () => { AccountUpdate.fundNewAccount(sequencerKey.toPublicKey(), 3); From aa5c082131ddc178e7d7c4e1ea4838a35b3eb5bb Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Mon, 22 Dec 2025 16:27:42 +0530 Subject: [PATCH 15/19] created the migration file --- .../migration.sql | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/persistance/prisma/migrations/20251222105633_pending_l1_transactions/migration.sql diff --git a/packages/persistance/prisma/migrations/20251222105633_pending_l1_transactions/migration.sql b/packages/persistance/prisma/migrations/20251222105633_pending_l1_transactions/migration.sql new file mode 100644 index 000000000..b98317704 --- /dev/null +++ b/packages/persistance/prisma/migrations/20251222105633_pending_l1_transactions/migration.sql @@ -0,0 +1,44 @@ +/* + Warnings: + + - You are about to drop the column `settlementTransactionHash` on the `Batch` table. All the data in the column will be lost. + - The primary key for the `Settlement` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `transactionHash` on the `Settlement` table. All the data in the column will be lost. + - Added the required column `transactionId` to the `Settlement` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Batch" DROP CONSTRAINT "Batch_settlementTransactionHash_fkey"; + +-- AlterTable +ALTER TABLE "Batch" DROP COLUMN "settlementTransactionHash", +ADD COLUMN "settlementTransactionId" TEXT; + +-- AlterTable +ALTER TABLE "Settlement" DROP CONSTRAINT "Settlement_pkey", +DROP COLUMN "transactionHash", +ADD COLUMN "transactionId" TEXT NOT NULL, +ADD CONSTRAINT "Settlement_pkey" PRIMARY KEY ("transactionId"); + +-- CreateTable +CREATE TABLE "PendingL1Transaction" ( + "id" TEXT NOT NULL, + "sender" TEXT NOT NULL, + "nonce" INTEGER NOT NULL, + "attempts" INTEGER NOT NULL, + "status" VARCHAR(32) NOT NULL, + "transaction" JSON NOT NULL, + "lastError" TEXT, + "sentAt" TIMESTAMP(3), + + CONSTRAINT "PendingL1Transaction_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PendingL1Transaction_sender_nonce_key" ON "PendingL1Transaction"("sender", "nonce"); + +-- AddForeignKey +ALTER TABLE "Batch" ADD CONSTRAINT "Batch_settlementTransactionId_fkey" FOREIGN KEY ("settlementTransactionId") REFERENCES "Settlement"("transactionId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "PendingL1Transaction"("id") ON DELETE RESTRICT ON UPDATE CASCADE; From d06f2524093778a276463b99375b826b2238ad57 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Mon, 22 Dec 2025 18:27:42 +0530 Subject: [PATCH 16/19] refactoring, renaming mappers --- .../src/api/GeneratedResolverFactoryGraphqlModule.ts | 3 +-- .../services/prisma/PrismaPendingL1TransactionStorage.ts | 6 +++--- .../services/prisma/mappers/PendingL1TransactionMapper.ts | 7 ++++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts b/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts index c3094795f..df283c093 100644 --- a/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts +++ b/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts @@ -89,7 +89,7 @@ export class GeneratedResolverFactoryGraphqlModule extends ResolverFactoryGraphq public async initializePrismaClient() { // setup the prisma client and feed it to the server, // since this is necessary for the returned resolvers to work - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const prismaClient = new PrismaClient({ // datasourceUrl: 'postgresql://admin:password@localhost:5433/protokit-indexer?schema=public' }); @@ -100,7 +100,6 @@ export class GeneratedResolverFactoryGraphqlModule extends ResolverFactoryGraphq public async resolvers(): Promise> { this.graphqlServer.setContext({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment prisma: await this.initializePrismaClient(), }); diff --git a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts index 2a4b45fcf..fde4a9bc5 100644 --- a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts +++ b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts @@ -25,7 +25,7 @@ export class PrismaPendingL1TransactionStorage const { prismaClient } = this.connection; const status: PendingL1TransactionStatus = "queued"; const txnRecord = await prismaClient.pendingL1Transaction.create({ - data: this.mapper.mapIn({ ...record, status }), + data: this.mapper.mapOut({ ...record, status }), }); return txnRecord.id; } @@ -72,7 +72,7 @@ export class PrismaPendingL1TransactionStorage if (!record) { return undefined; } - return this.mapper.mapOut(record); + return this.mapper.mapIn(record); } public async findByStatuses( @@ -86,6 +86,6 @@ export class PrismaPendingL1TransactionStorage }, }, }); - return rows.map((record) => this.mapper.mapOut(record)); + return rows.map((record) => this.mapper.mapIn(record)); } } diff --git a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts index 8355bf672..c7fa673c9 100644 --- a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts +++ b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts @@ -6,20 +6,21 @@ import { import { Mina } from "o1js"; export class PendingL1TransactionMapper { - public mapOut(input: PendingL1Transaction): PendingL1TransactionRecord { + public mapIn(input: PendingL1Transaction): PendingL1TransactionRecord { return { id: input.id, sender: input.sender, nonce: input.nonce, attempts: input.attempts, status: input.status as PendingL1TransactionStatus, - transaction: Mina.Transaction.fromJSON(input.transaction as string), + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + transaction: Mina.Transaction.fromJSON(input.transaction as any), lastError: input.lastError ?? undefined, sentAt: input.sentAt ? new Date(input.sentAt) : undefined, }; } - public mapIn( + public mapOut( input: PendingL1TransactionRecord ): Prisma.PendingL1TransactionCreateInput { return { From c2a69b4cbe36a3d44c3c25881b6d31562ebea956 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Mon, 22 Dec 2025 20:34:13 +0530 Subject: [PATCH 17/19] lint fix --- .../indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts b/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts index df283c093..9aded3518 100644 --- a/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts +++ b/packages/indexer/src/api/GeneratedResolverFactoryGraphqlModule.ts @@ -5,6 +5,7 @@ import { } from "@proto-kit/api"; import { NonEmptyArray, createMethodMiddlewareDecorator } from "type-graphql"; import { inject } from "tsyringe"; +// eslint-disable-next-line import/no-extraneous-dependencies import { PrismaClient } from "@prisma/client-indexer"; import { From a213a7f9fec10aba1c7b7398ab4c4f85ce6eb7cf Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Tue, 6 Jan 2026 17:10:49 +0530 Subject: [PATCH 18/19] Add hash field to PendingL1Transaction model and added txn status polling --- packages/persistance/prisma/schema.prisma | 1 + .../PrismaPendingL1TransactionStorage.ts | 1 + .../mappers/PendingL1TransactionMapper.ts | 2 + .../transactions/MinaTransactionSender.ts | 79 +++++++++++++------ .../settlement/utils/MinaTransactionUtils.ts | 31 ++++++++ .../PendingL1TransactionStorage.ts | 1 + 6 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 packages/sequencer/src/settlement/utils/MinaTransactionUtils.ts diff --git a/packages/persistance/prisma/schema.prisma b/packages/persistance/prisma/schema.prisma index e76180c18..ab4920bc7 100644 --- a/packages/persistance/prisma/schema.prisma +++ b/packages/persistance/prisma/schema.prisma @@ -156,6 +156,7 @@ model PendingL1Transaction { attempts Int status String @db.VarChar(32) transaction Json @db.Json + hash String? lastError String? sentAt DateTime? diff --git a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts index fde4a9bc5..29af4479c 100644 --- a/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts +++ b/packages/persistance/src/services/prisma/PrismaPendingL1TransactionStorage.ts @@ -43,6 +43,7 @@ export class PrismaPendingL1TransactionStorage ...(updates.transaction !== undefined && { transaction: updates.transaction.toJSON(), }), + ...(updates.hash !== undefined && { hash: updates.hash }), ...(updates.lastError !== undefined && { lastError: updates.lastError, }), diff --git a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts index c7fa673c9..e672db0b4 100644 --- a/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts +++ b/packages/persistance/src/services/prisma/mappers/PendingL1TransactionMapper.ts @@ -15,6 +15,7 @@ export class PendingL1TransactionMapper { status: input.status as PendingL1TransactionStatus, // eslint-disable-next-line @typescript-eslint/no-unsafe-argument transaction: Mina.Transaction.fromJSON(input.transaction as any), + hash: input.hash ?? undefined, lastError: input.lastError ?? undefined, sentAt: input.sentAt ? new Date(input.sentAt) : undefined, }; @@ -30,6 +31,7 @@ export class PendingL1TransactionMapper { attempts: input.attempts, status: input.status, transaction: input.transaction.toJSON(), + hash: input.hash ?? null, lastError: input.lastError ?? null, sentAt: input.sentAt ? input.sentAt.toJSON() : null, }; diff --git a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts index 5b1257cbc..b11a8bd53 100644 --- a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts +++ b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts @@ -1,4 +1,4 @@ -import { fetchAccount, PublicKey, Transaction } from "o1js"; +import { fetchAccount, PublicKey, Transaction, UInt64 } from "o1js"; import { inject, injectable } from "tsyringe"; import { EventsRecord, @@ -20,6 +20,7 @@ import { import { FeeStrategy } from "../../protocol/baselayer/fees/FeeStrategy"; import { MinaSigner } from "../MinaSigner"; import { closeable, Closeable } from "../../sequencer/builder/Closeable"; +import { pollTransactionStatus } from "../utils/MinaTransactionUtils"; import { L1TransactionRetryStrategy } from "./L1TransactionRetryStrategy"; import { MinaTransactionSimulator } from "./MinaTransactionSimulator"; @@ -42,7 +43,7 @@ export class MinaTransactionSender implements Closeable { ReplayingSingleUseEventEmitter >(); - private interval?: any; + private pollingTimeout?: NodeJS.Timeout; public constructor( private readonly creator: FlowCreator, @@ -84,10 +85,10 @@ export class MinaTransactionSender implements Closeable { const { publicKey, nonce } = transaction.transaction.feePayer.body; const sender = publicKey.toBase58(); const nonceNum = Number(nonce.toString()); - // Set Fee [TODO] uncomment after the singer is implemented properly - // const unsignedTx = await transaction.setFee(UInt64.from(this.feeStrategy.getFee())); - // const signedTx = this.signer.signTransaction(unsignedTx); - const signedTx = transaction; + const unsignedTx = await transaction.setFee( + UInt64.from(this.feeStrategy.getFee()) + ); + const signedTx = this.signer.signTx(unsignedTx); // Setup emitter before queueing const emitterKey = this.getEmitterKey(sender, nonceNum); @@ -178,6 +179,16 @@ export class MinaTransactionSender implements Closeable { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { transactionId: txId } as TxSendResult; } + // sent to L1, wait for inclusion + await this.sendTransaction({ + id: txnId, + status: "sent", + sender, + nonce: nonceNum, + attempts: 0, + transaction: result.transaction, + sentAt: new Date(), + }); // If waitOnStatus is none, delete the emitter. this.activeEmitters.delete(emitterKey); @@ -188,21 +199,22 @@ export class MinaTransactionSender implements Closeable { private startPolling() { const intervalMs = 5000; - - this.interval = setInterval(async () => { + const poll = async () => { try { await this.processPendingTransactions(); } catch (e) { log.error("Error in MinaTransactionSender polling loop", e); + } finally { + this.pollingTimeout = setTimeout(poll, intervalMs); } - }, intervalMs); + }; + this.pollingTimeout = setTimeout(poll, intervalMs); } public async close(): Promise { - if (this.interval !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - clearInterval(this.interval); - this.interval = undefined; + if (this.pollingTimeout !== undefined) { + clearTimeout(this.pollingTimeout); + this.pollingTimeout = undefined; } } @@ -218,6 +230,7 @@ export class MinaTransactionSender implements Closeable { (bySender[tx.sender] ??= []).push(tx); } + /* eslint-disable no-await-in-loop */ for (const sender of Object.keys(bySender)) { // Sort in ascending order of nonce const txs = bySender[sender].sort((a, b) => a.nonce - b.nonce); @@ -228,19 +241,38 @@ export class MinaTransactionSender implements Closeable { // transactions stays in queued state until the previous transaction is included or rejected const txToSend = txs[0]; if (txToSend.status === "queued") { - // eslint-disable-next-line no-await-in-loop await this.sendTransaction(txToSend); } // If the transaction is sent and the emitter is not active, - // [TODO] check L1 for inclusion and retry if needed - // else if ( - // txToSend.status === "sent" && - // !this.activeEmitters.has( - // this.getEmitterKey(txToSend.sender, txToSend.nonce) - // ) - // ) { - // await this.sendTransaction(txToSend); - // } + else if ( + txToSend.status === "sent" && + !this.activeEmitters.has( + this.getEmitterKey(txToSend.sender, txToSend.nonce) + ) + ) { + await this.checkSentTransactionStatusAndRetry(txToSend); + } + } + /* eslint-enable no-await-in-loop */ + } + + private async checkSentTransactionStatusAndRetry( + txToSend: PendingL1TransactionRecord + ) { + // Check L1 for inclusion and retry if needed + if (txToSend.hash === undefined) { + // Transaction not found, send again + await this.retryTransaction(txToSend); + return; + } + const inclusionStatus = await pollTransactionStatus(txToSend.hash); + if (inclusionStatus === "included") { + await this.pendingStorage.update(txToSend.id, { + status: "included", + }); + } else { + // Transaction not included, retry + await this.retryTransaction(txToSend); } } @@ -257,6 +289,7 @@ export class MinaTransactionSender implements Closeable { attempts: record.attempts + 1, sentAt: new Date(), transaction: tx, + hash: pendingTx.hash, }); log.info( diff --git a/packages/sequencer/src/settlement/utils/MinaTransactionUtils.ts b/packages/sequencer/src/settlement/utils/MinaTransactionUtils.ts new file mode 100644 index 000000000..56d799715 --- /dev/null +++ b/packages/sequencer/src/settlement/utils/MinaTransactionUtils.ts @@ -0,0 +1,31 @@ +import { checkZkappTransaction } from "o1js"; +import { log, sleep } from "@proto-kit/common"; + +/** + * Polls checkZkappTransaction until the transaction is successful or times out + * @param txnHash - The transaction hash to check + * @param pollIntervalMs - Interval between polls in milliseconds (default: 10000) + * @param maxPolls - Maximum number of polls before giving up (default: 60) + * @returns + */ +export async function pollTransactionStatus( + txnHash: string, + pollIntervalMs: number = 10000, + maxPolls: number = 60 +): Promise<"included" | "not-found"> { + /* eslint-disable no-await-in-loop */ + for (let pollCount = 0; pollCount < maxPolls; pollCount++) { + const result = await checkZkappTransaction(txnHash); + + if (result.success) { + log.info(`Transaction ${txnHash} confirmed as successful`); + return "included"; + } + // Wait before next poll + if (pollCount < maxPolls - 1) { + await sleep(pollIntervalMs); + } + } + /* eslint-enable no-await-in-loop */ + return "not-found"; +} diff --git a/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts b/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts index 455f0085c..0586b7ce8 100644 --- a/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts +++ b/packages/sequencer/src/storage/repositories/PendingL1TransactionStorage.ts @@ -13,6 +13,7 @@ export interface PendingL1TransactionRecord { attempts: number; status: PendingL1TransactionStatus; transaction: Transaction; + hash?: string; lastError?: string; sentAt?: Date; } From 84bc7725ad5329f4f5aeaff31c52db091d94e02d Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Wed, 7 Jan 2026 06:13:58 +0530 Subject: [PATCH 19/19] added minaTransactionSender tests --- console-jest.config.js | 4 +- .../transactions/MinaTransactionSender.ts | 65 +++-- .../settlement/MinaTransactionSender.test.ts | 266 ++++++++++++++++++ 3 files changed, 308 insertions(+), 27 deletions(-) create mode 100644 packages/sequencer/test/settlement/MinaTransactionSender.test.ts diff --git a/console-jest.config.js b/console-jest.config.js index 2f3268a45..c933afc6e 100644 --- a/console-jest.config.js +++ b/console-jest.config.js @@ -1,3 +1,3 @@ -import console from "console"; +import "reflect-metadata"; -global.console = console; +globalThis.console = console; diff --git a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts index b11a8bd53..aaee57e09 100644 --- a/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts +++ b/packages/sequencer/src/settlement/transactions/MinaTransactionSender.ts @@ -156,39 +156,52 @@ export class MinaTransactionSender implements Closeable { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { transactionId: txnId } as TxSendResult; } - + let waitPromise: Promise> | undefined = + undefined; if (waitOnStatus !== "none") { const waitInstruction: "sent" | "included" = waitOnStatus; - const { transactionId: txId } = await new Promise< - TxSendResult<"sent" | "included"> - >((resolve, reject) => { - emitter.on(waitInstruction, (txSendResult) => { - log.info(`Tx ${txnId} ${waitInstruction}`); - resolve({ transactionId: txnId }); - if (waitInstruction === "included") { + waitPromise = new Promise>( + (resolve, reject) => { + emitter.on(waitInstruction, (txSendResult) => { + log.info(`Tx ${txnId} ${waitInstruction}`); + resolve({ transactionId: txnId }); + if (waitInstruction === "included") { + this.activeEmitters.delete(emitterKey); + } + }); + emitter.on("rejected", (error) => { + reject(error); this.activeEmitters.delete(emitterKey); - } - }); - emitter.on("rejected", (error) => { - reject(error); - this.activeEmitters.delete(emitterKey); - }); + }); + } + ); + } + + // Send immediately only if there is no pending tx with a lower nonce for this sender. + const pendingForSender = ( + await this.pendingStorage.findByStatuses(["queued", "sent"]) + ).filter((r) => r.sender === sender); + const hasLowerNoncePending = pendingForSender.some( + (r) => r.id !== txnId && r.nonce < nonceNum + ); + + if (!hasLowerNoncePending) { + await this.sendTransaction({ + id: txnId, + status: "queued", + sender, + nonce: nonceNum, + attempts: 0, + transaction: result.transaction, + sentAt: new Date(), }); + } - // Yeah that's not super clean, but couldn't figure out a better way tbh + if (waitPromise) { + const { transactionId: txId } = await waitPromise; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { transactionId: txId } as TxSendResult; } - // sent to L1, wait for inclusion - await this.sendTransaction({ - id: txnId, - status: "sent", - sender, - nonce: nonceNum, - attempts: 0, - transaction: result.transaction, - sentAt: new Date(), - }); // If waitOnStatus is none, delete the emitter. this.activeEmitters.delete(emitterKey); @@ -206,9 +219,11 @@ export class MinaTransactionSender implements Closeable { log.error("Error in MinaTransactionSender polling loop", e); } finally { this.pollingTimeout = setTimeout(poll, intervalMs); + this.pollingTimeout.unref?.(); // important for unit tests. } }; this.pollingTimeout = setTimeout(poll, intervalMs); + this.pollingTimeout.unref?.(); } public async close(): Promise { diff --git a/packages/sequencer/test/settlement/MinaTransactionSender.test.ts b/packages/sequencer/test/settlement/MinaTransactionSender.test.ts new file mode 100644 index 000000000..44b5ea601 --- /dev/null +++ b/packages/sequencer/test/settlement/MinaTransactionSender.test.ts @@ -0,0 +1,266 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import "reflect-metadata"; +import { jest } from "@jest/globals"; +import { Transaction } from "o1js"; + +import type { PendingL1TransactionStorage } from "../../src/storage/repositories/PendingL1TransactionStorage"; +import { InMemoryPendingL1TransactionStorage } from "../../src/storage/inmemory/InMemoryPendingL1TransactionStorage"; + +let MinaTransactionSender: any; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +let pollTransactionStatus: jest.Mock; + +beforeAll(async () => { + // MinaTransactionSender imports pollTransactionStatus directly, so we need to mock + // the module BEFORE importing MinaTransactionSender (ESM). + jest.unstable_mockModule( + "../../src/settlement/utils/MinaTransactionUtils", + () => ({ + pollTransactionStatus: jest.fn(), + }) + ); + ({ MinaTransactionSender } = await import( + "../../src/settlement/transactions/MinaTransactionSender" + )); + const utils = await import("../../src/settlement/utils/MinaTransactionUtils"); + pollTransactionStatus = utils.pollTransactionStatus as unknown as jest.Mock; +}); + +function makeSender(pendingStorage: PendingL1TransactionStorage) { + const sender = new MinaTransactionSender( + { + createFlow: jest.fn(() => ({ + withFlow: async (fn: any) => + await new Promise((resolve, reject) => { + fn(resolve, reject); + }), + pushTask: async ( + _task: any, + params: { transaction: any }, + onResult: (result: any) => Promise + ) => { + await onResult({ transaction: params.transaction }); + }, + })), + }, + {}, + { + getAccount: jest.fn(async () => ({ nonce: { toString: () => "0" } })), + getAccounts: jest.fn(async () => undefined), + applyTransaction: jest.fn(async () => undefined), + } as any, + { config: { network: { type: "local" } } } as any, + pendingStorage as any, + { + shouldRetry: jest.fn(async () => true), + prepareRetryTransaction: jest.fn( + async (record: any) => record.transaction + ), + } as any, + { signTx: jest.fn((tx: any) => tx) } as any, + { getFee: () => 0 } as any + ); + return sender as typeof MinaTransactionSender; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flush(): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +function makeTx({ senderBase58 = "S", nonce = 0, hash = "TX_HASH" } = {}) { + const waitDeferred = deferred<{ hash: string }>(); + + const pendingTx = { + hash, + wait: jest.fn(async () => await waitDeferred.promise), + }; + + const tx: any = { + // used by proveAndSendTransaction() before proving + transaction: { + feePayer: { + body: { + publicKey: { toBase58: () => senderBase58 }, + nonce: { toString: () => String(nonce) }, + }, + }, + accountUpdates: [], + }, + setFee: jest.fn(async () => tx), + send: jest.fn(async () => pendingTx), + toPretty: () => "", + }; + + return { tx: tx as Transaction, pendingTx, waitDeferred }; +} + +describe("MinaTransactionSender (unit)", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("proveAndSendTransaction should immediately send if there is no lower-nonce pending tx, and resolve 'sent'", async () => { + const pendingStorage: PendingL1TransactionStorage = + new InMemoryPendingL1TransactionStorage(); + const sender = makeSender(pendingStorage); + await sender.close(); // to avoid polling + + const { tx } = makeTx({ senderBase58: "S", nonce: 0, hash: "H1" }); + + const { transactionId } = await sender.proveAndSendTransaction(tx, "sent"); + + const record = await pendingStorage.findById(transactionId as string); + + expect(record?.status).toBe("sent"); + expect(record?.hash).toBe("H1"); + expect(record?.attempts).toBe(1); + expect(tx.send).toHaveBeenCalledTimes(1); + }); + + it("proveAndSendTransaction should transition sent -> included after pendingTx.wait resolves", async () => { + const pendingStorage: PendingL1TransactionStorage = + new InMemoryPendingL1TransactionStorage(); + const sender = makeSender(pendingStorage); + await sender.close(); + + const { tx, waitDeferred } = makeTx({ + senderBase58: "S", + nonce: 0, + hash: "H2", + }); + + const txnPromise = sender.proveAndSendTransaction(tx, "included"); + // Ensure sendTransaction installed the wait().then handlers before resolving + await flush(); + waitDeferred.resolve({ hash: "H2" }); + const { transactionId } = await txnPromise; + // waitPromise resolves on emitter "included" (before the DB update finishes) + await flush(); + const record = await pendingStorage.findById(transactionId as string); + expect(record?.status).toBe("included"); + }); + + it("should work for multiple transactions", async () => { + const pendingStorage: PendingL1TransactionStorage = + new InMemoryPendingL1TransactionStorage(); + const sender = makeSender(pendingStorage); + await sender.close(); // we'll manually drive polling + + // tx0 will be sent immediately, but we delay inclusion so tx1 should be queued. + const tx0 = makeTx({ senderBase58: "S", nonce: 0, hash: "H0" }); + const tx1 = makeTx({ senderBase58: "S", nonce: 1, hash: "H1" }); + + const sent0 = await sender.proveAndSendTransaction(tx0.tx, "sent"); + expect(tx0.tx.send).toHaveBeenCalledTimes(1); + + const p1 = sender.proveAndSendTransaction(tx1.tx, "sent"); + + // tx1 must NOT be sent while tx0 is still pending (lower nonce). + await flush(); + expect(tx1.tx.send).toHaveBeenCalledTimes(0); + + // Include tx0, then run the poll loop once to send the next queued tx. + tx0.waitDeferred.resolve({ hash: "H0" }); + // Let the inclusion handler update storage + clear the active emitter + await flush(); + await flush(); + + await (sender as any).processPendingTransactions(); + await flush(); + + // Now tx1 should get sent and the waiting promise should resolve. + expect(tx1.tx.send).toHaveBeenCalledTimes(1); + const sent1 = await p1; + + const r0 = await pendingStorage.findById(sent0.transactionId as string); + const r1 = await pendingStorage.findById(sent1.transactionId as string); + expect(r0).toBeDefined(); + expect(r1).toBeDefined(); + }); + + it("should retry a transaction if first attempt fails", async () => { + const pendingStorage: PendingL1TransactionStorage = + new InMemoryPendingL1TransactionStorage(); + const sender = makeSender(pendingStorage); + await sender.close(); + + const { tx } = makeTx({ senderBase58: "S", nonce: 0, hash: "H-R1" }); + + // First send returns hash H-R1 and then wait() rejects. + const wait1 = deferred<{ hash: string }>(); + const wait2 = deferred<{ hash: string }>(); + const pending1 = { + hash: "H-R1", + wait: jest.fn(async () => await wait1.promise), + }; + const pending2 = { + hash: "H-R2", + wait: jest.fn(async () => await wait2.promise), + }; + + (tx as any).send = jest + .fn() + .mockImplementationOnce(async () => pending1) + .mockImplementationOnce(async () => pending2); + + // Wait for first send ("sent" resolves immediately after tx.send()) so handlers are installed. + const { transactionId } = await sender.proveAndSendTransaction(tx, "sent"); + + // Fail first attempt (wait rejects), which should trigger retryTransaction and a second send. + wait1.reject(new Error("first attempt failed")); + await flush(); + await flush(); + + // Second send should happen. + expect((tx as any).send).toHaveBeenCalledTimes(2); + + // Let second attempt include. + wait2.resolve({ hash: "H-R2" }); + + const record = await pendingStorage.findById(transactionId as string); + + expect(record?.status).toBe("sent"); + expect(record?.hash).toBe("H-R2"); + // Note: attempts increments by 2 on retry due to how retryTransaction builds the next record. + expect(record?.attempts).toBeGreaterThanOrEqual(2); + }); + + it("should stop retrying when shouldRetry returns false", async () => { + const pendingStorage: PendingL1TransactionStorage = + new InMemoryPendingL1TransactionStorage(); + const sender = makeSender(pendingStorage); + await sender.close(); + + // Force "no retry" + sender.retryStrategy.shouldRetry = jest.fn(async () => false); + + const { tx } = makeTx({ senderBase58: "S", nonce: 0, hash: "H-F" }); + + const waitFail = deferred<{ hash: string }>(); + const pending = { + hash: "H-F", + wait: jest.fn(async () => await waitFail.promise), + }; + (tx as any).send = jest.fn(async () => pending); + + const promise = sender.proveAndSendTransaction(tx, "included"); + + await flush(); + waitFail.reject(new Error("no more attempts")); + + await expect(promise).rejects.toBeDefined(); + }); +}); +/* eslint-enable @typescript-eslint/no-unsafe-assignment */