diff --git a/.github/workflows/ci-dart.yml b/.github/workflows/ci-dart.yml
index e7eaa99e..2310a100 100644
--- a/.github/workflows/ci-dart.yml
+++ b/.github/workflows/ci-dart.yml
@@ -5,14 +5,11 @@ on:
paths:
- "packages/core-dart/**"
- "spec/**"
+ - "!spec/vectors.json"
# `verify-parity` owns this file's CI surface; skip here to avoid
# duplicate runs on vectors-only PRs.
- paths-ignore:
- - "spec/vectors.json"
- # Note: paths + paths-ignore combine as an AND across all changed files.
- # If a PR changes spec/vectors.json *together with* packages/core-dart/**,
- # this workflow is skipped; verify-parity.yml still runs the identical
- # commands, so functional coverage is preserved under a different check.
+ # Negative patterns must come after positive ones. A vectors-only PR is
+ # handled by verify-parity.yml; PRs touching Dart code still run here.
jobs:
test:
diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml
index 01dc8901..19ab62e5 100644
--- a/.github/workflows/ci-go.yml
+++ b/.github/workflows/ci-go.yml
@@ -5,14 +5,11 @@ on:
paths:
- "packages/core-go/**"
- "spec/**"
+ - "!spec/vectors.json"
# `verify-parity` owns this file's CI surface; skip here to avoid
# duplicate runs on vectors-only PRs.
- paths-ignore:
- - "spec/vectors.json"
- # Note: paths + paths-ignore combine as an AND across all changed files.
- # If a PR changes spec/vectors.json *together with* packages/core-go/**,
- # this workflow is skipped; verify-parity.yml still runs the identical
- # commands, so functional coverage is preserved under a different check.
+ # Negative patterns must come after positive ones. A vectors-only PR is
+ # handled by verify-parity.yml; PRs touching Go code still run here.
jobs:
test:
diff --git a/.github/workflows/ci-ts.yml b/.github/workflows/ci-ts.yml
index 8c40e329..86197bdc 100644
--- a/.github/workflows/ci-ts.yml
+++ b/.github/workflows/ci-ts.yml
@@ -5,14 +5,11 @@ on:
paths:
- "packages/core-ts/**"
- "spec/**"
+ - "!spec/vectors.json"
# `verify-parity` owns this file's CI surface; skip here to avoid
# duplicate runs on vectors-only PRs.
- paths-ignore:
- - "spec/vectors.json"
- # Note: paths + paths-ignore combine as an AND across all changed files.
- # If a PR changes spec/vectors.json *together with* packages/core-ts/**,
- # this workflow is skipped; verify-parity.yml still runs the identical
- # commands, so functional coverage is preserved under a different check.
+ # Negative patterns must come after positive ones. A vectors-only PR is
+ # handled by verify-parity.yml; PRs touching TS code still run here.
jobs:
test:
diff --git a/packages/core-ts/README.md b/packages/core-ts/README.md
index b7e4c89e..cb52bbd3 100644
--- a/packages/core-ts/README.md
+++ b/packages/core-ts/README.md
@@ -35,6 +35,16 @@ if (!result.destinationError) {
}
```
+## CLI Debugger
+
+After building or installing the package, you can inspect routing behavior from the terminal:
+
+```bash
+stellar-route --dest G... --memo 123 --type id
+```
+
+The command prints the `RoutingResult` object as pretty JSON, which is useful for checking muxed-address precedence, memo normalization, and routing warnings without writing a scratch script.
+
## Documentation
For full guides, integration examples, and deep dives into the routing logic, see our [comprehensive Guides](https://github.com/Boxkit-Labs/stellar-address-kit/tree/main/docs/guides).
diff --git a/packages/core-ts/package.json b/packages/core-ts/package.json
index 1ae0c686..9c20657c 100644
--- a/packages/core-ts/package.json
+++ b/packages/core-ts/package.json
@@ -5,11 +5,14 @@
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
+ "bin": {
+ "stellar-route": "dist/cli.js"
+ },
"files": [
"dist"
],
"scripts": {
- "build": "tsup src/index.ts --format cjs,esm --dts",
+ "build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/**/*.ts"
diff --git a/packages/core-ts/src/cli.ts b/packages/core-ts/src/cli.ts
new file mode 100644
index 00000000..0a2952c9
--- /dev/null
+++ b/packages/core-ts/src/cli.ts
@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+
+import { runRouteDebuggerCli } from "./cli/routeDebugger";
+
+declare const process: {
+ argv: string[];
+ stdout: { write: (message: string) => void };
+ stderr: { write: (message: string) => void };
+ exit: (code?: number) => never;
+};
+
+void (async () => {
+ const exitCode = await runRouteDebuggerCli(process.argv.slice(2), {
+ stdout: (message) => {
+ process.stdout.write(message);
+ },
+ stderr: (message) => {
+ process.stderr.write(message);
+ },
+ });
+
+ process.exit(exitCode);
+})();
diff --git a/packages/core-ts/src/cli/routeDebugger.ts b/packages/core-ts/src/cli/routeDebugger.ts
new file mode 100644
index 00000000..a8146a96
--- /dev/null
+++ b/packages/core-ts/src/cli/routeDebugger.ts
@@ -0,0 +1,153 @@
+import { extractRouting, ExtractRoutingError } from "../routing/extract";
+import type { KnownMemoType } from "../routing/types";
+
+export type CliIo = {
+ stdout: (message: string) => void;
+ stderr: (message: string) => void;
+};
+
+function stringifyCliResult(value: unknown): string {
+ return JSON.stringify(
+ value,
+ (_, nestedValue) =>
+ typeof nestedValue === "bigint" ? nestedValue.toString() : nestedValue,
+ 2
+ );
+}
+
+const VALID_MEMO_TYPES: KnownMemoType[] = [
+ "none",
+ "id",
+ "text",
+ "hash",
+ "return",
+];
+
+const HELP_TEXT = `Debug Stellar deposit routing from the command line.
+
+Usage:
+ stellar-route --dest
[--memo ] [--type ] [--source-account ]
+
+Options:
+ --dest Destination G-address or M-address
+ --memo Memo value to test
+ --type Memo type: none, id, text, hash, or return
+ --source-account Optional source account for completeness in routing input
+ -h, --help Show this help message
+
+Examples:
+ stellar-route --dest G... --memo 123 --type id
+ stellar-route --dest M... --memo customer-42 --type text
+`;
+
+type ParsedArgs = {
+ dest: string;
+ memo?: string;
+ type: KnownMemoType;
+ sourceAccount?: string;
+};
+
+function parseMemoType(rawValue: string): KnownMemoType {
+ if (VALID_MEMO_TYPES.includes(rawValue as KnownMemoType)) {
+ return rawValue as KnownMemoType;
+ }
+
+ throw new Error(
+ `Invalid memo type "${rawValue}". Expected one of: ${VALID_MEMO_TYPES.join(", ")}.`
+ );
+}
+
+function readValue(argv: string[], index: number, flag: string): string {
+ const value = argv[index + 1];
+
+ if (!value || value.startsWith("-")) {
+ throw new Error(`Missing value for ${flag}.`);
+ }
+
+ return value;
+}
+
+function parseArgs(argv: string[]): ParsedArgs {
+ const parsed: ParsedArgs = {
+ dest: "",
+ type: "none",
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+
+ if (token === "--dest") {
+ parsed.dest = readValue(argv, index, token);
+ index += 1;
+ continue;
+ }
+
+ if (token === "--memo") {
+ parsed.memo = readValue(argv, index, token);
+ index += 1;
+ continue;
+ }
+
+ if (token === "--type") {
+ parsed.type = parseMemoType(readValue(argv, index, token));
+ index += 1;
+ continue;
+ }
+
+ if (token === "--source-account") {
+ parsed.sourceAccount = readValue(argv, index, token);
+ index += 1;
+ continue;
+ }
+
+ if (token === "-h" || token === "--help") {
+ throw new Error("__HELP__");
+ }
+
+ throw new Error(`Unknown option: ${token}`);
+ }
+
+ if (!parsed.dest) {
+ throw new Error("Missing required option --dest.");
+ }
+
+ return parsed;
+}
+
+export async function runRouteDebuggerCli(
+ argv: string[],
+ io: CliIo
+): Promise {
+ try {
+ const options = parseArgs(argv);
+
+ if (options.memo !== undefined && options.type === "none") {
+ throw new Error(
+ '--memo requires --type to be one of "id", "text", "hash", or "return".'
+ );
+ }
+
+ const result = extractRouting({
+ destination: options.dest,
+ memoType: options.type,
+ memoValue: options.memo ?? null,
+ sourceAccount: options.sourceAccount ?? null,
+ });
+
+ io.stdout(`${stringifyCliResult(result)}\n`);
+ return 0;
+ } catch (error) {
+ if (error instanceof Error && error.message === "__HELP__") {
+ io.stdout(HELP_TEXT);
+ return 0;
+ }
+
+ const message =
+ error instanceof ExtractRoutingError || error instanceof Error
+ ? error.message
+ : "Unknown CLI failure.";
+
+ io.stderr(`${message}\n`);
+ return 1;
+ }
+}
diff --git a/packages/core-ts/src/routing/extractFromURI.test.ts b/packages/core-ts/src/routing/extractFromURI.test.ts
index 9c0c4bdf..8f6c53d4 100644
--- a/packages/core-ts/src/routing/extractFromURI.test.ts
+++ b/packages/core-ts/src/routing/extractFromURI.test.ts
@@ -1,10 +1,15 @@
-import { isSuccessfulURIResult } from "../src/lib/extractRoutingFromURI";
-import { extractRoutingFromURI } from '../lib/extractRoutingFromURI';
+import { describe, expect, it } from "vitest";
+import {
+ extractRoutingFromURI,
+ isSuccessfulURIResult,
+} from "./extractFromURI";
describe("extractRoutingFromURI", () => {
describe("scheme validation", () => {
it("rejects URIs without web+stellar scheme", () => {
- const result = extractRoutingFromURI("https://example.com/pay?destination=G...");
+ const result = extractRoutingFromURI(
+ "https://example.com/pay?destination=G..."
+ );
expect(result.success).toBe(false);
if (!result.success) {
expect(result.code).toBe("INVALID_URI");
@@ -22,9 +27,7 @@ describe("extractRoutingFromURI", () => {
describe("operation validation", () => {
it("rejects unsupported 'tx' operation", () => {
- const result = extractRoutingFromURI(
- "web+stellar:tx?xdr=AAAA..."
- );
+ const result = extractRoutingFromURI("web+stellar:tx?xdr=AAAA...");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.code).toBe("UNSUPPORTED_OPERATION");
@@ -67,7 +70,9 @@ describe("extractRoutingFromURI", () => {
);
expect(result.success).toBe(true);
if (isSuccessfulURIResult(result)) {
- expect(result.rawParams.destination).toBe("GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO");
+ expect(result.rawParams.destination).toBe(
+ "GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO"
+ );
expect(result.rawParams.memo).toBeUndefined();
expect(result.rawParams.memoType).toBeUndefined();
}
@@ -81,8 +86,8 @@ describe("extractRoutingFromURI", () => {
if (isSuccessfulURIResult(result)) {
expect(result.rawParams.memo).toBe("123");
expect(result.rawParams.memoType).toBe("MEMO_ID");
- // routing should contain the memo-derived routingId
- expect(result.routing).toBeDefined();
+ expect(result.routing.routingId).toBe("123");
+ expect(result.routing.routingSource).toBe("memo");
}
});
@@ -112,13 +117,19 @@ describe("extractRoutingFromURI", () => {
const result = extractRoutingFromURI(uri);
expect(result.success).toBe(true);
if (isSuccessfulURIResult(result)) {
- expect(result.rawParams.destination).toBe("GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO");
+ expect(result.rawParams.destination).toBe(
+ "GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO"
+ );
expect(result.rawParams.amount).toBe("100.1234567");
expect(result.rawParams.assetCode).toBe("USDC");
- expect(result.rawParams.assetIssuer).toBe("GAP5LETOV6YIE62YAM56STDANPRDO7ZFDBGSNHJQIYGGKSMOZAHOOS2S");
+ expect(result.rawParams.assetIssuer).toBe(
+ "GAP5LETOV6YIE62YAM56STDANPRDO7ZFDBGSNHJQIYGGKSMOZAHOOS2S"
+ );
expect(result.rawParams.memo).toBe("invoice#123");
expect(result.rawParams.memoType).toBe("MEMO_TEXT");
- expect(result.rawParams.callback).toBe("url:https://example.com/callback");
+ expect(result.rawParams.callback).toBe(
+ "url:https://example.com/callback"
+ );
expect(result.rawParams.msg).toBe("Pay me with lumens");
expect(result.rawParams.originDomain).toBe("example.com");
}
@@ -132,9 +143,9 @@ describe("extractRoutingFromURI", () => {
);
expect(result.success).toBe(true);
if (isSuccessfulURIResult(result)) {
- // extractRouting should expand M-address to G-address + routingId
- expect(result.routing.address).toMatch(/^G/);
+ expect(result.routing.destinationBaseAccount).toMatch(/^G/);
expect(result.routing.routingId).toBeDefined();
+ expect(result.routing.routingSource).toBe("muxed");
}
});
});
@@ -152,10 +163,9 @@ describe("extractRoutingFromURI", () => {
const result = extractRoutingFromURI(
"web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&memo=%ZZ"
);
- // Should still succeed with raw memo value if decode fails
expect(result.success).toBe(true);
if (isSuccessfulURIResult(result)) {
- expect(result.rawParams.memo).toBe("%ZZ"); // Raw fallback
+ expect(result.rawParams.memo).toBe("%ZZ");
}
});
@@ -165,7 +175,9 @@ describe("extractRoutingFromURI", () => {
);
expect(result.success).toBe(true);
if (isSuccessfulURIResult(result)) {
- expect(result.rawParams.destination).toBe("GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO");
+ expect(result.rawParams.destination).toBe(
+ "GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO"
+ );
}
});
});
diff --git a/packages/core-ts/src/test/cli.test.ts b/packages/core-ts/src/test/cli.test.ts
new file mode 100644
index 00000000..48295dbe
--- /dev/null
+++ b/packages/core-ts/src/test/cli.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import { encodeMuxed } from "../muxed/encode";
+import { runRouteDebuggerCli } from "../cli/routeDebugger";
+
+const G_ADDRESS =
+ "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI";
+const M_ADDRESS = encodeMuxed(G_ADDRESS, 42n);
+
+function createIo() {
+ const stdout: string[] = [];
+ const stderr: string[] = [];
+
+ return {
+ stdout,
+ stderr,
+ io: {
+ stdout: (message: string) => {
+ stdout.push(message);
+ },
+ stderr: (message: string) => {
+ stderr.push(message);
+ },
+ },
+ };
+}
+
+describe("stellar-route CLI", () => {
+ it("prints a RoutingResult as pretty JSON for a G-address memo-id", async () => {
+ const { io, stdout, stderr } = createIo();
+
+ const exitCode = await runRouteDebuggerCli(
+ ["--dest", G_ADDRESS, "--memo", "007", "--type", "id"],
+ io
+ );
+
+ expect(exitCode).toBe(0);
+ expect(stderr).toEqual([]);
+
+ const result = JSON.parse(stdout.join(""));
+ expect(result.destinationBaseAccount).toBe(G_ADDRESS);
+ expect(result.routingId).toBe("7");
+ expect(result.routingSource).toBe("memo");
+ expect(result.warnings).toHaveLength(1);
+ });
+
+ it("routes from the muxed address and ignores conflicting memo routing", async () => {
+ const { io, stdout } = createIo();
+
+ const exitCode = await runRouteDebuggerCli(
+ ["--dest", M_ADDRESS, "--memo", "99999", "--type", "id"],
+ io
+ );
+
+ expect(exitCode).toBe(0);
+
+ const result = JSON.parse(stdout.join(""));
+ expect(result.destinationBaseAccount).toBe(G_ADDRESS);
+ expect(result.routingId).toBe("42");
+ expect(result.routingSource).toBe("muxed");
+ expect(result.warnings[0].code).toBe("MEMO_PRESENT_WITH_MUXED");
+ });
+
+ it("returns a helpful error when --memo is supplied without a memo type", async () => {
+ const { io, stdout, stderr } = createIo();
+
+ const exitCode = await runRouteDebuggerCli(
+ ["--dest", G_ADDRESS, "--memo", "123"],
+ io
+ );
+
+ expect(exitCode).toBe(1);
+ expect(stdout).toEqual([]);
+ expect(stderr.join("")).toContain("--memo requires --type");
+ });
+
+ it("returns a helpful error for unsupported memo types", async () => {
+ const { io, stdout, stderr } = createIo();
+
+ const exitCode = await runRouteDebuggerCli(
+ ["--dest", G_ADDRESS, "--type", "memo-id"],
+ io
+ );
+
+ expect(exitCode).toBe(1);
+ expect(stdout).toEqual([]);
+ expect(stderr.join("")).toContain("Invalid memo type");
+ });
+});