From a52ca474089d463ba5a62f603d46b9149b310608 Mon Sep 17 00:00:00 2001 From: ranjeet2063 Date: Thu, 10 Sep 2026 17:36:25 +0545 Subject: [PATCH] feat(stellar): add formatAssetIdentifier helper for uniform asset representations (closes #441) --- src/utils/stellar.ts | 143 +++++++++++++++++++++++++ tests/stellar-asset-formatting.test.ts | 110 +++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/utils/stellar.ts create mode 100644 tests/stellar-asset-formatting.test.ts diff --git a/src/utils/stellar.ts b/src/utils/stellar.ts new file mode 100644 index 0000000..461330e --- /dev/null +++ b/src/utils/stellar.ts @@ -0,0 +1,143 @@ +/** + * Stellar asset formatting and utility functions. + * + * Provides standard, uniform string representations for Stellar native assets (XLM) + * and issued credit assets (e.g. USDC:) across API responses, logging, + * and internal payloads. + */ +import { Asset } from "@stellar/stellar-sdk"; + +export interface StellarAssetInput { + code: string; + issuer?: string | null; +} + +/** + * Formats a Stellar asset representation into a canonical string identifier. + * + * Standard representation rules: + * - Native assets (XLM / native) are formatted strictly as `"native"`. + * - Issued credit assets are formatted strictly as `":"`. + * + * @param asset - A Stellar Asset instance, an object with code and optional issuer, + * or the asset code string. + * @param issuer - Optional issuer account public key when passing code as the first argument. + * @returns Canonical asset string identifier (e.g. "native" or "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"). + * + * @throws {Error} If the asset input is missing, or a non-native asset is missing its issuing account. + */ +export function formatAssetIdentifier( + asset: Asset | StellarAssetInput | string, + issuer?: string | null +): string { + if (!asset) { + throw new Error("Asset parameter is required"); + } + + // 1. Instance of Stellar SDK Asset + if (asset instanceof Asset) { + if (asset.isNative()) { + return "native"; + } + const assetIssuer = asset.getIssuer(); + const assetCode = asset.getCode(); + if (!assetIssuer) { + throw new Error(`Issued asset ${assetCode} is missing an issuer`); + } + return `${assetCode}:${assetIssuer}`; + } + + // 2. Object with code (and optional issuer property) + if (typeof asset === "object") { + const code = asset.code?.trim(); + if (!code) { + throw new Error("Asset object must have a non-empty code property"); + } + + if (code.toUpperCase() === "XLM" || code.toLowerCase() === "native") { + if (!asset.issuer && !issuer) { + return "native"; + } + } + + const effectiveIssuer = asset.issuer?.trim() || issuer?.trim(); + if (!effectiveIssuer) { + if (code.toUpperCase() === "XLM" || code.toLowerCase() === "native") { + return "native"; + } + throw new Error(`Asset ${code} requires a valid issuer public key`); + } + + return `${code}:${effectiveIssuer}`; + } + + // 3. String representation + if (typeof asset === "string") { + const trimmed = asset.trim(); + if (!trimmed) { + throw new Error("Asset string cannot be empty"); + } + + if (trimmed.toLowerCase() === "native" || (trimmed.toUpperCase() === "XLM" && !issuer)) { + return "native"; + } + + // Check if already in : format + if (trimmed.includes(":")) { + const parts = trimmed.split(":"); + if (parts.length === 2 && parts[0].trim() && parts[1].trim()) { + const c = parts[0].trim(); + const i = parts[1].trim(); + if (c.toLowerCase() === "native" || c.toUpperCase() === "XLM") { + if (!i || i.toLowerCase() === "native") return "native"; + } + return `${c}:${i}`; + } + throw new Error(`Invalid asset identifier format: "${trimmed}"`); + } + + // Code provided as string, issuer provided as second argument + const effectiveIssuer = issuer?.trim(); + if (!effectiveIssuer) { + if (trimmed.toUpperCase() === "XLM" || trimmed.toLowerCase() === "native") { + return "native"; + } + throw new Error(`Asset ${trimmed} requires a valid issuer public key`); + } + + return `${trimmed}:${effectiveIssuer}`; + } + + throw new Error("Unsupported asset input type"); +} + +/** + * Parses a canonical asset identifier string into its constituent code and issuer. + * + * @param identifier - Asset identifier string (e.g. "native" or "USDC:GBBD47...") + * @returns Object with code and issuer (issuer is null for native asset). + */ +export function parseAssetIdentifier(identifier: string): { code: string; issuer: string | null } { + if (!identifier || typeof identifier !== "string") { + throw new Error("Identifier must be a non-empty string"); + } + + const trimmed = identifier.trim(); + if (trimmed.toLowerCase() === "native" || trimmed.toUpperCase() === "XLM") { + return { code: "XLM", issuer: null }; + } + + const colonIdx = trimmed.indexOf(":"); + if (colonIdx === -1) { + throw new Error(`Invalid non-native asset identifier: "${trimmed}". Expected ":"`); + } + + const code = trimmed.slice(0, colonIdx).trim(); + const issuer = trimmed.slice(colonIdx + 1).trim(); + + if (!code || !issuer) { + throw new Error(`Invalid asset identifier components in: "${trimmed}"`); + } + + return { code, issuer }; +} diff --git a/tests/stellar-asset-formatting.test.ts b/tests/stellar-asset-formatting.test.ts new file mode 100644 index 0000000..5f06a5b --- /dev/null +++ b/tests/stellar-asset-formatting.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; +import { Asset } from "@stellar/stellar-sdk"; +import { formatAssetIdentifier, parseAssetIdentifier } from "../src/utils/stellar"; + +describe("formatAssetIdentifier", () => { + const testIssuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + describe("Native asset formatting", () => { + it("formats Asset.native() instance as 'native'", () => { + const nativeAsset = Asset.native(); + expect(formatAssetIdentifier(nativeAsset)).toBe("native"); + }); + + it("formats object with code 'native' as 'native'", () => { + expect(formatAssetIdentifier({ code: "native" })).toBe("native"); + }); + + it("formats object with code 'XLM' as 'native'", () => { + expect(formatAssetIdentifier({ code: "XLM" })).toBe("native"); + expect(formatAssetIdentifier({ code: "xlm" })).toBe("native"); + }); + + it("formats string 'native' as 'native'", () => { + expect(formatAssetIdentifier("native")).toBe("native"); + expect(formatAssetIdentifier("NATIVE")).toBe("native"); + expect(formatAssetIdentifier(" native ")).toBe("native"); + }); + + it("formats string 'XLM' as 'native' when no issuer provided", () => { + expect(formatAssetIdentifier("XLM")).toBe("native"); + expect(formatAssetIdentifier("xlm")).toBe("native"); + }); + }); + + describe("Issued credit asset formatting", () => { + it("formats Stellar SDK Asset instance as 'code:issuer'", () => { + const usdcAsset = new Asset("USDC", testIssuer); + expect(formatAssetIdentifier(usdcAsset)).toBe(`USDC:${testIssuer}`); + }); + + it("formats object with code and issuer as 'code:issuer'", () => { + const input = { code: "USDC", issuer: testIssuer }; + expect(formatAssetIdentifier(input)).toBe(`USDC:${testIssuer}`); + }); + + it("formats string code and second-argument issuer as 'code:issuer'", () => { + expect(formatAssetIdentifier("USDC", testIssuer)).toBe(`USDC:${testIssuer}`); + }); + + it("preserves already formatted 'code:issuer' string", () => { + const canonical = `USDC:${testIssuer}`; + expect(formatAssetIdentifier(canonical)).toBe(canonical); + }); + + it("trims whitespace from code and issuer strings", () => { + expect(formatAssetIdentifier(" USDC ", ` ${testIssuer} `)).toBe(`USDC:${testIssuer}`); + expect(formatAssetIdentifier(` USDC:${testIssuer} `)).toBe(`USDC:${testIssuer}`); + }); + }); + + describe("Validation & Error handling", () => { + it("throws when asset input is null or undefined", () => { + expect(() => formatAssetIdentifier(null as any)).toThrow(/required/i); + expect(() => formatAssetIdentifier(undefined as any)).toThrow(/required/i); + expect(() => formatAssetIdentifier("")).toThrow(/required|empty/i); + }); + + it("throws when non-native asset object has no issuer", () => { + expect(() => formatAssetIdentifier({ code: "USDC" })).toThrow(/requires a valid issuer/i); + expect(() => formatAssetIdentifier({ code: "USDC", issuer: "" })).toThrow(/requires a valid issuer/i); + expect(() => formatAssetIdentifier({ code: "USDC", issuer: null })).toThrow(/requires a valid issuer/i); + }); + + it("throws when non-native asset string has no issuer", () => { + expect(() => formatAssetIdentifier("USDC")).toThrow(/requires a valid issuer/i); + }); + + it("throws on malformed colon string identifier", () => { + expect(() => formatAssetIdentifier("USDC:")).toThrow(/invalid asset identifier/i); + expect(() => formatAssetIdentifier(":GBBD47")).toThrow(/invalid asset identifier/i); + expect(() => formatAssetIdentifier("USDC:ISSUER:EXTRA")).toThrow(/invalid asset identifier/i); + }); + }); +}); + +describe("parseAssetIdentifier", () => { + const testIssuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + it("parses 'native' into code XLM with null issuer", () => { + expect(parseAssetIdentifier("native")).toEqual({ code: "XLM", issuer: null }); + expect(parseAssetIdentifier("NATIVE")).toEqual({ code: "XLM", issuer: null }); + expect(parseAssetIdentifier("XLM")).toEqual({ code: "XLM", issuer: null }); + }); + + it("parses 'code:issuer' string into code and issuer components", () => { + expect(parseAssetIdentifier(`USDC:${testIssuer}`)).toEqual({ + code: "USDC", + issuer: testIssuer, + }); + }); + + it("throws on empty or non-string identifier", () => { + expect(() => parseAssetIdentifier("")).toThrow(/non-empty string/i); + expect(() => parseAssetIdentifier(null as any)).toThrow(/non-empty string/i); + }); + + it("throws on non-native string missing colon separator", () => { + expect(() => parseAssetIdentifier("USDC")).toThrow(/Expected ":"/i); + }); +});