diff --git a/payments.csv b/payments.csv new file mode 100644 index 000000000..035fbc6d3 --- /dev/null +++ b/payments.csv @@ -0,0 +1,4 @@ +to,amount +0.0.100,1.5 +0.0.4530,2 +0.0.95215,500t diff --git a/src/core/plugins/plugin-manager.ts b/src/core/plugins/plugin-manager.ts index ed0d8c31c..f38419799 100644 --- a/src/core/plugins/plugin-manager.ts +++ b/src/core/plugins/plugin-manager.ts @@ -72,30 +72,35 @@ export class PluginManager { */ initializePluginState(defaultState: PluginManifest[]): PluginStateEntry[] { const existingEntries = this.pluginManagement.listPlugins(); + const existingNames = new Set(existingEntries.map((e) => e.name)); if (existingEntries.length === 0) { this.logger.info( '[PLUGIN-MANAGEMENT] Initializing default plugin state (first run)...', ); + } - const initialState: PluginStateEntry[] = defaultState.map((manifest) => { - const pluginName = manifest.name; - - return { - name: pluginName, + // Add any default plugins not yet in state (e.g. newly added plugins) + const newEntries: PluginStateEntry[] = []; + for (const manifest of defaultState) { + if (!existingNames.has(manifest.name)) { + const entry: PluginStateEntry = { + name: manifest.name, enabled: true, description: manifest.description, }; - }); - - for (const plugin of initialState) { - this.pluginManagement.savePluginState(plugin); + this.pluginManagement.savePluginState(entry); + newEntries.push(entry); + existingNames.add(manifest.name); } - - return initialState; + } + if (newEntries.length > 0) { + this.logger.info( + `[PLUGIN-MANAGEMENT] Registered ${newEntries.length} new default plugin(s): ${newEntries.map((e) => e.name).join(', ')}`, + ); } - return existingEntries; + return existingEntries.concat(newEntries); } /** diff --git a/src/core/shared/config/cli-options.ts b/src/core/shared/config/cli-options.ts index 37ec87fa3..126c32061 100644 --- a/src/core/shared/config/cli-options.ts +++ b/src/core/shared/config/cli-options.ts @@ -9,6 +9,7 @@ import credentialsPluginManifest from '@/plugins/credentials/manifest'; import hbarPluginManifest from '@/plugins/hbar/manifest'; import networkPluginManifest from '@/plugins/network/manifest'; import pluginManagementManifest from '@/plugins/plugin-management/manifest'; +import splitPaymentsPluginManifest from '@/plugins/split-payments/manifest'; import tokenPluginManifest from '@/plugins/token/manifest'; import topicPluginManifest from '@/plugins/topic/manifest'; @@ -44,6 +45,7 @@ export const DEFAULT_PLUGIN_STATE: PluginManifest[] = [ credentialsPluginManifest, topicPluginManifest, hbarPluginManifest, + splitPaymentsPluginManifest, contractPluginManifest, configPluginManifest, contractErc20PluginManifest, diff --git a/src/plugins/split-payments/README.md b/src/plugins/split-payments/README.md new file mode 100644 index 000000000..c7e903239 --- /dev/null +++ b/src/plugins/split-payments/README.md @@ -0,0 +1,94 @@ +# Split Payments Plugin + +Batch HBAR transfers from a single CSV file so you don’t have to run `hbar transfer` many times. + +## What it does + +- Reads a CSV with **recipient** and **amount** (one transfer per row). +- Uses your configured **operator** (or `--from`) as the payer for all transfers. +- Runs each transfer one after another and reports success/failure per row. +- Optional **dry run** to validate the file and list planned transfers without sending. + +## Command + +```bash +hcli split-payments transfer --file +``` + +### Options + +| Option | Short | Required | Description | +| --------------- | ----- | -------- | ---------------------------------------------------------- | +| `--file` | `-f` | Yes | Path to CSV file (see format below). | +| `--from` | `-F` | No | Payer: alias or `accountId:privateKey`. Default: operator. | +| `--key-manager` | `-k` | No | `local` or `local_encrypted`. Default: config. | +| `--dry-run` | — | No | Validate and list planned transfers only; no transactions. | + +### CSV format + +- **Columns:** `to`, `amount` (order matters). +- **Separator:** comma (`,`) or semicolon (`;`). +- **Header (optional):** first line can be `to,amount` or `address,amount`; it’s skipped. +- **Amount:** HBAR (e.g. `10`, `1.5`) or **tinybars** with a trailing `t` (e.g. `100t` = 100 tinybars; `500t` = 500 tinybars). Without `t`, the value is in HBAR (1 HBAR = 100,000,000 tinybars). +- **To:** Hedera account ID (`0.0.123`), EVM address, or CLI account alias. + +**Where to put the file:** You can put the CSV anywhere. Pass the path with `--file` (absolute or relative to your current working directory). Examples: + +- Project root: `hcli split-payments transfer --file payments.csv` (from repo root) +- Full path: `hcli split-payments transfer --file C:\Users\you\payments.csv` +- Subfolder: `hcli split-payments transfer --file data/payments.csv` + +Example `payments.csv`: + +```csv +to,amount +0.0.100,1.5 +0.0.101,2 +alice,10 +0.0.102,500t +``` + +## When to run what + +1. **First-time / check setup** + - Set network: `hcli network use -g testnet` + - Set operator: `hcli network set-operator -o ` + - Optional: `hcli config set -o default_key_manager -v local_encrypted` + +2. **Validate CSV without sending** + + ```bash + hcli split-payments transfer --file payments.csv --dry-run + ``` + +3. **Run batch transfer** + + ```bash + hcli split-payments transfer --file payments.csv + ``` + +4. **Use another payer or network** + ```bash + hcli split-payments transfer --file payments.csv --from my-other-account + hcli split-payments transfer --file payments.csv --network mainnet + ``` + +## Output + +- **Human (default):** Summary (total / success / failed) and per-row result with HashScan links for successful transfers. +- **JSON:** `--format json` for scriptable output with the same data. + +## Requirements + +- Operator (or `--from`) must have enough HBAR for all transfers and fees. +- Each transfer is a separate Hedera transaction (one per row). +- Failed rows are reported; successful rows are still committed. + +## Troubleshooting + +- **"unknown command 'split-payments'"** + Run the CLI from the **built** project, not a globally installed package: + ```bash + node dist/hiero-cli.js split-payments transfer --file payments.csv + ``` + From the repo root after `npm run build`. If you had run the CLI before this plugin was added, the fix in the core plugin manager now merges new default plugins into existing state, so a fresh run should register `split-payments` automatically. diff --git a/src/plugins/split-payments/commands/transfer/handler.ts b/src/plugins/split-payments/commands/transfer/handler.ts new file mode 100644 index 000000000..7727cf186 --- /dev/null +++ b/src/plugins/split-payments/commands/transfer/handler.ts @@ -0,0 +1,266 @@ +/** + * Split Payments Transfer Command Handler + * Reads a CSV of (to, amount) and executes multiple HBAR transfers in one command. + */ +/// +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import type { KeyManagerName } from '@/core/services/kms/kms-types.interface'; +import type { SplitPaymentsTransferOutput, TransferItemResult } from './output'; + +import * as fs from 'fs'; +import * as path from 'path'; + +import { EntityIdSchema } from '@/core/schemas'; +import { HBAR_DECIMALS, Status } from '@/core/shared/constants'; +import { formatError } from '@/core/utils/errors'; +import { processBalanceInput } from '@/core/utils/process-balance-input'; + +import { SplitPaymentsTransferInputSchema } from './input'; + +/** Parse a single line of CSV (handles quoted values) */ +function parseCsvLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === '"') { + inQuotes = !inQuotes; + } else if (inQuotes) { + current += c; + } else if (c === ',' || c === ';') { + result.push(current.trim()); + current = ''; + } else { + current += c; + } + } + result.push(current.trim()); + return result; +} + +/** Check if first line looks like a header */ +function isHeader(row: string[]): boolean { + if (row.length < 2) return false; + const first = row[0].toLowerCase(); + const second = row[1].toLowerCase(); + return ( + (first === 'to' || first === 'address' || first === 'account') && + (second === 'amount' || second === 'amount_hbar' || second === 'value') + ); +} + +/** + * Parse CSV file into { to, amount } rows. + * Expected columns: to (address or alias), amount (HBAR or e.g. 100t for tinybars). + */ +function parseCsvFile(filePath: string): Array<{ to: string; amount: string }> { + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) { + throw new Error(`File not found: ${resolved}`); + } + const content = fs.readFileSync(resolved, 'utf-8'); + const lines = content + .split(/\r?\n/) + .map((l: string) => l.trim()) + .filter((l: string) => l.length > 0); + + if (lines.length === 0) { + throw new Error('CSV file is empty'); + } + + const rows = lines.map(parseCsvLine); + const startIndex = rows.length > 0 && isHeader(rows[0]) ? 1 : 0; + const result: Array<{ to: string; amount: string }> = []; + + for (let i = startIndex; i < rows.length; i++) { + const row = rows[i]; + if (row.length < 2) { + throw new Error( + `Row ${i + 1}: expected "to,amount" (got ${row.length} columns)`, + ); + } + const to = row[0].replace(/^["']|["']$/g, '').trim(); + const amount = row[1].replace(/^["']|["']$/g, '').trim(); + if (!to || !amount) { + throw new Error(`Row ${i + 1}: empty to or amount`); + } + result.push({ to, amount }); + } + + return result; +} + +export async function splitPaymentsTransferHandler( + args: CommandHandlerArgs, +): Promise { + const { api, logger } = args; + + const validArgs = SplitPaymentsTransferInputSchema.parse(args.args); + const keyManager: KeyManagerName = + validArgs.keyManager ?? + api.config.getOption('default_key_manager'); + + let rows: Array<{ to: string; amount: string }>; + try { + rows = parseCsvFile(validArgs.file); + } catch (err) { + return { + status: Status.Failure, + errorMessage: formatError('Invalid CSV file', err), + }; + } + + if (rows.length === 0) { + return { + status: Status.Failure, + errorMessage: 'CSV file has no data rows (only header or empty).', + }; + } + + const from = await api.keyResolver.getOrInitKeyWithFallback( + validArgs.from, + keyManager, + ['split-payments:transfer'], + ); + + const currentNetwork = api.network.getCurrentNetwork(); + const transfers: TransferItemResult[] = []; + let successCount = 0; + let failureCount = 0; + + const resolveToAccountId = (to: string): string => { + const alias = api.alias.resolve(to, 'account', currentNetwork); + if (alias?.entityId) return alias.entityId; + const parsed = EntityIdSchema.safeParse(to); + if (parsed.success) return parsed.data; + throw new Error(`Invalid account: ${to} is not a valid ID or alias`); + }; + + if (validArgs.dryRun) { + for (const { to, amount } of rows) { + try { + const amountTinybar = processBalanceInput(amount, HBAR_DECIMALS); + if (amountTinybar <= 0n) throw new Error('Amount must be positive'); + const toAccountId = resolveToAccountId(to); + if (from.accountId === toAccountId) + throw new Error('Cannot transfer to self'); + transfers.push({ + toAccountId, + amountTinybar, + status: 'success', + }); + successCount++; + } catch (e) { + transfers.push({ + toAccountId: to as string, + amountTinybar: 0n, + status: 'failure', + errorMessage: e instanceof Error ? e.message : String(e), + }); + failureCount++; + } + } + const outputData: SplitPaymentsTransferOutput = { + network: currentNetwork, + fromAccountId: from.accountId, + totalTransfers: rows.length, + successCount, + failureCount, + dryRun: true, + transfers, + }; + return { + status: Status.Success, + outputJson: JSON.stringify(outputData), + }; + } + + for (const { to, amount } of rows) { + let toAccountId: string; + let amountTinybar: bigint; + try { + amountTinybar = processBalanceInput(amount, HBAR_DECIMALS); + if (amountTinybar <= 0n) throw new Error('Amount must be positive'); + toAccountId = resolveToAccountId(to); + if (from.accountId === toAccountId) { + transfers.push({ + toAccountId, + amountTinybar, + status: 'failure', + errorMessage: 'Cannot transfer to the same account', + }); + failureCount++; + continue; + } + } catch (e) { + transfers.push({ + toAccountId: to as string, + amountTinybar: 0n, + status: 'failure', + errorMessage: e instanceof Error ? e.message : String(e), + }); + failureCount++; + continue; + } + + try { + const transferResult = await api.hbar.transferTinybar({ + amount: amountTinybar, + from: from.accountId, + to: toAccountId, + }); + const result = await api.txExecution.signAndExecuteWith( + transferResult.transaction, + [from.keyRefId], + ); + + if (result.success && result.transactionId) { + transfers.push({ + toAccountId, + amountTinybar, + transactionId: result.transactionId, + status: 'success', + }); + successCount++; + logger.info( + `[split-payments] Transferred to ${toAccountId}: ${amountTinybar} tinybars (${result.transactionId})`, + ); + } else { + transfers.push({ + toAccountId, + amountTinybar, + status: 'failure', + errorMessage: result.receipt?.status?.status?.toString() ?? 'UNKNOWN', + }); + failureCount++; + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + transfers.push({ + toAccountId, + amountTinybar, + status: 'failure', + errorMessage: msg, + }); + failureCount++; + logger.warn( + `[split-payments] Failed to transfer to ${toAccountId}: ${msg}`, + ); + } + } + + const outputData: SplitPaymentsTransferOutput = { + network: currentNetwork, + fromAccountId: from.accountId, + totalTransfers: rows.length, + successCount, + failureCount, + transfers, + }; + + return { + status: Status.Success, + outputJson: JSON.stringify(outputData), + }; +} diff --git a/src/plugins/split-payments/commands/transfer/index.ts b/src/plugins/split-payments/commands/transfer/index.ts new file mode 100644 index 000000000..671340dfd --- /dev/null +++ b/src/plugins/split-payments/commands/transfer/index.ts @@ -0,0 +1,6 @@ +export { splitPaymentsTransferHandler } from './handler'; +export type { SplitPaymentsTransferOutput, TransferItemResult } from './output'; +export { + SplitPaymentsTransferOutputSchema, + SPLIT_PAYMENTS_TRANSFER_TEMPLATE, +} from './output'; diff --git a/src/plugins/split-payments/commands/transfer/input.ts b/src/plugins/split-payments/commands/transfer/input.ts new file mode 100644 index 000000000..0594e2964 --- /dev/null +++ b/src/plugins/split-payments/commands/transfer/input.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { KeyManagerTypeSchema, KeyOrAccountAliasSchema } from '@/core/schemas'; + +/** + * Input schema for split-payments transfer command. + * Validates: file path (CSV), optional payer (from), key-manager, dry-run. + */ +export const SplitPaymentsTransferInputSchema = z.object({ + file: z + .string() + .min(1, 'CSV file path is required') + .describe( + 'Path to CSV file with columns: to (address or alias), amount (HBAR or amount with t for tinybars)', + ), + from: KeyOrAccountAliasSchema.optional().describe( + 'Payer account: alias or accountId:privateKey. Defaults to operator.', + ), + keyManager: KeyManagerTypeSchema.optional().describe( + 'Key manager: local or local_encrypted (defaults to config)', + ), + dryRun: z + .boolean() + .optional() + .default(false) + .describe('If true, only validate and list planned transfers; do not send'), +}); + +export type SplitPaymentsTransferInput = z.infer< + typeof SplitPaymentsTransferInputSchema +>; diff --git a/src/plugins/split-payments/commands/transfer/output.ts b/src/plugins/split-payments/commands/transfer/output.ts new file mode 100644 index 000000000..33805e322 --- /dev/null +++ b/src/plugins/split-payments/commands/transfer/output.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +import { + EntityIdSchema, + NetworkSchema, + TinybarSchema, + TransactionIdSchema, +} from '@/core/schemas/common-schemas'; + +/** Single transfer result (success or failure) */ +export const TransferItemResultSchema = z.object({ + toAccountId: z + .string() + .describe('Recipient account ID or raw input if invalid'), + amountTinybar: TinybarSchema, + transactionId: TransactionIdSchema.optional(), + status: z.enum(['success', 'failure']), + errorMessage: z.string().optional(), +}); + +export type TransferItemResult = z.infer; + +/** Full batch output */ +export const SplitPaymentsTransferOutputSchema = z.object({ + network: NetworkSchema, + fromAccountId: EntityIdSchema, + totalTransfers: z.number(), + successCount: z.number(), + failureCount: z.number(), + dryRun: z.boolean().optional(), + transfers: z.array(TransferItemResultSchema), +}); + +export type SplitPaymentsTransferOutput = z.infer< + typeof SplitPaymentsTransferOutputSchema +>; + +export const SPLIT_PAYMENTS_TRANSFER_TEMPLATE = ` +{{#if dryRun}} +✅ Dry run — no transfers sent + +Network: {{network}} +From: {{hashscanLink fromAccountId "account" network}} +Total: {{totalTransfers}} | Valid: {{successCount}} | Invalid: {{failureCount}} + +{{#each transfers}} +{{#if_eq status "success"}} + ✓ Would send {{amountTinybar}} tinybars → {{toAccountId}} +{{else}} + ✗ {{toAccountId}} — {{amountTinybar}} tinybars — {{errorMessage}} +{{/if_eq}} +{{/each}} +{{else}} +✅ Split payments completed + +Network: {{network}} +From: {{hashscanLink fromAccountId "account" network}} +Total: {{totalTransfers}} | Success: {{successCount}} | Failed: {{failureCount}} + +{{#each transfers}} +{{#if_eq status "success"}} + ✓ {{toAccountId}} — {{amountTinybar}} tinybars — {{#if transactionId}}{{hashscanLink transactionId "transaction" ../network}}{{else}}-{{/if}} +{{else}} + ✗ {{toAccountId}} — {{amountTinybar}} tinybars — {{errorMessage}} +{{/if_eq}} +{{/each}} +{{/if}} +`.trim(); diff --git a/src/plugins/split-payments/index.ts b/src/plugins/split-payments/index.ts new file mode 100644 index 000000000..e0c1a011e --- /dev/null +++ b/src/plugins/split-payments/index.ts @@ -0,0 +1,5 @@ +export { + splitPaymentsPluginManifest, + splitPaymentsPluginManifest as default, +} from './manifest'; +export { splitPaymentsTransferHandler } from './commands/transfer'; diff --git a/src/plugins/split-payments/manifest.ts b/src/plugins/split-payments/manifest.ts new file mode 100644 index 000000000..afa3dc26b --- /dev/null +++ b/src/plugins/split-payments/manifest.ts @@ -0,0 +1,69 @@ +/** + * Split Payments Plugin Manifest + * Batch HBAR transfers from a CSV file in a single command. + */ +import type { PluginManifest } from '@/core'; +import { OptionType } from '@/core/types/shared.types'; + +import { + splitPaymentsTransferHandler, + SplitPaymentsTransferOutputSchema, + SPLIT_PAYMENTS_TRANSFER_TEMPLATE, +} from './commands/transfer'; + +export const splitPaymentsPluginManifest: PluginManifest = { + name: 'split-payments', + version: '1.0.0', + displayName: 'Split Payments', + description: + 'Batch HBAR transfers from a CSV file (address, amount) in a single command', + commands: [ + { + name: 'transfer', + summary: 'Batch transfer HBAR from a CSV file', + description: + 'Read a CSV file with columns (to, amount) and execute one HBAR transfer per row. Use --dry-run to validate without sending.', + options: [ + { + name: 'file', + short: 'f', + type: OptionType.STRING, + required: true, + description: + 'Path to CSV file. Columns: to (address or alias), amount (e.g. 10 or 100t for tinybars). Optional header: to,amount', + }, + { + name: 'from', + short: 'F', + type: OptionType.STRING, + required: false, + description: + 'Payer: account alias or accountId:privateKey. Defaults to operator.', + }, + { + name: 'key-manager', + short: 'k', + type: OptionType.STRING, + required: false, + description: + 'Key manager: local or local_encrypted (defaults to config)', + }, + { + name: 'dry-run', + type: OptionType.BOOLEAN, + required: false, + default: false, + description: + 'Validate CSV and list planned transfers without sending', + }, + ], + handler: splitPaymentsTransferHandler, + output: { + schema: SplitPaymentsTransferOutputSchema, + humanTemplate: SPLIT_PAYMENTS_TRANSFER_TEMPLATE, + }, + }, + ], +}; + +export default splitPaymentsPluginManifest;