From 7d5b4f299c3bf2763b9efad83b4c875680dd514d Mon Sep 17 00:00:00 2001 From: guoyunfeng Date: Thu, 22 Oct 2026 11:08:28 +0800 Subject: [PATCH] feat(backend): add asset pair canonical key builder module - Implement backend/src/assetPairKeyBuilder.js to build order-independent canonical keys for trading asset pairs - Support native (normalized to native) and credit assets (normalized to CODE:ISSUER) - Export constants CANONICAL_NATIVE_ASSET, ASSET_PAIR_SEPARATOR, and ASSET_PAIR_KEY_ERROR - Provide parseAssetPairKey to decompose canonical keys back into assets - Handle both positional and options object inputs - Add 9 single-behavior node:test test files covering order-independence, object inputs, parsing, constants, and invalid code/issuer validations - Add documentation under docs/features/assetPairKeyBuilder.md Closes #372 --- .../src/assetPairKeyBuilder-constants.test.js | 22 +++ ...assetPairKeyBuilder-invalid-inputs.test.js | 10 ++ ...assetPairKeyBuilder-invalid-issuer.test.js | 28 +++ .../assetPairKeyBuilder-native-credit.test.js | 16 ++ .../assetPairKeyBuilder-object-inputs.test.js | 12 ++ ...assetPairKeyBuilder-options-object.test.js | 14 ++ ...etPairKeyBuilder-order-independent.test.js | 14 ++ backend/src/assetPairKeyBuilder-parse.test.js | 12 ++ .../assetPairKeyBuilder-two-credits.test.js | 15 ++ backend/src/assetPairKeyBuilder.js | 167 ++++++++++++++++++ backend/src/assetPairKeyBuilderConstants.js | 24 +++ docs/features/assetPairKeyBuilder.md | 52 ++++++ 12 files changed, 386 insertions(+) create mode 100644 backend/src/assetPairKeyBuilder-constants.test.js create mode 100644 backend/src/assetPairKeyBuilder-invalid-inputs.test.js create mode 100644 backend/src/assetPairKeyBuilder-invalid-issuer.test.js create mode 100644 backend/src/assetPairKeyBuilder-native-credit.test.js create mode 100644 backend/src/assetPairKeyBuilder-object-inputs.test.js create mode 100644 backend/src/assetPairKeyBuilder-options-object.test.js create mode 100644 backend/src/assetPairKeyBuilder-order-independent.test.js create mode 100644 backend/src/assetPairKeyBuilder-parse.test.js create mode 100644 backend/src/assetPairKeyBuilder-two-credits.test.js create mode 100644 backend/src/assetPairKeyBuilder.js create mode 100644 backend/src/assetPairKeyBuilderConstants.js create mode 100644 docs/features/assetPairKeyBuilder.md diff --git a/backend/src/assetPairKeyBuilder-constants.test.js b/backend/src/assetPairKeyBuilder-constants.test.js new file mode 100644 index 0000000..5ea76b7 --- /dev/null +++ b/backend/src/assetPairKeyBuilder-constants.test.js @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + CANONICAL_NATIVE_ASSET, + ASSET_PAIR_SEPARATOR, + ASSET_PAIR_KEY_ERROR, +} from "./assetPairKeyBuilderConstants.js"; + +test("CANONICAL_NATIVE_ASSET is 'native'", () => { + assert.equal(CANONICAL_NATIVE_ASSET, "native"); +}); + +test("ASSET_PAIR_SEPARATOR is '/'", () => { + assert.equal(ASSET_PAIR_SEPARATOR, "/"); +}); + +test("ASSET_PAIR_KEY_ERROR defines frozen error fragments", () => { + assert.equal(ASSET_PAIR_KEY_ERROR.REQUIRED, "is required"); + assert.equal(ASSET_PAIR_KEY_ERROR.EMPTY, "cannot be empty"); + assert.equal(Object.isFrozen(ASSET_PAIR_KEY_ERROR), true); +}); diff --git a/backend/src/assetPairKeyBuilder-invalid-inputs.test.js b/backend/src/assetPairKeyBuilder-invalid-inputs.test.js new file mode 100644 index 0000000..7778139 --- /dev/null +++ b/backend/src/assetPairKeyBuilder-invalid-inputs.test.js @@ -0,0 +1,10 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildAssetPairKey, canonicalizeAsset } from "./assetPairKeyBuilder.js"; + +test("rejects missing, null, or empty asset inputs with TypeError", () => { + assert.throws(() => buildAssetPairKey(undefined, "native"), TypeError); + assert.throws(() => buildAssetPairKey("native", null), TypeError); + assert.throws(() => buildAssetPairKey("", "native"), TypeError); + assert.throws(() => canonicalizeAsset(12345), TypeError); +}); diff --git a/backend/src/assetPairKeyBuilder-invalid-issuer.test.js b/backend/src/assetPairKeyBuilder-invalid-issuer.test.js new file mode 100644 index 0000000..86fd7c7 --- /dev/null +++ b/backend/src/assetPairKeyBuilder-invalid-issuer.test.js @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildAssetPairKey } from "./assetPairKeyBuilder.js"; + +test("rejects invalid issuer length or non-Stellar public keys", () => { + // Issuer too short + assert.throws(() => buildAssetPairKey("native", "USDC:GSHORT"), TypeError); + + // Issuer does not start with G + assert.throws( + () => + buildAssetPairKey( + "native", + `USDC:S${"A".repeat(55)}`, + ), + TypeError, + ); + + // Asset code too long (> 12 chars) + assert.throws( + () => + buildAssetPairKey( + "native", + `TOOLONGASSETCODE123:${"G".padEnd(56, "A")}`, + ), + TypeError, + ); +}); diff --git a/backend/src/assetPairKeyBuilder-native-credit.test.js b/backend/src/assetPairKeyBuilder-native-credit.test.js new file mode 100644 index 0000000..0ac6e22 --- /dev/null +++ b/backend/src/assetPairKeyBuilder-native-credit.test.js @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildAssetPairKey } from "./assetPairKeyBuilder.js"; + +test("canonicalizes XLM and native variations uniformly", () => { + const issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + const credit = `USDC:${issuer}`; + + const key1 = buildAssetPairKey("XLM", credit); + const key2 = buildAssetPairKey("native", credit); + const key3 = buildAssetPairKey({ asset_type: "native" }, credit); + + assert.equal(key1, `native/${credit}`); + assert.equal(key2, `native/${credit}`); + assert.equal(key3, `native/${credit}`); +}); diff --git a/backend/src/assetPairKeyBuilder-object-inputs.test.js b/backend/src/assetPairKeyBuilder-object-inputs.test.js new file mode 100644 index 0000000..d7605de --- /dev/null +++ b/backend/src/assetPairKeyBuilder-object-inputs.test.js @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildAssetPairKey } from "./assetPairKeyBuilder.js"; + +test("accepts Horizon-shaped asset objects and normalizes to uppercase", () => { + const issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + const objA = { code: "usdc", issuer }; + const objB = { asset_type: "native" }; + + const key = buildAssetPairKey(objA, objB); + assert.equal(key, `native/USDC:${issuer}`); +}); diff --git a/backend/src/assetPairKeyBuilder-options-object.test.js b/backend/src/assetPairKeyBuilder-options-object.test.js new file mode 100644 index 0000000..9c614b9 --- /dev/null +++ b/backend/src/assetPairKeyBuilder-options-object.test.js @@ -0,0 +1,14 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildAssetPairKey, assetPairKeyBuilder } from "./assetPairKeyBuilder.js"; + +test("accepts options object with base/counter or assetA/assetB", () => { + const issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + const credit = `USDC:${issuer}`; + + const res1 = buildAssetPairKey({ base: credit, counter: "native" }); + assert.equal(res1, `native/${credit}`); + + const res2 = assetPairKeyBuilder({ assetA: "native", assetB: credit }); + assert.equal(res2, `native/${credit}`); +}); diff --git a/backend/src/assetPairKeyBuilder-order-independent.test.js b/backend/src/assetPairKeyBuilder-order-independent.test.js new file mode 100644 index 0000000..02a2b37 --- /dev/null +++ b/backend/src/assetPairKeyBuilder-order-independent.test.js @@ -0,0 +1,14 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildAssetPairKey } from "./assetPairKeyBuilder.js"; + +test("builds identical canonical key regardless of argument order", () => { + const issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + const credit = `USDC:${issuer}`; + + const keyAB = buildAssetPairKey("native", credit); + const keyBA = buildAssetPairKey(credit, "native"); + + assert.equal(keyAB, keyBA); + assert.equal(keyAB, `native/${credit}`); +}); diff --git a/backend/src/assetPairKeyBuilder-parse.test.js b/backend/src/assetPairKeyBuilder-parse.test.js new file mode 100644 index 0000000..19ad5cf --- /dev/null +++ b/backend/src/assetPairKeyBuilder-parse.test.js @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseAssetPairKey } from "./assetPairKeyBuilder.js"; + +test("parses a canonical pair key into its two component asset strings", () => { + const issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + const canonicalKey = `native/USDC:${issuer}`; + + const [part1, part2] = parseAssetPairKey(canonicalKey); + assert.equal(part1, "native"); + assert.equal(part2, `USDC:${issuer}`); +}); diff --git a/backend/src/assetPairKeyBuilder-two-credits.test.js b/backend/src/assetPairKeyBuilder-two-credits.test.js new file mode 100644 index 0000000..e2b5b64 --- /dev/null +++ b/backend/src/assetPairKeyBuilder-two-credits.test.js @@ -0,0 +1,15 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildAssetPairKey } from "./assetPairKeyBuilder.js"; + +test("orders two credit assets deterministically by alphabetical comparison", () => { + const issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + const btc = `BTC:${issuer}`; + const usdc = `USDC:${issuer}`; + + const key1 = buildAssetPairKey(usdc, btc); + const key2 = buildAssetPairKey(btc, usdc); + + assert.equal(key1, `${btc}/${usdc}`); + assert.equal(key2, `${btc}/${usdc}`); +}); diff --git a/backend/src/assetPairKeyBuilder.js b/backend/src/assetPairKeyBuilder.js new file mode 100644 index 0000000..c4fda4b --- /dev/null +++ b/backend/src/assetPairKeyBuilder.js @@ -0,0 +1,167 @@ +/** + * Asset pair canonical key builder. + * + * Builds a canonical, order-independent string key for a trading asset pair + * (e.g. `native/CODE:ISSUER`) suitable for use as a Map/Set key for order-book + * or ticker caching. + */ + +import { + CANONICAL_NATIVE_ASSET, + ASSET_PAIR_SEPARATOR, + ASSET_PAIR_KEY_ERROR, +} from "./assetPairKeyBuilderConstants.js"; + +/** + * Validate and canonicalize a single Stellar asset representation. + * + * Accepts: + * - "native" or "XLM" + * - "CODE:ISSUER" or "CODE-ISSUER" + * - Object with { asset_type: "native" } or { type: "native" } + * - Object with { asset_code, issuer } or { code, issuer } + * + * @param {unknown} asset + * @param {string} [label="asset"] + * @returns {string} Canonical string: "native" or "CODE:ISSUER" + * @throws {TypeError} When asset is missing or has an invalid structure + */ +export function canonicalizeAsset(asset, label = "asset") { + if (asset === undefined || asset === null) { + throw new TypeError(`${label} ${ASSET_PAIR_KEY_ERROR.REQUIRED}`); + } + + // String format handling + if (typeof asset === "string") { + const trimmed = asset.trim(); + if (!trimmed) { + throw new TypeError(`${label} ${ASSET_PAIR_KEY_ERROR.EMPTY}`); + } + + const lower = trimmed.toLowerCase(); + if (lower === "native" || lower === "xlm") { + return CANONICAL_NATIVE_ASSET; + } + + const parts = trimmed.includes(":") ? trimmed.split(":") : trimmed.split("-"); + if (parts.length === 2) { + const [code, issuer] = parts; + return formatCreditAsset(code, issuer, label); + } + + throw new TypeError(`Invalid ${label} format: "${trimmed}". ${ASSET_PAIR_KEY_ERROR.INVALID_FORMAT}.`); + } + + // Object format handling + if (typeof asset === "object") { + const type = asset.asset_type ?? asset.type; + if (type === "native" || (typeof type === "string" && type.toLowerCase() === "native")) { + return CANONICAL_NATIVE_ASSET; + } + + const code = asset.asset_code ?? asset.code; + const issuer = asset.issuer; + + if (!code && (asset.code === "XLM" || asset.asset_code === "XLM") && !issuer) { + return CANONICAL_NATIVE_ASSET; + } + + if (code && issuer) { + return formatCreditAsset(code, issuer, label); + } + + throw new TypeError(`Invalid ${label} object: ${ASSET_PAIR_KEY_ERROR.INVALID_OBJECT}`); + } + + throw new TypeError(`${label} must be a string or asset object`); +} + +/** + * Helper to validate and format credit asset code and issuer. + * + * @param {string} code + * @param {string} issuer + * @param {string} label + * @returns {string} + */ +function formatCreditAsset(code, issuer, label) { + const cleanCode = String(code).trim().toUpperCase(); + const cleanIssuer = String(issuer).trim(); + + if (!/^[A-Z0-9]{1,12}$/.test(cleanCode)) { + throw new TypeError(`Invalid ${label} code: "${code}". ${ASSET_PAIR_KEY_ERROR.INVALID_CODE}.`); + } + + if (!cleanIssuer.startsWith("G") || cleanIssuer.length !== 56) { + throw new TypeError(`Invalid ${label} issuer: "${issuer}". ${ASSET_PAIR_KEY_ERROR.INVALID_ISSUER}.`); + } + + return `${cleanCode}:${cleanIssuer}`; +} + +/** + * Build a canonical, order-independent string key for an asset trading pair. + * + * Supports both positional parameters: + * `buildAssetPairKey(assetA, assetB)` + * and options object: + * `buildAssetPairKey({ assetA, assetB })` or `buildAssetPairKey({ base, counter })` + * + * @param {unknown} assetAInput + * @param {unknown} [assetBInput] + * @returns {string} Canonical pair key (e.g. "native/USDC:GA..." or "BTC:GA.../USDC:GA...") + * @throws {TypeError} When either asset is invalid or missing + */ +export function buildAssetPairKey(assetAInput, assetBInput) { + let firstAsset; + let secondAsset; + + if ( + typeof assetAInput === "object" && + assetAInput !== null && + ("assetA" in assetAInput || "base" in assetAInput || "selling" in assetAInput) + ) { + firstAsset = assetAInput.assetA ?? assetAInput.base ?? assetAInput.selling; + secondAsset = assetAInput.assetB ?? assetAInput.counter ?? assetAInput.buying; + } else { + firstAsset = assetAInput; + secondAsset = assetBInput; + } + + const canonA = canonicalizeAsset(firstAsset, "assetA"); + const canonB = canonicalizeAsset(secondAsset, "assetB"); + + // Order-independent: sort canonically by string comparison + return canonA.localeCompare(canonB) <= 0 + ? `${canonA}${ASSET_PAIR_SEPARATOR}${canonB}` + : `${canonB}${ASSET_PAIR_SEPARATOR}${canonA}`; +} + +/** + * Parse a canonical pair key into its two component asset strings. + * + * @param {string} canonicalKey + * @returns {[string, string]} + * @throws {TypeError} When key format is invalid + */ +export function parseAssetPairKey(canonicalKey) { + if (typeof canonicalKey !== "string" || !canonicalKey.includes(ASSET_PAIR_SEPARATOR)) { + throw new TypeError(`canonicalKey ${ASSET_PAIR_KEY_ERROR.INVALID_KEY_SEPARATOR}`); + } + + const [assetA, assetB] = canonicalKey.split(ASSET_PAIR_SEPARATOR); + return [canonicalizeAsset(assetA, "keyPart1"), canonicalizeAsset(assetB, "keyPart2")]; +} + +/** + * Alias matching the module name. + */ +export const assetPairKeyBuilder = buildAssetPairKey; + +export { + CANONICAL_NATIVE_ASSET, + ASSET_PAIR_SEPARATOR, + ASSET_PAIR_KEY_ERROR, +}; + +export default buildAssetPairKey; diff --git a/backend/src/assetPairKeyBuilderConstants.js b/backend/src/assetPairKeyBuilderConstants.js new file mode 100644 index 0000000..c00fd5f --- /dev/null +++ b/backend/src/assetPairKeyBuilderConstants.js @@ -0,0 +1,24 @@ +/** + * Constants and error messages for canonical asset pair key builder. + */ + +/** Canonical identifier string for the Stellar native asset. */ +export const CANONICAL_NATIVE_ASSET = "native"; + +/** Standard separator character between base and counter assets in a canonical key. */ +export const ASSET_PAIR_SEPARATOR = "/"; + +/** Credit asset code and issuer delimiters. */ +export const ASSET_DELIMITER_COLON = ":"; +export const ASSET_DELIMITER_DASH = "-"; + +/** Stable error message fragments for asset pair key building and validation. */ +export const ASSET_PAIR_KEY_ERROR = Object.freeze({ + REQUIRED: "is required", + EMPTY: "cannot be empty", + INVALID_FORMAT: "Expected \"native\" or \"CODE:ISSUER\"", + INVALID_CODE: "Must be 1-12 alphanumeric characters", + INVALID_ISSUER: "Must be a 56-character Stellar public key", + INVALID_OBJECT: "must specify type \"native\" or both code and issuer", + INVALID_KEY_SEPARATOR: "must be a string containing a '/' separator", +}); diff --git a/docs/features/assetPairKeyBuilder.md b/docs/features/assetPairKeyBuilder.md new file mode 100644 index 0000000..452fae9 --- /dev/null +++ b/docs/features/assetPairKeyBuilder.md @@ -0,0 +1,52 @@ +# Asset pair canonical key builder + +`buildAssetPairKey` (also exported as `assetPairKeyBuilder`) builds a canonical, order-independent string key for a trading asset pair (e.g. `native/CODE:ISSUER`) suitable for use as a Map/Set key for order-book or ticker caching. + +## Overview + +In decentralized exchange (SDEX) and order-book operations, order books can be queried in either direction (`selling/buying` or `buying/selling`). Caches need a deterministic, order-independent key regardless of the orientation supplied by callers. + +`buildAssetPairKey` normalizes both assets, validates format rules, sorts the canonical strings lexicographically, and joins them with `/`. + +## Usage + +```js +import { + buildAssetPairKey, + parseAssetPairKey, + CANONICAL_NATIVE_ASSET, + ASSET_PAIR_SEPARATOR, +} from "./assetPairKeyBuilder.js"; + +const issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +// Order-independent: +const key1 = buildAssetPairKey("native", `USDC:${issuer}`); +const key2 = buildAssetPairKey(`USDC:${issuer}`, "native"); +console.log(key1 === key2); // true: "native/USDC:GBBD..." + +// Options object parameter: +const key3 = buildAssetPairKey({ + base: { type: "native" }, + counter: { code: "USDC", issuer }, +}); + +// Decompose a key: +const [asset1, asset2] = parseAssetPairKey(key1); +``` + +## API + +### `buildAssetPairKey(assetAInput, [assetBInput])` + +- **`assetAInput`**: First asset representation or options object (`{ assetA, assetB }`, `{ base, counter }`, or `{ selling, buying }`). +- **`assetBInput`**: Second asset representation (string or object). +- **Returns**: `string` (canonical pair key, e.g. `native/USDC:GB...`). +- **Throws**: `TypeError` if an asset is missing or has an invalid code/issuer format. + +### Constants + +`backend/src/assetPairKeyBuilderConstants.js` exports: +- `CANONICAL_NATIVE_ASSET`: `"native"` +- `ASSET_PAIR_SEPARATOR`: `"/"` +- `ASSET_PAIR_KEY_ERROR`: Error string fragments.