Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d667476
Add in removeTransactionWhen for Account State Hook.
ejMina226 Mar 11, 2025
96f483d
Add in test
ejMina226 Mar 11, 2025
734d4ca
Update test
ejMina226 Mar 11, 2025
632b11e
Change code to remove tx from list
ejMina226 Mar 12, 2025
288165d
Increase timeout
ejMina226 Mar 13, 2025
087ca11
Deletemany to delete
ejMina226 Mar 13, 2025
ccd776c
Merge branch 'develop' into feature/tx-hooks-fail
rpanic Nov 3, 2025
c38b83f
Added general tx dropping to block production
rpanic Nov 4, 2025
11ac9fc
Added consideration for shouldRemove hook when dropping txs
rpanic Nov 4, 2025
80f0df4
Added BlockProver handling of message + tx hooks
rpanic Nov 3, 2025
1df5f56
Fixed test
rpanic Nov 5, 2025
5fff36d
Fixed wrong handling of tx removal for mempool simulation mode
rpanic Nov 5, 2025
df3b129
Merge branch 'feature/tx-hooks-fail' into fix/tx-hook-fail-messages
rpanic Nov 5, 2025
adb0bd3
Increased test timeout for mempoolremovetx test
rpanic Nov 5, 2025
0c83274
Fixed status message propagation
rpanic Nov 5, 2025
0419fcc
Fixed linting, refactored typing
rpanic Nov 5, 2025
8b7f183
Merge pull request #325 from proto-kit/fix/tx-hook-fail-messages
rpanic Nov 5, 2025
6ad9401
Added prisma column
rpanic Nov 5, 2025
b29ac0c
Added test for block pipeline mempool removal
rpanic Nov 5, 2025
2fe29f0
Applied limit to non-simulated mempool mode
rpanic Nov 7, 2025
0297371
Reenabled validation for fee test
rpanic Nov 7, 2025
a4ac17b
Added removeTransactionWhen to TransactionHook
rpanic Nov 14, 2025
4037f65
Implemented removeTx as a set to increase lookup performance
rpanic Nov 14, 2025
2221445
Fixed implementation of TransactionFeeHook removeWhen
rpanic Nov 14, 2025
e210ef7
Fix fees failure error message
rpanic Nov 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/library/src/hooks/TransactionFeeHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import {
BeforeTransactionHookArguments,
ProvableTransactionHook,
PublicKeyOption,
StateMap,
} from "@proto-kit/protocol";
import { Field, Provable, PublicKey } from "o1js";
import { noop } from "@proto-kit/common";

import { UInt64 } from "../math/UInt64";
import { Balance, TokenId } from "../runtime/Balances";
import { Balance, BalancesKey, TokenId } from "../runtime/Balances";

import {
MethodFeeConfigData,
Expand All @@ -29,6 +30,8 @@ interface Balances {
to: PublicKey,
amount: Balance
) => Promise<void>;

balances: StateMap<BalancesKey, Balance>;
}

export interface TransactionFeeHookConfig
Expand Down Expand Up @@ -159,4 +162,24 @@ export class TransactionFeeHook extends ProvableTransactionHook<TransactionFeeHo
public async afterTransaction(): Promise<void> {
noop();
}

