diff --git a/packages/txm/lib/GasEstimator.ts b/packages/txm/lib/GasEstimator.ts index ce30c6410f..b79e436fb6 100644 --- a/packages/txm/lib/GasEstimator.ts +++ b/packages/txm/lib/GasEstimator.ts @@ -1,6 +1,5 @@ import { SpanStatusCode, context, trace } from "@opentelemetry/api" import { type Result, err, ok } from "neverthrow" -import { encodeFunctionData } from "viem" import type { Transaction } from "./Transaction.js" import type { TransactionManager } from "./TransactionManager.js" import { TraceMethod } from "./telemetry/traces" @@ -70,26 +69,10 @@ export class DefaultGasLimitEstimator implements GasEstimator { ): Promise> { const span = trace.getSpan(context.active())! - const abi = transactionManager.abiManager.get(transaction.contractName) - - if (!abi) { - const description = `ABI not found for contract ${transaction.contractName}` - span.recordException(new Error(description)) - span.setStatus({ code: SpanStatusCode.ERROR }) - return err({ - cause: EstimateGasErrorCause.EstimateGasABINotFound, - description, - }) - } - - const functionName = transaction.functionName - const args = transaction.args - const data = encodeFunctionData({ abi, functionName, args }) - const gasResult = await transactionManager.viemClient.safeEstimateGas({ account: transactionManager.viemWallet.account, to: transaction.address, - data, + data: transaction.calldata, value: 0n, }) diff --git a/packages/txm/lib/RetryPolicyManager.ts b/packages/txm/lib/RetryPolicyManager.ts index 97617e6579..8ee9998dc7 100644 --- a/packages/txm/lib/RetryPolicyManager.ts +++ b/packages/txm/lib/RetryPolicyManager.ts @@ -1,5 +1,5 @@ -import { type Result, err, ok } from "neverthrow" -import { type TransactionReceipt, encodeErrorResult } from "viem" +import { type Result, ok } from "neverthrow" +import { type Abi, type TransactionReceipt, encodeErrorResult } from "viem" import type { Attempt, Transaction } from "./Transaction" import type { TransactionManager } from "./TransactionManager" import { TraceMethod } from "./telemetry/traces" @@ -89,8 +89,8 @@ export class DefaultRetryPolicyManager implements RetryPolicyManager { @TraceMethod("txm.retry-policy-manager.is-custom-error") protected async isCustomError( transactionManager: TransactionManager, - transaction: Transaction, attempt: Attempt, + abi: Abi, customError: string, ): Promise> { const { output } = await this.getRevertMessageAndOutput(transactionManager, attempt) @@ -99,12 +99,6 @@ export class DefaultRetryPolicyManager implements RetryPolicyManager { return ok(false) } - const abi = transactionManager.abiManager.get(transaction.contractName) - - if (!abi) { - return err(new Error("Contract not found")) - } - return ok( encodeErrorResult({ abi: abi, diff --git a/packages/txm/lib/Transaction.ts b/packages/txm/lib/Transaction.ts index 113654f755..c3bcee3ed0 100644 --- a/packages/txm/lib/Transaction.ts +++ b/packages/txm/lib/Transaction.ts @@ -1,7 +1,8 @@ -import { type UUID, bigIntReplacer, bigIntReviver, createUUID } from "@happy.tech/common" +import { type Hex, type UUID, bigIntReplacer, bigIntReviver, createUUID } from "@happy.tech/common" import { context, trace } from "@opentelemetry/api" import type { Insertable, Selectable } from "kysely" -import type { Address, ContractFunctionArgs, Hash } from "viem" +import { type Address, type ContractFunctionArgs, type Hash, encodeFunctionData } from "viem" +import type { ABIManager } from "./AbiManager" import type { LatestBlock } from "./BlockMonitor" import { Topics, eventBus } from "./EventBus.js" import type { TransactionTable } from "./db/types.js" @@ -54,26 +55,18 @@ export interface Attempt { gas: bigint } +export enum TransactionCallDataFormat { + Raw = "Raw", + Function = "Function", +} + export const NotFinalizedStatuses = [TransactionStatus.Pending, TransactionStatus.Cancelling] -export interface TransactionConstructorConfig { +interface TransactionConstructorBaseConfig { /** * The address of the contract that will be called */ address: Address - /** - * The function name of the contract that will be called - */ - functionName: string - /** - * This doesn't need to match the Solidity contract name but must match the contract alias of one of the contracts - * that you have provided when initializing the transaction manager with the ABI Manager - */ - contractName: string - /** - * The arguments of the function that will be called - */ - args: ContractFunctionArgs /** * The deadline of the transaction in seconds (optional) * This is used to try to cancel the transaction if it is not included in a block after the deadline to save gas @@ -85,6 +78,21 @@ export interface TransactionConstructorConfig { metadata?: Record } +type TransactionConstructorCalldataConfig = TransactionConstructorBaseConfig & { + calldata: Hex + functionName?: string + contractName?: string + args?: ContractFunctionArgs +} + +type TransactionConstructorFunctionConfig = TransactionConstructorBaseConfig & { + functionName: string + contractName: string + args: ContractFunctionArgs +} + +export type TransactionConstructorConfig = TransactionConstructorCalldataConfig | TransactionConstructorFunctionConfig + export class Transaction { readonly intentId: UUID @@ -94,12 +102,14 @@ export class Transaction { readonly address: Address - readonly functionName: string + readonly functionName?: string - readonly args: ContractFunctionArgs + readonly args?: ContractFunctionArgs // This doesn't need to match the Solidity contract name but must match the contract alias of one of the contracts that you have provided when initializing the transaction manager with the ABI Manager - readonly contractName: string + readonly contractName?: string + + readonly calldata: Hex readonly deadline: number | undefined @@ -129,51 +139,50 @@ export class Transaction { */ readonly metadata: Record - constructor({ - address, - functionName, - contractName, - args, - deadline, - metadata, - intentId, - from, - chainId, - status, - attempts, - collectionBlock, - createdAt, - updatedAt, - pendingFlush, - notPersisted, - }: TransactionConstructorConfig & { - intentId?: UUID - from: Address - chainId: number - status?: TransactionStatus - attempts?: Attempt[] - collectionBlock?: bigint - createdAt?: Date - updatedAt?: Date - pendingFlush?: boolean - notPersisted?: boolean - }) { - this.intentId = intentId ?? createUUID() - this.from = from - this.chainId = chainId - this.address = address - this.functionName = functionName - this.contractName = contractName - this.args = args - this.deadline = deadline - this.status = status ?? TransactionStatus.Pending - this.attempts = attempts ?? [] - this.collectionBlock = collectionBlock - this.createdAt = createdAt ?? new Date() - this.updatedAt = updatedAt ?? new Date() - this.metadata = metadata ?? {} - this.pendingFlush = pendingFlush ?? true - this.notPersisted = notPersisted ?? true + constructor( + config: TransactionConstructorConfig & { + intentId?: UUID + from: Address + chainId: number + status?: TransactionStatus + attempts?: Attempt[] + collectionBlock?: bigint + createdAt?: Date + updatedAt?: Date + pendingFlush?: boolean + notPersisted?: boolean + }, + abiManager: ABIManager, + ) { + this.intentId = config.intentId ?? createUUID() + this.from = config.from + this.chainId = config.chainId + this.address = config.address + this.deadline = config.deadline + this.status = config.status ?? TransactionStatus.Pending + this.attempts = config.attempts ?? [] + this.collectionBlock = config.collectionBlock + this.createdAt = config.createdAt ?? new Date() + this.updatedAt = config.updatedAt ?? new Date() + this.metadata = config.metadata ?? {} + this.pendingFlush = config.pendingFlush ?? true + this.notPersisted = config.notPersisted ?? true + + if ("calldata" in config) { + this.calldata = config.calldata + this.functionName = config.functionName + this.contractName = config.contractName + this.args = config.args + } else { + const abi = abiManager.get(config.contractName) + if (!abi) { + throw new Error(`ABI not found for contract ${config.contractName}`) + } + this.calldata = encodeFunctionData({ abi, functionName: config.functionName, args: config.args }) + this.functionName = config.functionName + this.contractName = config.contractName + this.args = config.args + } } addAttempt(attempt: Attempt): void { @@ -261,7 +270,8 @@ export class Transaction { address: this.address, functionName: this.functionName, contractName: this.contractName, - args: JSON.stringify(this.args, bigIntReplacer), + calldata: this.calldata, + args: this.args ? JSON.stringify(this.args, bigIntReplacer) : undefined, deadline: this.deadline, collectionBlock: this.collectionBlock ? Number(this.collectionBlock) : undefined, status: this.status, @@ -272,18 +282,21 @@ export class Transaction { } } - static fromDbRow(row: Selectable): Transaction { - return new Transaction({ - ...row, - args: JSON.parse(row.args, bigIntReviver), - attempts: JSON.parse(row.attempts, bigIntReviver), - collectionBlock: row.collectionBlock ? BigInt(row.collectionBlock) : undefined, - metadata: row.metadata ? JSON.parse(row.metadata, bigIntReviver) : undefined, - createdAt: new Date(row.createdAt), - updatedAt: new Date(row.updatedAt), - notPersisted: false, - pendingFlush: false, - }) + static fromDbRow(row: Selectable, abiManager: ABIManager): Transaction { + return new Transaction( + { + ...row, + args: row.args ? JSON.parse(row.args, bigIntReviver) : undefined, + attempts: JSON.parse(row.attempts, bigIntReviver), + collectionBlock: row.collectionBlock ? BigInt(row.collectionBlock) : undefined, + metadata: row.metadata ? JSON.parse(row.metadata, bigIntReviver) : undefined, + createdAt: new Date(row.createdAt), + updatedAt: new Date(row.updatedAt), + notPersisted: false, + pendingFlush: false, + }, + abiManager, + ) } toJson(): string { diff --git a/packages/txm/lib/TransactionManager.ts b/packages/txm/lib/TransactionManager.ts index 4ee8637b60..aab824844f 100644 --- a/packages/txm/lib/TransactionManager.ts +++ b/packages/txm/lib/TransactionManager.ts @@ -454,11 +454,14 @@ export class TransactionManager { * @returns A new transaction. */ public createTransaction(params: TransactionConstructorConfig): Transaction { - return new Transaction({ - ...params, - from: this.viemWallet.account.address, - chainId: this.viemWallet.chain.id, - }) + return new Transaction( + { + ...params, + from: this.viemWallet.account.address, + chainId: this.viemWallet.chain.id, + }, + this.abiManager, + ) } /** diff --git a/packages/txm/lib/TransactionRepository.ts b/packages/txm/lib/TransactionRepository.ts index f6dd6499cd..9add5d1059 100644 --- a/packages/txm/lib/TransactionRepository.ts +++ b/packages/txm/lib/TransactionRepository.ts @@ -33,7 +33,9 @@ export class TransactionRepository { .selectAll() .execute() - this.notFinalizedTransactions = transactionRows.map((row) => Transaction.fromDbRow(row)) + this.notFinalizedTransactions = transactionRows.map((row) => + Transaction.fromDbRow(row, this.transactionManager.abiManager), + ) if (this.transactionManager.finalizedTransactionPurgeTime > 0) { eventBus.on(Topics.NewBlock, this.purgeFinalizedTransactions.bind(this)) @@ -85,7 +87,9 @@ export class TransactionRepository { const persistedTransaction = persistedTransactionResult.value - return persistedTransaction ? ok(Transaction.fromDbRow(persistedTransaction)) : ok(undefined) + return persistedTransaction + ? ok(Transaction.fromDbRow(persistedTransaction, this.transactionManager.abiManager)) + : ok(undefined) } @TraceMethod("txm.transaction-repository.save-transactions") diff --git a/packages/txm/lib/TransactionSubmitter.ts b/packages/txm/lib/TransactionSubmitter.ts index b0e7d76ac7..6c9799a585 100644 --- a/packages/txm/lib/TransactionSubmitter.ts +++ b/packages/txm/lib/TransactionSubmitter.ts @@ -2,7 +2,7 @@ import { bigIntReplacer } from "@happy.tech/common" import { SpanStatusCode, context, trace } from "@opentelemetry/api" import { type Result, err, ok } from "neverthrow" import type { TransactionRequestEIP1559 } from "viem" -import { TransactionRejectedRpcError, encodeFunctionData, keccak256 } from "viem" +import { TransactionRejectedRpcError, keccak256 } from "viem" import type { EstimateGasErrorCause } from "./GasEstimator.js" import { type Attempt, AttemptType, type Transaction } from "./Transaction.js" import type { TransactionManager } from "./TransactionManager.js" @@ -101,26 +101,6 @@ export class TransactionSubmitter { gas: attempt.gas ?? 21000n, } } else { - const abi = this.txmgr.abiManager.get(transaction.contractName) - - if (!abi) { - span.addEvent("txm.transaction-submitter.send-attempt.abi-not-found", { - transactionIntentId: transaction.intentId, - contractName: transaction.contractName, - }) - span.setStatus({ code: SpanStatusCode.ERROR }) - logger.error(`ABI not found for contract ${transaction.contractName}`) - return err({ - cause: AttemptSubmissionErrorCause.ABINotFound, - description: `ABI not found for contract ${transaction.contractName}`, - flushed: false, - }) - } - - const functionName = transaction.functionName - const args = transaction.args - const data = encodeFunctionData({ abi, functionName, args }) - let gas: bigint if (attempt.gas === undefined) { const gasResult = await this.txmgr.gasEstimator.estimateGas(this.txmgr, transaction) @@ -147,7 +127,7 @@ export class TransactionSubmitter { type: "eip1559", from: this.txmgr.viemWallet.account.address, to: transaction.address, - data, + data: transaction.calldata, value: 0n, nonce: attempt.nonce, maxFeePerGas: attempt.maxFeePerGas, diff --git a/packages/txm/lib/db/migrations/Migration20241111163800.ts b/packages/txm/lib/db/migrations/Migration20241111163800.ts index 62c78f623d..c2065db400 100644 --- a/packages/txm/lib/db/migrations/Migration20241111163800.ts +++ b/packages/txm/lib/db/migrations/Migration20241111163800.ts @@ -5,14 +5,14 @@ async function up(db: Kysely) { await db.schema .createTable("transaction") .addColumn("intentId", "text", (col) => col.notNull()) - .addColumn("chainId", "integer", (col) => col.notNull()) - .addColumn("address", "text", (col) => col.notNull()) - .addColumn("functionName", "text", (col) => col.notNull()) - .addColumn("args", "json", (col) => col.notNull()) - .addColumn("contractName", "text", (col) => col.notNull()) + .addColumn("chainId", "integer") + .addColumn("address", "text") + .addColumn("functionName", "text") + .addColumn("args", "json") + .addColumn("contractName", "text") .addColumn("deadline", "integer") - .addColumn("status", "text", (col) => col.notNull()) - .addColumn("attempts", "json", (col) => col.notNull()) + .addColumn("status", "text") + .addColumn("attempts", "json") .addColumn("metadata", "json") .execute() } diff --git a/packages/txm/lib/db/migrations/Migration20241205104400.ts b/packages/txm/lib/db/migrations/Migration20241205104400.ts index ee07e57120..321f808606 100644 --- a/packages/txm/lib/db/migrations/Migration20241205104400.ts +++ b/packages/txm/lib/db/migrations/Migration20241205104400.ts @@ -2,10 +2,7 @@ import type { Kysely } from "kysely" import type { Database } from "../types" async function up(db: Kysely) { - await db.schema - .alterTable("transaction") - .addColumn("from", "text", (col) => col.notNull()) - .execute() + await db.schema.alterTable("transaction").addColumn("from", "text").execute() } export const migration20241205104400 = { up } diff --git a/packages/txm/lib/db/migrations/Migration20250410123000.ts b/packages/txm/lib/db/migrations/Migration20250410123000.ts new file mode 100644 index 0000000000..ca263e9deb --- /dev/null +++ b/packages/txm/lib/db/migrations/Migration20250410123000.ts @@ -0,0 +1,8 @@ +import type { Kysely } from "kysely" +import type { Database } from "../types" + +export async function up(db: Kysely) { + await db.schema.alterTable("transaction").addColumn("calldata", "text").execute() +} + +export const migration20250410123000 = { up } diff --git a/packages/txm/lib/db/migrations/index.ts b/packages/txm/lib/db/migrations/index.ts index 532b081b40..350cfd728d 100644 --- a/packages/txm/lib/db/migrations/index.ts +++ b/packages/txm/lib/db/migrations/index.ts @@ -2,10 +2,12 @@ import { migration20241111163800 } from "./Migration20241111163800" import { migration20241111223000 } from "./Migration20241111223000" import { migration20241205104400 } from "./Migration20241205104400" import { migration20250121110600 } from "./Migration20250121110600" +import { migration20250410123000 } from "./Migration20250410123000" export const migrations = { "20241111163800": migration20241111163800, "20241111223000": migration20241111223000, "20241205104400": migration20241205104400, "20250121110600": migration20250121110600, + "20250410123000": migration20250410123000, } diff --git a/packages/txm/lib/db/types.ts b/packages/txm/lib/db/types.ts index 1c1e54a226..d7f0024d41 100644 --- a/packages/txm/lib/db/types.ts +++ b/packages/txm/lib/db/types.ts @@ -1,4 +1,4 @@ -import type { UUID } from "@happy.tech/common" +import type { Hex, UUID } from "@happy.tech/common" import type { Address } from "viem" import type { TransactionStatus } from "../Transaction" @@ -7,9 +7,10 @@ export interface TransactionTable { from: Address chainId: number address: Address - functionName: string - args: string - contractName: string + functionName: string | undefined + args: string | undefined + contractName: string | undefined + calldata: Hex deadline: number | undefined status: TransactionStatus attempts: string diff --git a/packages/txm/test/txm.test.ts b/packages/txm/test/txm.test.ts index 69d96b03d9..01b3fc1b88 100644 --- a/packages/txm/test/txm.test.ts +++ b/packages/txm/test/txm.test.ts @@ -1,6 +1,13 @@ import { abis, deployment } from "@happy.tech/contracts/mocks/anvil" import { err } from "neverthrow" -import { type Block, type Chain, type TransactionReceipt, createPublicClient, createWalletClient } from "viem" +import { + type Block, + type Chain, + type TransactionReceipt, + createPublicClient, + createWalletClient, + encodeFunctionData, +} from "viem" import { http } from "viem" import { privateKeyToAccount, privateKeyToAddress } from "viem/accounts" import { anvil as anvilViemChain } from "viem/chains" @@ -316,6 +323,47 @@ test("Simple transaction executed", async () => { expect(retrievedTransaction.collectionBlock).toBe(previousBlock.number! + 1n) }) +test("A transaction created using calldata is executed correctly", async () => { + const previousCount = await getCurrentCounterValue() + + const calldata = encodeFunctionData({ + abi: abis.HappyCounter, + functionName: "increment", + args: [], + }) + + const transaction = await txm.createTransaction({ + address: deployment.HappyCounter, + calldata, + }) + + transactionQueue.push(transaction) + + await mineBlock(2) + + const persistedTransaction = await getPersistedTransaction(transaction.intentId) + + if (!assertIsDefined(persistedTransaction)) return + + const executedTransaction = await txm.getTransaction(transaction.intentId) + + if (!assertIsOk(executedTransaction)) return + + const executedTransactionValue = executedTransaction.value + + if (!assertIsDefined(executedTransactionValue)) return + + const receipt = await directBlockchainClient.getTransactionReceipt({ + hash: executedTransactionValue.attempts[0].hash, + }) + + expect(persistedTransaction.status).toBe(TransactionStatus.Success) + expect(persistedTransaction.calldata).toBe(calldata) + expect(executedTransactionValue.status).toBe(TransactionStatus.Success) + expect(receipt.status).toBe("success") + expect(await getCurrentCounterValue()).toBe(previousCount + 1n) +}) + test("Transaction retried", async () => { const previousCount = await getCurrentCounterValue() diff --git a/packages/txm/test/utils/TestRetryManager.ts b/packages/txm/test/utils/TestRetryManager.ts index e710e73c69..348f1e6775 100644 --- a/packages/txm/test/utils/TestRetryManager.ts +++ b/packages/txm/test/utils/TestRetryManager.ts @@ -30,6 +30,14 @@ export class TestRetryManager extends DefaultRetryPolicyManager { attempt: Attempt, customError: string, ) { - return super.isCustomError(txm, transaction, attempt, customError) + if (!transaction.contractName) { + throw new Error("Contract name is required to check if a transaction has reverted with a custom error") + } + + const abi = txm.abiManager.get(transaction.contractName) + if (!abi) { + throw new Error(`ABI not found for contract ${transaction.contractName}`) + } + return super.isCustomError(txm, attempt, abi, customError) } }