diff --git a/examples/scripts/batch-operations-demo.sh b/examples/scripts/batch-operations-demo.sh new file mode 100755 index 000000000..8d4f5d79d --- /dev/null +++ b/examples/scripts/batch-operations-demo.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash + +# Batch Operations Plugin Demo +# +# This script demonstrates the full batch plugin workflow on testnet: +# 1. Configure operator +# 2. Create 3 demo accounts +# 3. Batch transfer HBAR to all 3 from a CSV +# 4. Create a fungible token +# 5. Batch airdrop tokens to all 3 from a CSV (auto-handles association!) +# 6. Show final balances +# +# Prerequisites: +# - HEDERA_OPERATOR_ACCOUNT_ID and HEDERA_OPERATOR_KEY set in environment +# - npm run build (if running locally) +# +# Usage: +# ./examples/scripts/batch-operations-demo.sh +# HIERO_SCRIPT_CLI_MODE=global ./examples/scripts/batch-operations-demo.sh + +set -euo pipefail + +# --- Paths --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +TMP_DIR="$(mktemp -d)" + +HELPERS="$SCRIPT_DIR/common/helpers.sh" + +if [[ ! -f "$HELPERS" ]]; then + echo "[ERROR] helpers.sh not found" >&2 + exit 1 +fi + +source "$HELPERS" + +SETUP="$SCRIPT_DIR/common/setup.sh" + +if [[ ! -f "$SETUP" ]]; then + echo "[ERROR] setup.sh not found" >&2 + exit 1 +fi + +source "$SETUP" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +if [[ "${HIERO_SCRIPT_CLI_MODE}" == "global" ]]; then + print_step "CLI mode: global (using globally installed hcli)" +else + print_step "CLI mode: local (project directory: ${PROJECT_DIR})" +fi +print_info "Operator account ID: ${HEDERA_OPERATOR_ACCOUNT_ID}" + +# ============================================================ +# Step 1: Configure network and operator +# ============================================================ +print_step "Selecting Hedera testnet" +if [[ "${HIERO_SCRIPT_CLI_MODE}" == "global" ]]; then + run_hcli network use --network testnet +else + run_hcli network use --global testnet +fi + +print_step "Configuring CLI operator" +run_hcli network set-operator \ + --operator "${HEDERA_OPERATOR_ACCOUNT_ID}:${HEDERA_OPERATOR_KEY}" \ + --network testnet + +# ============================================================ +# Step 2: Create 3 demo accounts +# ============================================================ +ACCT1="$(pick_random_name)" +ACCT2="$(pick_random_name)" +ACCT3="$(pick_random_name)" + +# Ensure unique names +while [[ "$ACCT2" == "$ACCT1" ]]; do ACCT2="$(pick_random_name)"; done +while [[ "$ACCT3" == "$ACCT1" || "$ACCT3" == "$ACCT2" ]]; do ACCT3="$(pick_random_name)"; done + +print_step "Creating 3 demo accounts: $ACCT1, $ACCT2, $ACCT3" + +run_hcli account create --name "$ACCT1" --balance 1 --auto-associations 10 +run_hcli account create --name "$ACCT2" --balance 1 --auto-associations 10 +run_hcli account create --name "$ACCT3" --balance 1 --auto-associations 10 + +print_step "Accounts created. Listing all:" +run_hcli account list + +# ============================================================ +# Step 3: Batch HBAR transfers from CSV +# ============================================================ +CSV_HBAR="$TMP_DIR/hbar-transfers.csv" +cat > "$CSV_HBAR" < "$CSV_AIRDROP" <([ export const DEFAULT_PLUGIN_STATE: PluginManifest[] = [ accountPluginManifest, + batchPluginManifest, tokenPluginManifest, networkPluginManifest, pluginManagementManifest, diff --git a/src/plugins/batch/README.md b/src/plugins/batch/README.md new file mode 100644 index 000000000..c75675641 --- /dev/null +++ b/src/plugins/batch/README.md @@ -0,0 +1,310 @@ +# Batch Operations Plugin + +CSV-driven bulk operations for the Hedera network. Execute hundreds of transfers +or mints in a single command instead of running them one at a time. + +## Commands + +| Command | Description | +| --------------------- | ---------------------------------------------------------------------- | +| `batch transfer-hbar` | Batch HBAR transfers from a CSV file | +| `batch transfer-ft` | Batch fungible token transfers from a CSV file | +| `batch mint-nft` | Batch mint NFTs from a CSV file | +| `batch airdrop` | Batch airdrop tokens from a CSV (auto-handles association via HIP-904) | + +## Quick Start + +```bash +# 1. Ensure you have a network and operator configured +hiero network use testnet +hiero network set-operator --account-id 0.0.XXXXX --private-key + +# 2. Create a CSV file (or use the examples in src/plugins/batch/examples/) + +# 3. Dry-run to validate before executing +hiero batch transfer-hbar --file transfers.csv --dry-run + +# 4. Execute for real +hiero batch transfer-hbar --file transfers.csv +``` + +## batch transfer-hbar + +Transfer HBAR to multiple recipients from a single CSV file. + +### CSV Format + +| Column | Required | Description | +| -------- | -------- | ---------------------------------------------------------------------- | +| `to` | Yes | Destination account (ID like `0.0.12345` or stored alias like `alice`) | +| `amount` | Yes | HBAR amount. `10` = 10 HBAR, `100t` = 100 tinybars | +| `memo` | No | Per-transfer memo (overrides `--memo` flag) | + +### Example CSV + +```csv +to,amount,memo +0.0.12345,10,payment-batch-1 +0.0.67890,5.5,payment-batch-1 +alice,2,tip +bob,1.25,refund +``` + +### Usage + +```bash +# Dry run (validate only, no transactions) +hiero batch transfer-hbar --file hbar-transfers.csv --dry-run + +# Execute transfers from operator account +hiero batch transfer-hbar --file hbar-transfers.csv + +# Execute from a specific account +hiero batch transfer-hbar --file hbar-transfers.csv --from alice + +# With a default memo for all transfers +hiero batch transfer-hbar --file hbar-transfers.csv --memo "March payroll" + +# JSON output for scripting +hiero batch transfer-hbar --file hbar-transfers.csv --format json +``` + +### Example Output + +``` +✅ Batch HBAR transfer complete! + +From: 0.0.100000 +Network: testnet +Total: 4 | Succeeded: 3 | Failed: 1 + + Row 1: success — https://hashscan.io/testnet/transaction/0.0.100000@1700000000.123456789 + Row 2: success — https://hashscan.io/testnet/transaction/0.0.100000@1700000001.123456789 + Row 3: success — https://hashscan.io/testnet/transaction/0.0.100000@1700000002.123456789 + Row 4: failed — Invalid destination: "unknown-alias" is neither a valid account ID nor a known alias + +⚠️ 1 transfer(s) failed. Fix the errors above and re-run with a CSV containing only the failed rows. +``` + +## batch transfer-ft + +Transfer a fungible token to multiple recipients from a single CSV file. + +### CSV Format + +| Column | Required | Description | +| -------- | -------- | ------------------------------------------------------------ | +| `to` | Yes | Destination account (ID or alias) | +| `amount` | Yes | Token amount. `100` = display units, `100t` = raw base units | + +### Example CSV + +```csv +to,amount +0.0.12345,1000 +0.0.67890,500 +alice,250 +bob,100 +``` + +### Usage + +```bash +# Dry run +hiero batch transfer-ft --file ft-transfers.csv --token my-token --dry-run + +# Execute transfers using token alias +hiero batch transfer-ft --file ft-transfers.csv --token my-token + +# Execute transfers using token ID +hiero batch transfer-ft --file ft-transfers.csv --token 0.0.98765 + +# From a specific account (not the operator) +hiero batch transfer-ft --file ft-transfers.csv --token my-token --from alice +``` + +## batch mint-nft + +Mint multiple NFTs to an existing collection from a CSV file. + +### CSV Format + +| Column | Required | Description | +| ---------- | -------- | ----------------------------------------------------- | +| `metadata` | Yes | NFT metadata string (max 100 bytes). Typically a URI. | + +### Example CSV + +```csv +metadata +https://example.com/nft/1.json +https://example.com/nft/2.json +ipfs://QmXyz123456789abcdef/3.json +``` + +### Usage + +```bash +# Dry run (validates metadata sizes, token type, supply capacity) +hiero batch mint-nft --file nft-metadata.csv --token my-nft --supply-key supply-account --dry-run + +# Mint NFTs +hiero batch mint-nft --file nft-metadata.csv --token my-nft --supply-key supply-account + +# Using token ID and explicit key +hiero batch mint-nft --file nft-metadata.csv --token 0.0.98765 --supply-key 0.0.12345:302e... +``` + +### Example Output + +``` +✅ Batch NFT mint complete! + +Token: https://hashscan.io/testnet/token/0.0.98765 +Network: testnet +Total: 3 | Succeeded: 3 | Failed: 0 + + Row 1: success — Serial #1 — https://hashscan.io/testnet/transaction/... + Row 2: success — Serial #2 — https://hashscan.io/testnet/transaction/... + Row 3: success — Serial #3 — https://hashscan.io/testnet/transaction/... +``` + +## batch airdrop + +Airdrop fungible tokens to multiple recipients using Hedera's native +`TokenAirdropTransaction` (HIP-904). Unlike `transfer-ft`, airdrop +**auto-handles token association** — recipients do NOT need to pre-associate. + +How it works: + +- **Already associated** accounts receive tokens immediately +- Accounts with **auto-association slots** get associated + receive immediately +- Other accounts get a **pending airdrop** they can claim later via the Hedera portal + +Only the sender signs — no recipient keys required. + +### CSV Format + +| Column | Required | Description | +| -------- | -------- | ------------------------------------------------------------ | +| `to` | Yes | Destination account (ID or alias) | +| `amount` | Yes | Token amount. `100` = display units, `100t` = raw base units | + +### Example CSV + +```csv +to,amount +0.0.12345,5000 +alice,2500 +bob,1000 +``` + +### Usage + +```bash +# Dry run +hiero batch airdrop --file airdrop.csv --token my-token --dry-run + +# Execute airdrop (recipients auto-associated if possible) +hiero batch airdrop --file airdrop.csv --token my-token + +# From a specific account +hiero batch airdrop --file airdrop.csv --token 0.0.98765 --from treasury +``` + +### Example Output + +``` +✅ Batch airdrop complete! + +Token: https://hashscan.io/testnet/token/0.0.98765 +From: 0.0.100000 +Network: testnet +Total: 3 | Succeeded: 3 | Failed: 0 + + Row 1: success — https://hashscan.io/testnet/transaction/... + Row 2: success — https://hashscan.io/testnet/transaction/... + Row 3: success — https://hashscan.io/testnet/transaction/... +``` + +### When to use `airdrop` vs `transfer-ft` + +| Scenario | Use | +| -------------------------------------------- | ------------- | +| Recipients are already associated with token | `transfer-ft` | +| Recipients may NOT be associated yet | `airdrop` | +| You don't have recipients' private keys | `airdrop` | +| Token distribution to new community members | `airdrop` | +| Internal transfers between your own accounts | `transfer-ft` | + +## Demo Script + +A full end-to-end demo script is available at `examples/scripts/batch-operations-demo.sh`. +It creates accounts, performs batch HBAR transfers, creates a token, and runs a batch airdrop +— all from CSV files. + +```bash +# Set your testnet operator credentials +export HEDERA_OPERATOR_ACCOUNT_ID=0.0.XXXXX +export HEDERA_OPERATOR_KEY=302e... + +# Build and run +npm run build +./examples/scripts/batch-operations-demo.sh +``` + +## Features + +- **CSV input**: Simple, spreadsheet-friendly format for bulk data +- **Dry-run mode**: Validate all rows before spending any HBAR (`--dry-run`) +- **Account aliases**: Use stored account names (`alice`, `bob`) alongside raw IDs +- **Amount formats**: Display units (`10` HBAR, `100` tokens) or base units (`100t`) +- **Per-row results**: See exactly which rows succeeded or failed +- **Transaction links**: HashScan links for every successful transaction +- **Retry guidance**: Failed rows are clearly identified for re-run +- **JSON output**: Machine-readable output with `--format json` for CI/CD pipelines +- **Supply validation**: NFT mints check max supply capacity before starting +- **Native airdrop**: Uses Hedera's `TokenAirdropTransaction` (HIP-904) for auto-association + +## Error Handling + +The plugin validates all rows **before** executing any transactions: + +1. **CSV structure**: Missing headers, wrong column count, empty files +2. **Field validation**: Empty fields, invalid amounts, unresolvable aliases +3. **Token validation** (mint-nft): Token type, supply key match, supply capacity +4. **Per-row errors**: If a transaction fails mid-batch, the remaining rows still execute + +Failed rows are reported with clear error messages. Create a new CSV with only the +failed rows and re-run to retry. + +## JSON Output Schema + +When using `--format json`, the output includes: + +```json +{ + "total": 4, + "succeeded": 3, + "failed": 1, + "fromAccount": "0.0.100000", + "network": "testnet", + "dryRun": false, + "results": [ + { + "row": 1, + "status": "success", + "to": "0.0.12345", + "amount": "10", + "transactionId": "0.0.100000@1700000000.123456789" + }, + { + "row": 2, + "status": "failed", + "to": "0.0.67890", + "amount": "5", + "errorMessage": "Transfer failed: INSUFFICIENT_PAYER_BALANCE" + } + ] +} +``` diff --git a/src/plugins/batch/__tests__/unit/batch-airdrop.test.ts b/src/plugins/batch/__tests__/unit/batch-airdrop.test.ts new file mode 100644 index 000000000..cf7d16e3d --- /dev/null +++ b/src/plugins/batch/__tests__/unit/batch-airdrop.test.ts @@ -0,0 +1,225 @@ +import '@/core/utils/json-serialize'; + +import * as fs from 'node:fs'; + +import { MOCK_TX_ID } from '@/__tests__/mocks/fixtures'; +import { makeArgs, makeLogger, makeNetworkMock } from '@/__tests__/mocks/mocks'; +import { Status } from '@/core/shared/constants'; +import { batchAirdrop } from '@/plugins/batch/commands/airdrop'; + +jest.mock('node:fs'); +const mockFs = fs as jest.Mocked; + +// Mock only TokenAirdropTransaction, preserve the rest of the SDK +jest.mock('@hashgraph/sdk', () => { + const actual = jest.requireActual('@hashgraph/sdk'); + const mockAddTokenTransfer = jest.fn().mockReturnThis(); + return { + ...actual, + TokenAirdropTransaction: jest.fn().mockImplementation(() => ({ + addTokenTransfer: mockAddTokenTransfer, + })), + }; +}); + +describe('batch plugin - airdrop command (unit)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFs.existsSync.mockReturnValue(true); + }); + + function buildArgs( + overrides: Record = {}, + apiOverrides: Record = {}, + ) { + const logger = makeLogger(); + const network = makeNetworkMock('testnet'); + + const txExecution = { + signAndExecuteWith: jest.fn().mockResolvedValue({ + success: true, + transactionId: MOCK_TX_ID, + receipt: { status: { status: 'SUCCESS' } }, + }), + signAndExecute: jest.fn(), + signAndExecuteContractCreateFlowWith: jest.fn(), + }; + + const mirror = { + getTokenInfo: jest.fn().mockResolvedValue({ + decimals: '8', + type: 'FUNGIBLE_COMMON', + }), + getAccount: jest.fn(), + getAccountHBarBalance: jest.fn(), + getAccountTokenBalances: jest.fn(), + getTopicMessage: jest.fn(), + getTopicMessages: jest.fn(), + getNftInfo: jest.fn(), + getTopicInfo: jest.fn(), + getTransactionRecord: jest.fn(), + getContractInfo: jest.fn(), + getPendingAirdrops: jest.fn(), + getOutstandingAirdrops: jest.fn(), + getExchangeRate: jest.fn(), + postContractCall: jest.fn(), + }; + + const alias = { + resolve: jest.fn().mockImplementation((name: string, type: string) => { + if (name === 'my-token' && type === 'token') { + return { entityId: '0.0.99999' }; + } + if (name === 'alice' && type === 'account') { + return { entityId: '0.0.200000' }; + } + return null; + }), + register: jest.fn(), + resolveOrThrow: jest.fn(), + resolveByEvmAddress: jest.fn(), + list: jest.fn(), + remove: jest.fn(), + exists: jest.fn(), + availableOrThrow: jest.fn(), + clear: jest.fn(), + }; + + const api = { + network, + txExecution, + mirror, + alias, + ...apiOverrides, + }; + + return makeArgs(api, logger, { + file: '/test/airdrop.csv', + token: '0.0.99999', + dryRun: false, + ...overrides, + }); + } + + test('executes batch airdrop successfully from CSV', async () => { + mockFs.readFileSync.mockReturnValue( + 'to,amount\n0.0.12345,100\n0.0.67890,50', + ); + + const args = buildArgs(); + + const result = await batchAirdrop(args); + expect(result.status).toBe(Status.Success); + expect(result.outputJson).toBeDefined(); + + const output = JSON.parse(result.outputJson!); + expect(output.total).toBe(2); + expect(output.succeeded).toBe(2); + expect(output.failed).toBe(0); + expect(output.dryRun).toBe(false); + expect(output.tokenId).toBe('0.0.99999'); + expect(output.results[0].status).toBe('success'); + expect(output.results[0].transactionId).toBe(MOCK_TX_ID); + }); + + test('dry run validates without executing', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\n0.0.12345,100'); + + const args = buildArgs({ dryRun: true }); + + const result = await batchAirdrop(args); + expect(result.status).toBe(Status.Success); + + const output = JSON.parse(result.outputJson!); + expect(output.dryRun).toBe(true); + expect(output.total).toBe(1); + + expect(args.api.txExecution.signAndExecuteWith).not.toHaveBeenCalled(); + }); + + test('returns failure when CSV file is missing', async () => { + mockFs.existsSync.mockReturnValue(false); + + const args = buildArgs(); + + const result = await batchAirdrop(args); + expect(result.status).toBe(Status.Failure); + expect(result.errorMessage).toContain('CSV file not found'); + }); + + test('returns failure when CSV has invalid amounts', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\n0.0.12345,abc'); + + const args = buildArgs(); + + const result = await batchAirdrop(args); + expect(result.status).toBe(Status.Failure); + expect(result.errorMessage).toContain('invalid amount'); + }); + + test('resolves account aliases in to field', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\nalice,100'); + + const args = buildArgs(); + + const result = await batchAirdrop(args); + expect(result.status).toBe(Status.Success); + + const output = JSON.parse(result.outputJson!); + expect(output.results[0].status).toBe('success'); + }); + + test('handles partial failures', async () => { + mockFs.readFileSync.mockReturnValue( + 'to,amount\n0.0.12345,100\n0.0.67890,50', + ); + + const txExecution = { + signAndExecuteWith: jest + .fn() + .mockResolvedValueOnce({ + success: true, + transactionId: MOCK_TX_ID, + receipt: { status: { status: 'SUCCESS' } }, + }) + .mockResolvedValueOnce({ + success: false, + receipt: { status: { status: 'INSUFFICIENT_SENDER_BALANCE' } }, + }), + signAndExecute: jest.fn(), + signAndExecuteContractCreateFlowWith: jest.fn(), + }; + + const args = buildArgs({}, { txExecution }); + + const result = await batchAirdrop(args); + expect(result.status).toBe(Status.Success); + expect(result.errorMessage).toContain('1 of 2 airdrops failed'); + + const output = JSON.parse(result.outputJson!); + expect(output.succeeded).toBe(1); + expect(output.failed).toBe(1); + }); + + test('returns failure when token cannot be resolved', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\n0.0.12345,100'); + + const alias = { + resolve: jest.fn().mockReturnValue(null), + register: jest.fn(), + resolveOrThrow: jest.fn(), + resolveByEvmAddress: jest.fn(), + list: jest.fn(), + remove: jest.fn(), + exists: jest.fn(), + availableOrThrow: jest.fn(), + clear: jest.fn(), + }; + + const args = buildArgs({ token: 'nonexistent-token' }, { alias }); + + const result = await batchAirdrop(args); + expect(result.status).toBe(Status.Failure); + expect(result.errorMessage).toContain('Failed to resolve token'); + }); +}); diff --git a/src/plugins/batch/__tests__/unit/batch-mint-nft.test.ts b/src/plugins/batch/__tests__/unit/batch-mint-nft.test.ts new file mode 100644 index 000000000..dc49bccf0 --- /dev/null +++ b/src/plugins/batch/__tests__/unit/batch-mint-nft.test.ts @@ -0,0 +1,245 @@ +import '@/core/utils/json-serialize'; + +import * as fs from 'node:fs'; + +import { MOCK_TX_ID } from '@/__tests__/mocks/fixtures'; +import { makeArgs, makeLogger, makeNetworkMock } from '@/__tests__/mocks/mocks'; +import { Status } from '@/core/shared/constants'; +import { batchMintNft } from '@/plugins/batch/commands/mint-nft'; + +jest.mock('node:fs'); +const mockFs = fs as jest.Mocked; + +const MOCK_SUPPLY_PUBLIC_KEY = '302a300506032b6570032100' + '8'.repeat(64); + +describe('batch plugin - mint-nft command (unit)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFs.existsSync.mockReturnValue(true); + }); + + function buildArgs( + overrides: Record = {}, + apiOverrides: Record = {}, + ) { + const logger = makeLogger(); + const network = makeNetworkMock('testnet'); + + const token = { + createMintTransaction: jest.fn().mockReturnValue('mock-mint-tx'), + createTransferTransaction: jest.fn(), + createTokenTransaction: jest.fn(), + createTokenAssociationTransaction: jest.fn(), + createNftTransferTransaction: jest.fn(), + }; + + const txExecution = { + signAndExecuteWith: jest.fn().mockResolvedValue({ + success: true, + transactionId: MOCK_TX_ID, + receipt: { + status: { status: 'SUCCESS' }, + serials: [1n], + }, + }), + signAndExecute: jest.fn(), + signAndExecuteContractCreateFlowWith: jest.fn(), + }; + + const mirror = { + getTokenInfo: jest.fn().mockResolvedValue({ + type: 'NON_FUNGIBLE_UNIQUE', + supply_key: { key: MOCK_SUPPLY_PUBLIC_KEY }, + max_supply: '0', + total_supply: '0', + }), + getAccount: jest.fn(), + getAccountHBarBalance: jest.fn(), + getAccountTokenBalances: jest.fn(), + getTopicMessage: jest.fn(), + getTopicMessages: jest.fn(), + getNftInfo: jest.fn(), + getTopicInfo: jest.fn(), + getTransactionRecord: jest.fn(), + getContractInfo: jest.fn(), + getPendingAirdrops: jest.fn(), + getOutstandingAirdrops: jest.fn(), + getExchangeRate: jest.fn(), + postContractCall: jest.fn(), + }; + + const alias = { + resolve: jest.fn().mockImplementation((name: string, type: string) => { + if (name === 'my-nft' && type === 'token') { + return { entityId: '0.0.99999' }; + } + if (name === 'supply-key' && type === 'account') { + return { + entityId: '0.0.300000', + publicKey: MOCK_SUPPLY_PUBLIC_KEY, + keyRefId: 'supply-key-ref-id', + }; + } + return null; + }), + register: jest.fn(), + resolveOrThrow: jest.fn(), + resolveByEvmAddress: jest.fn(), + list: jest.fn(), + remove: jest.fn(), + exists: jest.fn(), + availableOrThrow: jest.fn(), + clear: jest.fn(), + }; + + const keyResolver = { + getOrInitKey: jest.fn().mockResolvedValue({ + accountId: '0.0.300000', + publicKey: MOCK_SUPPLY_PUBLIC_KEY, + keyRefId: 'supply-key-ref-id', + }), + getOrInitKeyWithFallback: jest.fn(), + }; + + const api = { + network, + token, + txExecution, + mirror, + alias, + keyResolver, + ...apiOverrides, + }; + + return makeArgs(api, logger, { + file: '/test/nfts.csv', + token: '0.0.99999', + supplyKey: 'supply-key', + dryRun: false, + ...overrides, + }); + } + + test('mints NFTs successfully from CSV', async () => { + const txExecution = { + signAndExecuteWith: jest + .fn() + .mockResolvedValueOnce({ + success: true, + transactionId: MOCK_TX_ID, + receipt: { status: { status: 'SUCCESS' }, serials: [1n] }, + }) + .mockResolvedValueOnce({ + success: true, + transactionId: MOCK_TX_ID, + receipt: { status: { status: 'SUCCESS' }, serials: [2n] }, + }), + signAndExecute: jest.fn(), + signAndExecuteContractCreateFlowWith: jest.fn(), + }; + + mockFs.readFileSync.mockReturnValue( + 'metadata\nhttps://example.com/1.json\nhttps://example.com/2.json', + ); + + const args = buildArgs({}, { txExecution }); + + const result = await batchMintNft(args); + expect(result.status).toBe(Status.Success); + + const output = JSON.parse(result.outputJson!); + expect(output.total).toBe(2); + expect(output.succeeded).toBe(2); + expect(output.failed).toBe(0); + expect(output.results[0].serialNumber).toBe(1); + expect(output.results[1].serialNumber).toBe(2); + }); + + test('dry run validates without minting', async () => { + mockFs.readFileSync.mockReturnValue('metadata\nhttps://example.com/1.json'); + + const args = buildArgs({ dryRun: true }); + + const result = await batchMintNft(args); + expect(result.status).toBe(Status.Success); + + const output = JSON.parse(result.outputJson!); + expect(output.dryRun).toBe(true); + expect(output.total).toBe(1); + + expect(args.api.txExecution.signAndExecuteWith).not.toHaveBeenCalled(); + }); + + test('returns failure when token is not an NFT', async () => { + mockFs.readFileSync.mockReturnValue('metadata\ntest'); + + const mirror = { + getTokenInfo: jest.fn().mockResolvedValue({ + type: 'FUNGIBLE_COMMON', + supply_key: { key: MOCK_SUPPLY_PUBLIC_KEY }, + }), + getAccount: jest.fn(), + getAccountHBarBalance: jest.fn(), + getAccountTokenBalances: jest.fn(), + getTopicMessage: jest.fn(), + getTopicMessages: jest.fn(), + getNftInfo: jest.fn(), + getTopicInfo: jest.fn(), + getTransactionRecord: jest.fn(), + getContractInfo: jest.fn(), + getPendingAirdrops: jest.fn(), + getOutstandingAirdrops: jest.fn(), + getExchangeRate: jest.fn(), + postContractCall: jest.fn(), + }; + + const args = buildArgs({}, { mirror }); + + const result = await batchMintNft(args); + expect(result.status).toBe(Status.Failure); + expect(result.errorMessage).toContain('not an NFT'); + }); + + test('returns failure when metadata exceeds 100 bytes', async () => { + const longMetadata = 'x'.repeat(101); + mockFs.readFileSync.mockReturnValue(`metadata\n${longMetadata}`); + + const args = buildArgs(); + + const result = await batchMintNft(args); + expect(result.status).toBe(Status.Failure); + expect(result.errorMessage).toContain('exceeds 100 bytes'); + }); + + test('returns failure when supply would exceed max', async () => { + mockFs.readFileSync.mockReturnValue('metadata\ntest1\ntest2'); + + const mirror = { + getTokenInfo: jest.fn().mockResolvedValue({ + type: 'NON_FUNGIBLE_UNIQUE', + supply_key: { key: MOCK_SUPPLY_PUBLIC_KEY }, + max_supply: '1', + total_supply: '0', + }), + getAccount: jest.fn(), + getAccountHBarBalance: jest.fn(), + getAccountTokenBalances: jest.fn(), + getTopicMessage: jest.fn(), + getTopicMessages: jest.fn(), + getNftInfo: jest.fn(), + getTopicInfo: jest.fn(), + getTransactionRecord: jest.fn(), + getContractInfo: jest.fn(), + getPendingAirdrops: jest.fn(), + getOutstandingAirdrops: jest.fn(), + getExchangeRate: jest.fn(), + postContractCall: jest.fn(), + }; + + const args = buildArgs({}, { mirror }); + + const result = await batchMintNft(args); + expect(result.status).toBe(Status.Failure); + expect(result.errorMessage).toContain('Cannot mint'); + }); +}); diff --git a/src/plugins/batch/__tests__/unit/batch-transfer-hbar.test.ts b/src/plugins/batch/__tests__/unit/batch-transfer-hbar.test.ts new file mode 100644 index 000000000..385236120 --- /dev/null +++ b/src/plugins/batch/__tests__/unit/batch-transfer-hbar.test.ts @@ -0,0 +1,243 @@ +import '@/core/utils/json-serialize'; + +import * as fs from 'node:fs'; + +import { MOCK_TX_ID } from '@/__tests__/mocks/fixtures'; +import { makeArgs, makeLogger, makeNetworkMock } from '@/__tests__/mocks/mocks'; +import { Status } from '@/core/shared/constants'; +import { batchTransferHbar } from '@/plugins/batch/commands/transfer-hbar'; + +// Mock node:fs for CSV parsing +jest.mock('node:fs'); +const mockFs = fs as jest.Mocked; + +describe('batch plugin - transfer-hbar command (unit)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFs.existsSync.mockReturnValue(true); + }); + + function buildArgs( + overrides: Record = {}, + apiOverrides: Record = {}, + ) { + const logger = makeLogger(); + const network = makeNetworkMock('testnet'); + + const hbar = { + transferTinybar: jest.fn().mockResolvedValue({ + transaction: 'mock-transaction', + }), + }; + + const txExecution = { + signAndExecuteWith: jest.fn().mockResolvedValue({ + success: true, + transactionId: MOCK_TX_ID, + receipt: { status: { status: 'SUCCESS' } }, + }), + signAndExecute: jest.fn(), + signAndExecuteContractCreateFlowWith: jest.fn(), + }; + + const alias = { + resolve: jest.fn().mockReturnValue(null), + register: jest.fn(), + resolveOrThrow: jest.fn(), + resolveByEvmAddress: jest.fn(), + list: jest.fn(), + remove: jest.fn(), + exists: jest.fn(), + availableOrThrow: jest.fn(), + clear: jest.fn(), + }; + + const api = { + network, + hbar, + txExecution, + alias, + ...apiOverrides, + }; + + return makeArgs(api, logger, { + file: '/test/transfers.csv', + dryRun: false, + ...overrides, + }); + } + + test('executes batch HBAR transfers successfully from CSV', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\n0.0.12345,10\n0.0.67890,5'); + + const args = buildArgs(); + + const result = await batchTransferHbar(args); + expect(result.status).toBe(Status.Success); + expect(result.outputJson).toBeDefined(); + + const output = JSON.parse(result.outputJson!); + expect(output.total).toBe(2); + expect(output.succeeded).toBe(2); + expect(output.failed).toBe(0); + expect(output.dryRun).toBe(false); + expect(output.results).toHaveLength(2); + expect(output.results[0].status).toBe('success'); + expect(output.results[0].transactionId).toBe(MOCK_TX_ID); + }); + + test('dry run validates without executing transactions', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\n0.0.12345,10\n0.0.67890,5'); + + const args = buildArgs({ dryRun: true }); + + const result = await batchTransferHbar(args); + expect(result.status).toBe(Status.Success); + + const output = JSON.parse(result.outputJson!); + expect(output.total).toBe(2); + expect(output.succeeded).toBe(2); + expect(output.failed).toBe(0); + expect(output.dryRun).toBe(true); + + // No transactions should have been submitted + expect(args.api.hbar.transferTinybar).not.toHaveBeenCalled(); + }); + + test('returns failure when CSV file is missing', async () => { + mockFs.existsSync.mockReturnValue(false); + + const args = buildArgs(); + + const result = await batchTransferHbar(args); + expect(result.status).toBe(Status.Failure); + expect(result.errorMessage).toContain('CSV file not found'); + }); + + test('returns failure when CSV has missing required headers', async () => { + mockFs.readFileSync.mockReturnValue('name,value\nfoo,bar'); + + const args = buildArgs(); + + const result = await batchTransferHbar(args); + expect(result.status).toBe(Status.Failure); + expect(result.errorMessage).toContain('missing required headers'); + }); + + test('returns failure when CSV has invalid amount', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\n0.0.12345,abc'); + + const args = buildArgs(); + + const result = await batchTransferHbar(args); + expect(result.status).toBe(Status.Failure); + expect(result.errorMessage).toContain('invalid amount'); + }); + + test('returns failure when a row has empty to field', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\n,10'); + + const args = buildArgs(); + + const result = await batchTransferHbar(args); + expect(result.status).toBe(Status.Failure); + expect(result.errorMessage).toContain('missing "to" field'); + }); + + test('handles partial failures gracefully', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\n0.0.12345,10\n0.0.67890,5'); + + const txExecution = { + signAndExecuteWith: jest + .fn() + .mockResolvedValueOnce({ + success: true, + transactionId: MOCK_TX_ID, + receipt: { status: { status: 'SUCCESS' } }, + }) + .mockResolvedValueOnce({ + success: false, + receipt: { status: { status: 'INSUFFICIENT_PAYER_BALANCE' } }, + }), + signAndExecute: jest.fn(), + signAndExecuteContractCreateFlowWith: jest.fn(), + }; + + const args = buildArgs({}, { txExecution }); + + const result = await batchTransferHbar(args); + // Partial success should still return Success with error message + expect(result.status).toBe(Status.Success); + expect(result.errorMessage).toContain('1 of 2 transfers failed'); + + const output = JSON.parse(result.outputJson!); + expect(output.succeeded).toBe(1); + expect(output.failed).toBe(1); + expect(output.results[0].status).toBe('success'); + expect(output.results[1].status).toBe('failed'); + }); + + test('resolves account aliases in to field', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\nalice,10'); + + const alias = { + resolve: jest.fn().mockImplementation((name: string, type: string) => { + if (name === 'alice' && type === 'account') { + return { entityId: '0.0.200000' }; + } + return null; + }), + register: jest.fn(), + resolveOrThrow: jest.fn(), + resolveByEvmAddress: jest.fn(), + list: jest.fn(), + remove: jest.fn(), + exists: jest.fn(), + availableOrThrow: jest.fn(), + clear: jest.fn(), + }; + + const args = buildArgs({}, { alias }); + + const result = await batchTransferHbar(args); + expect(result.status).toBe(Status.Success); + + const output = JSON.parse(result.outputJson!); + expect(output.results[0].status).toBe('success'); + }); + + test('uses memo from CSV row when available', async () => { + mockFs.readFileSync.mockReturnValue( + 'to,amount,memo\n0.0.12345,10,test-memo', + ); + + const args = buildArgs(); + + const result = await batchTransferHbar(args); + expect(result.status).toBe(Status.Success); + + expect(args.api.hbar.transferTinybar).toHaveBeenCalledWith( + expect.objectContaining({ + memo: 'test-memo', + }), + ); + }); + + test('returns full failure when all transfers fail', async () => { + mockFs.readFileSync.mockReturnValue('to,amount\n0.0.12345,10'); + + const txExecution = { + signAndExecuteWith: jest.fn().mockResolvedValue({ + success: false, + receipt: { status: { status: 'INSUFFICIENT_PAYER_BALANCE' } }, + }), + signAndExecute: jest.fn(), + signAndExecuteContractCreateFlowWith: jest.fn(), + }; + + const args = buildArgs({}, { txExecution }); + + const result = await batchTransferHbar(args); + expect(result.status).toBe(Status.Failure); + }); +}); diff --git a/src/plugins/batch/__tests__/unit/csv-parser.test.ts b/src/plugins/batch/__tests__/unit/csv-parser.test.ts new file mode 100644 index 000000000..14ac6745f --- /dev/null +++ b/src/plugins/batch/__tests__/unit/csv-parser.test.ts @@ -0,0 +1,140 @@ +import * as fs from 'node:fs'; + +import { parseCsvFile } from '@/plugins/batch/utils/csv-parser'; + +// Mock node:fs +jest.mock('node:fs'); + +const mockFs = fs as jest.Mocked; + +describe('csv-parser (unit)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFs.existsSync.mockReturnValue(true); + }); + + test('parses a simple CSV with required headers', () => { + mockFs.readFileSync.mockReturnValue( + 'to,amount\n0.0.12345,10\n0.0.67890,20', + ); + + const result = parseCsvFile('/test/data.csv', ['to', 'amount']); + + expect(result.headers).toEqual(['to', 'amount']); + expect(result.rows).toHaveLength(2); + expect(result.rows[0]).toEqual({ to: '0.0.12345', amount: '10' }); + expect(result.rows[1]).toEqual({ to: '0.0.67890', amount: '20' }); + }); + + test('handles headers case-insensitively', () => { + mockFs.readFileSync.mockReturnValue('TO,AMOUNT\n0.0.12345,10'); + + const result = parseCsvFile('/test/data.csv', ['to', 'amount']); + + expect(result.headers).toEqual(['to', 'amount']); + expect(result.rows[0]).toEqual({ to: '0.0.12345', amount: '10' }); + }); + + test('trims whitespace from fields', () => { + mockFs.readFileSync.mockReturnValue('to, amount\n 0.0.12345 , 10 '); + + const result = parseCsvFile('/test/data.csv', ['to', 'amount']); + + expect(result.rows[0]).toEqual({ to: '0.0.12345', amount: '10' }); + }); + + test('handles quoted fields with commas', () => { + mockFs.readFileSync.mockReturnValue( + 'metadata\n"hello, world"\n"another, value"', + ); + + const result = parseCsvFile('/test/data.csv', ['metadata']); + + expect(result.rows[0]).toEqual({ metadata: 'hello, world' }); + expect(result.rows[1]).toEqual({ metadata: 'another, value' }); + }); + + test('handles escaped quotes in fields', () => { + mockFs.readFileSync.mockReturnValue('metadata\n"say ""hello"""\nsimple'); + + const result = parseCsvFile('/test/data.csv', ['metadata']); + + expect(result.rows[0]).toEqual({ metadata: 'say "hello"' }); + expect(result.rows[1]).toEqual({ metadata: 'simple' }); + }); + + test('handles Windows-style line endings', () => { + mockFs.readFileSync.mockReturnValue( + 'to,amount\r\n0.0.12345,10\r\n0.0.67890,20', + ); + + const result = parseCsvFile('/test/data.csv', ['to', 'amount']); + + expect(result.rows).toHaveLength(2); + }); + + test('skips blank lines', () => { + mockFs.readFileSync.mockReturnValue( + 'to,amount\n0.0.12345,10\n\n0.0.67890,20\n', + ); + + const result = parseCsvFile('/test/data.csv', ['to', 'amount']); + + expect(result.rows).toHaveLength(2); + }); + + test('throws when file does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseCsvFile('/test/missing.csv', ['to'])).toThrow( + 'CSV file not found', + ); + }); + + test('throws when file is empty', () => { + mockFs.readFileSync.mockReturnValue(''); + + expect(() => parseCsvFile('/test/empty.csv', ['to'])).toThrow( + 'CSV file is empty', + ); + }); + + test('throws when file has only headers', () => { + mockFs.readFileSync.mockReturnValue('to,amount'); + + expect(() => parseCsvFile('/test/headers-only.csv', ['to'])).toThrow( + 'CSV file contains only headers and no data rows', + ); + }); + + test('throws when required headers are missing', () => { + mockFs.readFileSync.mockReturnValue('name,value\nfoo,bar'); + + expect(() => parseCsvFile('/test/data.csv', ['to', 'amount'])).toThrow( + 'CSV file is missing required headers: to, amount', + ); + }); + + test('throws when row has wrong number of fields', () => { + mockFs.readFileSync.mockReturnValue('to,amount\n0.0.12345,10,extra'); + + expect(() => parseCsvFile('/test/data.csv', ['to', 'amount'])).toThrow( + 'CSV row 1 has 3 fields but expected 2', + ); + }); + + test('handles optional extra columns beyond required', () => { + mockFs.readFileSync.mockReturnValue( + 'to,amount,memo\n0.0.12345,10,test memo', + ); + + const result = parseCsvFile('/test/data.csv', ['to', 'amount']); + + expect(result.headers).toEqual(['to', 'amount', 'memo']); + expect(result.rows[0]).toEqual({ + to: '0.0.12345', + amount: '10', + memo: 'test memo', + }); + }); +}); diff --git a/src/plugins/batch/commands/airdrop/handler.ts b/src/plugins/batch/commands/airdrop/handler.ts new file mode 100644 index 000000000..d3cb4b84d --- /dev/null +++ b/src/plugins/batch/commands/airdrop/handler.ts @@ -0,0 +1,256 @@ +/** + * Batch Airdrop Command Handler + * + * Reads a CSV file with columns: to, amount + * Uses Hedera's native TokenAirdropTransaction to distribute tokens. + * + * Unlike a plain transfer, airdrop handles association automatically: + * - Already-associated accounts receive tokens immediately + * - Accounts with auto-association slots get associated + receive immediately + * - Other accounts get a pending airdrop they can claim later + * + * Only the sender needs to sign — no recipient keys required. + * + * Follows ADR-003 contract: returns CommandExecutionResult + */ +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import type { KeyManagerName } from '@/core/services/kms/kms-types.interface'; +import type { BatchAirdropOutput } from './output'; + +import { TokenAirdropTransaction } from '@hashgraph/sdk'; + +import { Status } from '@/core/shared/constants'; +import { formatError } from '@/core/utils/errors'; +import { processBalanceInput } from '@/core/utils/process-balance-input'; +import { + type BatchRowResult, + executeBatch, +} from '@/plugins/batch/utils/batch-executor'; +import { parseCsvFile } from '@/plugins/batch/utils/csv-parser'; +import { + resolveDestinationAccountParameter, + resolveTokenParameter, +} from '@/plugins/token/resolver-helper'; +import { isRawUnits } from '@/plugins/token/utils/token-amount-helpers'; + +import { BatchAirdropInputSchema } from './input'; + +interface AirdropRow { + to: string; + amount: string; +} + +const REQUIRED_HEADERS = ['to', 'amount']; + +export async function batchAirdrop( + args: CommandHandlerArgs, +): Promise { + const { api, logger } = args; + + const validArgs = BatchAirdropInputSchema.parse(args.args); + + const keyManagerArg = validArgs.keyManager; + const keyManager = + keyManagerArg || + api.config.getOption('default_key_manager'); + + const network = api.network.getCurrentNetwork(); + + // Resolve token + let resolvedToken; + try { + resolvedToken = resolveTokenParameter(validArgs.token, api, network); + } catch (error) { + return { + status: Status.Failure, + errorMessage: formatError( + `Failed to resolve token: ${validArgs.token}`, + error, + ), + }; + } + + if (!resolvedToken) { + return { + status: Status.Failure, + errorMessage: `Failed to resolve token: ${validArgs.token}. Expected format: token-name OR token-id`, + }; + } + + const tokenId = resolvedToken.tokenId; + + // Look up token decimals + let tokenDecimals = 0; + try { + const tokenInfo = await api.mirror.getTokenInfo(tokenId); + tokenDecimals = parseInt(tokenInfo.decimals) || 0; + } catch (error) { + return { + status: Status.Failure, + errorMessage: formatError( + `Failed to fetch token info for ${tokenId}`, + error, + ), + }; + } + + // Parse CSV + let rows: AirdropRow[]; + try { + const csv = parseCsvFile(validArgs.file, REQUIRED_HEADERS); + rows = csv.rows; + } catch (error) { + return { + status: Status.Failure, + errorMessage: formatError('Failed to parse CSV file', error), + }; + } + + logger.info(`Parsed ${rows.length} airdrop(s) from CSV for token ${tokenId}`); + + // Resolve source account + const from = await api.keyResolver.getOrInitKeyWithFallback( + validArgs.from, + keyManager, + ['token:account'], + ); + + const fromAccountId = from.accountId; + + // Validate all rows + const validationErrors: string[] = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row.to || row.to.trim().length === 0) { + validationErrors.push(`Row ${i + 1}: missing "to" field`); + } + if (!row.amount || row.amount.trim().length === 0) { + validationErrors.push(`Row ${i + 1}: missing "amount" field`); + } else { + try { + const decimals = isRawUnits(row.amount) ? 0 : tokenDecimals; + processBalanceInput(row.amount, decimals); + } catch { + validationErrors.push(`Row ${i + 1}: invalid amount "${row.amount}"`); + } + } + } + + if (validationErrors.length > 0) { + return { + status: Status.Failure, + errorMessage: + `CSV validation failed:\n` + + validationErrors.map((e) => ` - ${e}`).join('\n'), + }; + } + + // Dry run + if (validArgs.dryRun) { + const dryRunResults = rows.map((row, i) => ({ + row: i + 1, + status: 'success' as const, + to: row.to, + amount: row.amount, + })); + + const output: BatchAirdropOutput = { + total: rows.length, + succeeded: rows.length, + failed: 0, + tokenId, + fromAccount: fromAccountId, + network, + dryRun: true, + results: dryRunResults, + }; + + return { + status: Status.Success, + outputJson: JSON.stringify(output), + }; + } + + // Execute airdrops using Hedera's native TokenAirdropTransaction + const summary = await executeBatch( + rows, + async (row: AirdropRow): Promise> => { + // Resolve destination + const resolvedTo = resolveDestinationAccountParameter( + row.to, + api, + network, + ); + + if (!resolvedTo) { + return { + status: 'failed', + errorMessage: `Invalid destination: "${row.to}" is neither a valid account ID nor a known alias`, + details: { to: row.to, amount: row.amount }, + }; + } + + const toAccountId = resolvedTo.accountId; + const decimals = isRawUnits(row.amount) ? 0 : tokenDecimals; + const rawAmount = processBalanceInput(row.amount, decimals); + + // Build a TokenAirdropTransaction + // This handles association automatically: + // - Already associated → immediate transfer + // - Has auto-association slots → auto-associate + transfer + // - Otherwise → pending airdrop (recipient claims later) + const airdropTx = new TokenAirdropTransaction() + .addTokenTransfer(tokenId, fromAccountId, -rawAmount) + .addTokenTransfer(tokenId, toAccountId, rawAmount); + + const result = await api.txExecution.signAndExecuteWith(airdropTx, [ + from.keyRefId, + ]); + + if (!result.success) { + return { + status: 'failed', + errorMessage: `Airdrop failed: ${result.receipt?.status?.status ?? 'UNKNOWN'}`, + details: { to: row.to, amount: row.amount }, + }; + } + + return { + status: 'success', + transactionId: result.transactionId, + details: { to: toAccountId, amount: row.amount }, + }; + }, + logger, + ); + + const outputResults = summary.results.map((r) => ({ + row: r.row, + status: r.status, + to: r.details.to as string | undefined, + amount: r.details.amount as string | undefined, + transactionId: r.transactionId, + errorMessage: r.errorMessage, + })); + + const output: BatchAirdropOutput = { + total: summary.total, + succeeded: summary.succeeded, + failed: summary.failed, + tokenId, + fromAccount: fromAccountId, + network, + dryRun: false, + results: outputResults, + }; + + return { + status: summary.failed === summary.total ? Status.Failure : Status.Success, + outputJson: JSON.stringify(output), + ...(summary.failed > 0 && summary.failed < summary.total + ? { + errorMessage: `${summary.failed} of ${summary.total} airdrops failed. See results for details.`, + } + : {}), + }; +} diff --git a/src/plugins/batch/commands/airdrop/index.ts b/src/plugins/batch/commands/airdrop/index.ts new file mode 100644 index 000000000..0571fd46c --- /dev/null +++ b/src/plugins/batch/commands/airdrop/index.ts @@ -0,0 +1,6 @@ +/** + * Batch Airdrop Command Exports + */ +export { batchAirdrop } from './handler'; +export type { BatchAirdropOutput } from './output'; +export { BATCH_AIRDROP_TEMPLATE, BatchAirdropOutputSchema } from './output'; diff --git a/src/plugins/batch/commands/airdrop/input.ts b/src/plugins/batch/commands/airdrop/input.ts new file mode 100644 index 000000000..ec2299d1f --- /dev/null +++ b/src/plugins/batch/commands/airdrop/input.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +import { + EntityReferenceSchema, + FilePathSchema, + KeyManagerTypeSchema, + KeyOrAccountAliasSchema, +} from '@/core/schemas'; + +/** + * Input schema for batch airdrop command. + * + * Uses Hedera's native TokenAirdropTransaction which handles + * association automatically — no need for recipients to pre-associate. + * + * CSV format expected: + * to,amount + * 0.0.12345,100 + * alice,50 + */ +export const BatchAirdropInputSchema = z.object({ + file: FilePathSchema.describe('Path to CSV file with airdrop data'), + token: EntityReferenceSchema.describe( + 'Token to airdrop: either a token alias or token-id', + ), + from: KeyOrAccountAliasSchema.optional().describe( + 'Source account for all airdrops. Can be alias or AccountID:privateKey pair. Defaults to operator.', + ), + keyManager: KeyManagerTypeSchema.optional().describe( + 'Key manager type (defaults to config setting)', + ), + dryRun: z + .union([z.boolean(), z.string().transform((v) => v === 'true')]) + .optional() + .default(false) + .describe('Validate CSV without executing transactions'), +}); + +export type BatchAirdropInput = z.infer; diff --git a/src/plugins/batch/commands/airdrop/output.ts b/src/plugins/batch/commands/airdrop/output.ts new file mode 100644 index 000000000..aac45979a --- /dev/null +++ b/src/plugins/batch/commands/airdrop/output.ts @@ -0,0 +1,50 @@ +/** + * Batch Airdrop Command Output Schema and Template + */ +import { z } from 'zod'; + +import { EntityIdSchema, NetworkSchema } from '@/core/schemas/common-schemas'; + +const BatchAirdropRowResultSchema = z.object({ + row: z.number(), + status: z.enum(['success', 'failed']), + to: z.string().optional(), + amount: z.string().optional(), + transactionId: z.string().optional(), + errorMessage: z.string().optional(), +}); + +export const BatchAirdropOutputSchema = z.object({ + total: z.number(), + succeeded: z.number(), + failed: z.number(), + tokenId: EntityIdSchema, + fromAccount: z.string(), + network: NetworkSchema, + dryRun: z.boolean(), + results: z.array(BatchAirdropRowResultSchema), +}); + +export type BatchAirdropOutput = z.infer; + +export const BATCH_AIRDROP_TEMPLATE = ` +{{#if dryRun}} +🔍 Dry run complete — no transactions were submitted. +{{else}} +✅ Batch airdrop complete! +{{/if}} + +Token: {{hashscanLink tokenId "token" network}} +From: {{fromAccount}} +Network: {{network}} +Total: {{total}} | Succeeded: {{succeeded}} | Failed: {{failed}} + +{{#each results}} + Row {{row}}: {{status}}{{#if transactionId}} — {{hashscanLink transactionId "transaction" ../network}}{{/if}}{{#if errorMessage}} — {{errorMessage}}{{/if}} +{{/each}} +{{#if failed}} + +⚠️ {{failed}} airdrop(s) failed. Fix the errors above and re-run with a CSV containing only the failed rows. +Tip: If recipients lack auto-association slots, they can claim pending airdrops via the Hedera portal. +{{/if}} +`.trim(); diff --git a/src/plugins/batch/commands/mint-nft/handler.ts b/src/plugins/batch/commands/mint-nft/handler.ts new file mode 100644 index 000000000..af1edf05a --- /dev/null +++ b/src/plugins/batch/commands/mint-nft/handler.ts @@ -0,0 +1,259 @@ +/** + * Batch Mint NFT Command Handler + * + * Reads a CSV file with column: metadata + * Mints NFTs sequentially to an existing NFT collection and reports results. + * + * Follows ADR-003 contract: returns CommandExecutionResult + */ +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import type { KeyManagerName } from '@/core/services/kms/kms-types.interface'; +import type { BatchMintNftOutput } from './output'; + +import { Status } from '@/core/shared/constants'; +import { formatError } from '@/core/utils/errors'; +import { + type BatchRowResult, + executeBatch, +} from '@/plugins/batch/utils/batch-executor'; +import { parseCsvFile } from '@/plugins/batch/utils/csv-parser'; +import { resolveTokenParameter } from '@/plugins/token/resolver-helper'; + +import { BatchMintNftInputSchema } from './input'; + +interface NftMintRow { + metadata: string; +} + +const REQUIRED_HEADERS = ['metadata']; +const MAX_METADATA_BYTES = 100; + +export async function batchMintNft( + args: CommandHandlerArgs, +): Promise { + const { api, logger } = args; + + const validArgs = BatchMintNftInputSchema.parse(args.args); + + const keyManagerArg = validArgs.keyManager; + const keyManager = + keyManagerArg || + api.config.getOption('default_key_manager'); + + const network = api.network.getCurrentNetwork(); + + // Resolve token + let resolvedToken; + try { + resolvedToken = resolveTokenParameter(validArgs.token, api, network); + } catch (error) { + return { + status: Status.Failure, + errorMessage: formatError( + `Failed to resolve token: ${validArgs.token}`, + error, + ), + }; + } + + if (!resolvedToken) { + return { + status: Status.Failure, + errorMessage: `Failed to resolve token: ${validArgs.token}. Expected format: token-name OR token-id`, + }; + } + + const tokenId = resolvedToken.tokenId; + + // Verify the token is an NFT and has a supply key + let tokenInfo; + try { + tokenInfo = await api.mirror.getTokenInfo(tokenId); + } catch (error) { + return { + status: Status.Failure, + errorMessage: formatError( + `Failed to fetch token info for ${tokenId}`, + error, + ), + }; + } + + if (tokenInfo.type !== 'NON_FUNGIBLE_UNIQUE') { + return { + status: Status.Failure, + errorMessage: `Token ${tokenId} is not an NFT. This command only supports NFT tokens.`, + }; + } + + if (!tokenInfo.supply_key) { + return { + status: Status.Failure, + errorMessage: `Token ${tokenId} does not have a supply key. Cannot mint NFTs without a supply key.`, + }; + } + + // Resolve supply key + const supplyKeyResolved = await api.keyResolver.getOrInitKey( + validArgs.supplyKey, + keyManager, + ['token:supply'], + ); + + const tokenSupplyKeyPublicKey = tokenInfo.supply_key.key; + if (tokenSupplyKeyPublicKey !== supplyKeyResolved.publicKey) { + return { + status: Status.Failure, + errorMessage: `The provided supply key does not match the token's supply key for ${tokenId}.`, + }; + } + + // Parse CSV + let rows: NftMintRow[]; + try { + const csv = parseCsvFile(validArgs.file, REQUIRED_HEADERS); + rows = csv.rows; + } catch (error) { + return { + status: Status.Failure, + errorMessage: formatError('Failed to parse CSV file', error), + }; + } + + logger.info( + `Parsed ${rows.length} NFT(s) to mint from CSV for token ${tokenId}`, + ); + + // Check supply capacity + const maxSupply = BigInt(tokenInfo.max_supply || '0'); + const totalSupply = BigInt(tokenInfo.total_supply || '0'); + + if (maxSupply > 0n) { + const newTotalSupply = totalSupply + BigInt(rows.length); + if (newTotalSupply > maxSupply) { + return { + status: Status.Failure, + errorMessage: + `Cannot mint ${rows.length} NFTs. ` + + `Current supply: ${totalSupply.toString()}, ` + + `Max supply: ${maxSupply.toString()}, ` + + `Remaining capacity: ${(maxSupply - totalSupply).toString()}`, + }; + } + } + + // Validate metadata sizes + const validationErrors: string[] = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row.metadata || row.metadata.trim().length === 0) { + validationErrors.push(`Row ${i + 1}: missing "metadata" field`); + continue; + } + const metadataBytes = new TextEncoder().encode(row.metadata); + if (metadataBytes.length > MAX_METADATA_BYTES) { + validationErrors.push( + `Row ${i + 1}: metadata exceeds ${MAX_METADATA_BYTES} bytes (got ${metadataBytes.length})`, + ); + } + } + + if (validationErrors.length > 0) { + return { + status: Status.Failure, + errorMessage: + `CSV validation failed:\n` + + validationErrors.map((e) => ` - ${e}`).join('\n'), + }; + } + + // Dry run + if (validArgs.dryRun) { + const dryRunResults = rows.map((row, i) => ({ + row: i + 1, + status: 'success' as const, + metadata: row.metadata, + })); + + const output: BatchMintNftOutput = { + total: rows.length, + succeeded: rows.length, + failed: 0, + tokenId, + network, + dryRun: true, + results: dryRunResults, + }; + + return { + status: Status.Success, + outputJson: JSON.stringify(output), + }; + } + + // Execute mints + const summary = await executeBatch( + rows, + async (row: NftMintRow): Promise> => { + const metadataBytes = new TextEncoder().encode(row.metadata); + + const mintTransaction = api.token.createMintTransaction({ + tokenId, + metadata: metadataBytes, + }); + + const result = await api.txExecution.signAndExecuteWith(mintTransaction, [ + supplyKeyResolved.keyRefId, + ]); + + if (!result.success) { + return { + status: 'failed', + errorMessage: 'NFT mint transaction failed', + details: { metadata: row.metadata }, + }; + } + + const serialNumber = result.receipt.serials![0]; + + return { + status: 'success', + transactionId: result.transactionId, + details: { + metadata: row.metadata, + serialNumber: Number(serialNumber), + }, + }; + }, + logger, + ); + + const outputResults = summary.results.map((r) => ({ + row: r.row, + status: r.status, + metadata: r.details.metadata as string | undefined, + serialNumber: r.details.serialNumber as number | undefined, + transactionId: r.transactionId, + errorMessage: r.errorMessage, + })); + + const output: BatchMintNftOutput = { + total: summary.total, + succeeded: summary.succeeded, + failed: summary.failed, + tokenId, + network, + dryRun: false, + results: outputResults, + }; + + return { + status: summary.failed === summary.total ? Status.Failure : Status.Success, + outputJson: JSON.stringify(output), + ...(summary.failed > 0 && summary.failed < summary.total + ? { + errorMessage: `${summary.failed} of ${summary.total} mints failed. See results for details.`, + } + : {}), + }; +} diff --git a/src/plugins/batch/commands/mint-nft/index.ts b/src/plugins/batch/commands/mint-nft/index.ts new file mode 100644 index 000000000..686c03b07 --- /dev/null +++ b/src/plugins/batch/commands/mint-nft/index.ts @@ -0,0 +1,6 @@ +/** + * Batch Mint NFT Command Exports + */ +export { batchMintNft } from './handler'; +export type { BatchMintNftOutput } from './output'; +export { BATCH_MINT_NFT_TEMPLATE, BatchMintNftOutputSchema } from './output'; diff --git a/src/plugins/batch/commands/mint-nft/input.ts b/src/plugins/batch/commands/mint-nft/input.ts new file mode 100644 index 000000000..8704848e2 --- /dev/null +++ b/src/plugins/batch/commands/mint-nft/input.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +import { + EntityReferenceSchema, + FilePathSchema, + KeyManagerTypeSchema, + KeyOrAccountAliasSchema, +} from '@/core/schemas'; + +/** + * Input schema for batch mint-nft command. + * + * CSV format expected: + * metadata + * https://example.com/nft/1.json + * https://example.com/nft/2.json + */ +export const BatchMintNftInputSchema = z.object({ + file: FilePathSchema.describe( + 'Path to CSV file with NFT metadata (one per row)', + ), + token: EntityReferenceSchema.describe( + 'NFT token collection: either a token alias or token-id', + ), + supplyKey: KeyOrAccountAliasSchema.describe( + 'Supply key as account name or {accountId}:{private_key} format', + ), + keyManager: KeyManagerTypeSchema.optional().describe( + 'Key manager type (defaults to config setting)', + ), + dryRun: z + .union([z.boolean(), z.string().transform((v) => v === 'true')]) + .optional() + .default(false) + .describe('Validate CSV without executing transactions'), +}); + +export type BatchMintNftInput = z.infer; diff --git a/src/plugins/batch/commands/mint-nft/output.ts b/src/plugins/batch/commands/mint-nft/output.ts new file mode 100644 index 000000000..c792dc965 --- /dev/null +++ b/src/plugins/batch/commands/mint-nft/output.ts @@ -0,0 +1,47 @@ +/** + * Batch Mint NFT Command Output Schema and Template + */ +import { z } from 'zod'; + +import { EntityIdSchema, NetworkSchema } from '@/core/schemas/common-schemas'; + +const BatchMintNftRowResultSchema = z.object({ + row: z.number(), + status: z.enum(['success', 'failed']), + metadata: z.string().optional(), + serialNumber: z.number().optional(), + transactionId: z.string().optional(), + errorMessage: z.string().optional(), +}); + +export const BatchMintNftOutputSchema = z.object({ + total: z.number(), + succeeded: z.number(), + failed: z.number(), + tokenId: EntityIdSchema, + network: NetworkSchema, + dryRun: z.boolean(), + results: z.array(BatchMintNftRowResultSchema), +}); + +export type BatchMintNftOutput = z.infer; + +export const BATCH_MINT_NFT_TEMPLATE = ` +{{#if dryRun}} +🔍 Dry run complete — no transactions were submitted. +{{else}} +✅ Batch NFT mint complete! +{{/if}} + +Token: {{hashscanLink tokenId "token" network}} +Network: {{network}} +Total: {{total}} | Succeeded: {{succeeded}} | Failed: {{failed}} + +{{#each results}} + Row {{row}}: {{status}}{{#if serialNumber}} — Serial #{{serialNumber}}{{/if}}{{#if transactionId}} — {{hashscanLink transactionId "transaction" ../network}}{{/if}}{{#if errorMessage}} — {{errorMessage}}{{/if}} +{{/each}} +{{#if failed}} + +⚠️ {{failed}} mint(s) failed. Fix the errors above and re-run with a CSV containing only the failed rows. +{{/if}} +`.trim(); diff --git a/src/plugins/batch/commands/transfer-ft/handler.ts b/src/plugins/batch/commands/transfer-ft/handler.ts new file mode 100644 index 000000000..714bd48ed --- /dev/null +++ b/src/plugins/batch/commands/transfer-ft/handler.ts @@ -0,0 +1,248 @@ +/** + * Batch Transfer FT Command Handler + * + * Reads a CSV file with columns: to, amount + * Executes fungible token transfers sequentially and reports results. + * + * Follows ADR-003 contract: returns CommandExecutionResult + */ +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import type { KeyManagerName } from '@/core/services/kms/kms-types.interface'; +import type { BatchTransferFtOutput } from './output'; + +import { Status } from '@/core/shared/constants'; +import { formatError } from '@/core/utils/errors'; +import { processBalanceInput } from '@/core/utils/process-balance-input'; +import { + type BatchRowResult, + executeBatch, +} from '@/plugins/batch/utils/batch-executor'; +import { parseCsvFile } from '@/plugins/batch/utils/csv-parser'; +import { + resolveDestinationAccountParameter, + resolveTokenParameter, +} from '@/plugins/token/resolver-helper'; +import { isRawUnits } from '@/plugins/token/utils/token-amount-helpers'; + +import { BatchTransferFtInputSchema } from './input'; + +interface FtTransferRow { + to: string; + amount: string; +} + +const REQUIRED_HEADERS = ['to', 'amount']; + +export async function batchTransferFt( + args: CommandHandlerArgs, +): Promise { + const { api, logger } = args; + + const validArgs = BatchTransferFtInputSchema.parse(args.args); + + const keyManagerArg = validArgs.keyManager; + const keyManager = + keyManagerArg || + api.config.getOption('default_key_manager'); + + const network = api.network.getCurrentNetwork(); + + // Resolve token + let resolvedToken; + try { + resolvedToken = resolveTokenParameter(validArgs.token, api, network); + } catch (error) { + return { + status: Status.Failure, + errorMessage: formatError( + `Failed to resolve token: ${validArgs.token}`, + error, + ), + }; + } + + if (!resolvedToken) { + return { + status: Status.Failure, + errorMessage: `Failed to resolve token: ${validArgs.token}. Expected format: token-name OR token-id`, + }; + } + + const tokenId = resolvedToken.tokenId; + + // Look up token decimals for display-unit conversion + let tokenDecimals = 0; + try { + const tokenInfo = await api.mirror.getTokenInfo(tokenId); + tokenDecimals = parseInt(tokenInfo.decimals) || 0; + } catch (error) { + return { + status: Status.Failure, + errorMessage: formatError( + `Failed to fetch token decimals for ${tokenId}`, + error, + ), + }; + } + + // Parse CSV + let rows: FtTransferRow[]; + try { + const csv = parseCsvFile(validArgs.file, REQUIRED_HEADERS); + rows = csv.rows; + } catch (error) { + return { + status: Status.Failure, + errorMessage: formatError('Failed to parse CSV file', error), + }; + } + + logger.info( + `Parsed ${rows.length} transfer(s) from CSV for token ${tokenId}`, + ); + + // Resolve source account + const from = await api.keyResolver.getOrInitKeyWithFallback( + validArgs.from, + keyManager, + ['token:account'], + ); + + const fromAccountId = from.accountId; + + // Validate all rows + const validationErrors: string[] = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row.to || row.to.trim().length === 0) { + validationErrors.push(`Row ${i + 1}: missing "to" field`); + } + if (!row.amount || row.amount.trim().length === 0) { + validationErrors.push(`Row ${i + 1}: missing "amount" field`); + } else { + try { + const decimals = isRawUnits(row.amount) ? 0 : tokenDecimals; + processBalanceInput(row.amount, decimals); + } catch { + validationErrors.push(`Row ${i + 1}: invalid amount "${row.amount}"`); + } + } + } + + if (validationErrors.length > 0) { + return { + status: Status.Failure, + errorMessage: + `CSV validation failed:\n` + + validationErrors.map((e) => ` - ${e}`).join('\n'), + }; + } + + // Dry run + if (validArgs.dryRun) { + const dryRunResults = rows.map((row, i) => ({ + row: i + 1, + status: 'success' as const, + to: row.to, + amount: row.amount, + })); + + const output: BatchTransferFtOutput = { + total: rows.length, + succeeded: rows.length, + failed: 0, + tokenId, + fromAccount: fromAccountId, + network, + dryRun: true, + results: dryRunResults, + }; + + return { + status: Status.Success, + outputJson: JSON.stringify(output), + }; + } + + // Execute transfers + const summary = await executeBatch( + rows, + async (row: FtTransferRow): Promise> => { + // Resolve destination + const resolvedTo = resolveDestinationAccountParameter( + row.to, + api, + network, + ); + + if (!resolvedTo) { + return { + status: 'failed', + errorMessage: `Invalid destination: "${row.to}" is neither a valid account ID nor a known alias`, + details: { to: row.to, amount: row.amount }, + }; + } + + const toAccountId = resolvedTo.accountId; + const decimals = isRawUnits(row.amount) ? 0 : tokenDecimals; + const rawAmount = processBalanceInput(row.amount, decimals); + + const transferTransaction = api.token.createTransferTransaction({ + tokenId, + fromAccountId, + toAccountId, + amount: rawAmount, + }); + + const result = await api.txExecution.signAndExecuteWith( + transferTransaction, + [from.keyRefId], + ); + + if (!result.success) { + return { + status: 'failed', + errorMessage: 'Token transfer failed', + details: { to: row.to, amount: row.amount }, + }; + } + + return { + status: 'success', + transactionId: result.transactionId, + details: { to: toAccountId, amount: row.amount }, + }; + }, + logger, + ); + + const outputResults = summary.results.map((r) => ({ + row: r.row, + status: r.status, + to: r.details.to as string | undefined, + amount: r.details.amount as string | undefined, + transactionId: r.transactionId, + errorMessage: r.errorMessage, + })); + + const output: BatchTransferFtOutput = { + total: summary.total, + succeeded: summary.succeeded, + failed: summary.failed, + tokenId, + fromAccount: fromAccountId, + network, + dryRun: false, + results: outputResults, + }; + + return { + status: summary.failed === summary.total ? Status.Failure : Status.Success, + outputJson: JSON.stringify(output), + ...(summary.failed > 0 && summary.failed < summary.total + ? { + errorMessage: `${summary.failed} of ${summary.total} transfers failed. See results for details.`, + } + : {}), + }; +} diff --git a/src/plugins/batch/commands/transfer-ft/index.ts b/src/plugins/batch/commands/transfer-ft/index.ts new file mode 100644 index 000000000..d0dc4014f --- /dev/null +++ b/src/plugins/batch/commands/transfer-ft/index.ts @@ -0,0 +1,9 @@ +/** + * Batch Transfer FT Command Exports + */ +export { batchTransferFt } from './handler'; +export type { BatchTransferFtOutput } from './output'; +export { + BATCH_TRANSFER_FT_TEMPLATE, + BatchTransferFtOutputSchema, +} from './output'; diff --git a/src/plugins/batch/commands/transfer-ft/input.ts b/src/plugins/batch/commands/transfer-ft/input.ts new file mode 100644 index 000000000..703ad838a --- /dev/null +++ b/src/plugins/batch/commands/transfer-ft/input.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +import { + EntityReferenceSchema, + FilePathSchema, + KeyManagerTypeSchema, + KeyOrAccountAliasSchema, +} from '@/core/schemas'; + +/** + * Input schema for batch transfer-ft command. + * + * CSV format expected: + * to,amount + * 0.0.12345,100 + * alice,50.5 + */ +export const BatchTransferFtInputSchema = z.object({ + file: FilePathSchema.describe('Path to CSV file with transfer data'), + token: EntityReferenceSchema.describe( + 'Token to transfer: either a token alias or token-id', + ), + from: KeyOrAccountAliasSchema.optional().describe( + 'Source account for all transfers. Can be alias or AccountID:privateKey pair. Defaults to operator.', + ), + keyManager: KeyManagerTypeSchema.optional().describe( + 'Key manager type (defaults to config setting)', + ), + dryRun: z + .union([z.boolean(), z.string().transform((v) => v === 'true')]) + .optional() + .default(false) + .describe('Validate CSV without executing transactions'), +}); + +export type BatchTransferFtInput = z.infer; diff --git a/src/plugins/batch/commands/transfer-ft/output.ts b/src/plugins/batch/commands/transfer-ft/output.ts new file mode 100644 index 000000000..dac25290a --- /dev/null +++ b/src/plugins/batch/commands/transfer-ft/output.ts @@ -0,0 +1,49 @@ +/** + * Batch Transfer FT Command Output Schema and Template + */ +import { z } from 'zod'; + +import { EntityIdSchema, NetworkSchema } from '@/core/schemas/common-schemas'; + +const BatchTransferFtRowResultSchema = z.object({ + row: z.number(), + status: z.enum(['success', 'failed']), + to: z.string().optional(), + amount: z.string().optional(), + transactionId: z.string().optional(), + errorMessage: z.string().optional(), +}); + +export const BatchTransferFtOutputSchema = z.object({ + total: z.number(), + succeeded: z.number(), + failed: z.number(), + tokenId: EntityIdSchema, + fromAccount: z.string(), + network: NetworkSchema, + dryRun: z.boolean(), + results: z.array(BatchTransferFtRowResultSchema), +}); + +export type BatchTransferFtOutput = z.infer; + +export const BATCH_TRANSFER_FT_TEMPLATE = ` +{{#if dryRun}} +🔍 Dry run complete — no transactions were submitted. +{{else}} +✅ Batch fungible token transfer complete! +{{/if}} + +Token: {{hashscanLink tokenId "token" network}} +From: {{fromAccount}} +Network: {{network}} +Total: {{total}} | Succeeded: {{succeeded}} | Failed: {{failed}} + +{{#each results}} + Row {{row}}: {{status}}{{#if transactionId}} — {{hashscanLink transactionId "transaction" ../network}}{{/if}}{{#if errorMessage}} — {{errorMessage}}{{/if}} +{{/each}} +{{#if failed}} + +⚠️ {{failed}} transfer(s) failed. Fix the errors above and re-run with a CSV containing only the failed rows. +{{/if}} +`.trim(); diff --git a/src/plugins/batch/commands/transfer-hbar/handler.ts b/src/plugins/batch/commands/transfer-hbar/handler.ts new file mode 100644 index 000000000..304bbb50b --- /dev/null +++ b/src/plugins/batch/commands/transfer-hbar/handler.ts @@ -0,0 +1,209 @@ +/** + * Batch Transfer HBAR Command Handler + * + * Reads a CSV file with columns: to, amount + * Executes HBAR transfers sequentially and reports results. + * + * Follows ADR-003 contract: returns CommandExecutionResult + */ +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import type { KeyManagerName } from '@/core/services/kms/kms-types.interface'; +import type { BatchTransferHbarOutput } from './output'; + +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 { + type BatchRowResult, + executeBatch, +} from '@/plugins/batch/utils/batch-executor'; +import { parseCsvFile } from '@/plugins/batch/utils/csv-parser'; + +import { BatchTransferHbarInputSchema } from './input'; + +interface HbarTransferRow { + to: string; + amount: string; + memo?: string; +} + +const REQUIRED_HEADERS = ['to', 'amount']; + +export async function batchTransferHbar( + args: CommandHandlerArgs, +): Promise { + const { api, logger } = args; + + const validArgs = BatchTransferHbarInputSchema.parse(args.args); + + const keyManagerArg = validArgs.keyManager; + const keyManager = + keyManagerArg || + api.config.getOption('default_key_manager'); + + const network = api.network.getCurrentNetwork(); + + // Parse CSV + let rows: HbarTransferRow[]; + try { + const csv = parseCsvFile(validArgs.file, REQUIRED_HEADERS); + rows = csv.rows; + } catch (error) { + return { + status: Status.Failure, + errorMessage: formatError('Failed to parse CSV file', error), + }; + } + + logger.info(`Parsed ${rows.length} transfer(s) from CSV`); + + // Resolve source account + const from = await api.keyResolver.getOrInitKeyWithFallback( + validArgs.from, + keyManager, + ['hbar:transfer'], + ); + + const fromAccountId = from.accountId; + + // Validate all rows before executing + const validationErrors: string[] = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + + if (!row.to || row.to.trim().length === 0) { + validationErrors.push(`Row ${i + 1}: missing "to" field`); + } + if (!row.amount || row.amount.trim().length === 0) { + validationErrors.push(`Row ${i + 1}: missing "amount" field`); + } else { + try { + processBalanceInput(row.amount, HBAR_DECIMALS); + } catch { + validationErrors.push(`Row ${i + 1}: invalid amount "${row.amount}"`); + } + } + } + + if (validationErrors.length > 0) { + return { + status: Status.Failure, + errorMessage: + `CSV validation failed:\n` + + validationErrors.map((e) => ` - ${e}`).join('\n'), + }; + } + + // Dry run: validate only, no transactions + if (validArgs.dryRun) { + const dryRunResults = rows.map((row, i) => ({ + row: i + 1, + status: 'success' as const, + to: row.to, + amount: row.amount, + })); + + const output: BatchTransferHbarOutput = { + total: rows.length, + succeeded: rows.length, + failed: 0, + fromAccount: fromAccountId, + network, + dryRun: true, + results: dryRunResults, + }; + + return { + status: Status.Success, + outputJson: JSON.stringify(output), + }; + } + + // Execute transfers + const summary = await executeBatch( + rows, + async (row: HbarTransferRow): Promise> => { + // Resolve destination + let toAccountId = row.to; + const toAlias = api.alias.resolve(row.to, 'account', network); + + if (toAlias && toAlias.entityId) { + toAccountId = toAlias.entityId; + } else if (!EntityIdSchema.safeParse(row.to).success) { + return { + status: 'failed', + errorMessage: `Invalid destination: "${row.to}" is neither a valid account ID nor a known alias`, + details: { to: row.to, amount: row.amount }, + }; + } + + if (fromAccountId === toAccountId) { + return { + status: 'failed', + errorMessage: 'Cannot transfer to the same account', + details: { to: row.to, amount: row.amount }, + }; + } + + const amount = processBalanceInput(row.amount, HBAR_DECIMALS); + const memo = row.memo || validArgs.memo; + + const transferResult = await api.hbar.transferTinybar({ + amount, + from: fromAccountId, + to: toAccountId, + memo, + }); + + const result = await api.txExecution.signAndExecuteWith( + transferResult.transaction, + [from.keyRefId], + ); + + if (!result.success) { + return { + status: 'failed', + errorMessage: `Transfer failed: ${result.receipt?.status?.status ?? 'UNKNOWN'}`, + details: { to: row.to, amount: row.amount }, + }; + } + + return { + status: 'success', + transactionId: result.transactionId, + details: { to: toAccountId, amount: row.amount }, + }; + }, + logger, + ); + + const outputResults = summary.results.map((r) => ({ + row: r.row, + status: r.status, + to: r.details.to as string | undefined, + amount: r.details.amount as string | undefined, + transactionId: r.transactionId, + errorMessage: r.errorMessage, + })); + + const output: BatchTransferHbarOutput = { + total: summary.total, + succeeded: summary.succeeded, + failed: summary.failed, + fromAccount: fromAccountId, + network, + dryRun: false, + results: outputResults, + }; + + return { + status: summary.failed === summary.total ? Status.Failure : Status.Success, + outputJson: JSON.stringify(output), + ...(summary.failed > 0 && summary.failed < summary.total + ? { + errorMessage: `${summary.failed} of ${summary.total} transfers failed. See results for details.`, + } + : {}), + }; +} diff --git a/src/plugins/batch/commands/transfer-hbar/index.ts b/src/plugins/batch/commands/transfer-hbar/index.ts new file mode 100644 index 000000000..808c5a395 --- /dev/null +++ b/src/plugins/batch/commands/transfer-hbar/index.ts @@ -0,0 +1,9 @@ +/** + * Batch Transfer HBAR Command Exports + */ +export { batchTransferHbar } from './handler'; +export type { BatchTransferHbarOutput } from './output'; +export { + BATCH_TRANSFER_HBAR_TEMPLATE, + BatchTransferHbarOutputSchema, +} from './output'; diff --git a/src/plugins/batch/commands/transfer-hbar/input.ts b/src/plugins/batch/commands/transfer-hbar/input.ts new file mode 100644 index 000000000..7a518bddf --- /dev/null +++ b/src/plugins/batch/commands/transfer-hbar/input.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +import { + FilePathSchema, + KeyManagerTypeSchema, + KeyOrAccountAliasSchema, +} from '@/core/schemas'; + +/** + * Input schema for batch transfer-hbar command. + * + * CSV format expected: + * to,amount + * 0.0.12345,10 + * alice,5.5 + */ +export const BatchTransferHbarInputSchema = z.object({ + file: FilePathSchema.describe('Path to CSV file with transfer data'), + from: KeyOrAccountAliasSchema.optional().describe( + 'Source account for all transfers. Can be alias or AccountID:privateKey pair. Defaults to operator.', + ), + memo: z + .string() + .optional() + .describe('Optional memo applied to all transfers'), + keyManager: KeyManagerTypeSchema.optional().describe( + 'Key manager type (defaults to config setting)', + ), + dryRun: z + .union([z.boolean(), z.string().transform((v) => v === 'true')]) + .optional() + .default(false) + .describe('Validate CSV without executing transactions'), +}); + +export type BatchTransferHbarInput = z.infer< + typeof BatchTransferHbarInputSchema +>; diff --git a/src/plugins/batch/commands/transfer-hbar/output.ts b/src/plugins/batch/commands/transfer-hbar/output.ts new file mode 100644 index 000000000..cb814fed1 --- /dev/null +++ b/src/plugins/batch/commands/transfer-hbar/output.ts @@ -0,0 +1,49 @@ +/** + * Batch Transfer HBAR Command Output Schema and Template + */ +import { z } from 'zod'; + +import { NetworkSchema } from '@/core/schemas/common-schemas'; + +const BatchTransferHbarRowResultSchema = z.object({ + row: z.number(), + status: z.enum(['success', 'failed']), + to: z.string().optional(), + amount: z.string().optional(), + transactionId: z.string().optional(), + errorMessage: z.string().optional(), +}); + +export const BatchTransferHbarOutputSchema = z.object({ + total: z.number(), + succeeded: z.number(), + failed: z.number(), + fromAccount: z.string(), + network: NetworkSchema, + dryRun: z.boolean(), + results: z.array(BatchTransferHbarRowResultSchema), +}); + +export type BatchTransferHbarOutput = z.infer< + typeof BatchTransferHbarOutputSchema +>; + +export const BATCH_TRANSFER_HBAR_TEMPLATE = ` +{{#if dryRun}} +🔍 Dry run complete — no transactions were submitted. +{{else}} +✅ Batch HBAR transfer complete! +{{/if}} + +From: {{fromAccount}} +Network: {{network}} +Total: {{total}} | Succeeded: {{succeeded}} | Failed: {{failed}} + +{{#each results}} + Row {{row}}: {{status}}{{#if transactionId}} — {{hashscanLink transactionId "transaction" ../network}}{{/if}}{{#if errorMessage}} — {{errorMessage}}{{/if}} +{{/each}} +{{#if failed}} + +⚠️ {{failed}} transfer(s) failed. Fix the errors above and re-run with a CSV containing only the failed rows. +{{/if}} +`.trim(); diff --git a/src/plugins/batch/examples/ft-transfers.csv b/src/plugins/batch/examples/ft-transfers.csv new file mode 100644 index 000000000..4c2241ee6 --- /dev/null +++ b/src/plugins/batch/examples/ft-transfers.csv @@ -0,0 +1,5 @@ +to,amount +0.0.12345,1000 +0.0.67890,500 +alice,250 +bob,100 diff --git a/src/plugins/batch/examples/hbar-transfers.csv b/src/plugins/batch/examples/hbar-transfers.csv new file mode 100644 index 000000000..d9291dc4c --- /dev/null +++ b/src/plugins/batch/examples/hbar-transfers.csv @@ -0,0 +1,5 @@ +to,amount,memo +0.0.12345,10,payment-batch-1 +0.0.67890,5.5,payment-batch-1 +alice,2,tip +bob,1.25,refund diff --git a/src/plugins/batch/examples/nft-metadata.csv b/src/plugins/batch/examples/nft-metadata.csv new file mode 100644 index 000000000..272161ab2 --- /dev/null +++ b/src/plugins/batch/examples/nft-metadata.csv @@ -0,0 +1,6 @@ +metadata +https://example.com/nft/1.json +https://example.com/nft/2.json +https://example.com/nft/3.json +ipfs://QmXyz123456789abcdef/4.json +ipfs://QmXyz123456789abcdef/5.json diff --git a/src/plugins/batch/manifest.ts b/src/plugins/batch/manifest.ts new file mode 100644 index 000000000..5c29f638e --- /dev/null +++ b/src/plugins/batch/manifest.ts @@ -0,0 +1,263 @@ +/** + * Batch Plugin Manifest + * Defines the batch plugin for CSV-driven bulk operations on the Hedera network. + * + * Commands: + * batch transfer-hbar — Batch HBAR transfers from CSV + * batch transfer-ft — Batch fungible token transfers from CSV + * batch mint-nft — Batch NFT mints from CSV + * batch airdrop — Batch airdrop tokens from CSV (auto-handles association) + */ +import type { PluginManifest } from '@/core/plugins/plugin.interface'; + +import { OptionType } from '@/core/types/shared.types'; + +import { + BATCH_AIRDROP_TEMPLATE, + batchAirdrop, + BatchAirdropOutputSchema, +} from './commands/airdrop'; +import { + BATCH_MINT_NFT_TEMPLATE, + batchMintNft, + BatchMintNftOutputSchema, +} from './commands/mint-nft'; +import { + BATCH_TRANSFER_FT_TEMPLATE, + batchTransferFt, + BatchTransferFtOutputSchema, +} from './commands/transfer-ft'; +import { + BATCH_TRANSFER_HBAR_TEMPLATE, + batchTransferHbar, + BatchTransferHbarOutputSchema, +} from './commands/transfer-hbar'; + +export const batchPluginManifest: PluginManifest = { + name: 'batch', + version: '1.0.0', + displayName: 'Batch Operations Plugin', + description: + 'CSV-driven bulk operations: transfers, mints, and distributions on the Hedera network', + commands: [ + { + name: 'transfer-hbar', + summary: 'Batch transfer HBAR from a CSV file', + description: + 'Read a CSV file with columns "to" and "amount", then execute HBAR transfers sequentially. ' + + 'Outputs a results report with transaction links. Use --dry-run to validate without executing.', + options: [ + { + name: 'file', + short: 'f', + type: OptionType.STRING, + required: true, + description: + 'Path to CSV file. Required columns: to, amount. Optional: memo', + }, + { + name: 'from', + short: 'F', + type: OptionType.STRING, + required: false, + description: + 'Source account: alias or AccountID:privateKey pair (defaults to operator)', + }, + { + name: 'memo', + short: 'm', + type: OptionType.STRING, + required: false, + description: + 'Default memo applied to all transfers (overridden by per-row memo in CSV)', + }, + { + name: 'dry-run', + short: 'd', + type: OptionType.BOOLEAN, + required: false, + default: false, + description: + 'Validate CSV and resolve accounts without executing transactions', + }, + { + name: 'key-manager', + short: 'k', + type: OptionType.STRING, + required: false, + description: + 'Key manager to use: local or local_encrypted (defaults to config setting)', + }, + ], + handler: batchTransferHbar, + output: { + schema: BatchTransferHbarOutputSchema, + humanTemplate: BATCH_TRANSFER_HBAR_TEMPLATE, + }, + }, + { + name: 'transfer-ft', + summary: 'Batch transfer fungible tokens from a CSV file', + description: + 'Read a CSV file with columns "to" and "amount", then transfer a specified fungible token ' + + 'to each recipient. Outputs a results report with transaction links. Use --dry-run to validate.', + options: [ + { + name: 'file', + short: 'f', + type: OptionType.STRING, + required: true, + description: 'Path to CSV file. Required columns: to, amount', + }, + { + name: 'token', + short: 'T', + type: OptionType.STRING, + required: true, + description: 'Token to transfer: either a token alias or token-id', + }, + { + name: 'from', + short: 'F', + type: OptionType.STRING, + required: false, + description: + 'Source account: alias or AccountID:privateKey pair (defaults to operator)', + }, + { + name: 'dry-run', + short: 'd', + type: OptionType.BOOLEAN, + required: false, + default: false, + description: + 'Validate CSV and resolve accounts without executing transactions', + }, + { + name: 'key-manager', + short: 'k', + type: OptionType.STRING, + required: false, + description: + 'Key manager to use: local or local_encrypted (defaults to config setting)', + }, + ], + handler: batchTransferFt, + output: { + schema: BatchTransferFtOutputSchema, + humanTemplate: BATCH_TRANSFER_FT_TEMPLATE, + }, + }, + { + name: 'mint-nft', + summary: 'Batch mint NFTs from a CSV file', + description: + 'Read a CSV file with column "metadata", then mint NFTs to an existing collection. ' + + 'Each row becomes one NFT with the given metadata string (max 100 bytes). ' + + 'Outputs serial numbers and transaction links. Use --dry-run to validate.', + options: [ + { + name: 'file', + short: 'f', + type: OptionType.STRING, + required: true, + description: 'Path to CSV file. Required column: metadata', + }, + { + name: 'token', + short: 'T', + type: OptionType.STRING, + required: true, + description: 'NFT token collection: either a token alias or token-id', + }, + { + name: 'supply-key', + short: 's', + type: OptionType.STRING, + required: true, + description: + 'Supply key as account name or {accountId}:{private_key} format', + }, + { + name: 'dry-run', + short: 'd', + type: OptionType.BOOLEAN, + required: false, + default: false, + description: + 'Validate CSV and verify token/supply key without executing transactions', + }, + { + name: 'key-manager', + short: 'k', + type: OptionType.STRING, + required: false, + description: + 'Key manager to use: local or local_encrypted (defaults to config setting)', + }, + ], + handler: batchMintNft, + output: { + schema: BatchMintNftOutputSchema, + humanTemplate: BATCH_MINT_NFT_TEMPLATE, + }, + }, + { + name: 'airdrop', + summary: 'Batch airdrop fungible tokens from a CSV file', + description: + 'Read a CSV file with columns "to" and "amount", then airdrop a fungible token ' + + "using Hedera's native TokenAirdropTransaction. Unlike transfer-ft, airdrop " + + 'auto-handles association: recipients do NOT need to pre-associate with the token. ' + + 'Only the sender signs. Use --dry-run to validate.', + options: [ + { + name: 'file', + short: 'f', + type: OptionType.STRING, + required: true, + description: 'Path to CSV file. Required columns: to, amount', + }, + { + name: 'token', + short: 'T', + type: OptionType.STRING, + required: true, + description: 'Token to airdrop: either a token alias or token-id', + }, + { + name: 'from', + short: 'F', + type: OptionType.STRING, + required: false, + description: + 'Source account: alias or AccountID:privateKey pair (defaults to operator)', + }, + { + name: 'dry-run', + short: 'd', + type: OptionType.BOOLEAN, + required: false, + default: false, + description: + 'Validate CSV and resolve accounts without executing transactions', + }, + { + name: 'key-manager', + short: 'k', + type: OptionType.STRING, + required: false, + description: + 'Key manager to use: local or local_encrypted (defaults to config setting)', + }, + ], + handler: batchAirdrop, + output: { + schema: BatchAirdropOutputSchema, + humanTemplate: BATCH_AIRDROP_TEMPLATE, + }, + }, + ], +}; + +export default batchPluginManifest; diff --git a/src/plugins/batch/utils/batch-executor.ts b/src/plugins/batch/utils/batch-executor.ts new file mode 100644 index 000000000..939b0bc01 --- /dev/null +++ b/src/plugins/batch/utils/batch-executor.ts @@ -0,0 +1,74 @@ +/** + * Batch Executor Utility + * + * Executes a batch of operations sequentially, collecting results + * and generating a summary report. + */ +import type { Logger } from '@/core'; + +export interface BatchRowResult { + row: number; + status: 'success' | 'failed'; + transactionId?: string; + errorMessage?: string; + details: Record; +} + +export interface BatchSummary { + total: number; + succeeded: number; + failed: number; + results: BatchRowResult[]; +} + +/** + * Execute a batch of operations sequentially. + * + * @param rows - Array of items to process + * @param executor - Async function that processes a single row + * @param logger - Logger instance for progress tracking + * @returns BatchSummary with per-row results + */ +export async function executeBatch( + rows: T[], + executor: (row: T) => Promise>, + logger: Logger, +): Promise { + const results: BatchRowResult[] = []; + let succeeded = 0; + let failed = 0; + + for (let i = 0; i < rows.length; i++) { + logger.info(`Processing row ${i + 1} of ${rows.length}...`); + + try { + const result = await executor(rows[i]); + const rowResult: BatchRowResult = { + row: i + 1, + ...result, + }; + results.push(rowResult); + + if (result.status === 'success') { + succeeded++; + } else { + failed++; + } + } catch (error: unknown) { + failed++; + results.push({ + row: i + 1, + status: 'failed', + errorMessage: error instanceof Error ? error.message : String(error), + details: {}, + }); + } + } + + return { + total: rows.length, + succeeded, + failed, + results, + }; +} diff --git a/src/plugins/batch/utils/csv-parser.ts b/src/plugins/batch/utils/csv-parser.ts new file mode 100644 index 000000000..9e061f913 --- /dev/null +++ b/src/plugins/batch/utils/csv-parser.ts @@ -0,0 +1,117 @@ +/** + * CSV Parser Utility for Batch Plugin + * + * Parses CSV files with header row into typed row objects. + * Supports quoted fields, trimming, and validation. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export interface CsvParseResult { + headers: string[]; + rows: T[]; +} + +/** + * Parse a single CSV line, respecting quoted fields. + * Handles fields containing commas when wrapped in double quotes. + */ +function parseCsvLine(line: string): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (inQuotes) { + if (char === '"') { + // Check for escaped quote ("") + if (i + 1 < line.length && line[i + 1] === '"') { + current += '"'; + i++; // skip next quote + } else { + inQuotes = false; + } + } else { + current += char; + } + } else { + if (char === '"') { + inQuotes = true; + } else if (char === ',') { + fields.push(current.trim()); + current = ''; + } else { + current += char; + } + } + } + + fields.push(current.trim()); + return fields; +} + +/** + * Parse a CSV file into an array of row objects. + * + * @param filePath - Path to the CSV file (absolute or relative) + * @param requiredHeaders - Headers that must be present in the CSV + * @returns Parsed CSV data with headers and typed row objects + * @throws Error if file not found, empty, or missing required headers + */ +export function parseCsvFile( + filePath: string, + requiredHeaders: string[], +): CsvParseResult { + const resolvedPath = path.resolve(filePath); + + if (!fs.existsSync(resolvedPath)) { + throw new Error(`CSV file not found: ${resolvedPath}`); + } + + const content = fs.readFileSync(resolvedPath, 'utf-8'); + const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0); + + if (lines.length === 0) { + throw new Error('CSV file is empty'); + } + + if (lines.length === 1) { + throw new Error('CSV file contains only headers and no data rows'); + } + + const headers = parseCsvLine(lines[0]).map((h) => h.toLowerCase()); + + // Validate required headers + const missingHeaders = requiredHeaders.filter( + (rh) => !headers.includes(rh.toLowerCase()), + ); + if (missingHeaders.length > 0) { + throw new Error( + `CSV file is missing required headers: ${missingHeaders.join(', ')}. ` + + `Found headers: ${headers.join(', ')}`, + ); + } + + const rows: T[] = []; + + for (let i = 1; i < lines.length; i++) { + const values = parseCsvLine(lines[i]); + + if (values.length !== headers.length) { + throw new Error( + `CSV row ${i} has ${values.length} fields but expected ${headers.length} (based on headers)`, + ); + } + + const row: Record = {}; + for (let j = 0; j < headers.length; j++) { + row[headers[j]] = values[j]; + } + + rows.push(row as T); + } + + return { headers, rows }; +}