diff --git a/docs/adr/ADR-010-batch-transaction-plugin.md b/docs/adr/ADR-010-batch-transaction-plugin.md new file mode 100644 index 000000000..aff2a41ff --- /dev/null +++ b/docs/adr/ADR-010-batch-transaction-plugin.md @@ -0,0 +1,561 @@ +### ADR-010: Batch Transaction Plugin + +- Status: Proposed +- Date: 2026-03-09 +- Related: `src/plugins/batch/*`, `src/core/services/batch/*`, `src/core/commands/command.ts`, `src/core/hooks/abstract-hook.ts`, `docs/adr/ADR-001-plugin-architecture.md`, `docs/adr/ADR-009-class-based-handler-and-hook-architecture.md` + +## Context + +Hedera introduced `BatchTransaction` in [HIP-551](https://hips.hedera.com/hip/hip-551), which allows multiple inner transactions to be submitted atomically as a single network call. This is useful when a user wants to group operations (e.g. mint an NFT, create a topic, transfer tokens) and execute them together with all-or-nothing semantics. + +The CLI already supports individual commands for token, topic, account, and contract operations, each building and executing transactions independently. To support batch workflows we need: + +1. A way to **create** a named batch container with a signing key. +2. A way to **collect** inner transactions from arbitrary plugin commands (token mint, topic create, etc.) into that batch container, _without_ executing them immediately. +3. A way to **execute** the batch, deserializing all collected inner transactions, wrapping them in a `BatchTransaction`, and submitting them to the network. +4. A way for domain plugins (token, topic, account) to **persist their own state** after a successful batch execution (e.g. recording a newly created token ID). + +This ADR builds on the class-based command system and hook architecture defined in ADR-009. + +## Decision + +### Part 1: Batch Plugin Structure + +The batch plugin lives at `src/plugins/batch/` and exposes two commands: + +``` +src/plugins/batch/ +├── index.ts +├── manifest.ts +├── schema.ts +├── zustand-state-helper.ts +├── hooks/ +│ └── batchify/ +│ ├── handler.ts +│ ├── index.ts +│ ├── output.ts +│ └── types.ts +└── commands/ + ├── create/ + │ ├── handler.ts + │ ├── index.ts + │ ├── input.ts + │ └── output.ts + └── execute/ + ├── handler.ts + ├── index.ts + ├── input.ts + ├── output.ts + └── types.ts +``` + +### Part 2: State Model + +Batch state is persisted via Zustand under the namespace `batch-batches`. The schema is defined in `src/plugins/batch/schema.ts`: + +```ts +export const BatchTransactionItemSchema = z.object({ + transactionBytes: z.string().min(1, 'Transaction raw bytes'), + order: z + .number() + .int() + .describe('Order of inner transaction in batch transaction'), +}); + +export const BatchDataSchema = z.object({ + name: AliasNameSchema, + keyRefId: z.string().min(1, 'Key reference ID is required'), + transactions: z.array(BatchTransactionItemSchema).default([]), +}); +``` + +| Field | Type | Purpose | +| --------------------------------- | ------------------------ | --------------------------------------------------------------------------- | +| `name` | `string` | Unique batch alias (validated with `AliasNameSchema`) | +| `keyRefId` | `string` | Reference to the signing key resolved at batch creation time | +| `transactions` | `BatchTransactionItem[]` | Ordered list of serialized inner transactions collected from other commands | +| `transactions[].transactionBytes` | `string` | Hex-encoded bytes of a signed Hedera `Transaction` | +| `transactions[].order` | `number` | Integer determining execution order (ascending) | + +State access is encapsulated in `ZustandBatchStateHelper` with methods: `saveBatch`, `getBatch`, `hasBatch`, `listBatches`. + +### Part 3: Create Command + +`CreateBatchCommand` implements the `Command` interface directly (not `BaseTransactionCommand`) because it does not involve a network transaction -- it only persists local state. + +```ts +// src/plugins/batch/commands/create/handler.ts +export class CreateBatchCommand implements Command { + async execute(args: CommandHandlerArgs): Promise { + const { api, logger } = args; + const batchState = new ZustandBatchStateHelper(api.state, logger); + const validArgs = CreateBatchInputSchema.parse(args.args); + + if (batchState.hasBatch(validArgs.name)) { + throw new ValidationError( + `Batch with name '${validArgs.name}' already exists`, + ); + } + + const keyManager = + validArgs.keyManager || + api.config.getOption('default_key_manager'); + + const resolved = await api.keyResolver.resolveSigningKey( + validArgs.key, + keyManager, + ['batch:signer'], + ); + + batchState.saveBatch(validArgs.name, { + name: validArgs.name, + keyRefId: resolved.keyRefId, + }); + + return { result: { name: validArgs.name, keyRefId: resolved.keyRefId } }; + } +} +``` + +CLI usage: `hiero batch create --name my-batch --key ` + +### Part 4: Execute Command + +`ExecuteBatchCommand` extends `BaseTransactionCommand` from ADR-009, decomposing execution into five lifecycle phases: + +| Phase | Responsibility | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `normalizeParams` | Parse input, resolve batch from state by name + network key, throw `NotFoundError` if missing | +| `buildTransaction` | Deserialize inner transactions from hex bytes, sort by `order` (ascending), wrap them in a `BatchTransaction` via `BatchTransactionService` | +| `signTransaction` | Sign the `BatchTransaction` with the batch's `keyRefId` | +| `executeTransaction` | Submit the signed `BatchTransaction` to the network | +| `outputPreparation` | Map the `TransactionResult` into the output schema | + +```ts +// src/plugins/batch/commands/execute/handler.ts (simplified) +export class ExecuteBatchCommand extends BaseTransactionCommand< + BatchNormalisedParams, + BatchBuildTransactionResult, + BatchSignTransactionResult, + TransactionResult +> { + async buildTransaction( + args, + normalisedParams, + ): Promise { + const innerTransactions = [...normalisedParams.batchData.transactions] + .sort((a, b) => a.order - b.order) + .map((txItem) => + Transaction.fromBytes( + Uint8Array.from(Buffer.from(txItem.transactionBytes, 'hex')), + ), + ); + const result = args.api.batch.createBatchTransaction({ + transactions: innerTransactions, + }); + return { transaction: result.transaction }; + } + + async signTransaction( + args, + normalisedParams, + buildTransactionResult, + ): Promise { + const signedTransaction = await args.api.signer.sign({ + transaction: buildTransactionResult.transaction, + signerKeys: [normalisedParams.batchData.keyRefId], + }); + return { transaction: signedTransaction }; + } + + async executeTransaction( + args, + normalisedParams, + buildTransactionResult, + signTransactionResult, + ): Promise { + const result = await args.api.txExecution.execute( + signTransactionResult.transaction, + ); + if (!result.success) { + throw new TransactionError( + `Failed to execute batch (txId: ${result.transactionId})`, + false, + ); + } + return result; + } + // normalizeParams and outputPreparation omitted for brevity +} +``` + +CLI usage: `hiero batch execute --name my-batch` + +### Part 5: BatchTransactionService + +The core service at `src/core/services/batch/batch-transaction-service.ts` wraps the Hedera SDK `BatchTransaction` class: + +```ts +export class BatchTransactionServiceImpl implements BatchTransactionService { + createBatchTransaction( + params: CreateBatchTransactionParams, + ): CreateBatchTransactionResult { + const batchTransaction = new BatchTransaction(); + params.transactions.forEach((tx) => { + batchTransaction.addInnerTransaction(tx); + }); + return { transaction: batchTransaction }; + } +} +``` + +The service accepts an array of deserialized `Transaction` objects and returns a `BatchTransaction` ready for signing and submission. + +### Part 6: Hook System + +Because `ExecuteBatchCommand` extends `BaseTransactionCommand`, it participates in the full hook lifecycle defined in ADR-009. Two concrete hook types are planned to enable cross-plugin batch integration: + +#### 6.1 BatchifyTransactionHook + +**Owner:** Batch plugin (`src/plugins/batch/hooks/batchify/handler.ts`) + +**Purpose:** Intercept transaction-producing commands from other plugins (e.g. `token mint-nft`, `token create-nft`, `topic create`) and, instead of submitting the transaction to the network, serialize it and append it to the active batch's state. + +**Lifecycle point:** `preExecuteTransactionHook` -- fires after `buildTransaction` and `signTransaction` have produced a signed transaction but before `executeTransaction` would submit it to the network. + +**Flow control:** Returns `breakFlow: true` to prevent the original command from executing the transaction on-chain. The inner transaction bytes are stored in batch state for later execution via `batch execute`. + +**Hook registration:** Following ADR-009, the hook is defined in the batch plugin manifest. Each command that wants batch support opts in by including `'batchify'` in its `registeredHooks` array. + +The hook declares an `options` array with a `--batch` / `-b` option. This option is automatically injected into every command that registers the hook (per ADR-009 Hook Option Injection), so commands do not need to declare it themselves. + +**Registration in batch plugin manifest:** + +```ts +// src/plugins/batch/manifest.ts (hooks section) +hooks: [ + { + name: 'batchify', + hook: new BatchifyTransactionHook(), + options: [ + { + name: 'batch', + type: OptionType.STRING, + description: 'Name of the batch to add this transaction to', + short: 'b', + }, + ], + }, +], +``` + +**Commands opting in (examples):** + +```ts +// src/plugins/token/manifest.ts (command example) +{ + name: 'mint-nft', + summary: 'Mint an NFT', + description: '...', + options: [ /* ... token-specific options ... */ ], + registeredHooks: ['batchify'], + command: new MintNftCommand(), + handler: mintNft, + output: { schema: MintNftOutputSchema, humanTemplate: MINT_NFT_TEMPLATE }, +} + +// src/plugins/topic/manifest.ts (command example) +{ + name: 'create', + summary: 'Create a new Hedera topic', + description: '...', + options: [ /* ... topic-specific options ... */ ], + registeredHooks: ['batchify'], + command: new CreateTopicCommand(), + handler: createTopic, + output: { schema: CreateTopicOutputSchema, humanTemplate: CREATE_TOPIC_TEMPLATE }, +} +``` + +Any command that includes `'batchify'` in its `registeredHooks` automatically gains the `--batch` / `-b` option without modifying its own option list. + +**Hook implementation:** + +```ts +// src/plugins/batch/hooks/batchify/handler.ts +import { AbstractHook } from '@/core/hooks/abstract-hook'; +import type { CommandHandlerArgs } from '@/core'; +import type { + HookResult, + PreExecuteTransactionParams, +} from '@/core/hooks/types'; +import { ZustandBatchStateHelper } from '@/plugins/batch/zustand-state-helper'; + +export class BatchifyTransactionHook extends AbstractHook { + override async preExecuteTransactionHook( + args: CommandHandlerArgs, + params: PreExecuteTransactionParams, + ): Promise { + const { api, logger } = args; + const batchName = args.args.batch as string | undefined; + + // If no --batch flag was provided, let the command execute normally + if (!batchName) { + return { breakFlow: false, result: { message: 'no batch context' } }; + } + + const batchState = new ZustandBatchStateHelper(api.state, logger); + const batch = batchState.getBatch(batchName); + + if (!batch) { + return { + breakFlow: false, + result: { message: 'batch not found, proceeding normally' }, + }; + } + + // Serialize the signed transaction produced by signTransaction + const signedTransaction = params.signTransactionResult; + const transactionBytes = Buffer.from(signedTransaction.toBytes()).toString( + 'hex', + ); + + // Determine the next order index + const nextOrder = batch.transactions.length; + + // Append the inner transaction to batch state + batch.transactions.push({ + transactionBytes, + order: nextOrder, + }); + batchState.saveBatch(batchName, batch); + + logger.info( + `Transaction added to batch '${batchName}' at position ${nextOrder}`, + ); + + // Break flow: prevent the original command from executing the transaction on-chain + return { + breakFlow: true, + result: { + message: `Transaction added to batch '${batchName}'`, + batchName, + order: nextOrder, + }, + humanTemplate: `Transaction added to batch '{{batchName}}' (position {{order}})`, + }; + } +} +``` + +**How the `--batch` flag reaches the hook:** The `batchify` hook declares a `batch` option in its `HookSpec.options`. When a command lists `'batchify'` in its `registeredHooks`, `PluginManager` automatically injects the `--batch` / `-b` option into that command (as non-required). If the user passes `--batch my-batch`, the hook detects it in `args.args.batch` and activates the interception logic. If absent, the hook is a no-op and the command executes normally. + +#### 6.2 StateHook (for example TokenStateHook) + +**Owner:** Each domain plugin (token, topic, account) that needs to persist state after batch execution. + +**Purpose:** After `batch execute` successfully submits a `BatchTransaction` to the network, domain plugins need to update their own state to reflect the results of the inner transactions (e.g. store a new token ID, record a new topic, update account associations). + +**Lifecycle point:** `postExecuteTransactionHook` -- fires after `executeTransaction` has successfully submitted the batch and a receipt is available. + +**Flow control:** Returns `breakFlow: false` to allow the batch execute command to continue to output preparation and any subsequent hooks. + +**Consuming command:** `batch execute` -- the `ExecuteBatchCommand` opts in to domain-state hooks by listing them in its `registeredHooks`. + +**Registration in token plugin manifest (example):** + +```ts +// src/plugins/token/manifest.ts (hooks section) +hooks: [ + { + name: 'token-batch-state', + hook: new TokenBatchStateHook(), + }, +], +``` + +**Registration in batch execute command (example):** + +```ts +// src/plugins/batch/manifest.ts (execute command) +{ + name: 'execute', + summary: 'Execute a batch transaction', + description: '...', + options: [ /* ... */ ], + registeredHooks: ['token-batch-state', 'topic-batch-state', 'account-batch-state'], + command: new ExecuteBatchCommand(), + handler: executeBatch, + output: { schema: ExecuteBatchOutputSchema, humanTemplate: EXECUTE_BATCH_TEMPLATE }, +} +``` + +```ts +// src/plugins/token/hooks/batch-state/handler.ts +import { AbstractHook } from '@/core/hooks/abstract-hook'; +import type { CommandHandlerArgs } from '@/core'; +import type { HookResult, PostExecuteTransactionParams } from '@/core/hooks/types'; +import { ZustandTokenStateHelper } from '@/plugins/token/zustand-state-helper'; + +export class TokenStateHook extends AbstractHook { + override async postExecuteTransactionHook( + args: CommandHandlerArgs, + params: PostExecuteTransactionParams, + ): Promise { + const { api, logger } = args; + const batchData = params.normalisedParams.batchData; // BatchData with all inner transactions + const executeTransactionResult = params.executeTransactionResult; // TransactionResult from batch execution + + if (!executeTransactionResult?.success) { + return { breakFlow: false, result: { message: 'batch failed, skipping state update' } }; + } + + const tokenState = new ZustandTokenStateHelper(api.state, logger); + const receipt = executeTransactionResult.receipt; + + // Inspect the batch receipt for token-related child receipts + // and persist state for each token operation that was part of the batch + if (receipt.children) { + ... + } + + return { breakFlow: false, result: { message: 'token state updated' } }; + } +} +``` + +Analogous hooks would be created for other domain plugins: + +| Hook Class | Plugin | Purpose | +| ------------------ | ------- | ------------------------------------------------------- | +| `TokenStateHook` | token | Persist newly created tokens, minted NFTs, associations | +| `TopicStateHook` | topic | Persist newly created topics | +| `AccountStateHook` | account | Persist newly created accounts | + +Each domain-state hook inspects the batch execution receipt for child receipts relevant to its domain and updates its plugin's state accordingly. + +## Execution Flow + +### Full Batch Lifecycle + +```mermaid +sequenceDiagram + participant User + participant CLI + participant CreateBatchCmd as CreateBatchCommand + participant BatchState as ZustandBatchStateHelper + participant TokenCmd as MintNftCommand + participant AddHook as BatchifyTransactionHook + participant ExecuteCmd as ExecuteBatchCommand + participant BatchSvc as BatchTransactionService + participant Network as Hedera Network + participant DomainHook as TokenStateHook + + User->>CLI: batch create --name my-batch --key + CLI->>CreateBatchCmd: execute(args) + CreateBatchCmd->>BatchState: saveBatch("my-batch", data) + CreateBatchCmd-->>User: Batch 'my-batch' created + + User->>CLI: token mint-nft --token 0.0.123 --batch my-batch + CLI->>TokenCmd: execute(args) + TokenCmd->>TokenCmd: normalizeParams(args) + TokenCmd->>TokenCmd: buildTransaction(args, params) + Note over TokenCmd: Transaction built + TokenCmd->>TokenCmd: signTransaction(args, params, buildResult) + Note over TokenCmd: Signed transaction ready + + TokenCmd->>AddHook: preExecuteTransactionHook(args, params) + AddHook->>AddHook: detect --batch flag in args + AddHook->>BatchState: getBatch("my-batch") + AddHook->>AddHook: serialize signed tx to hex bytes + AddHook->>BatchState: saveBatch("my-batch", updated) + AddHook-->>TokenCmd: breakFlow: true + TokenCmd-->>User: Transaction added to batch 'my-batch' + Note over TokenCmd: executeTransaction skipped + + User->>CLI: batch execute --name my-batch + CLI->>ExecuteCmd: execute(args) + ExecuteCmd->>ExecuteCmd: normalizeParams(args) + ExecuteCmd->>BatchState: getBatch("my-batch") + ExecuteCmd->>ExecuteCmd: buildTransaction: sort by order, deserialize tx bytes + ExecuteCmd->>BatchSvc: createBatchTransaction(innerTxs) + BatchSvc-->>ExecuteCmd: BatchTransaction + ExecuteCmd->>ExecuteCmd: signTransaction(batchTx, keyRefId) + + ExecuteCmd->>Network: executeTransaction(signedBatchTx) + Network-->>ExecuteCmd: TransactionResult + + ExecuteCmd->>DomainHook: postExecuteTransactionHook(args, params) + DomainHook->>DomainHook: inspect receipt children + DomainHook->>DomainHook: persist domain state + DomainHook-->>ExecuteCmd: breakFlow: false + + ExecuteCmd->>ExecuteCmd: outputPreparation + ExecuteCmd-->>User: Batch executed successfully +``` + +### BatchifyTransactionHook Interception Detail + +```mermaid +flowchart TD + A["Command: token mint-nft --batch my-batch"] --> B[normalizeParams] + B --> C[buildTransaction] + C --> C2[signTransaction] + C2 --> D{"preExecuteTransactionHook"} + D -->|"--batch flag present"| E[BatchifyTransactionHook activates] + E --> F[Serialize signed transaction to hex] + F --> G[Append to batch state] + G --> H["Return breakFlow: true"] + H --> I["Output: Transaction added to batch"] + D -->|"no --batch flag"| J[executeTransaction] + J --> K[Submit to network normally] +``` + +## Pros and Cons + +### Pros + +- **Atomic multi-operation execution.** Multiple operations from different plugins (token, topic, account) can be grouped and submitted as a single `BatchTransaction`, providing all-or-nothing semantics at the network level. +- **Non-intrusive collection.** The `BatchifyTransactionHook` intercepts existing commands at `preExecuteTransactionHook` without modifying the command's own code. Adding batch support to a new command only requires listing `'batchify'` in the command's `registeredHooks` -- the `--batch` option is injected automatically via hook option injection (ADR-009). +- **Leverages ADR-009 architecture.** Both hooks (`BatchifyTransactionHook` and domain-state hooks) use the established `AbstractHook` lifecycle, `HookResult` flow control, command-driven hook registration (`registeredHooks`), and hook option injection, requiring no changes to the core framework. +- **Decoupled state persistence.** Domain plugins own their state hooks. The batch plugin does not need to know about token, topic, or account data models. The `batch execute` command registers domain-state hooks (e.g. `'token-batch-state'`) in its `registeredHooks`. +- **Order control.** The `order` field on `BatchTransactionItem` gives deterministic transaction ordering within the batch, important for operations with dependencies (e.g. create token before mint). +- **Incremental adoption.** Commands that are not yet migrated to `BaseTransactionCommand` (and therefore lack hook support) simply cannot participate in batching. Migration can happen command by command, with batch support becoming available automatically once a command adopts the class-based pattern. + +### Cons + +- **Deferred execution complexity.** When a transaction is added to a batch, the user does not get immediate feedback on whether it will succeed on-chain. Validation happens at build time, but network-level failures are only surfaced at `batch execute`. +- **State consistency risk.** If `batch execute` partially fails at the network level, the domain-state hooks may not fire, leaving state out of sync with the ledger. Recovery mechanisms (retry, rollback) are not yet defined. +- **Receipt parsing complexity.** Domain-state hooks must parse `BatchTransaction` child receipts to find results relevant to their domain. The mapping between inner transaction position and receipt child index must be reliable and well-documented. +- **Implicit coupling via `--batch` flag.** The `BatchifyTransactionHook` relies on a `--batch` option being present in `args.args`. While hook option injection (ADR-009) eliminates the need to manually declare the option in each command, the hook still assumes the option name convention. +- **No partial execution.** `BatchTransaction` is all-or-nothing. If one inner transaction fails, the entire batch fails. There is no mechanism for partial success or selective retry of individual inner transactions. + +## Consequences + +- Commands that produce transactions and need batch support must: + 1. Be migrated to `BaseTransactionCommand` (per ADR-009) so they participate in the hook lifecycle. + 2. Include `'batchify'` in their `registeredHooks` array. The `--batch` option is then injected automatically via hook option injection. +- Domain plugins that need to persist state after batch execution must define a state hook (e.g. `'token-batch-state'`) in their manifest. The `batch execute` command must list these hooks in its `registeredHooks`. +- The `order` field must be managed carefully. When `BatchifyTransactionHook` appends a transaction, it should assign the next sequential order value. +- Error handling in `batch execute` should provide clear messages about which inner transaction caused a failure, mapping back to the original command when possible. +- Future enhancements may include: + - A `batch list` command to view collected transactions before execution. + - A `batch remove` command to remove a transaction from the batch by order. + - A `batch clear` command to discard all transactions in a batch. + +## Testing Strategy + +- **Unit: CreateBatchCommand.** Test that a batch is created in state with the correct name and key reference. Verify `ValidationError` when a duplicate name is used. +- **Unit: ExecuteBatchCommand phases.** Test each `BaseTransactionCommand` phase independently: + - `normalizeParams`: verify `NotFoundError` for missing batch. + - `buildTransaction`: verify transactions are sorted by `order` and deserialized correctly. + - `signTransaction`: verify the batch transaction is signed with the correct `keyRefId`. + - `executeTransaction`: verify `TransactionError` on failed submission. + - `outputPreparation`: verify output schema conformance. +- **Unit: BatchifyTransactionHook.** Invoke `preExecuteTransactionHook` with mock args containing `--batch` flag. Assert that the transaction bytes are appended to batch state and `breakFlow: true` is returned. Invoke without `--batch` flag and assert `breakFlow: false`. +- **Unit: TokenStateHook (and domain hooks).** Invoke `postExecuteTransactionHook` with a mock `TransactionResult` containing child receipts. Assert that domain state is updated. Invoke with a failed result and assert no state changes. +- **Unit: BatchTransactionService.** Verify that `createBatchTransaction` correctly adds each inner transaction to the `BatchTransaction` via `addInnerTransaction`. +- **Unit: Schema validation.** Test `BatchDataSchema` and `BatchTransactionItemSchema` with valid and invalid inputs. +- **Integration: Full batch lifecycle.** Create a batch, run a command with `--batch` flag (verifying interception), then execute the batch and verify both the network submission and domain state updates. +- **Integration: Hook filtering.** Verify that `BatchifyTransactionHook` is only injected into commands that include `'batchify'` in their `registeredHooks` and not into unrelated commands. +- **Integration: Hook option injection.** Verify that commands with `registeredHooks: ['batchify']` automatically gain the `--batch` / `-b` option without declaring it in their own `CommandSpec.options`. diff --git a/src/core/core-api/core-api.interface.ts b/src/core/core-api/core-api.interface.ts index 85f5d58b1..686be4244 100644 --- a/src/core/core-api/core-api.interface.ts +++ b/src/core/core-api/core-api.interface.ts @@ -4,6 +4,7 @@ */ import type { AccountService } from '@/core/services/account/account-transaction-service.interface'; import type { AliasService } from '@/core/services/alias/alias-service.interface'; +import type { BatchTransactionService } from '@/core/services/batch/batch-transaction-service.interface'; import type { ConfigService } from '@/core/services/config/config-service.interface'; import type { ContractCompilerService } from '@/core/services/contract-compiler/contract-compiler-service.interface'; import type { ContractQueryService } from '@/core/services/contract-query/contract-query-service.interface'; @@ -109,4 +110,5 @@ export interface CoreApi { contractVerifier: ContractVerifierService; contractQuery: ContractQueryService; identityResolution: IdentityResolutionService; + batch: BatchTransactionService; } diff --git a/src/core/core-api/core-api.ts b/src/core/core-api/core-api.ts index ff44a3e43..b4ba38a5b 100644 --- a/src/core/core-api/core-api.ts +++ b/src/core/core-api/core-api.ts @@ -5,6 +5,7 @@ import type { CoreApi } from '@/core'; import type { AccountService } from '@/core/services/account/account-transaction-service.interface'; import type { AliasService } from '@/core/services/alias/alias-service.interface'; +import type { BatchTransactionService } from '@/core/services/batch/batch-transaction-service.interface'; import type { ConfigService } from '@/core/services/config/config-service.interface'; import type { ContractCompilerService } from '@/core/services/contract-compiler/contract-compiler-service.interface'; import type { ContractQueryService } from '@/core/services/contract-query/contract-query-service.interface'; @@ -30,6 +31,7 @@ import type { TxSignService } from '@/core/services/tx-sign/tx-sign-service.inte import { AccountServiceImpl } from '@/core/services/account/account-transaction-service'; import { AliasServiceImpl } from '@/core/services/alias/alias-service'; +import { BatchTransactionServiceImpl } from '@/core/services/batch/batch-transaction-service'; import { ConfigServiceImpl } from '@/core/services/config/config-service'; import { ContractCompilerServiceImpl } from '@/core/services/contract-compiler/contract-compiler-service'; import { ContractQueryServiceImpl } from '@/core/services/contract-query/contract-query-service'; @@ -72,6 +74,7 @@ export class CoreApiImplementation implements CoreApi { public contractVerifier: ContractVerifierService; public contractQuery: ContractQueryService; public identityResolution: IdentityResolutionService; + public batch: BatchTransactionService; constructor(storageDir?: string) { this.logger = new LoggerService(); @@ -128,6 +131,7 @@ export class CoreApiImplementation implements CoreApi { this.alias, this.mirror, ); + this.batch = new BatchTransactionServiceImpl(this.logger); } } diff --git a/src/core/plugins/plugin-manager.ts b/src/core/plugins/plugin-manager.ts index cc6e32846..6ec3a2587 100644 --- a/src/core/plugins/plugin-manager.ts +++ b/src/core/plugins/plugin-manager.ts @@ -366,8 +366,10 @@ export class PluginManager { let result: CommandResult; if (commandSpec.command) { result = await commandSpec.command.execute(handlerArgs); - } else { + } else if (commandSpec.handler) { result = await commandSpec.handler(handlerArgs); + } else { + throw new InternalError('Command handler not found'); } const outputSchema = result.overrideSchema ?? commandSpec.output.schema; outputSchema.parse(result.result); diff --git a/src/core/plugins/plugin.types.ts b/src/core/plugins/plugin.types.ts index 25ad5b7bd..26c1fa344 100644 --- a/src/core/plugins/plugin.types.ts +++ b/src/core/plugins/plugin.types.ts @@ -61,7 +61,7 @@ export interface CommandSpec { description: string; options?: CommandOption[]; command?: Command; - handler: CommandHandler; + handler?: CommandHandler; output: CommandOutputSpec; excessArguments?: boolean; registeredHooks?: string[]; diff --git a/src/core/services/batch/batch-transaction-service.interface.ts b/src/core/services/batch/batch-transaction-service.interface.ts new file mode 100644 index 000000000..ee39afa8a --- /dev/null +++ b/src/core/services/batch/batch-transaction-service.interface.ts @@ -0,0 +1,10 @@ +import type { + CreateBatchTransactionParams, + CreateBatchTransactionResult, +} from '@/core/services/batch/types'; + +export interface BatchTransactionService { + createBatchTransaction( + params: CreateBatchTransactionParams, + ): CreateBatchTransactionResult; +} diff --git a/src/core/services/batch/batch-transaction-service.ts b/src/core/services/batch/batch-transaction-service.ts new file mode 100644 index 000000000..68ccd0338 --- /dev/null +++ b/src/core/services/batch/batch-transaction-service.ts @@ -0,0 +1,31 @@ +import type { BatchTransactionService } from '@/core/services/batch/batch-transaction-service.interface'; +import type { + CreateBatchTransactionParams, + CreateBatchTransactionResult, +} from '@/core/services/batch/types'; +import type { Logger } from '@/core/services/logger/logger-service.interface'; + +import { BatchTransaction } from '@hashgraph/sdk'; + +export class BatchTransactionServiceImpl implements BatchTransactionService { + private logger: Logger; + + constructor(logger: Logger) { + this.logger = logger; + } + + createBatchTransaction( + params: CreateBatchTransactionParams, + ): CreateBatchTransactionResult { + this.logger.debug( + `[BATCH TX] Creating batch transaction with ${params.transactions.length} inner transactions`, + ); + const batchTransaction = new BatchTransaction(); + params.transactions.forEach((tx) => { + batchTransaction.addInnerTransaction(tx); + }); + return { + transaction: batchTransaction, + }; + } +} diff --git a/src/core/services/batch/types.ts b/src/core/services/batch/types.ts new file mode 100644 index 000000000..431402a59 --- /dev/null +++ b/src/core/services/batch/types.ts @@ -0,0 +1,10 @@ +import type { BatchTransaction, Transaction } from '@hashgraph/sdk'; + +export interface CreateBatchTransactionResult { + transaction: BatchTransaction; +} + +// Parameter types for account operations +export interface CreateBatchTransactionParams { + transactions: Transaction[]; +} diff --git a/src/core/services/mirrornode/hedera-mirrornode-service.ts b/src/core/services/mirrornode/hedera-mirrornode-service.ts index 99833edb8..7fdcad9a6 100644 --- a/src/core/services/mirrornode/hedera-mirrornode-service.ts +++ b/src/core/services/mirrornode/hedera-mirrornode-service.ts @@ -358,7 +358,6 @@ export class HederaMirrornodeServiceDefaultImpl implements HederaMirrornodeServi async getTopicInfo(topicId: string): Promise { const url = `${this.getBaseUrl()}/topics/${topicId}`; - console.log(url); try { const response = await fetch(url); diff --git a/src/core/shared/config/cli-options.ts b/src/core/shared/config/cli-options.ts index 37ec87fa3..150eab107 100644 --- a/src/core/shared/config/cli-options.ts +++ b/src/core/shared/config/cli-options.ts @@ -1,6 +1,7 @@ import type { PluginManifest } from '@/core/plugins/plugin.types'; import accountPluginManifest from '@/plugins/account/manifest'; +import batchPluginManifest from '@/plugins/batch/manifest'; import configPluginManifest from '@/plugins/config/manifest'; import contractPluginManifest from '@/plugins/contract/manifest'; import contractErc20PluginManifest from '@/plugins/contract-erc20/manifest'; @@ -38,6 +39,7 @@ export const RESERVED_SHORT_OPTIONS = new Set([ export const DEFAULT_PLUGIN_STATE: PluginManifest[] = [ accountPluginManifest, + batchPluginManifest, tokenPluginManifest, networkPluginManifest, pluginManagementManifest, diff --git a/src/plugins/batch/commands/create/handler.ts b/src/plugins/batch/commands/create/handler.ts new file mode 100644 index 000000000..f204f3dad --- /dev/null +++ b/src/plugins/batch/commands/create/handler.ts @@ -0,0 +1,52 @@ +/** + * Batch Create Command Handler + */ +import type { CommandHandlerArgs, CommandResult } from '@/core'; +import type { Command } from '@/core/commands/command.interface'; +import type { KeyManagerName } from '@/core/services/kms/kms-types.interface'; +import type { CreateBatchOutput } from './output'; + +import { ValidationError } from '@/core/errors'; +import { ZustandBatchStateHelper } from '@/plugins/batch/zustand-state-helper'; + +import { CreateBatchInputSchema } from './input'; + +export class CreateBatchCommand implements Command { + async execute(args: CommandHandlerArgs): Promise { + const { api, logger } = args; + + const batchState = new ZustandBatchStateHelper(api.state, logger); + const validArgs = CreateBatchInputSchema.parse(args.args); + + if (batchState.hasBatch(validArgs.name)) { + throw new ValidationError( + `Batch with name '${validArgs.name}' already exists`, + ); + } + + const keyManager = + validArgs.keyManager || + api.config.getOption('default_key_manager'); + + const resolved = await api.keyResolver.resolveSigningKey( + validArgs.key, + keyManager, + ['batch:signer'], + ); + + const batchData = { + name: validArgs.name, + keyRefId: resolved.keyRefId, + transactions: [], + }; + + batchState.saveBatch(validArgs.name, batchData); + + const outputData: CreateBatchOutput = { + name: batchData.name, + keyRefId: batchData.keyRefId, + }; + + return { result: outputData }; + } +} diff --git a/src/plugins/batch/commands/create/index.ts b/src/plugins/batch/commands/create/index.ts new file mode 100644 index 000000000..a44433148 --- /dev/null +++ b/src/plugins/batch/commands/create/index.ts @@ -0,0 +1,3 @@ +export { CreateBatchCommand } from './handler'; +export type { CreateBatchOutput } from './output'; +export { CREATE_BATCH_TEMPLATE, CreateBatchOutputSchema } from './output'; diff --git a/src/plugins/batch/commands/create/input.ts b/src/plugins/batch/commands/create/input.ts new file mode 100644 index 000000000..f7bbe54ff --- /dev/null +++ b/src/plugins/batch/commands/create/input.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { + AliasNameSchema, + KeyManagerTypeSchema, + PrivateKeySchema, +} from '@/core/schemas'; + +/** + * Input schema for batch create command + */ +export const CreateBatchInputSchema = z.object({ + name: AliasNameSchema.describe('Batch name'), + key: PrivateKeySchema.describe( + 'Key to sign transactions in the batch. Can be {accountId}:{privateKey} pair, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias', + ), + keyManager: KeyManagerTypeSchema.optional().describe( + 'Key manager type (defaults to config setting)', + ), +}); + +export type CreateBatchInput = z.infer; diff --git a/src/plugins/batch/commands/create/output.ts b/src/plugins/batch/commands/create/output.ts new file mode 100644 index 000000000..175fbc206 --- /dev/null +++ b/src/plugins/batch/commands/create/output.ts @@ -0,0 +1,23 @@ +/** + * Create Batch Command Output Schema and Template + */ +import { z } from 'zod'; + +/** + * Create Batch Command Output Schema + */ +export const CreateBatchOutputSchema = z.object({ + name: z.string().describe('Batch name'), + keyRefId: z.string().describe('Key reference ID for signing'), +}); + +export type CreateBatchOutput = z.infer; + +/** + * Human-readable template for create batch output + */ +export const CREATE_BATCH_TEMPLATE = ` +✅ Batch created successfully + Name: {{name}} + Batch Key Reference ID: {{keyRefId}} +`.trim(); diff --git a/src/plugins/batch/commands/execute/handler.ts b/src/plugins/batch/commands/execute/handler.ts new file mode 100644 index 000000000..1e242f2b5 --- /dev/null +++ b/src/plugins/batch/commands/execute/handler.ts @@ -0,0 +1,124 @@ +/** + * Batch Execute Command Handler + */ +import type { + CommandHandlerArgs, + CommandResult, + TransactionResult, +} from '@/core'; +import type { + BatchBuildTransactionResult, + BatchNormalisedParams, + BatchSignTransactionResult, +} from '@/plugins/batch/commands/execute/types'; +import type { ExecuteBatchOutput } from './output'; + +import { Transaction } from '@hashgraph/sdk'; + +import { BaseTransactionCommand } from '@/core/commands/command'; +import { NotFoundError, TransactionError } from '@/core/errors'; +import { composeKey } from '@/core/utils/key-composer'; +import { ZustandBatchStateHelper } from '@/plugins/batch/zustand-state-helper'; + +import { ExecuteBatchInputSchema } from './input'; + +export class ExecuteBatchCommand extends BaseTransactionCommand< + BatchNormalisedParams, + BatchBuildTransactionResult, + BatchSignTransactionResult, + TransactionResult +> { + async normalizeParams( + args: CommandHandlerArgs, + ): Promise { + const { api, logger } = args; + const batchState = new ZustandBatchStateHelper(api.state, logger); + const validArgs = ExecuteBatchInputSchema.parse(args.args); + const name = validArgs.name; + const network = api.network.getCurrentNetwork(); + const key = composeKey(network, name); + const batchData = batchState.getBatch(key); + if (!batchData) { + throw new NotFoundError(`Batch not found: ${validArgs.name}`); + } + return { + name, + network, + batchData, + }; + } + async buildTransaction( + args: CommandHandlerArgs, + normalisedParams: BatchNormalisedParams, + ): Promise { + void normalisedParams; + const { api } = args; + const innerTransactions = [...normalisedParams.batchData.transactions] + .sort((a, b) => a.order - b.order) + .map((txItem) => { + return Transaction.fromBytes( + //@todo ensure that transaction will be stored in hex format + Uint8Array.from(Buffer.from(txItem.transactionBytes, 'hex')), + ); + }); + const result = api.batch.createBatchTransaction({ + transactions: innerTransactions, + }); + return { transaction: result.transaction }; + } + async signTransaction( + args: CommandHandlerArgs, + normalisedParams: BatchNormalisedParams, + buildTransactionResult: BatchBuildTransactionResult, + ): Promise { + const { api } = args; + void normalisedParams; + const batchKey = normalisedParams.batchData.keyRefId; + const signedTransaction = await api.txSign.sign( + buildTransactionResult.transaction, + [batchKey], + ); + return { + transaction: signedTransaction, + }; + } + async executeTransaction( + args: CommandHandlerArgs, + normalisedParams: BatchNormalisedParams, + buildTransactionResult: BatchBuildTransactionResult, + signTransactionResult: BatchSignTransactionResult, + ): Promise { + void normalisedParams; + void buildTransactionResult; + const { api } = args; + const result = await api.txExecute.execute( + signTransactionResult.transaction, + ); + if (!result.success) { + throw new TransactionError( + `Failed to execute batch (txId: ${result.transactionId})`, + false, + ); + } + return result; + } + async outputPreparation( + args: CommandHandlerArgs, + normalisedParams: BatchNormalisedParams, + buildTransactionResult: BatchBuildTransactionResult, + signTransactionResult: BatchSignTransactionResult, + executeTransactionResult: TransactionResult, + ): Promise { + void args; + void buildTransactionResult; + void signTransactionResult; + const outputData: ExecuteBatchOutput = { + batchName: normalisedParams.name, + transactionId: executeTransactionResult?.transactionId || '', + success: executeTransactionResult?.success || false, + network: normalisedParams.network, + }; + + return { result: outputData }; + } +} diff --git a/src/plugins/batch/commands/execute/index.ts b/src/plugins/batch/commands/execute/index.ts new file mode 100644 index 000000000..c470d0f3b --- /dev/null +++ b/src/plugins/batch/commands/execute/index.ts @@ -0,0 +1,3 @@ +export { ExecuteBatchCommand } from './handler'; +export type { ExecuteBatchOutput } from './output'; +export { EXECUTE_BATCH_TEMPLATE, ExecuteBatchOutputSchema } from './output'; diff --git a/src/plugins/batch/commands/execute/input.ts b/src/plugins/batch/commands/execute/input.ts new file mode 100644 index 000000000..081d5dbf8 --- /dev/null +++ b/src/plugins/batch/commands/execute/input.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { AliasNameSchema } from '@/core/schemas'; + +/** + * Input schema for batch execute command + */ +export const ExecuteBatchInputSchema = z.object({ + name: AliasNameSchema.describe('Batch name'), +}); + +export type ExecuteBatchInput = z.infer; diff --git a/src/plugins/batch/commands/execute/output.ts b/src/plugins/batch/commands/execute/output.ts new file mode 100644 index 000000000..68b478707 --- /dev/null +++ b/src/plugins/batch/commands/execute/output.ts @@ -0,0 +1,31 @@ +/** + * Execute Batch Command Output Schema and Template + */ +import { z } from 'zod'; + +import { + NetworkSchema, + TransactionIdSchema, +} from '@/core/schemas/common-schemas'; + +/** + * Execute Batch Command Output Schema + */ +export const ExecuteBatchOutputSchema = z.object({ + batchName: z.string().describe('Batch name'), + transactionId: TransactionIdSchema.describe('Transaction ID'), + success: z.boolean().describe('Whether the transaction succeeded'), + network: NetworkSchema.describe('Network'), +}); + +export type ExecuteBatchOutput = z.infer; + +/** + * Human-readable template for execute batch output + */ +export const EXECUTE_BATCH_TEMPLATE = ` +✅ Batch executed successfully + Batch: {{batchName}} + Transaction ID: {{hashscanLink transactionId "transaction" network}} + Success: {{success}} +`.trim(); diff --git a/src/plugins/batch/commands/execute/types.ts b/src/plugins/batch/commands/execute/types.ts new file mode 100644 index 000000000..e471c9ad6 --- /dev/null +++ b/src/plugins/batch/commands/execute/types.ts @@ -0,0 +1,17 @@ +import type { Transaction } from '@hashgraph/sdk'; +import type { SupportedNetwork } from '@/core'; +import type { BatchData } from '@/plugins/batch/schema'; + +export interface BatchNormalisedParams { + name: string; + network: SupportedNetwork; + batchData: BatchData; +} + +export interface BatchBuildTransactionResult { + transaction: Transaction; +} + +export interface BatchSignTransactionResult { + transaction: Transaction; +} diff --git a/src/plugins/batch/index.ts b/src/plugins/batch/index.ts new file mode 100644 index 000000000..c7ed3fb8f --- /dev/null +++ b/src/plugins/batch/index.ts @@ -0,0 +1,7 @@ +/** + * Batch Plugin + * Exports plugin manifest and command handlers + */ +export { CreateBatchCommand } from './commands/create'; +export { ExecuteBatchCommand } from './commands/execute'; +export { batchPluginManifest } from './manifest'; diff --git a/src/plugins/batch/manifest.ts b/src/plugins/batch/manifest.ts new file mode 100644 index 000000000..c39e946b4 --- /dev/null +++ b/src/plugins/batch/manifest.ts @@ -0,0 +1,88 @@ +/** + * Batch Plugin Manifest + * Defines the batch plugin according to ADR-001 + */ +import type { PluginManifest } from '@/core'; + +import { OptionType } from '@/core/types/shared.types'; + +import { + CREATE_BATCH_TEMPLATE, + CreateBatchCommand, + CreateBatchOutputSchema, +} from './commands/create'; +import { + EXECUTE_BATCH_TEMPLATE, + ExecuteBatchCommand, + ExecuteBatchOutputSchema, +} from './commands/execute'; + +export const BATCH_NAMESPACE = 'batch-batches'; + +export const batchPluginManifest: PluginManifest = { + name: 'batch', + version: '1.0.0', + displayName: 'Batch Plugin', + description: + 'Plugin for creating and executing batches of Hedera transactions', + commands: [ + { + name: 'create', + summary: 'Create a new batch', + description: + 'Create a new batch with a name and signing key for transaction execution', + options: [ + { + name: 'name', + short: 'n', + type: OptionType.STRING, + required: true, + description: 'Name/alias for the batch', + }, + { + name: 'key', + short: 'k', + type: OptionType.STRING, + required: true, + description: + 'Key to sign transactions. Can be {accountId}:{privateKey} pair, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias', + }, + { + name: 'key-manager', + short: 'm', + type: OptionType.STRING, + required: false, + description: + 'Key manager to use: local or local_encrypted (defaults to config setting)', + }, + ], + command: new CreateBatchCommand(), + output: { + schema: CreateBatchOutputSchema, + humanTemplate: CREATE_BATCH_TEMPLATE, + }, + }, + { + name: 'execute', + summary: 'Execute a batch', + description: + 'Execute a batch by name, signing and submitting its transactions', + options: [ + { + name: 'name', + short: 'n', + type: OptionType.STRING, + required: true, + description: 'Name of the batch to execute', + }, + ], + command: new ExecuteBatchCommand(), + output: { + schema: ExecuteBatchOutputSchema, + humanTemplate: EXECUTE_BATCH_TEMPLATE, + }, + }, + ], +}; + +export default batchPluginManifest; diff --git a/src/plugins/batch/schema.ts b/src/plugins/batch/schema.ts new file mode 100644 index 000000000..e7e53a50f --- /dev/null +++ b/src/plugins/batch/schema.ts @@ -0,0 +1,38 @@ +/** + * Batch Plugin State Schema + * Single source of truth for batch data structure and validation + */ +import { z } from 'zod'; + +import { AliasNameSchema } from '@/core/schemas/common-schemas'; + +/** Schema for a single batch list item */ +export const BatchTransactionItemSchema = z.object({ + transactionBytes: z.string().min(1, 'Transaction raw bytes'), + order: z + .number() + .int() + .describe('Order of inner transaction in batch transaction'), +}); + +// Zod schema for runtime validation +// Minimal schema - user will add proper fields later +export const BatchDataSchema = z.object({ + name: AliasNameSchema, + keyRefId: z.string().min(1, 'Key reference ID is required'), + transactions: z + .array(BatchTransactionItemSchema) + .default([]) + .describe('Inner transactions for a batch'), +}); + +// TypeScript types inferred from Zod schemas +export type BatchItem = z.infer; +export type BatchData = z.infer; + +/** + * Safe parse batch data (returns success/error instead of throwing) + */ +export function safeParseBatchData(data: unknown) { + return BatchDataSchema.safeParse(data); +} diff --git a/src/plugins/batch/zustand-state-helper.ts b/src/plugins/batch/zustand-state-helper.ts new file mode 100644 index 000000000..21722feb4 --- /dev/null +++ b/src/plugins/batch/zustand-state-helper.ts @@ -0,0 +1,77 @@ +/** + * Zustand-based Batch State Helper + * Provides rich state management for batch data + */ +import type { Logger, StateService } from '@/core'; + +import { ValidationError } from '@/core/errors'; + +import { BATCH_NAMESPACE } from './manifest'; +import { type BatchData, safeParseBatchData } from './schema'; + +export class ZustandBatchStateHelper { + private state: StateService; + private logger: Logger; + private namespace: string; + + constructor(state: StateService, logger: Logger) { + this.state = state; + this.logger = logger; + this.namespace = BATCH_NAMESPACE; + } + + /** + * Save batch with validation + */ + saveBatch(key: string, batchData: BatchData): void { + this.logger.debug(`[ZUSTAND BATCH STATE] Saving batch: ${key}`); + + const validation = safeParseBatchData(batchData); + if (!validation.success) { + const errors = validation.error.issues + .map((e) => `${e.path.join('.')}: ${e.message}`) + .join(', '); + throw new ValidationError(`Invalid batch data: ${errors}`); + } + + this.state.set(this.namespace, key, batchData); + this.logger.debug(`[ZUSTAND BATCH STATE] Batch saved: ${key}`); + } + + /** + * Load batch with validation + */ + getBatch(key: string): BatchData | null { + this.logger.debug(`[ZUSTAND BATCH STATE] Loading batch: ${key}`); + const data = this.state.get(this.namespace, key); + + if (data) { + const validation = safeParseBatchData(data); + if (!validation.success) { + this.logger.warn( + `[ZUSTAND BATCH STATE] Invalid data for batch: ${key}. Errors: ${validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`, + ); + return null; + } + } + + return data || null; + } + + /** + * List all batches with validation + */ + listBatches(): BatchData[] { + this.logger.debug(`[ZUSTAND BATCH STATE] Listing all batches`); + const allData = this.state.list(this.namespace); + return allData.filter((data) => safeParseBatchData(data).success); + } + + /** + * Check if batch exists by name + */ + hasBatch(key: string): boolean { + this.logger.debug(`[ZUSTAND BATCH STATE] Checking if batch exists: ${key}`); + return this.state.has(this.namespace, key); + } +} diff --git a/src/plugins/contract-erc20/__tests__/unit/helpers/mocks.ts b/src/plugins/contract-erc20/__tests__/unit/helpers/mocks.ts index 3f0a9a615..473efdcb0 100644 --- a/src/plugins/contract-erc20/__tests__/unit/helpers/mocks.ts +++ b/src/plugins/contract-erc20/__tests__/unit/helpers/mocks.ts @@ -4,6 +4,7 @@ */ import type { AccountService } from '@/core'; import type { CoreApi } from '@/core/core-api/core-api.interface'; +import type { BatchTransactionService } from '@/core/services/batch/batch-transaction-service.interface'; import type { ConfigService } from '@/core/services/config/config-service.interface'; import type { ContractCompilerService } from '@/core/services/contract-compiler/contract-compiler-service.interface'; import type { ContractQueryService } from '@/core/services/contract-query/contract-query-service.interface'; @@ -120,6 +121,9 @@ export const makeApiMocks = (config?: ApiMocksConfig) => { createMintTransaction: jest.fn(), createNftTransferTransaction: jest.fn(), } as unknown as TokenService, + batch: { + createBatchTransaction: jest.fn(), + } as unknown as BatchTransactionService, topic: {} as unknown as TopicService, txSign, txExecute, diff --git a/src/plugins/contract-erc721/__tests__/unit/helpers/mocks.ts b/src/plugins/contract-erc721/__tests__/unit/helpers/mocks.ts index 3abd64dc5..1fcc809bb 100644 --- a/src/plugins/contract-erc721/__tests__/unit/helpers/mocks.ts +++ b/src/plugins/contract-erc721/__tests__/unit/helpers/mocks.ts @@ -5,6 +5,7 @@ import type { AccountService } from '@/core'; import type { CoreApi } from '@/core/core-api/core-api.interface'; import type { AliasService } from '@/core/services/alias/alias-service.interface'; +import type { BatchTransactionService } from '@/core/services/batch/batch-transaction-service.interface'; import type { ConfigService } from '@/core/services/config/config-service.interface'; import type { ContractCompilerService } from '@/core/services/contract-compiler/contract-compiler-service.interface'; import type { ContractQueryService } from '@/core/services/contract-query/contract-query-service.interface'; @@ -126,6 +127,9 @@ export const makeApiMocks = (config?: ApiMocksConfig) => { createMintTransaction: jest.fn(), createNftTransferTransaction: jest.fn(), } as unknown as TokenService, + batch: { + createBatchTransaction: jest.fn(), + } as unknown as BatchTransactionService, topic: {} as unknown as TopicService, txSign, txExecute, diff --git a/src/plugins/token/__tests__/unit/helpers/mocks.ts b/src/plugins/token/__tests__/unit/helpers/mocks.ts index abf0c8613..18c730821 100644 --- a/src/plugins/token/__tests__/unit/helpers/mocks.ts +++ b/src/plugins/token/__tests__/unit/helpers/mocks.ts @@ -5,6 +5,7 @@ import type { CoreApi } from '@/core/core-api/core-api.interface'; import type { AccountService } from '@/core/services/account/account-transaction-service.interface'; import type { AliasService } from '@/core/services/alias/alias-service.interface'; +import type { BatchTransactionService } from '@/core/services/batch/batch-transaction-service.interface'; import type { ConfigService } from '@/core/services/config/config-service.interface'; import type { ContractCompilerService } from '@/core/services/contract-compiler/contract-compiler-service.interface'; import type { ContractQueryService } from '@/core/services/contract-query/contract-query-service.interface'; @@ -366,6 +367,9 @@ export const makeApiMocks = (config?: ApiMocksConfig) => { topic: {} as unknown as TopicService, txSign, txExecute, + batch: { + createBatchTransaction: jest.fn(), + } as unknown as BatchTransactionService, kms, alias, state,