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"); + }); +});