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
22 changes: 22 additions & 0 deletions backend/src/assetPairKeyBuilder-constants.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
10 changes: 10 additions & 0 deletions backend/src/assetPairKeyBuilder-invalid-inputs.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
28 changes: 28 additions & 0 deletions backend/src/assetPairKeyBuilder-invalid-issuer.test.js
Original file line number Diff line number Diff line change
@@ -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,
);
});
16 changes: 16 additions & 0 deletions backend/src/assetPairKeyBuilder-native-credit.test.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
12 changes: 12 additions & 0 deletions backend/src/assetPairKeyBuilder-object-inputs.test.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
14 changes: 14 additions & 0 deletions backend/src/assetPairKeyBuilder-options-object.test.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
14 changes: 14 additions & 0 deletions backend/src/assetPairKeyBuilder-order-independent.test.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
12 changes: 12 additions & 0 deletions backend/src/assetPairKeyBuilder-parse.test.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
15 changes: 15 additions & 0 deletions backend/src/assetPairKeyBuilder-two-credits.test.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
167 changes: 167 additions & 0 deletions backend/src/assetPairKeyBuilder.js
Original file line number Diff line number Diff line change
@@ -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;
24 changes: 24 additions & 0 deletions backend/src/assetPairKeyBuilderConstants.js
Original file line number Diff line number Diff line change
@@ -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",
});
52 changes: 52 additions & 0 deletions docs/features/assetPairKeyBuilder.md
Original file line number Diff line number Diff line change
@@ -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.