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
143 changes: 143 additions & 0 deletions src/utils/stellar.ts
Original file line number Diff line number Diff line change
@@ -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:<issuer>) 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 `"<code>:<issuer>"`.
*
* @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 <code>:<issuer> 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 "<code>:<issuer>"`);
}

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 };
}
110 changes: 110 additions & 0 deletions tests/stellar-asset-formatting.test.ts
Original file line number Diff line number Diff line change
@@ -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 "<code>:<issuer>"/i);
});
});
Loading