public async removeTransactionWhen(
args: BeforeTransactionHookArguments
): Promise<boolean> {
const feeConfig = this.feeAnalyzer.getFeeConfig(
args.transaction.methodId.toBigInt()
);

const fee = this.getFee(feeConfig);

const tokenId = new TokenId(this.config.tokenId);
const feeRecipient = PublicKey.fromBase58(this.config.feeRecipient);

const balanceAvailable = await this.balances.balances.get({
tokenId,
address: feeRecipient,
});

return balanceAvailable.orElse(Balance.from(0)).lessThan(fee).toBoolean();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
Warnings:

- Added the required column `hooksStatus` to the `TransactionExecutionResult` table without a default value. This is not possible if the table is not empty.

*/
-- AlterTable
ALTER TABLE "TransactionExecutionResult" ADD COLUMN "hooksStatus" BOOLEAN NOT NULL;
1 change: 1 addition & 0 deletions packages/persistance/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ model TransactionExecutionResult {
status Boolean
statusMessage String?
events Json @db.Json
hooksStatus Boolean

tx Transaction @relation(fields: [txHash], references: [hash])
txHash String @id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export class PrismaBlockStorage implements BlockQueue, BlockStorage {
data: transactions.map((tx) => {
return {
status: tx.status,
hooksStatus: tx.hooksStatus,
statusMessage: tx.statusMessage,
txHash: tx.txHash,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ export class PrismaTransactionStorage implements TransactionStorage {
return txs.map((tx) => this.transactionMapper.mapIn(tx));
}

public async removeTx(hashes: string[], type: "included" | "dropped") {
// In our schema, included txs are simply just linked with blocks, so we only
// need to delete if we drop a tx
if (type === "dropped") {
const { prismaClient } = this.connection;

await prismaClient.transaction.deleteMany({
where: {
hash: {
in: hashes,
},
},
});
}
}

public async pushUserTransaction(tx: PendingTransaction): Promise<boolean> {
const { prismaClient } = this.connection;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export class TransactionExecutionResultMapper
return {
tx: this.transactionMapper.mapIn(input[1]),
status: Bool(executionResult.status),
hooksStatus: Bool(executionResult.hooksStatus),
statusMessage: executionResult.statusMessage ?? undefined,
stateTransitions: this.stBatchMapper.mapIn(
executionResult.stateTransitions
Expand All @@ -80,6 +81,7 @@ export class TransactionExecutionResultMapper
const tx = this.transactionMapper.mapOut(input.tx);
const executionResult = {
status: input.status.toBoolean(),
hooksStatus: input.hooksStatus.toBoolean(),
statusMessage: input.statusMessage ?? null,
stateTransitions: this.stBatchMapper.mapOut(input.stateTransitions),
events: this.eventArrayMapper.mapOut(input.events),
Expand Down
15 changes: 15 additions & 0 deletions packages/protocol/src/hooks/AccountStateHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,19 @@ export class AccountStateHook extends ProvableTransactionHook {
public async afterTransaction() {
noop();
}

// Under these conditions we want the tx removed from the mempool.
public async removeTransactionWhen({
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be a silly question, but in the current version I couldn't be sure whether the transaction gets removed from the mempool when there isn't enough gas fee. Also, an option could be added for after a certain timeout period.

Copy link
Member

Choose a reason for hiding this comment

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

Added it, thanks for spotting this!

transaction,
}: BeforeTransactionHookArguments): Promise<boolean> {
const sender = transaction.sender.value;

const aso = await this.accountState.get(sender);

const accountState = aso.orElse(new AccountState({ nonce: UInt64.zero }));

const currentNonce = accountState.nonce;

return transaction.nonce.value.lessThan(currentNonce).toBoolean();
}
}
6 changes: 6 additions & 0 deletions packages/protocol/src/protocol/ProvableTransactionHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,10 @@ export abstract class ProvableTransactionHook<
public abstract afterTransaction(
execution: AfterTransactionHookArguments
): Promise<void>;

public async removeTransactionWhen(
execution: BeforeTransactionHookArguments
): Promise<boolean> {
return false;
}
}
47 changes: 36 additions & 11 deletions packages/protocol/src/prover/block/BlockProver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ export class BlockProverProgrammable extends ZkProgrammable<
// Apply beforeTransaction hook state transitions
const beforeBatch = await this.executeTransactionHooks(
async (module, args) => await module.beforeTransaction(args),
beforeTxHookArguments
beforeTxHookArguments,
isMessage
);

state = this.addTransactionToBundle(
Expand Down Expand Up @@ -200,7 +201,8 @@ export class BlockProverProgrammable extends ZkProgrammable<

const afterBatch = await this.executeTransactionHooks(
async (module, args) => await module.afterTransaction(args),
afterTxHookArguments
afterTxHookArguments,
isMessage
);
state.pendingSTBatches.push(afterBatch);

Expand Down Expand Up @@ -266,20 +268,31 @@ export class BlockProverProgrammable extends ZkProgrammable<
T extends BeforeTransactionHookArguments | AfterTransactionHookArguments,
>(
hook: (module: ProvableTransactionHook<unknown>, args: T) => Promise<void>,
hookArguments: T
hookArguments: T,
isMessage: Bool
) {
const { batch } = await this.executeHooks(hookArguments, async () => {
for (const module of this.transactionHooks) {
// eslint-disable-next-line no-await-in-loop
await hook(module, hookArguments);
}
});
const { batch, rawStatus } = await this.executeHooks(
hookArguments,
async () => {
for (const module of this.transactionHooks) {
// eslint-disable-next-line no-await-in-loop
await hook(module, hookArguments);
}
},
isMessage
);

// This is going to set applied to false in case the hook fails
// (that's only possible for messages though as others are hard-asserted)
batch.applied = rawStatus;

return batch;
}

private async executeHooks<T>(
contextArguments: RuntimeMethodExecutionData,
method: () => Promise<T>
method: () => Promise<T>,
isMessage: Bool | undefined = undefined
) {
const executionContext = container.resolve(RuntimeMethodExecutionContext);
executionContext.clear();
Expand All @@ -297,11 +310,23 @@ export class BlockProverProgrammable extends ZkProgrammable<
const { stateTransitions, status, statusMessage } =
executionContext.current().result;

status.assertTrue(`Transaction hook call failed: ${statusMessage ?? "-"}`);
// See https://github.com/proto-kit/framework/issues/321 for why we do this here
if (isMessage !== undefined) {
// isMessage is defined for all tx hooks
status
.or(isMessage)
.assertTrue(
`Transaction hook call failed for non-message tx: ${statusMessage ?? "-"}`
);
} else {
// isMessage is undefined for all block hooks
status.assertTrue(`Block hook call failed: ${statusMessage ?? "-"}`);
}

return {
batch: this.constructBatch(stateTransitions, Bool(true)),
result,
rawStatus: status,
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/protocol/src/state/assert/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function assert(condition: Bool, message?: string | (() => string)) {
const status = condition.and(previousStatus);

Provable.asProver(() => {
if (!condition.toBoolean()) {
if (!condition.toBoolean() && previousStatus.toBoolean()) {
const messageString =
message !== undefined && typeof message === "function"
? message()
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk/test/fees-failures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ describe("fee errors due to limited funds in sender accounts", () => {
appChain.setSigner(senderKey);
});

afterAll(async () => {
await appChain.close();
});

it("should allow a free faucet transaction", async () => {
expect.assertions(2);

Expand Down Expand Up @@ -105,8 +109,7 @@ describe("fee errors due to limited funds in sender accounts", () => {
await appChain.produceBlock();

expect(logSpy).toHaveBeenCalledWith(
"Error in inclusion of tx, skipping",
Error("Protocol hooks not executable: From balance is insufficient")
"Error in inclusion of tx, removing as to removeWhen hooks: Protocol hooks not executable: From balance is insufficient"
);

const balance = await appChain.query.runtime.Balances.balances.get(
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/test/fees-multi-zkprograms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,21 @@ describe("check fee analyzer", () => {
},
},
},
Sequencer: {
Mempool: {
validationEnabled: true,
},
},
});

await appChain.start();
appChain.setSigner(senderKey);
});

afterAll(async () => {
await appChain.close();
});

it("with multiple zk programs", async () => {
expect.assertions(12);
const testModule1 = appChain.runtime.resolve("TestModule1");
Expand Down
2 changes: 2 additions & 0 deletions packages/sequencer/src/mempool/Mempool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ export interface Mempool<Events extends MempoolEvents = MempoolEvents>
* Retrieve all transactions that are currently in the mempool
*/
getTxs: (limit?: number) => Promise<PendingTransaction[]>;

removeTxs: (included: string[], dropped: string[]) => Promise<void>;
}
31 changes: 29 additions & 2 deletions packages/sequencer/src/mempool/private/PrivateMempool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,22 @@ export class PrivateMempool
return result?.result.afterNetworkState;
}

public async removeTxs(included: string[], dropped: string[]) {
await this.transactionStorage.removeTx(included, "included");
Copy link
Contributor

@kadirchan kadirchan Nov 11, 2025

Choose a reason for hiding this comment

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

if (type === "dropped") check in removeTx() method makes this call redundant, we can refactor removeTxs() if there is a need of dropping for included ones or remove that call.
Mentioned removeTx()

Copy link
Member

Choose a reason for hiding this comment

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

While what you're saying is true - its only the case for the PrismaTransactionStorage, not for the InMemoryTransactionStorage. This is because they are implemented differently, and for the inmemory version, we will need to remove them explicitely (while for prisma, the DB schema takes care of it via foreign key relationships)

await this.transactionStorage.removeTx(dropped, "dropped");
}

@trace("mempool.get_txs")
public async getTxs(limit?: number): Promise<PendingTransaction[]> {
// TODO Add limit to the storage (or do something smarter entirely)
const txs = await this.transactionStorage.getPendingUserTransactions();

const baseCachedStateService = new CachedStateService(this.stateService);

const networkState =
(await this.getStagedNetworkState()) ?? NetworkState.empty();

const validationEnabled = this.config.validationEnabled ?? true;
const validationEnabled = this.config.validationEnabled ?? false;
const sortedTxs = validationEnabled
? await this.checkTxValid(
txs,
Expand All @@ -128,7 +134,7 @@ export class PrivateMempool
networkState,
limit
)
: txs;
: txs.slice(0, limit);
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be better if we add limit parameter to getPendingUserTransactions method with db level limiting instead and slicing after getting all transactions. This could be a performance problem as txs grows

Copy link
Member

Choose a reason for hiding this comment

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

Absolutely - that's covered in #330


this.protocol.stateServiceProvider.popCurrentStateService();
return sortedTxs;
Expand Down Expand Up @@ -188,6 +194,7 @@ export class PrivateMempool
executionContext.setup(contextInputs);

const signedTransaction = tx.toProtocolTransaction();

// eslint-disable-next-line no-await-in-loop
await this.accountStateHook.beforeTransaction({
networkState: networkState,
Expand Down Expand Up @@ -218,6 +225,26 @@ export class PrivateMempool
queue = queue.filter(distinctByPredicate((a, b) => a === b));
}
} else {
// eslint-disable-next-line no-await-in-loop
const removeTxWhen = await this.accountStateHook.removeTransactionWhen({
networkState: networkState,
transaction: signedTransaction.transaction,
signature: signedTransaction.signature,
prover: proverState,
});
if (removeTxWhen) {
// eslint-disable-next-line no-await-in-loop
await this.transactionStorage.removeTx(
[tx.hash().toString()],
"dropped"
);
log.trace(
`Deleting tx ${tx.hash().toString()} from mempool because removeTransactionWhen condition is satisfied`
);
// eslint-disable-next-line no-continue
continue;
}

log.trace(
`Skipped tx ${tx.hash().toString()} because ${statusMessage}`
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,16 @@ export class BlockProducerModule extends SequencerModule<BlockConfig> {
height: block.height.toString(),
}
);

// Remove included or dropped txs, leave skipped ones alone
await this.mempool.removeTxs(
blockResult.includedTxs
.filter((x) => x.type === "included")
.map((x) => x.hash),
blockResult.includedTxs
.filter((x) => x.type === "shouldRemove")
.map((x) => x.hash)
);
}

this.productionInProgress = false;
Expand Down
Loading