diff --git a/packages/core-ts/package.json b/packages/core-ts/package.json
index 05a4fddd..6b6c28a5 100644
--- a/packages/core-ts/package.json
+++ b/packages/core-ts/package.json
@@ -12,7 +12,7 @@
"dist"
],
"scripts": {
- "build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts",
+ "build": "tsup src/index.ts src/cli.ts src/cli/ndjson.ts --format cjs,esm --dts",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/**/*.ts"
@@ -23,6 +23,7 @@
"devDependencies": {
"@stellar-address-kit/spec": "workspace:*",
"@stellar/stellar-sdk": "^12.3.0",
+ "@types/node": "^20.11.0",
"eslint": "^8.56.0",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
@@ -31,4 +32,4 @@
"dependencies": {
"commander": "^15.0.0"
}
-}
\ No newline at end of file
+}
diff --git a/packages/core-ts/src/cli.ts b/packages/core-ts/src/cli.ts
index c423d4cd..6e1c8318 100644
--- a/packages/core-ts/src/cli.ts
+++ b/packages/core-ts/src/cli.ts
@@ -2,6 +2,14 @@
import { Command } from "commander";
import { extractRouting } from "./routing/extract";
import type { RoutingInput } from "./routing/types";
+import {
+ detectFormat,
+ openInputStream,
+ parseCsvStream,
+ parseJsonStream,
+ processRows,
+} from "./cli/batch";
+import { writeNdjson } from "./cli/ndjson";
const program = new Command();
@@ -10,34 +18,82 @@ program
.description(
"Debug Stellar deposit routing: test a destination address + memo combination and see the resulting RoutingResult."
)
- .requiredOption("--dest
", "Destination Stellar address (G... or M...)")
+ .option("--dest ", "Destination Stellar address (G... or M...)")
.option("--memo ", "Memo value (e.g. a MEMO_ID or MEMO_TEXT value)")
.option("--type ", "Memo type: none, id, text, hash, return", "none")
+ .option("--file ", "Read batch inputs from a CSV or JSON file")
+ .option("--stdin", "Read batch inputs from stdin")
.parse(process.argv);
const opts = program.opts();
-const input: RoutingInput = {
- destination: opts.dest,
- memoType: opts.type,
- memoValue: opts.memo ?? null,
- sourceAccount: null,
-};
-
-try {
- const result = extractRouting(input);
- console.log(
- JSON.stringify(
- result,
- (_key, value) => (typeof value === "bigint" ? value.toString() : value),
- 2
- )
- );
-} catch (error) {
+async function runSingle(): Promise {
+ const input: RoutingInput = {
+ destination: opts.dest,
+ memoType: opts.type,
+ memoValue: opts.memo ?? null,
+ sourceAccount: null,
+ };
+
+ try {
+ const result = extractRouting(input);
+ console.log(
+ JSON.stringify(
+ result,
+ (_key, value) => (typeof value === "bigint" ? value.toString() : value),
+ 2
+ )
+ );
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error(`Error: ${error.message}`);
+ } else {
+ console.error("An unknown error occurred.");
+ }
+ process.exitCode = 1;
+ }
+}
+
+async function runBatch(filePath: string | null): Promise {
+ const format = detectFormat(filePath);
+ const stream = openInputStream(filePath);
+ const parser = format === "csv" ? parseCsvStream(stream) : parseJsonStream(stream);
+
+ const rows: unknown[] = [];
+ for await (const row of parser) {
+ rows.push(row);
+ }
+
+ const { records, hasErrors } = processRows(rows);
+ for (const record of records) {
+ writeNdjson(process.stdout, record);
+ }
+
+ if (hasErrors) {
+ process.exitCode = 1;
+ }
+}
+
+async function main(): Promise {
+ if (opts.file || opts.stdin) {
+ await runBatch(opts.file ?? null);
+ return;
+ }
+
+ if (!opts.dest) {
+ console.error("Error: either --dest or --file/--stdin is required.");
+ process.exitCode = 1;
+ return;
+ }
+
+ await runSingle();
+}
+
+main().catch((error) => {
if (error instanceof Error) {
console.error(`Error: ${error.message}`);
} else {
console.error("An unknown error occurred.");
}
process.exitCode = 1;
-}
\ No newline at end of file
+});
diff --git a/packages/core-ts/src/cli/batch.ts b/packages/core-ts/src/cli/batch.ts
new file mode 100644
index 00000000..ef9653ea
--- /dev/null
+++ b/packages/core-ts/src/cli/batch.ts
@@ -0,0 +1,222 @@
+import { createReadStream } from "node:fs";
+import { createInterface } from "node:readline";
+import type { Readable } from "node:stream";
+import { extractRouting } from "../routing/extract";
+import type { RoutingInput } from "../routing/types";
+import type { NdjsonRecord } from "./ndjson";
+
+export interface BatchRowInput {
+ destination: string;
+ memoType?: string;
+ memoValue?: string | null;
+}
+
+export interface BatchProcessResult {
+ records: NdjsonRecord[];
+ hasErrors: boolean;
+}
+
+/**
+ * Parse a CSV line into fields, handling quoted values and escaped 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 === '"') {
+ if (line[i + 1] === '"') {
+ current += '"';
+ i++;
+ } else {
+ inQuotes = false;
+ }
+ } else {
+ current += char;
+ }
+ } else {
+ if (char === '"') {
+ inQuotes = true;
+ } else if (char === ",") {
+ fields.push(current);
+ current = "";
+ } else {
+ current += char;
+ }
+ }
+ }
+
+ fields.push(current);
+ return fields;
+}
+
+/**
+ * Convert a raw row object into a BatchRowInput.
+ */
+function toBatchRow(row: Record): BatchRowInput | null {
+ const dest = row.destination ?? row.dest ?? row.address;
+ if (typeof dest !== "string" || dest.trim() === "") {
+ return null;
+ }
+
+ const memoTypeRaw = row.memoType ?? row.type ?? "none";
+ const memoValueRaw = row.memoValue ?? row.memo ?? null;
+
+ return {
+ destination: dest.trim(),
+ memoType: typeof memoTypeRaw === "string" ? memoTypeRaw : "none",
+ memoValue: memoValueRaw === null || memoValueRaw === undefined ? null : String(memoValueRaw),
+ };
+}
+
+/**
+ * Process a single row and produce an NDJSON record.
+ */
+function processRow(row: BatchRowInput, index: number): NdjsonRecord {
+ const input: RoutingInput = {
+ destination: row.destination,
+ memoType: row.memoType ?? "none",
+ memoValue: row.memoValue ?? null,
+ sourceAccount: null,
+ };
+
+ try {
+ const result = extractRouting(input);
+ return {
+ index,
+ destination: row.destination,
+ memoType: input.memoType,
+ memoValue: input.memoValue,
+ ok: true,
+ result,
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ return {
+ index,
+ destination: row.destination,
+ memoType: input.memoType,
+ memoValue: input.memoValue,
+ ok: false,
+ error: message,
+ };
+ }
+}
+
+/**
+ * Process an array of rows and return NDJSON records along with an error flag.
+ */
+export function processRows(rows: BatchRowInput[]): BatchProcessResult {
+ const records = rows.map((row, index) => processRow(row, index));
+ const hasErrors = records.some((r) => !r.ok);
+ return { records, hasErrors };
+}
+
+/**
+ * Parse a CSV stream into BatchRowInput rows.
+ */
+export async function* parseCsvStream(
+ stream: Readable
+): AsyncGenerator {
+ const rii = createInterface({ input: stream, crlfDelay: Infinity });
+ let headers: string[] | null = null;
+
+ for await (const line of rli) {
+ const trimmed = line.trim();
+ if (trimmed === "") continue;
+
+ const fields = parseCsvLine(trimmed);
+
+ if (!headers) {
+ headers = fields.map((h) => h.trim());
+ continue;
+ }
+
+ const row: Record = {};
+ for (let i = 0; i < headers.length; i++) {
+ row[headers[i]] = fields[i] ?? "";
+ }
+
+ const batchRow = toBatchRow(row);
+ if (batchRow) {
+ yield batchRow;
+ }
+ }
+}
+
+/**
+ * Parse a JSON stream (either an array or NDJSON) into BatchRowInput rows.
+ */
+export async function* parseJsonStream(
+ stream: Readable
+): AsyncGenerator {
+ const rli = createInterface({ input: stream, crlfDelay: Infinity });
+ const chunks: string[] = [];
+
+ for await (const line of rli) {
+ chunks.push(line);
+ }
+
+ const raw = chunks.join("\n").trim();
+ if (raw === "") return;
+
+ // Try parsing as a JSON array or object first.
+ try {
+ const parsed = JSON.parse(raw);
+ const array = Array.isArray(parsed) ? parsed : [parsed];
+ for (const item of array) {
+ if (item && typeof item === "object") {
+ const batchRow = toBatchRow(item as Record);
+ if (batchRow) {
+ yield batchRow;
+ }
+ }
+ }
+ return;
+ } catch {
+ // Fall through to NDJSON parsing.
+ }
+
+ // Fall back to NDJSON (one JSON object per line).
+ for (const line of chunks) {
+ const trimmed = line.trim();
+ if (trimmed === "") continue;
+ try {
+ const item = JSON.parse(trimmed);
+ if (item && typeof item === "object") {
+ const batchRow = toBatchRow(item as Record);
+ if (batchRow) {
+ yield batchRow;
+ }
+ }
+ } catch {
+ // Skip malformed lines.
+ }
+ }
+}
+
+/**
+ * Detect the input format based on file extension or content sniffer.
+ */
+export function detectFormat(filePath: string | null): "csv" | "json" {
+ if (filePath) {
+ const lower = filePath.toLowerCase();
+ if (lower.endsWith(".csv")) return "csv";
+ if (lower.endsWith(".json") || lower.endsWith(".ndjson")) return "json";
+ }
+ return "csv";
+}
+
+/**
+ * Open a readable stream from a file path or stdin.
+ */
+export function openInputStream(filePath: string | null): Readable {
+ if (filePath) {
+ return createReadStream(filePath, { encoding: "utf8" });
+ }
+ return process.stdin;
+}
diff --git a/packages/core-ts/src/cli/ndjson.ts b/packages/core-ts/src/cli/ndjson.ts
new file mode 100644
index 00000000..c3a55504
--- /dev/null
+++ b/packages/core-ts/src/cli/ndjson.ts
@@ -0,0 +1,30 @@
+import type { RoutingResult } from "../routing/types";
+
+export interface NdjsonRecord {
+ index: number;
+ destination: string;
+ memoType: string;
+ memoValue: string | null;
+ ok: boolean;
+ result?: RoutingResult;
+ error?: string;
+}
+
+/**
+ * Serialize a value to a JSON string, converting bigints to strings.
+ */
+export function stringifyWithBigint(value: unknown): string {
+ return JSON.stringify(value, (_key, val) =>
+ typeof val === "bigint" ? val.toString() : val
+ );
+}
+
+/**
+ * Write a single NDJSON record as a line to the provided writable stream.
+ */
+export function writeNdjson(
+ stream: NodeJS.WritableStream,
+ record: NdjsonRecord
+): void {
+ stream.write(stringifyWithBigint(record) + "\n");
+}