Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions .github/workflows/ci-dart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 3 additions & 6 deletions .github/workflows/ci-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 3 additions & 6 deletions .github/workflows/ci-ts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions packages/core-ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 4 additions & 1 deletion packages/core-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 23 additions & 0 deletions packages/core-ts/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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);
})();
153 changes: 153 additions & 0 deletions packages/core-ts/src/cli/routeDebugger.ts
Original file line number Diff line number Diff line change
@@ -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 <address> [--memo <value>] [--type <memoType>] [--source-account <account>]

Options:
--dest <address> Destination G-address or M-address
--memo <value> Memo value to test
--type <memoType> Memo type: none, id, text, hash, or return
--source-account <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<number> {
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;
}
}
46 changes: 29 additions & 17 deletions packages/core-ts/src/routing/extractFromURI.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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();
}
Expand All @@ -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");
}
});

Expand Down Expand Up @@ -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");
}
Expand All @@ -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");
}
});
});
Expand All @@ -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");
}
});

Expand All @@ -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"
);
}
});
});
Expand Down
Loading