diff --git a/apps/web/app/assets/page.tsx b/apps/web/app/assets/page.tsx new file mode 100644 index 0000000..7c37a29 --- /dev/null +++ b/apps/web/app/assets/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useState } from "react"; +import { PageShell } from "@/components/PageShell"; +import { Alert, Button, Card, Input, Label, Select } from "@/components/ui"; +import { + DEFAULT_TESTNET_REGISTRY, + checkAssetOnNetwork, + parseAssetString, + getNativeAsset, +} from "@anchorkit/stellar-kit"; +import type { StellarAsset, StellarNetwork } from "@anchorkit/types"; + +const NETWORKS: StellarNetwork[] = ["testnet", "mainnet", "futurenet"]; + +export default function AssetsPage() { + const [input, setInput] = useState("USDC:GC5HTWCIAUD72MGI7AHMJEF5ZJRKXS7II2PYVYOJEYKN4UYH6QTPCPZV"); + const [network, setNetwork] = useState("testnet"); + const [result, setResult] = useState | null>(null); + + const native = getNativeAsset(); + const registryAssets: StellarAsset[] = [ + native, + ...DEFAULT_TESTNET_REGISTRY.entries.map((e) => e.asset), + ]; + + function handleCheck() { + const parsed = parseAssetString(input); + if (!parsed.success) { + setResult({ + ok: false, + code: "ASSET_INVALID", + error: parsed.error.issues[0]?.message ?? "Invalid asset string", + }); + return; + } + setResult(checkAssetOnNetwork(parsed.data, network)); + } + + return ( + +
+ +

Check an asset

+
+
+ + setInput(e.target.value)} + placeholder="USDC:GC5H..." + /> +
+
+ + +
+ + + {result && !result.ok && ( + + {result.error} + + )} + {result && result.ok && ( + + Supported on {network}. + + )} +
+
+ + +

Registered assets (testnet MVP)

+
    + {registryAssets.map((a) => { + const key = a.type === "native" ? "XLM" : `${a.code}:${a.issuer}`; + const entry = DEFAULT_TESTNET_REGISTRY.byKey.get(key) ?? null; + return ( +
  • +
    {key}
    +
    + {a.type === "native" + ? "Native — supported on all networks." + : entry?.testnetOnly + ? "Testnet-only issued asset." + : "Issued asset."} + {entry?.note ? ` ${entry.note}` : ""} +
    +
  • + ); + })} +
+
+
+
+ ); +} diff --git a/docs/asset-registry.md b/docs/asset-registry.md new file mode 100644 index 0000000..4fd8f8f --- /dev/null +++ b/docs/asset-registry.md @@ -0,0 +1,81 @@ +# Network-aware asset registry (issue #23) + +AnchorKit provides a typed, network-aware asset registry on top of the shared +`StellarAsset` primitives. It distinguishes native XLM, issued assets, +testnet-only assets, and unsupported assets, and returns a typed +`ASSET_UNSUPPORTED` error for assets that are structurally valid but not +permitted on the target network. + +## Core types + +```ts +type AssetSupport = "supported" | "testnetOnly" | "unsupported"; + +interface RegistryEntry { + asset: StellarAsset; + networks: StellarNetwork[]; // networks where the asset is allowed + testnetOnly?: boolean; // demo/testnet issued asset + note?: string; +} + +interface AssetLookupResult { + asset: StellarAsset; + network: StellarNetwork; + support: AssetSupport; + entry: RegistryEntry | null; + error: { code: "ASSET_UNSUPPORTED"; message: string } | null; +} +``` + +## API + +- `createAssetRegistry(entries)` — build a registry (native XLM is always + supported implicitly; no need to list it). +- `lookupAsset(asset, network, registry?)` — returns the support state without + throwing. +- `validateAssetOnNetwork(asset, network, registry?)` — validates structure AND + network support; throws `ASSET_INVALID` or `ASSET_UNSUPPORTED`. +- `checkAssetOnNetwork(asset, network, registry?)` — safe variant returning + `{ ok: true, value } | { ok: false, code, error }`. +- `DEFAULT_TESTNET_REGISTRY` — MVP registry defaulting to safe testnet examples + (native XLM + a demo testnet USDC). + +## Behaviour + +| Asset | testnet | mainnet | futurenet | +| --- | --- | --- | --- | +| Native XLM | supported | supported | supported | +| Registered testnet USDC | supported | testnetOnly (error) | testnetOnly (error) | +| Unregistered issued asset | unsupported (error) | unsupported (error) | unsupported (error) | + +## Configuration + +The default MVP registry is testnet-first. For production, build your own +registry and pass it explicitly: + +```ts +import { createAssetRegistry, validateAssetOnNetwork } from "@anchorkit/stellar-kit"; + +const registry = createAssetRegistry([ + { + asset: { type: "issued", code: "USDC", issuer: "GA5ZSEJ..." }, + networks: ["mainnet", "testnet"], + }, +]); + +const asset = validateAssetOnNetwork(input, "mainnet", registry); +``` + +Example fixtures live in `examples/assets-registry.testnet.json`. + +## UI + +`apps/web/app/assets/page.tsx` lets you paste an asset string, pick a network, +and see the support state (including the `ASSET_UNSUPPORTED` message for +disallowed assets), plus the list of registered assets. + +## Notes + +- No secrets are involved — issuers are public Stellar accounts. +- The MVP ships only testnet demo assets by default; consumers supply + mainnet production lists via a custom registry. diff --git a/examples/assets-registry.testnet.json b/examples/assets-registry.testnet.json new file mode 100644 index 0000000..40c88fe --- /dev/null +++ b/examples/assets-registry.testnet.json @@ -0,0 +1,24 @@ +{ + "network": "testnet", + "entries": [ + { + "asset": { + "type": "native", + "code": "XLM", + "issuer": null + }, + "networks": ["testnet", "mainnet", "futurenet"], + "note": "Native lumens — supported on every network." + }, + { + "asset": { + "type": "issued", + "code": "USDC", + "issuer": "GC5HTWCIAUD72MGI7AHMJEF5ZJRKXS7II2PYVYOJEYKN4UYH6QTPCPZV" + }, + "networks": ["testnet"], + "testnetOnly": true, + "note": "Demo testnet USDC (issuer is a generated testnet account)." + } + ] +} diff --git a/packages/stellar-kit/src/assetRegistry.ts b/packages/stellar-kit/src/assetRegistry.ts new file mode 100644 index 0000000..47105f1 --- /dev/null +++ b/packages/stellar-kit/src/assetRegistry.ts @@ -0,0 +1,219 @@ +/** + * Network-aware Stellar asset registry and validation layer (issue #23). + * + * Sits on top of the existing `assets.ts` primitives and the shared + * `StellarAssetSchema`. The registry records which assets are known/supported + * on which networks, distinguishes testnet-only assets (e.g. demo issued + * assets) from mainnet assets, and returns a typed `ASSET_UNSUPPORTED` error + * for assets that are structurally valid but not permitted on the target + * network. + * + * The MVP defaults to safe testnet examples. + */ + +import type { StellarAsset, StellarNetwork } from "@anchorkit/types"; +import { + getNativeAsset, + isNativeAsset, + validateAsset, +} from "./assets"; +import { createStellarError } from "./errors"; + +/** How an asset is treated on a given network. */ +export type AssetSupport = + | "supported" + | "testnetOnly" + | "unsupported"; + +/** A single registry entry: an asset plus the networks it is allowed on. */ +export interface RegistryEntry { + asset: StellarAsset; + /** Networks where the asset is generally available. */ + networks: StellarNetwork[]; + /** When true, the asset is only meaningful on testnet (demo/issued asset). */ + testnetOnly?: boolean; + /** Human-readable note, surfaced in UI / diagnostics. */ + note?: string; +} + +/** A registry is just an indexed collection of entries, keyed by asset string. */ +export interface AssetRegistry { + entries: RegistryEntry[]; + /** Fast lookup by `assetToString`. */ + byKey: Map; +} + +/** The result of looking an asset up against the registry on a network. */ +export interface AssetLookupResult { + asset: StellarAsset; + network: StellarNetwork; + support: AssetSupport; + entry: RegistryEntry | null; + /** Present when support === "unsupported". */ + error: { code: "ASSET_UNSUPPORTED"; message: string } | null; +} + +/** Canonical key for an asset (reuses existing `assetToString` semantics). */ +function assetKey(asset: StellarAsset): string { + if (isNativeAsset(asset)) return "XLM"; + return `${asset.code}:${asset.issuer}`; +} + +/** + * Build a registry from a list of entries. Native XLM is always implicitly + * supported on every network, so it does not need to be listed. + */ +export function createAssetRegistry(entries: RegistryEntry[]): AssetRegistry { + const byKey = new Map(); + for (const entry of entries) { + byKey.set(assetKey(entry.asset), entry); + } + return { entries, byKey }; +} + +/** + * Look up an asset on a network. This does NOT validate the asset structure + * (use `validateAssetOnNetwork` for that). It reports the registry support + * state and returns a typed `ASSET_UNSUPPORTED` error when the asset is not + * permitted on the target network. + */ +export function lookupAsset( + asset: StellarAsset, + network: StellarNetwork, + registry: AssetRegistry = DEFAULT_TESTNET_REGISTRY +): AssetLookupResult { + // Native XLM is universally supported. + if (isNativeAsset(asset)) { + return { + asset, + network, + support: "supported", + entry: null, + error: null, + }; + } + + const entry = registry.byKey.get(assetKey(asset)) ?? null; + + if (!entry) { + return { + asset, + network, + support: "unsupported", + entry: null, + error: { + code: "ASSET_UNSUPPORTED", + message: `Asset ${asset.code}:${asset.issuer} is not in the asset registry for network "${network}".`, + }, + }; + } + + if (entry.testnetOnly && network !== "testnet") { + return { + asset, + network, + support: "testnetOnly", + entry, + error: { + code: "ASSET_UNSUPPORTED", + message: `Asset ${asset.code}:${asset.issuer} is testnet-only and not supported on "${network}".`, + }, + }; + } + + if (!entry.networks.includes(network)) { + return { + asset, + network, + support: "unsupported", + entry, + error: { + code: "ASSET_UNSUPPORTED", + message: `Asset ${asset.code}:${asset.issuer} is not supported on network "${network}".`, + }, + }; + } + + return { asset, network, support: "supported", entry, error: null }; +} + +/** + * Validate an asset's structure AND its registry support for a network. + * Throws `ASSET_INVALID` for structurally invalid assets, and + * `ASSET_UNSUPPORTED` for valid-but-disallowed assets. Returns the asset when + * both checks pass. + */ +export function validateAssetOnNetwork( + asset: unknown, + network: StellarNetwork, + registry: AssetRegistry = DEFAULT_TESTNET_REGISTRY +): StellarAsset { + const parsed = validateAsset(asset); + if (!parsed.success) { + const firstIssue = parsed.error.issues[0]; + throw createStellarError( + "ASSET_INVALID", + firstIssue?.message ?? "Invalid asset configuration" + ); + } + + const result = lookupAsset(parsed.data, network, registry); + if (result.support === "unsupported" || result.support === "testnetOnly") { + throw createStellarError( + "ASSET_UNSUPPORTED", + result.error?.message ?? "Asset is not supported on this network" + ); + } + + return parsed.data; +} + +/** + * Safe variant of `validateAssetOnNetwork` — never throws, returns a typed + * result so callers can branch without try/catch. + */ +export function checkAssetOnNetwork( + asset: unknown, + network: StellarNetwork, + registry: AssetRegistry = DEFAULT_TESTNET_REGISTRY +): { ok: true; value: StellarAsset } | { ok: false; code: "ASSET_INVALID" | "ASSET_UNSUPPORTED"; error: string } { + const parsed = validateAsset(asset); + if (!parsed.success) { + const firstIssue = parsed.error.issues[0]; + return { + ok: false, + code: "ASSET_INVALID", + error: firstIssue?.message ?? "Invalid asset configuration", + }; + } + + const result = lookupAsset(parsed.data, network, registry); + if (result.support === "unsupported" || result.support === "testnetOnly") { + return { + ok: false, + code: "ASSET_UNSUPPORTED", + error: result.error?.message ?? "Asset is not supported on this network", + }; + } + + return { ok: true, value: parsed.data }; +} + +/** + * Default MVP registry. Defaults to safe testnet examples — a couple of + * well-known testnet issued assets plus the implicit native XLM. Mainnet is + * intentionally empty in the MVP; consumers may supply their own registry for + * production asset lists. + */ +export const DEFAULT_TESTNET_REGISTRY: AssetRegistry = createAssetRegistry([ + { + asset: { + type: "issued", + code: "USDC", + issuer: "GC5HTWCIAUD72MGI7AHMJEF5ZJRKXS7II2PYVYOJEYKN4UYH6QTPCPZV", + } as StellarAsset, + networks: ["testnet"], + testnetOnly: true, + note: "Demo testnet USDC (issuer is a generated testnet account).", + }, +]); diff --git a/packages/stellar-kit/src/index.ts b/packages/stellar-kit/src/index.ts index 74901fc..d82628f 100644 --- a/packages/stellar-kit/src/index.ts +++ b/packages/stellar-kit/src/index.ts @@ -10,4 +10,5 @@ export * from "./logging"; export * from "./explorer"; export * from "./balances"; export * from "./diagnostics"; +export * from "./assetRegistry"; export type { StellarKeypair } from "@anchorkit/types"; diff --git a/packages/stellar-kit/test/assetRegistry.test.ts b/packages/stellar-kit/test/assetRegistry.test.ts new file mode 100644 index 0000000..95e45fe --- /dev/null +++ b/packages/stellar-kit/test/assetRegistry.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { + createAssetRegistry, + lookupAsset, + validateAssetOnNetwork, + checkAssetOnNetwork, + DEFAULT_TESTNET_REGISTRY, +} from "../src/assetRegistry"; +import { getNativeAsset, createIssuedAsset } from "../src/assets"; +import type { StellarAsset } from "@anchorkit/types"; + +const testnetUsdc: StellarAsset = createIssuedAsset( + "USDC", + "GC5HTWCIAUD72MGI7AHMJEF5ZJRKXS7II2PYVYOJEYKN4UYH6QTPCPZV" +); + +describe("assetRegistry — lookup", () => { + it("always supports native XLM on any network", () => { + const xlm = getNativeAsset(); + expect(lookupAsset(xlm, "mainnet").support).toBe("supported"); + expect(lookupAsset(xlm, "testnet").support).toBe("supported"); + }); + + it("supports a registered testnet asset on testnet", () => { + const r = lookupAsset(testnetUsdc, "testnet"); + expect(r.support).toBe("supported"); + expect(r.error).toBeNull(); + }); + + it("flags a testnet-only asset as testnetOnly on mainnet", () => { + const r = lookupAsset(testnetUsdc, "mainnet"); + expect(r.support).toBe("testnetOnly"); + expect(r.error?.code).toBe("ASSET_UNSUPPORTED"); + }); + + it("returns unsupported for an unregistered issued asset", () => { + const other = createIssuedAsset( + "XYZ", + "GABCABCABCABCABCABCABCABCABCABCABCABCABCABCABCABCABCABCA" + ); + const r = lookupAsset(other, "testnet"); + expect(r.support).toBe("unsupported"); + expect(r.error?.code).toBe("ASSET_UNSUPPORTED"); + }); +}); + +describe("assetRegistry — validation", () => { + it("validates a supported asset on its network", () => { + expect(() => validateAssetOnNetwork(testnetUsdc, "testnet")).not.toThrow(); + }); + + it("throws ASSET_UNSUPPORTED for testnet-only asset on mainnet", () => { + expect(() => validateAssetOnNetwork(testnetUsdc, "mainnet")).toThrow( + /testnet-only|not supported/i + ); + }); + + it("throws ASSET_INVALID for a structurally invalid asset", () => { + expect(() => + validateAssetOnNetwork({ type: "issued", code: "", issuer: "" }, "testnet") + ).toThrow(); + }); + + it("checkAssetOnNetwork returns a safe result (no throw)", () => { + const ok = checkAssetOnNetwork(testnetUsdc, "testnet"); + expect(ok.ok).toBe(true); + const bad = checkAssetOnNetwork(testnetUsdc, "mainnet"); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.code).toBe("ASSET_UNSUPPORTED"); + }); + + it("checkAssetOnNetwork reports ASSET_INVALID for bad structure", () => { + const res = checkAssetOnNetwork({ type: "bogus" }, "testnet"); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("ASSET_INVALID"); + }); +}); + +describe("assetRegistry — custom registry", () => { + it("honors a user-supplied registry with mainnet assets", () => { + const mainnetUsdc = createIssuedAsset( + "USDC", + "GA5ZSEJYB4J7FEWIOISDVX2ENQ3FAWQFS2ITYMYCU5Q3XTPTVNNROQZP" + ); + const reg = createAssetRegistry([ + { asset: mainnetUsdc, networks: ["mainnet", "testnet"] }, + ]); + expect(lookupAsset(mainnetUsdc, "mainnet", reg).support).toBe("supported"); + expect(lookupAsset(mainnetUsdc, "testnet", reg).support).toBe("supported"); + }); + + it("DEFAULT_TESTNET_REGISTRY contains the demo USDC", () => { + expect(DEFAULT_TESTNET_REGISTRY.entries.length).toBe(1); + expect(DEFAULT_TESTNET_REGISTRY.entries[0].testnetOnly).toBe(true); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 002f7aa..64e084c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -292,6 +292,7 @@ export type StellarErrorCode = | "ACCOUNT_NOT_FOUND" | "ACCOUNT_MALFORMED" | "ASSET_INVALID" + | "ASSET_UNSUPPORTED" | "AMOUNT_INVALID" | "MEMO_INVALID" | "TRANSACTION_HASH_INVALID"