diff --git a/apps/web/app/anchors/page.tsx b/apps/web/app/anchors/page.tsx index 45b729c..65ddc4d 100644 --- a/apps/web/app/anchors/page.tsx +++ b/apps/web/app/anchors/page.tsx @@ -13,6 +13,7 @@ import { Select, ValidationStateAlert, } from "@/components/ui"; +import { CapabilityMatrixCard } from "@/components/CapabilityMatrixCard"; import { anchorStatusToUserMessage, anchorValidationUiState, @@ -31,6 +32,7 @@ import { transition, ALLOWED_TRANSITIONS, } from "@anchorkit/anchor-utils"; +import { DEFAULT_ANCHOR_CAPABILITY_MATRIX } from "@anchorkit/config"; import { anchorRecordToReceipt } from "@anchorkit/stellar-kit"; import { validateCallbackUrl } from "@anchorkit/validators"; import type { ValidationResult } from "@anchorkit/validators"; @@ -544,6 +546,18 @@ export default function AnchorsPage() { )} + + {/* ── Capability matrix (issue #54) ───────────────────────────────── */} +
+

Anchor capability matrix

+

+ The matrix below describes which payment rails and assets are supported by the mock + anchor integration, and whether deposit and withdrawal flows are available for each. + Experimental and disabled behaviours are called out explicitly so contributors can avoid + building unsupported flows. +

+ +
); } diff --git a/apps/web/components/CapabilityMatrixCard.tsx b/apps/web/components/CapabilityMatrixCard.tsx new file mode 100644 index 0000000..28c64eb --- /dev/null +++ b/apps/web/components/CapabilityMatrixCard.tsx @@ -0,0 +1,254 @@ +"use client"; + +import clsx from "clsx"; +import type { + AnchorCapabilityMatrix, + AnchorRailCapability, + AnchorAssetCapability, + AnchorRailCapabilityState, +} from "@anchorkit/types"; +import { Card, CapabilityBadge } from "@/components/ui"; + +// ─── State badge ────────────────────────────────────────────────────────────── + +/** + * Renders a small pill badge for `AnchorRailCapabilityState`. Reuses the same + * colour semantics as `CapabilityBadge` but accepts the extended rail state + * union (which includes `"unsupported"`). + */ +function RailStateBadge({ state }: { state: AnchorRailCapabilityState }) { + // Map the extended rail states onto the base CapabilityState where possible + const mapped = state === "unsupported" ? "unavailable" : state; + return ; +} + +// ─── Rail row ───────────────────────────────────────────────────────────────── + +function RailRow({ rail }: { rail: AnchorRailCapability }) { + return ( +
  • +
    + {rail.name} + +
    +
    + + Deposit:{" "} + + {rail.depositSupported ? "✓" : "✗"} + + + + Withdrawal:{" "} + + {rail.withdrawalSupported ? "✓" : "✗"} + + + Currencies: {rail.currencies.join(", ")} + Countries: {rail.countries.join(", ")} +
    + {rail.note && ( +

    {rail.note}

    + )} +
  • + ); +} + +// ─── Asset row ──────────────────────────────────────────────────────────────── + +function AssetRow({ asset }: { asset: AnchorAssetCapability }) { + return ( +
  • +
    + {asset.code} + + {asset.enabled ? "enabled" : "disabled"} + +
    +
    + + Deposit:{" "} + + {asset.depositEnabled ? "✓" : "✗"} + + + + Withdrawal:{" "} + + {asset.withdrawalEnabled ? "✓" : "✗"} + + + {asset.feeFixed && Fee (fixed): {asset.feeFixed}} + {asset.feePercent && Fee (%): {asset.feePercent}%} +
    + {asset.note && ( +

    {asset.note}

    + )} +
  • + ); +} + +// ─── Behaviours section ──────────────────────────────────────────────────────── + +function BehavioursSection({ + title, + behaviours, + tone, +}: { + title: string; + behaviours: Record; + tone: "amber" | "red"; +}) { + const entries = Object.entries(behaviours); + if (entries.length === 0) return null; + + const classes = + tone === "amber" + ? "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300" + : "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300"; + + return ( +
    +

    {title}

    +
      + {entries.map(([key, desc]) => ( +
    • + {key}: {desc} +
    • + ))} +
    +
    + ); +} + +// ─── Main component ──────────────────────────────────────────────────────────── + +/** + * Renders the full anchor capability matrix as a dashboard card. Displays the + * overall anchor state, all rail capabilities, all asset capabilities, and any + * experimental/disabled behaviour notes. + * + * Used on the Anchors page to give contributors a single-glance view of what + * the anchor integration supports. + */ +export function CapabilityMatrixCard({ + matrix, +}: { + matrix: AnchorCapabilityMatrix; +}) { + return ( + + {/* Header */} +
    +
    +

    {matrix.anchorName}

    + {matrix.isMock && ( +

    + Mock anchor — no real transactions are submitted +

    + )} +
    +
    + +
    +
    + + {/* Flow states */} +
    +
    + Deposit flow: + +
    +
    + Withdrawal flow: + +
    +
    + + {/* Payment rails */} +
    +

    + Payment rails ({matrix.rails.length}) +

    +
      + {matrix.rails.map((rail) => ( + + ))} +
    +
    + + {/* Supported assets */} +
    +

    + Supported assets ({matrix.assets.length}) +

    +
      + {matrix.assets.map((asset) => ( + + ))} +
    +
    + + {/* Experimental behaviours */} + {matrix.experimentalBehaviours && + Object.keys(matrix.experimentalBehaviours).length > 0 && ( + + )} + + {/* Disabled behaviours */} + {matrix.disabledBehaviours && + Object.keys(matrix.disabledBehaviours).length > 0 && ( + + )} + + {/* Docs link */} + {matrix.docsHref && ( + + Anchor capability docs → + + )} +
    + ); +} diff --git a/apps/web/test/capability-matrix.test.ts b/apps/web/test/capability-matrix.test.ts new file mode 100644 index 0000000..8cb5c92 --- /dev/null +++ b/apps/web/test/capability-matrix.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for the anchor capability matrix integration in the web dashboard (issue #54). + * + * Verifies that `DEFAULT_ANCHOR_CAPABILITY_MATRIX` is structurally valid and + * that the `@anchorkit/anchor-utils` parse helpers accept it, without + * importing React or mounting the component (Next.js RSC compatibility). + */ + +import { describe, it, expect } from "vitest"; +import { DEFAULT_ANCHOR_CAPABILITY_MATRIX } from "@anchorkit/config"; +import { + isRailDepositReady, + isRailWithdrawalReady, +} from "@anchorkit/config"; +import { isAnchorCapabilityMatrixValid } from "@anchorkit/anchor-utils"; +import { ANCHOR_RAIL_CAPABILITY_STATES } from "@anchorkit/types"; + +describe("DEFAULT_ANCHOR_CAPABILITY_MATRIX (web integration)", () => { + it("is a valid AnchorCapabilityMatrix according to the validator", () => { + expect(isAnchorCapabilityMatrixValid(DEFAULT_ANCHOR_CAPABILITY_MATRIX)).toBe(true); + }); + + it("exposes SEPA as deposit-ready", () => { + expect(isRailDepositReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "SEPA")).toBe(true); + }); + + it("exposes ACH as withdrawal-ready", () => { + expect(isRailWithdrawalReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "ACH")).toBe(true); + }); + + it("marks CARD as not deposit-ready (unavailable)", () => { + expect(isRailDepositReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "CARD")).toBe(false); + }); + + it("marks WIRE as not withdrawal-ready (experimental, withdrawal unsupported)", () => { + expect(isRailWithdrawalReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "WIRE")).toBe(false); + }); + + it("all rail states are valid ANCHOR_RAIL_CAPABILITY_STATES", () => { + for (const rail of DEFAULT_ANCHOR_CAPABILITY_MATRIX.rails) { + expect(ANCHOR_RAIL_CAPABILITY_STATES).toContain(rail.state); + } + }); + + it("at least one asset is deposit-enabled", () => { + const depositEnabled = DEFAULT_ANCHOR_CAPABILITY_MATRIX.assets.filter( + (a) => a.enabled && a.depositEnabled + ); + expect(depositEnabled.length).toBeGreaterThan(0); + }); + + it("at least one asset is withdrawal-enabled", () => { + const withdrawalEnabled = DEFAULT_ANCHOR_CAPABILITY_MATRIX.assets.filter( + (a) => a.enabled && a.withdrawalEnabled + ); + expect(withdrawalEnabled.length).toBeGreaterThan(0); + }); +}); diff --git a/docs/ANCHOR_RAILS_CAPABILITY_MATRIX.md b/docs/ANCHOR_RAILS_CAPABILITY_MATRIX.md new file mode 100644 index 0000000..eda3433 --- /dev/null +++ b/docs/ANCHOR_RAILS_CAPABILITY_MATRIX.md @@ -0,0 +1,172 @@ +# Anchor Rails Capability Matrix + +This document describes the typed capability matrix for anchor rails, supported assets, deposit flows, withdrawal flows, and disabled or experimental behaviours. + +## Overview + +The capability matrix gives contributors a single-source-of-truth model for what the mock anchor supports. It prevents building unsupported flows by making the following explicit: + +- Which **payment rails** exist and whether each is `mock`, `experimental`, `unavailable`, or `unsupported`. +- Which **assets** are enabled and whether **deposit** and **withdrawal** are supported per asset. +- Which **behaviours** are explicitly **experimental** or **disabled**, with a human-readable reason. + +The matrix is rendered as a card on the **Anchors** dashboard page (`/anchors`). + +--- + +## Types + +All types are exported from `@anchorkit/types`. + +### `AnchorRailCapabilityState` + +Extends the base `CapabilityState` with one additional value: + +| Value | Meaning | +| :--- | :--- | +| `implemented` | Fully implemented and tested | +| `mock` | UI/data shape exists; backed by local state, not a real integration | +| `testnet-only` | Works against Stellar testnet; not mainnet | +| `experimental` | Preview code; may change or be disabled without notice | +| `unavailable` | Not built; card is disabled | +| `unsupported` | Structurally known but explicitly not offered by this anchor | + +### `AnchorRailCapability` + +```ts +interface AnchorRailCapability { + railId: string; // stable id, e.g. "SEPA", "ACH" + name: string; // display name + state: AnchorRailCapabilityState; + depositSupported: boolean; + withdrawalSupported: boolean; + currencies: string[]; // ISO 4217 codes + countries: string[]; // ISO 3166-1 alpha-2 codes + note?: string; // explains non-implemented state +} +``` + +### `AnchorAssetCapability` + +```ts +interface AnchorAssetCapability { + code: string; // e.g. "USDC", "XLM" + issuer: string | null; // null for native XLM + enabled: boolean; + depositEnabled: boolean; + withdrawalEnabled: boolean; + depositMinAmount?: string; + depositMaxAmount?: string; + withdrawalMinAmount?: string; + withdrawalMaxAmount?: string; + feeFixed?: string; + feePercent?: string; + note?: string; +} +``` + +### `AnchorCapabilityMatrix` + +```ts +interface AnchorCapabilityMatrix { + anchorName: string; + overallState: AnchorRailCapabilityState; + isMock: boolean; + depositState: AnchorRailCapabilityState; + withdrawalState: AnchorRailCapabilityState; + rails: AnchorRailCapability[]; + assets: AnchorAssetCapability[]; + experimentalBehaviours?: Record; + disabledBehaviours?: Record; + docsHref?: string; +} +``` + +--- + +## Default Capability Matrix + +The default matrix (`DEFAULT_ANCHOR_CAPABILITY_MATRIX`) is exported from `@anchorkit/config` and used by the web dashboard's Anchors page. + +### Payment rails + +| Rail | State | Deposit | Withdrawal | Currencies | Countries | +| :--- | :--- | :--- | :--- | :--- | :--- | +| SEPA | `mock` | ✓ | ✓ | EUR | DE, FR, ES, IT, NL, BE, AT, PT, IE, FI | +| ACH | `mock` | ✓ | ✓ | USD | US | +| WIRE | `experimental` | ✓ | ✗ | USD, EUR, GBP | US, GB, DE, FR | +| CARD | `unavailable` | ✗ | ✗ | USD, EUR | US, DE | + +### Supported assets + +| Asset | Deposit | Withdrawal | Notes | +| :--- | :--- | :--- | :--- | +| XLM (native) | ✓ | ✓ | Fee: 1.5 fixed + 0.1% | +| USDC | ✓ | ✓ | Fee: 0.50 fixed + 0.2% | +| EURC | ✓ | ✗ | Withdrawal experimental, currently disabled | + +### Disabled behaviours + +| Key | Reason | +| :--- | :--- | +| `card_payments` | Card payment rails are not available in this release | +| `eurc_withdrawal` | EURC withdrawal is not yet supported | +| `wire_withdrawal` | International wire withdrawal is not yet supported | + +--- + +## Where the code lives + +| Purpose | Location | +| :--- | :--- | +| Type definitions | `packages/types/src/railCapability.ts` | +| Zod schemas | `packages/validators/src/schemas/railCapability.ts` | +| Default matrix & query helpers | `packages/config/src/railConfig.ts` | +| Parse/validate utilities | `packages/anchor-utils/src/railCapability.ts` | +| Fixtures | `packages/fixtures/src/railCapability.ts` | +| Dashboard card component | `apps/web/components/CapabilityMatrixCard.tsx` | +| Dashboard page integration | `apps/web/app/anchors/page.tsx` | + +--- + +## Query helpers (`@anchorkit/config`) + +```ts +// Filter rails by state +getRailsByState(matrix, "mock") // → AnchorRailCapability[] + +// Filter assets by flow support +getDepositEnabledAssets(matrix) // → AnchorAssetCapability[] +getWithdrawalEnabledAssets(matrix) // → AnchorAssetCapability[] + +// Lookup by ID / code +findRailById(matrix, "SEPA") // → AnchorRailCapability | undefined +findAssetByCode(matrix, "USDC") // → AnchorAssetCapability | undefined + +// Readiness checks +isRailDepositReady(matrix, "SEPA") // → boolean +isRailWithdrawalReady(matrix, "ACH") // → boolean +``` + +## Validate helpers (`@anchorkit/anchor-utils`) + +```ts +parseAnchorCapabilityMatrix(input) // → SafeParseReturnType (never throws) +isAnchorCapabilityMatrixValid(input) // → boolean +parseAnchorRailCapability(input) // → SafeParseReturnType +isAnchorRailCapabilityValid(input) // → boolean +parseAnchorAssetCapability(input) // → SafeParseReturnType +isAnchorAssetCapabilityValid(input) // → boolean +``` + +--- + +## Extending the matrix + +To add a new rail or asset: + +1. Add a `AnchorRailCapability` or `AnchorAssetCapability` entry to `DEFAULT_ANCHOR_CAPABILITY_MATRIX` in `packages/config/src/railConfig.ts`. +2. If the state is `experimental` or `unavailable`, add a matching entry to `experimentalBehaviours` or `disabledBehaviours`. +3. Add a fixture in `packages/fixtures/src/railCapability.ts` if needed for testing. +4. Update the table in this document. +5. Run `pnpm verify` to confirm all tests and types pass. diff --git a/packages/anchor-utils/src/capabilities.ts b/packages/anchor-utils/src/capabilities.ts index 9852d8e..4316287 100644 --- a/packages/anchor-utils/src/capabilities.ts +++ b/packages/anchor-utils/src/capabilities.ts @@ -40,6 +40,12 @@ export const ANCHOR_UTILS_CAPABILITIES: PackageCapability = { state: "implemented", description: "Pre-built arrays representing successful deposits and failed/refunded withdrawals.", }, + { + id: "rail-capability", + label: "Rail Capability Matrix Validators", + state: "implemented", + description: "Parse and validate AnchorCapabilityMatrix, AnchorRailCapability, and AnchorAssetCapability objects with typed Zod-backed helpers.", + }, ], docsHref: "/docs#anchor-utils", }; diff --git a/packages/anchor-utils/src/index.ts b/packages/anchor-utils/src/index.ts index 139c21a..ed50322 100644 --- a/packages/anchor-utils/src/index.ts +++ b/packages/anchor-utils/src/index.ts @@ -353,3 +353,6 @@ export function anchorValidationUiState( export * from "./capabilities"; +// ─── Anchor rails capability matrix (issue #54) ────────────────────────────── +export * from "./railCapability"; + diff --git a/packages/anchor-utils/src/railCapability.ts b/packages/anchor-utils/src/railCapability.ts new file mode 100644 index 0000000..71c49d9 --- /dev/null +++ b/packages/anchor-utils/src/railCapability.ts @@ -0,0 +1,79 @@ +/** + * Anchor capability matrix utilities (issue #54). + * + * Provides parse/validate wrappers and convenience helpers for working with + * `AnchorCapabilityMatrix`, `AnchorRailCapability`, and `AnchorAssetCapability` + * from within `@anchorkit/anchor-utils`. + */ + +import type { + AnchorCapabilityMatrix, + AnchorRailCapability, + AnchorAssetCapability, +} from "@anchorkit/types"; +import { + AnchorCapabilityMatrixSchema, + AnchorRailCapabilitySchema, + AnchorAssetCapabilitySchema, +} from "@anchorkit/validators"; +import type { SafeParseReturnType } from "zod"; + +// ─── Parse helpers ──────────────────────────────────────────────────────────── + +/** + * Safely parse and validate an `AnchorCapabilityMatrix` object. + * Returns a Zod `SafeParseReturnType` — never throws. + */ +export function parseAnchorCapabilityMatrix( + input: unknown +): SafeParseReturnType { + return AnchorCapabilityMatrixSchema.safeParse(input) as SafeParseReturnType< + unknown, + AnchorCapabilityMatrix + >; +} + +/** + * Returns `true` when the input is a structurally valid `AnchorCapabilityMatrix`. + */ +export function isAnchorCapabilityMatrixValid(input: unknown): boolean { + return parseAnchorCapabilityMatrix(input).success; +} + +/** + * Safely parse and validate an `AnchorRailCapability` object. + */ +export function parseAnchorRailCapability( + input: unknown +): SafeParseReturnType { + return AnchorRailCapabilitySchema.safeParse(input) as SafeParseReturnType< + unknown, + AnchorRailCapability + >; +} + +/** + * Returns `true` when the input is a structurally valid `AnchorRailCapability`. + */ +export function isAnchorRailCapabilityValid(input: unknown): boolean { + return parseAnchorRailCapability(input).success; +} + +/** + * Safely parse and validate an `AnchorAssetCapability` object. + */ +export function parseAnchorAssetCapability( + input: unknown +): SafeParseReturnType { + return AnchorAssetCapabilitySchema.safeParse(input) as SafeParseReturnType< + unknown, + AnchorAssetCapability + >; +} + +/** + * Returns `true` when the input is a structurally valid `AnchorAssetCapability`. + */ +export function isAnchorAssetCapabilityValid(input: unknown): boolean { + return parseAnchorAssetCapability(input).success; +} diff --git a/packages/anchor-utils/test/railCapability.test.ts b/packages/anchor-utils/test/railCapability.test.ts new file mode 100644 index 0000000..186a0c8 --- /dev/null +++ b/packages/anchor-utils/test/railCapability.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for anchor-utils rail capability parse/validate helpers (issue #54). + */ + +import { describe, it, expect } from "vitest"; +import { + parseAnchorCapabilityMatrix, + isAnchorCapabilityMatrixValid, + parseAnchorRailCapability, + isAnchorRailCapabilityValid, + parseAnchorAssetCapability, + isAnchorAssetCapabilityValid, +} from "../src/railCapability"; + +// ─── Shared valid stubs ─────────────────────────────────────────────────────── + +const validRail = { + railId: "SEPA", + name: "SEPA Credit Transfer", + state: "mock", + depositSupported: true, + withdrawalSupported: true, + currencies: ["EUR"], + countries: ["DE"], +}; + +const validAsset = { + code: "XLM", + issuer: null, + enabled: true, + depositEnabled: true, + withdrawalEnabled: true, +}; + +const validMatrix = { + anchorName: "Test Anchor", + overallState: "mock", + isMock: true, + depositState: "mock", + withdrawalState: "mock", + rails: [validRail], + assets: [validAsset], +}; + +// ─── parseAnchorCapabilityMatrix ────────────────────────────────────────────── + +describe("parseAnchorCapabilityMatrix", () => { + it("returns success for a valid matrix", () => { + const result = parseAnchorCapabilityMatrix(validMatrix); + expect(result.success).toBe(true); + }); + + it("returns failure for a matrix missing anchorName", () => { + const { anchorName: _, ...bad } = validMatrix; + const result = parseAnchorCapabilityMatrix(bad); + expect(result.success).toBe(false); + }); + + it("returns failure for null input", () => { + expect(parseAnchorCapabilityMatrix(null).success).toBe(false); + }); + + it("never throws on invalid input", () => { + expect(() => parseAnchorCapabilityMatrix(undefined)).not.toThrow(); + expect(() => parseAnchorCapabilityMatrix("bad")).not.toThrow(); + expect(() => parseAnchorCapabilityMatrix(42)).not.toThrow(); + }); +}); + +describe("isAnchorCapabilityMatrixValid", () => { + it("returns true for a valid matrix", () => { + expect(isAnchorCapabilityMatrixValid(validMatrix)).toBe(true); + }); + + it("returns false for an empty object", () => { + expect(isAnchorCapabilityMatrixValid({})).toBe(false); + }); + + it("returns false for null", () => { + expect(isAnchorCapabilityMatrixValid(null)).toBe(false); + }); +}); + +// ─── parseAnchorRailCapability ──────────────────────────────────────────────── + +describe("parseAnchorRailCapability", () => { + it("returns success for a valid rail", () => { + expect(parseAnchorRailCapability(validRail).success).toBe(true); + }); + + it("returns failure for a rail with empty railId", () => { + const bad = { ...validRail, railId: "" }; + expect(parseAnchorRailCapability(bad).success).toBe(false); + }); + + it("returns failure for a rail with invalid state", () => { + const bad = { ...validRail, state: "not-a-state" }; + expect(parseAnchorRailCapability(bad).success).toBe(false); + }); + + it("never throws", () => { + expect(() => parseAnchorRailCapability(null)).not.toThrow(); + }); +}); + +describe("isAnchorRailCapabilityValid", () => { + it("returns true for a valid rail", () => { + expect(isAnchorRailCapabilityValid(validRail)).toBe(true); + }); + + it("returns false for an invalid rail", () => { + expect(isAnchorRailCapabilityValid({ railId: "X" })).toBe(false); + }); +}); + +// ─── parseAnchorAssetCapability ─────────────────────────────────────────────── + +describe("parseAnchorAssetCapability", () => { + it("returns success for a valid native asset", () => { + expect(parseAnchorAssetCapability(validAsset).success).toBe(true); + }); + + it("returns success for a valid issued asset", () => { + const issued = { + ...validAsset, + code: "USDC", + issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }; + expect(parseAnchorAssetCapability(issued).success).toBe(true); + }); + + it("returns failure for an asset with an empty code", () => { + const bad = { ...validAsset, code: "" }; + expect(parseAnchorAssetCapability(bad).success).toBe(false); + }); + + it("never throws", () => { + expect(() => parseAnchorAssetCapability(undefined)).not.toThrow(); + }); +}); + +describe("isAnchorAssetCapabilityValid", () => { + it("returns true for a valid asset", () => { + expect(isAnchorAssetCapabilityValid(validAsset)).toBe(true); + }); + + it("returns false for an empty object", () => { + expect(isAnchorAssetCapabilityValid({})).toBe(false); + }); +}); diff --git a/packages/config/src/capabilities.ts b/packages/config/src/capabilities.ts index 7cc7404..19f9443 100644 --- a/packages/config/src/capabilities.ts +++ b/packages/config/src/capabilities.ts @@ -70,6 +70,12 @@ export const CONFIG_PACKAGE_CAPABILITIES: PackageCapability = { state: "implemented", description: "Merge default settings with explicit configuration overrides and resolve metadata sources.", }, + { + id: "rail-config", + label: "Anchor Rails Configuration", + state: "implemented", + description: "Default anchor capability matrix with rail definitions, asset configurations, and query helpers for deposit/withdrawal readiness.", + }, ], docsHref: "/docs#config", }; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 3d57a53..c23c128 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -274,3 +274,6 @@ export function resolveConfigSourceMetadata( // ─── Module capabilities ──────────────────────────────────────────────────── export * from "./capabilities"; + +// ─── Anchor rails configuration and capability matrix (issue #54) ──────────── +export * from "./railConfig"; diff --git a/packages/config/src/railConfig.ts b/packages/config/src/railConfig.ts new file mode 100644 index 0000000..b3fb186 --- /dev/null +++ b/packages/config/src/railConfig.ts @@ -0,0 +1,222 @@ +/** + * Default anchor rails configuration and capability matrix (issue #54). + * + * Provides the default mock anchor capability matrix used by the web + * dashboard's Anchors page. Also exports helper functions for querying + * rail and asset capabilities. + */ + +import type { + AnchorCapabilityMatrix, + AnchorRailCapability, + AnchorAssetCapability, + AnchorRailCapabilityState, +} from "@anchorkit/types"; + +// ─── Default rail definitions ──────────────────────────────────────────────── + +/** SEPA Credit Transfer (EUR, Europe). */ +const SEPA_RAIL: AnchorRailCapability = { + railId: "SEPA", + name: "SEPA Credit Transfer", + state: "mock", + depositSupported: true, + withdrawalSupported: true, + currencies: ["EUR"], + countries: ["DE", "FR", "ES", "IT", "NL", "BE", "AT", "PT", "IE", "FI"], + note: "Mock implementation only. No real SEPA transfers are initiated.", +}; + +/** ACH (USD, United States). */ +const ACH_RAIL: AnchorRailCapability = { + railId: "ACH", + name: "ACH Bank Transfer", + state: "mock", + depositSupported: true, + withdrawalSupported: true, + currencies: ["USD"], + countries: ["US"], + note: "Mock implementation only. No real ACH transfers are initiated.", +}; + +/** WIRE transfer (multi-currency, global). */ +const WIRE_RAIL: AnchorRailCapability = { + railId: "WIRE", + name: "International Wire Transfer", + state: "experimental", + depositSupported: true, + withdrawalSupported: false, + currencies: ["USD", "EUR", "GBP"], + countries: ["US", "GB", "DE", "FR"], + note: "Experimental — deposit-only. Withdrawal via wire is not yet supported.", +}; + +/** Card payment (disabled in MVP). */ +const CARD_RAIL: AnchorRailCapability = { + railId: "CARD", + name: "Card Payment", + state: "unavailable", + depositSupported: false, + withdrawalSupported: false, + currencies: ["USD", "EUR"], + countries: ["US", "DE"], + note: "Card rails are not available in this release.", +}; + +// ─── Default asset definitions ──────────────────────────────────────────────── + +/** XLM (native Stellar asset). */ +const XLM_ASSET: AnchorAssetCapability = { + code: "XLM", + issuer: null, + enabled: true, + depositEnabled: true, + withdrawalEnabled: true, + depositMinAmount: "10.0000000", + depositMaxAmount: "100000.0000000", + withdrawalMinAmount: "10.0000000", + withdrawalMaxAmount: "100000.0000000", + feeFixed: "1.5000000", + feePercent: "0.1", +}; + +/** USDC (Circle USD Coin on Stellar testnet). */ +const USDC_ASSET: AnchorAssetCapability = { + code: "USDC", + issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + enabled: true, + depositEnabled: true, + withdrawalEnabled: true, + depositMinAmount: "5.00", + depositMaxAmount: "50000.00", + withdrawalMinAmount: "5.00", + withdrawalMaxAmount: "50000.00", + feeFixed: "0.50", + feePercent: "0.2", +}; + +/** EURC (experimental, deposit-only for now). */ +const EURC_ASSET: AnchorAssetCapability = { + code: "EURC", + issuer: "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP", + enabled: true, + depositEnabled: true, + withdrawalEnabled: false, + depositMinAmount: "5.00", + depositMaxAmount: "25000.00", + note: "EURC withdrawal is experimental and currently disabled.", +}; + +// ─── Default capability matrix ──────────────────────────────────────────────── + +/** + * Default mock anchor capability matrix. + * + * Represents the mock anchor used by the AnchorKit web dashboard's Anchors + * page. All flows are backed by local state — no real anchor server is called. + */ +export const DEFAULT_ANCHOR_CAPABILITY_MATRIX: AnchorCapabilityMatrix = { + anchorName: "Mock Anchor (AnchorKit Demo)", + overallState: "mock", + isMock: true, + depositState: "mock", + withdrawalState: "mock", + rails: [SEPA_RAIL, ACH_RAIL, WIRE_RAIL, CARD_RAIL], + assets: [XLM_ASSET, USDC_ASSET, EURC_ASSET], + experimentalBehaviours: { + wire_deposit: "International wire deposits are available experimentally for USD, EUR, GBP.", + eurc_deposit: "EURC deposits are available experimentally. Withdrawals are not yet enabled.", + }, + disabledBehaviours: { + card_payments: "Card payment rails are not available in this release.", + eurc_withdrawal: "EURC withdrawal is not yet supported.", + wire_withdrawal: "International wire withdrawal is not yet supported.", + }, + docsHref: "/docs#anchors", +}; + +// ─── Query helpers ──────────────────────────────────────────────────────────── + +/** + * Return only the rails in the matrix that match the given state. + */ +export function getRailsByState( + matrix: AnchorCapabilityMatrix, + state: AnchorRailCapabilityState +): AnchorRailCapability[] { + return matrix.rails.filter((r) => r.state === state); +} + +/** + * Return only the assets that have deposit enabled. + */ +export function getDepositEnabledAssets( + matrix: AnchorCapabilityMatrix +): AnchorAssetCapability[] { + return matrix.assets.filter((a) => a.enabled && a.depositEnabled); +} + +/** + * Return only the assets that have withdrawal enabled. + */ +export function getWithdrawalEnabledAssets( + matrix: AnchorCapabilityMatrix +): AnchorAssetCapability[] { + return matrix.assets.filter((a) => a.enabled && a.withdrawalEnabled); +} + +/** + * Find a specific rail by its stable `railId`. + * Returns `undefined` if no matching rail is found. + */ +export function findRailById( + matrix: AnchorCapabilityMatrix, + railId: string +): AnchorRailCapability | undefined { + return matrix.rails.find((r) => r.railId === railId); +} + +/** + * Find a specific asset by its code (case-insensitive). + * Returns `undefined` if no matching asset is found. + */ +export function findAssetByCode( + matrix: AnchorCapabilityMatrix, + code: string +): AnchorAssetCapability | undefined { + return matrix.assets.find((a) => a.code.toUpperCase() === code.toUpperCase()); +} + +/** + * Returns `true` if the given rail supports deposit flows and is not + * `unavailable` or `unsupported`. + */ +export function isRailDepositReady( + matrix: AnchorCapabilityMatrix, + railId: string +): boolean { + const rail = findRailById(matrix, railId); + if (!rail) return false; + return ( + rail.depositSupported && + rail.state !== "unavailable" && + rail.state !== "unsupported" + ); +} + +/** + * Returns `true` if the given rail supports withdrawal flows and is not + * `unavailable` or `unsupported`. + */ +export function isRailWithdrawalReady( + matrix: AnchorCapabilityMatrix, + railId: string +): boolean { + const rail = findRailById(matrix, railId); + if (!rail) return false; + return ( + rail.withdrawalSupported && + rail.state !== "unavailable" && + rail.state !== "unsupported" + ); +} diff --git a/packages/config/test/railConfig.test.ts b/packages/config/test/railConfig.test.ts new file mode 100644 index 0000000..0d188b4 --- /dev/null +++ b/packages/config/test/railConfig.test.ts @@ -0,0 +1,162 @@ +/** + * Tests for anchor rail configuration and capability matrix helpers (issue #54). + */ + +import { describe, it, expect } from "vitest"; +import { + DEFAULT_ANCHOR_CAPABILITY_MATRIX, + getRailsByState, + getDepositEnabledAssets, + getWithdrawalEnabledAssets, + findRailById, + findAssetByCode, + isRailDepositReady, + isRailWithdrawalReady, +} from "../src/railConfig"; +import { ANCHOR_RAIL_CAPABILITY_STATES } from "@anchorkit/types"; + +describe("DEFAULT_ANCHOR_CAPABILITY_MATRIX", () => { + it("has a non-empty anchorName", () => { + expect(DEFAULT_ANCHOR_CAPABILITY_MATRIX.anchorName.trim().length).toBeGreaterThan(0); + }); + + it("has a valid overallState", () => { + expect(ANCHOR_RAIL_CAPABILITY_STATES).toContain( + DEFAULT_ANCHOR_CAPABILITY_MATRIX.overallState + ); + }); + + it("has at least one rail", () => { + expect(DEFAULT_ANCHOR_CAPABILITY_MATRIX.rails.length).toBeGreaterThan(0); + }); + + it("has at least one asset", () => { + expect(DEFAULT_ANCHOR_CAPABILITY_MATRIX.assets.length).toBeGreaterThan(0); + }); + + it("marks isMock as true", () => { + expect(DEFAULT_ANCHOR_CAPABILITY_MATRIX.isMock).toBe(true); + }); + + it("every rail has a valid state", () => { + for (const rail of DEFAULT_ANCHOR_CAPABILITY_MATRIX.rails) { + expect(ANCHOR_RAIL_CAPABILITY_STATES).toContain(rail.state); + } + }); + + it("every rail has a non-empty currencies and countries list", () => { + for (const rail of DEFAULT_ANCHOR_CAPABILITY_MATRIX.rails) { + expect(rail.currencies.length).toBeGreaterThan(0); + expect(rail.countries.length).toBeGreaterThan(0); + } + }); + + it("every asset has a non-empty code", () => { + for (const asset of DEFAULT_ANCHOR_CAPABILITY_MATRIX.assets) { + expect(asset.code.trim().length).toBeGreaterThan(0); + } + }); + + it("has no duplicate rail IDs", () => { + const ids = DEFAULT_ANCHOR_CAPABILITY_MATRIX.rails.map((r) => r.railId); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("has no duplicate asset codes", () => { + const codes = DEFAULT_ANCHOR_CAPABILITY_MATRIX.assets.map((a) => a.code); + expect(new Set(codes).size).toBe(codes.length); + }); +}); + +describe("getRailsByState", () => { + it("returns only rails matching the given state", () => { + const mocks = getRailsByState(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "mock"); + expect(mocks.length).toBeGreaterThan(0); + for (const r of mocks) { + expect(r.state).toBe("mock"); + } + }); + + it("returns an empty array when no rails match", () => { + const result = getRailsByState(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "testnet-only"); + expect(result).toEqual([]); + }); +}); + +describe("getDepositEnabledAssets", () => { + it("returns only enabled assets with depositEnabled true", () => { + const assets = getDepositEnabledAssets(DEFAULT_ANCHOR_CAPABILITY_MATRIX); + expect(assets.length).toBeGreaterThan(0); + for (const a of assets) { + expect(a.enabled).toBe(true); + expect(a.depositEnabled).toBe(true); + } + }); +}); + +describe("getWithdrawalEnabledAssets", () => { + it("returns only enabled assets with withdrawalEnabled true", () => { + const assets = getWithdrawalEnabledAssets(DEFAULT_ANCHOR_CAPABILITY_MATRIX); + expect(assets.length).toBeGreaterThan(0); + for (const a of assets) { + expect(a.enabled).toBe(true); + expect(a.withdrawalEnabled).toBe(true); + } + }); +}); + +describe("findRailById", () => { + it("returns the matching rail for a known ID", () => { + const rail = findRailById(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "SEPA"); + expect(rail).toBeDefined(); + expect(rail?.railId).toBe("SEPA"); + }); + + it("returns undefined for an unknown rail ID", () => { + expect(findRailById(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "NONEXISTENT")).toBeUndefined(); + }); +}); + +describe("findAssetByCode", () => { + it("returns the matching asset for a known code (case-insensitive)", () => { + const asset = findAssetByCode(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "usdc"); + expect(asset).toBeDefined(); + expect(asset?.code).toBe("USDC"); + }); + + it("returns undefined for an unknown asset code", () => { + expect(findAssetByCode(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "BOGUS")).toBeUndefined(); + }); +}); + +describe("isRailDepositReady", () => { + it("returns true for a mock SEPA rail that supports deposit", () => { + expect(isRailDepositReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "SEPA")).toBe(true); + }); + + it("returns false for an unavailable CARD rail", () => { + expect(isRailDepositReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "CARD")).toBe(false); + }); + + it("returns false for an unknown rail ID", () => { + expect(isRailDepositReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "NONEXISTENT")).toBe(false); + }); +}); + +describe("isRailWithdrawalReady", () => { + it("returns true for a mock ACH rail that supports withdrawal", () => { + expect(isRailWithdrawalReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "ACH")).toBe(true); + }); + + it("returns false for the experimental WIRE rail (withdrawal not supported)", () => { + expect(isRailWithdrawalReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "WIRE")).toBe(false); + }); + + it("returns false for an unavailable CARD rail", () => { + expect(isRailWithdrawalReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "CARD")).toBe(false); + }); + + it("returns false for an unknown rail ID", () => { + expect(isRailWithdrawalReady(DEFAULT_ANCHOR_CAPABILITY_MATRIX, "NONEXISTENT")).toBe(false); + }); +}); diff --git a/packages/fixtures/src/capabilities.ts b/packages/fixtures/src/capabilities.ts index dbd67f9..f0c3907 100644 --- a/packages/fixtures/src/capabilities.ts +++ b/packages/fixtures/src/capabilities.ts @@ -52,6 +52,12 @@ export const FIXTURES_PACKAGE_CAPABILITIES: PackageCapability = { state: "implemented", description: "Intentionally invalid inputs for negative testing of validators and error handling.", }, + { + id: "rail-capability-fixtures", + label: "Rail Capability Matrix Fixtures", + state: "implemented", + description: "Pre-built AnchorCapabilityMatrix instances covering valid, all-disabled, and experimental-only configurations for deterministic testing.", + }, ], docsHref: "/docs#fixtures", }; diff --git a/packages/fixtures/src/index.ts b/packages/fixtures/src/index.ts index 12b6d2d..c60e89f 100644 --- a/packages/fixtures/src/index.ts +++ b/packages/fixtures/src/index.ts @@ -17,5 +17,8 @@ export * from "./milestoneUi"; export * from "./diagnostics"; export * from "./invalid"; +// ─── Rail capability matrix fixtures (issue #54) ───────────────────────────── +export * from "./railCapability"; + // ─── Package capability metadata ──────────────────────────────────────────── export * from "./capabilities"; diff --git a/packages/fixtures/src/railCapability.ts b/packages/fixtures/src/railCapability.ts new file mode 100644 index 0000000..211e016 --- /dev/null +++ b/packages/fixtures/src/railCapability.ts @@ -0,0 +1,164 @@ +/** + * Rail capability matrix fixtures (issue #54). + * + * Pre-built `AnchorCapabilityMatrix` instances for deterministic testing. + * These fixtures cover valid configurations, all-disabled rails, and + * experimental-only states. + */ + +import type { + AnchorCapabilityMatrix, + AnchorRailCapability, + AnchorAssetCapability, +} from "@anchorkit/types"; + +// ─── Reusable rail stubs ────────────────────────────────────────────────────── + +export const mockSepaRail: AnchorRailCapability = { + railId: "SEPA", + name: "SEPA Credit Transfer", + state: "mock", + depositSupported: true, + withdrawalSupported: true, + currencies: ["EUR"], + countries: ["DE", "FR", "ES"], +}; + +export const mockAchRail: AnchorRailCapability = { + railId: "ACH", + name: "ACH Bank Transfer", + state: "mock", + depositSupported: true, + withdrawalSupported: true, + currencies: ["USD"], + countries: ["US"], +}; + +export const experimentalWireRail: AnchorRailCapability = { + railId: "WIRE", + name: "International Wire Transfer", + state: "experimental", + depositSupported: true, + withdrawalSupported: false, + currencies: ["USD", "EUR"], + countries: ["US", "DE"], + note: "Experimental deposit-only wire rail.", +}; + +export const unavailableCardRail: AnchorRailCapability = { + railId: "CARD", + name: "Card Payment", + state: "unavailable", + depositSupported: false, + withdrawalSupported: false, + currencies: ["USD"], + countries: ["US"], + note: "Card rails are not available.", +}; + +// ─── Reusable asset stubs ───────────────────────────────────────────────────── + +export const mockXlmAsset: AnchorAssetCapability = { + code: "XLM", + issuer: null, + enabled: true, + depositEnabled: true, + withdrawalEnabled: true, + depositMinAmount: "10.0000000", + depositMaxAmount: "100000.0000000", + withdrawalMinAmount: "10.0000000", + withdrawalMaxAmount: "100000.0000000", + feeFixed: "1.5000000", + feePercent: "0.1", +}; + +export const mockUsdcAsset: AnchorAssetCapability = { + code: "USDC", + issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + enabled: true, + depositEnabled: true, + withdrawalEnabled: true, + depositMinAmount: "5.00", + depositMaxAmount: "50000.00", + withdrawalMinAmount: "5.00", + withdrawalMaxAmount: "50000.00", + feeFixed: "0.50", + feePercent: "0.2", +}; + +export const disabledEurcAsset: AnchorAssetCapability = { + code: "EURC", + issuer: "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP", + enabled: true, + depositEnabled: true, + withdrawalEnabled: false, + depositMinAmount: "5.00", + depositMaxAmount: "25000.00", + note: "EURC withdrawal is not yet enabled.", +}; + +// ─── Full matrix fixtures ───────────────────────────────────────────────────── + +/** + * A complete, valid mock anchor capability matrix covering all rail states and + * typical deposit/withdrawal asset configurations. + */ +export const mockAnchorCapabilityMatrix: AnchorCapabilityMatrix = { + anchorName: "Mock Anchor (fixture)", + overallState: "mock", + isMock: true, + depositState: "mock", + withdrawalState: "mock", + rails: [mockSepaRail, mockAchRail, experimentalWireRail, unavailableCardRail], + assets: [mockXlmAsset, mockUsdcAsset, disabledEurcAsset], + experimentalBehaviours: { + wire_deposit: "Wire deposit is experimental.", + eurc_deposit: "EURC deposit is experimental.", + }, + disabledBehaviours: { + card_payments: "Card rails unavailable.", + eurc_withdrawal: "EURC withdrawal not yet supported.", + wire_withdrawal: "Wire withdrawal not yet supported.", + }, + docsHref: "/docs#anchors", +}; + +/** + * A capability matrix where every rail is `unavailable` — used to test + * that UI correctly renders a fully-disabled anchor. + */ +export const allDisabledRailsMatrix: AnchorCapabilityMatrix = { + anchorName: "Disabled Anchor (fixture)", + overallState: "unavailable", + isMock: true, + depositState: "unavailable", + withdrawalState: "unavailable", + rails: [ + { ...mockSepaRail, state: "unavailable", depositSupported: false, withdrawalSupported: false }, + { ...mockAchRail, state: "unavailable", depositSupported: false, withdrawalSupported: false }, + ], + assets: [ + { ...mockXlmAsset, enabled: false, depositEnabled: false, withdrawalEnabled: false }, + { ...mockUsdcAsset, enabled: false, depositEnabled: false, withdrawalEnabled: false }, + ], + disabledBehaviours: { + all_rails: "All rails are disabled in this fixture.", + }, +}; + +/** + * A capability matrix with only experimental rails — useful for testing that + * `experimental` state is surfaced correctly in the UI. + */ +export const experimentalOnlyMatrix: AnchorCapabilityMatrix = { + anchorName: "Experimental Anchor (fixture)", + overallState: "experimental", + isMock: true, + depositState: "experimental", + withdrawalState: "unavailable", + rails: [experimentalWireRail], + assets: [disabledEurcAsset], + experimentalBehaviours: { + wire_deposit: "Wire deposit is experimental.", + }, +}; diff --git a/packages/fixtures/test/railCapability.test.ts b/packages/fixtures/test/railCapability.test.ts new file mode 100644 index 0000000..b40777d --- /dev/null +++ b/packages/fixtures/test/railCapability.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for rail capability matrix fixtures (issue #54). + */ + +import { describe, it, expect } from "vitest"; +import { + mockSepaRail, + mockAchRail, + experimentalWireRail, + unavailableCardRail, + mockXlmAsset, + mockUsdcAsset, + disabledEurcAsset, + mockAnchorCapabilityMatrix, + allDisabledRailsMatrix, + experimentalOnlyMatrix, +} from "../src/railCapability"; +import { ANCHOR_RAIL_CAPABILITY_STATES } from "@anchorkit/types"; + +// ─── Rail fixtures ──────────────────────────────────────────────────────────── + +describe("mockSepaRail", () => { + it("has railId SEPA and state mock", () => { + expect(mockSepaRail.railId).toBe("SEPA"); + expect(mockSepaRail.state).toBe("mock"); + }); + + it("supports deposit and withdrawal", () => { + expect(mockSepaRail.depositSupported).toBe(true); + expect(mockSepaRail.withdrawalSupported).toBe(true); + }); +}); + +describe("experimentalWireRail", () => { + it("has state experimental", () => { + expect(experimentalWireRail.state).toBe("experimental"); + }); + + it("supports deposit but not withdrawal", () => { + expect(experimentalWireRail.depositSupported).toBe(true); + expect(experimentalWireRail.withdrawalSupported).toBe(false); + }); +}); + +describe("unavailableCardRail", () => { + it("has state unavailable", () => { + expect(unavailableCardRail.state).toBe("unavailable"); + }); + + it("does not support deposit or withdrawal", () => { + expect(unavailableCardRail.depositSupported).toBe(false); + expect(unavailableCardRail.withdrawalSupported).toBe(false); + }); +}); + +// ─── Asset fixtures ──────────────────────────────────────────────────────────── + +describe("mockXlmAsset", () => { + it("has code XLM and null issuer", () => { + expect(mockXlmAsset.code).toBe("XLM"); + expect(mockXlmAsset.issuer).toBeNull(); + }); + + it("is enabled for deposit and withdrawal", () => { + expect(mockXlmAsset.depositEnabled).toBe(true); + expect(mockXlmAsset.withdrawalEnabled).toBe(true); + }); +}); + +describe("disabledEurcAsset", () => { + it("has depositEnabled true but withdrawalEnabled false", () => { + expect(disabledEurcAsset.depositEnabled).toBe(true); + expect(disabledEurcAsset.withdrawalEnabled).toBe(false); + }); +}); + +// ─── Matrix fixtures ───────────────────────────────────────────────────────── + +describe("mockAnchorCapabilityMatrix", () => { + it("has a non-empty anchor name", () => { + expect(mockAnchorCapabilityMatrix.anchorName.trim().length).toBeGreaterThan(0); + }); + + it("has isMock true", () => { + expect(mockAnchorCapabilityMatrix.isMock).toBe(true); + }); + + it("has 4 rails", () => { + expect(mockAnchorCapabilityMatrix.rails).toHaveLength(4); + }); + + it("has 3 assets", () => { + expect(mockAnchorCapabilityMatrix.assets).toHaveLength(3); + }); + + it("every rail has a valid state", () => { + for (const rail of mockAnchorCapabilityMatrix.rails) { + expect(ANCHOR_RAIL_CAPABILITY_STATES).toContain(rail.state); + } + }); + + it("has no duplicate rail IDs", () => { + const ids = mockAnchorCapabilityMatrix.rails.map((r) => r.railId); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("has experimental and disabled behaviour records", () => { + expect(mockAnchorCapabilityMatrix.experimentalBehaviours).toBeDefined(); + expect(mockAnchorCapabilityMatrix.disabledBehaviours).toBeDefined(); + expect( + Object.keys(mockAnchorCapabilityMatrix.experimentalBehaviours!).length + ).toBeGreaterThan(0); + expect( + Object.keys(mockAnchorCapabilityMatrix.disabledBehaviours!).length + ).toBeGreaterThan(0); + }); +}); + +describe("allDisabledRailsMatrix", () => { + it("has overallState unavailable", () => { + expect(allDisabledRailsMatrix.overallState).toBe("unavailable"); + }); + + it("has all rails with depositSupported false", () => { + for (const rail of allDisabledRailsMatrix.rails) { + expect(rail.depositSupported).toBe(false); + expect(rail.withdrawalSupported).toBe(false); + } + }); + + it("has all assets disabled", () => { + for (const asset of allDisabledRailsMatrix.assets) { + expect(asset.enabled).toBe(false); + } + }); +}); + +describe("experimentalOnlyMatrix", () => { + it("has overallState experimental", () => { + expect(experimentalOnlyMatrix.overallState).toBe("experimental"); + }); + + it("has exactly one rail", () => { + expect(experimentalOnlyMatrix.rails).toHaveLength(1); + }); + + it("the single rail is the experimental wire rail", () => { + expect(experimentalOnlyMatrix.rails[0]!.railId).toBe("WIRE"); + expect(experimentalOnlyMatrix.rails[0]!.state).toBe("experimental"); + }); +}); diff --git a/packages/types/src/capabilities.ts b/packages/types/src/capabilities.ts index 0098b6c..8d4ab56 100644 --- a/packages/types/src/capabilities.ts +++ b/packages/types/src/capabilities.ts @@ -40,6 +40,12 @@ export const TYPES_PACKAGE_CAPABILITIES: PackageCapability = { state: "implemented", description: "AssetDisplayInfo, AssetDisplayMetadata, and AssetDisplayState types for registry-based asset resolution.", }, + { + id: "rail-capability-types", + label: "Rail Capability Matrix Types", + state: "implemented", + description: "AnchorRailCapabilityState, AnchorRailCapability, AnchorAssetCapability, and AnchorCapabilityMatrix types for the anchor rails capability model.", + }, ], docsHref: "/docs#types", }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3d67a6b..59d6d9d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -633,6 +633,9 @@ export * from "./errors"; // ─── Package capability metadata ──────────────────────────────────────────── export * from "./capabilities"; +// ─── Anchor rails capability matrix ───────────────────────────────────────── +export * from "./railCapability"; + // ─── Capability states ────────────────────────────────────────────────────── export type CapabilityState = | "implemented" diff --git a/packages/types/src/railCapability.ts b/packages/types/src/railCapability.ts new file mode 100644 index 0000000..61afc3d --- /dev/null +++ b/packages/types/src/railCapability.ts @@ -0,0 +1,137 @@ +/** + * Anchor Rails Capability Matrix types (issue #54). + * + * These types describe which payment rails, assets, deposit flows, and + * withdrawal flows are available, disabled, or experimental for an anchor. + * They are designed to be consumed by the web dashboard, test fixtures, + * and validators without creating circular dependencies. + */ + +import type { CapabilityState } from "./index"; + +// ─── Rail capability ───────────────────────────────────────────────────────── + +/** + * A single rail's capability state. + * Extends `CapabilityState` with an `"unsupported"` value specifically for + * rails that are structurally known but explicitly not offered by this anchor. + */ +export type AnchorRailCapabilityState = CapabilityState | "unsupported"; + +export const ANCHOR_RAIL_CAPABILITY_STATES: readonly AnchorRailCapabilityState[] = [ + "implemented", + "mock", + "testnet-only", + "experimental", + "unavailable", + "unsupported", +] as const; + +/** + * Capability descriptor for a single payment rail offered (or not) by an anchor. + */ +export interface AnchorRailCapability { + /** Stable rail identifier, e.g. "SEPA", "ACH", "WIRE". */ + railId: string; + /** Human-readable display name. */ + name: string; + /** Readiness state for this rail. */ + state: AnchorRailCapabilityState; + /** Whether deposit flows are supported on this rail. */ + depositSupported: boolean; + /** Whether withdrawal flows are supported on this rail. */ + withdrawalSupported: boolean; + /** ISO 4217 currency codes accepted on this rail. */ + currencies: string[]; + /** ISO 3166-1 alpha-2 country codes where this rail operates. */ + countries: string[]; + /** + * Optional note for experimental or disabled rails explaining why the + * state is not `implemented`. + */ + note?: string; +} + +// ─── Asset capability ───────────────────────────────────────────────────────── + +/** + * Capability descriptor for a supported anchor asset. + */ +export interface AnchorAssetCapability { + /** Asset code (e.g. "USDC", "XLM"). */ + code: string; + /** Issuer public key — `null` for native XLM. */ + issuer: string | null; + /** Whether the asset is active. */ + enabled: boolean; + /** Whether deposit is available for this asset. */ + depositEnabled: boolean; + /** Whether withdrawal is available for this asset. */ + withdrawalEnabled: boolean; + /** + * Minimum deposit amount as a decimal string. + * Undefined means no minimum is enforced. + */ + depositMinAmount?: string; + /** + * Maximum deposit amount as a decimal string. + * Undefined means no maximum is enforced. + */ + depositMaxAmount?: string; + /** + * Minimum withdrawal amount as a decimal string. + * Undefined means no minimum is enforced. + */ + withdrawalMinAmount?: string; + /** + * Maximum withdrawal amount as a decimal string. + * Undefined means no maximum is enforced. + */ + withdrawalMaxAmount?: string; + /** Fixed fee as a decimal string, if any. */ + feeFixed?: string; + /** Percentage fee (0–100), if any. */ + feePercent?: string; + /** + * Optional note for disabled or experimental assets. + */ + note?: string; +} + +// ─── Capability matrix ──────────────────────────────────────────────────────── + +/** + * The full capability matrix for an anchor integration. + * + * Aggregates rail capabilities, asset capabilities, and top-level deposit + * and withdrawal readiness into a single queryable structure. The web + * dashboard renders this as a capability card on the Anchors page. + */ +export interface AnchorCapabilityMatrix { + /** Human-readable anchor name (e.g. "Mock Anchor", "Circle USDC Anchor"). */ + anchorName: string; + /** Overall readiness state of the anchor integration. */ + overallState: AnchorRailCapabilityState; + /** Whether this matrix entry represents a mock/demo anchor. */ + isMock: boolean; + /** Top-level deposit flow readiness. */ + depositState: AnchorRailCapabilityState; + /** Top-level withdrawal flow readiness. */ + withdrawalState: AnchorRailCapabilityState; + /** Payment rails supported by this anchor. */ + rails: AnchorRailCapability[]; + /** Assets supported by this anchor. */ + assets: AnchorAssetCapability[]; + /** + * Any experimental behaviours that are not yet fully supported. + * Keyed by feature id; value is a human-readable description. + */ + experimentalBehaviours?: Record; + /** + * Any explicitly disabled behaviours. + * Keyed by feature id; value is the reason it is disabled. + */ + disabledBehaviours?: Record; + /** Docs link for this anchor's capability page. */ + docsHref?: string; +} diff --git a/packages/types/test/railCapability.test.ts b/packages/types/test/railCapability.test.ts new file mode 100644 index 0000000..07d9055 --- /dev/null +++ b/packages/types/test/railCapability.test.ts @@ -0,0 +1,30 @@ +/** + * Tests for anchor rail capability types and constants (issue #54). + * Validates `ANCHOR_RAIL_CAPABILITY_STATES` and the exported type contracts. + */ + +import { describe, it, expect } from "vitest"; +import { ANCHOR_RAIL_CAPABILITY_STATES, CAPABILITY_STATES } from "@anchorkit/types"; + +describe("ANCHOR_RAIL_CAPABILITY_STATES", () => { + it("is a non-empty readonly array", () => { + expect(Array.isArray(ANCHOR_RAIL_CAPABILITY_STATES)).toBe(true); + expect(ANCHOR_RAIL_CAPABILITY_STATES.length).toBeGreaterThan(0); + }); + + it("contains all base CAPABILITY_STATES values", () => { + for (const state of CAPABILITY_STATES) { + expect(ANCHOR_RAIL_CAPABILITY_STATES).toContain(state); + } + }); + + it("additionally contains 'unsupported'", () => { + expect(ANCHOR_RAIL_CAPABILITY_STATES).toContain("unsupported"); + }); + + it("has no duplicate entries", () => { + expect(new Set(ANCHOR_RAIL_CAPABILITY_STATES).size).toBe( + ANCHOR_RAIL_CAPABILITY_STATES.length + ); + }); +}); diff --git a/packages/validators/src/capabilities.ts b/packages/validators/src/capabilities.ts index 622ac3d..f7e32ba 100644 --- a/packages/validators/src/capabilities.ts +++ b/packages/validators/src/capabilities.ts @@ -34,6 +34,12 @@ export const VALIDATORS_PACKAGE_CAPABILITIES: PackageCapability = { state: "implemented", description: "Uniform ValidationResult type and engine validators that never throw, with mapped error codes.", }, + { + id: "rail-capability-schemas", + label: "Rail Capability Schemas", + state: "implemented", + description: "Zod schemas for AnchorRailCapability, AnchorAssetCapability, and AnchorCapabilityMatrix.", + }, ], docsHref: "/docs#validators", }; diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 0e19257..e3d0cba 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -6,6 +6,7 @@ export * from "./schemas/anchor"; export * from "./schemas/escrow"; export * from "./schemas/milestoneUi"; export * from "./schemas/receipt"; +export * from "./schemas/railCapability"; // ─── Validation engine (issue #6) ─────────────────────────────────────────── export * from "./validationEngine"; diff --git a/packages/validators/src/schemas/railCapability.ts b/packages/validators/src/schemas/railCapability.ts new file mode 100644 index 0000000..cfb88d9 --- /dev/null +++ b/packages/validators/src/schemas/railCapability.ts @@ -0,0 +1,57 @@ +/** + * Zod schemas for anchor rails capability matrix (issue #54). + */ + +import { z } from "zod"; +import { CAPABILITY_STATES, ANCHOR_RAIL_CAPABILITY_STATES } from "@anchorkit/types"; + +export const AnchorRailCapabilityStateSchema = z.enum( + ANCHOR_RAIL_CAPABILITY_STATES as [string, ...string[]] +); + +export const CapabilityStateSchema = z.enum( + CAPABILITY_STATES as [string, ...string[]] +); + +export const AnchorRailCapabilitySchema = z.object({ + railId: z.string().min(1), + name: z.string().min(1), + state: AnchorRailCapabilityStateSchema, + depositSupported: z.boolean(), + withdrawalSupported: z.boolean(), + currencies: z.array(z.string().min(1)).min(1), + countries: z.array(z.string().length(2)).min(1), + note: z.string().optional(), +}); + +export const AnchorAssetCapabilitySchema = z.object({ + code: z.string().min(1).max(12), + issuer: z.string().nullable(), + enabled: z.boolean(), + depositEnabled: z.boolean(), + withdrawalEnabled: z.boolean(), + depositMinAmount: z.string().optional(), + depositMaxAmount: z.string().optional(), + withdrawalMinAmount: z.string().optional(), + withdrawalMaxAmount: z.string().optional(), + feeFixed: z.string().optional(), + feePercent: z.string().optional(), + note: z.string().optional(), +}); + +export const AnchorCapabilityMatrixSchema = z.object({ + anchorName: z.string().min(1), + overallState: AnchorRailCapabilityStateSchema, + isMock: z.boolean(), + depositState: AnchorRailCapabilityStateSchema, + withdrawalState: AnchorRailCapabilityStateSchema, + rails: z.array(AnchorRailCapabilitySchema), + assets: z.array(AnchorAssetCapabilitySchema), + experimentalBehaviours: z.record(z.string()).optional(), + disabledBehaviours: z.record(z.string()).optional(), + docsHref: z.string().optional(), +}); + +export type ParsedAnchorRailCapability = z.infer; +export type ParsedAnchorAssetCapability = z.infer; +export type ParsedAnchorCapabilityMatrix = z.infer; diff --git a/packages/validators/test/railCapability.test.ts b/packages/validators/test/railCapability.test.ts new file mode 100644 index 0000000..6c5f690 --- /dev/null +++ b/packages/validators/test/railCapability.test.ts @@ -0,0 +1,187 @@ +/** + * Tests for anchor rail capability matrix Zod schemas (issue #54). + */ + +import { describe, it, expect } from "vitest"; +import { + AnchorRailCapabilitySchema, + AnchorAssetCapabilitySchema, + AnchorCapabilityMatrixSchema, +} from "../src/schemas/railCapability"; + +// ─── Valid stubs ───────────────────────────────────────────────────────────── + +const validRail = { + railId: "SEPA", + name: "SEPA Credit Transfer", + state: "mock", + depositSupported: true, + withdrawalSupported: true, + currencies: ["EUR"], + countries: ["DE"], +}; + +const validAsset = { + code: "USDC", + issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + enabled: true, + depositEnabled: true, + withdrawalEnabled: true, + feeFixed: "0.50", + feePercent: "0.2", +}; + +const validNativeAsset = { + code: "XLM", + issuer: null, + enabled: true, + depositEnabled: true, + withdrawalEnabled: true, +}; + +const validMatrix = { + anchorName: "Test Anchor", + overallState: "mock", + isMock: true, + depositState: "mock", + withdrawalState: "mock", + rails: [validRail], + assets: [validAsset], +}; + +// ─── AnchorRailCapabilitySchema ─────────────────────────────────────────────── + +describe("AnchorRailCapabilitySchema", () => { + it("accepts a valid mock rail", () => { + expect(AnchorRailCapabilitySchema.safeParse(validRail).success).toBe(true); + }); + + it("accepts an unavailable rail", () => { + const rail = { ...validRail, state: "unavailable", depositSupported: false, withdrawalSupported: false }; + expect(AnchorRailCapabilitySchema.safeParse(rail).success).toBe(true); + }); + + it("accepts an experimental rail with a note", () => { + const rail = { ...validRail, state: "experimental", note: "Experimental only." }; + expect(AnchorRailCapabilitySchema.safeParse(rail).success).toBe(true); + }); + + it("accepts the 'unsupported' state", () => { + const rail = { ...validRail, state: "unsupported" }; + expect(AnchorRailCapabilitySchema.safeParse(rail).success).toBe(true); + }); + + it("rejects a rail with an empty railId", () => { + const bad = { ...validRail, railId: "" }; + expect(AnchorRailCapabilitySchema.safeParse(bad).success).toBe(false); + }); + + it("rejects a rail with an empty currencies array", () => { + const bad = { ...validRail, currencies: [] }; + expect(AnchorRailCapabilitySchema.safeParse(bad).success).toBe(false); + }); + + it("rejects a rail with an empty countries array", () => { + const bad = { ...validRail, countries: [] }; + expect(AnchorRailCapabilitySchema.safeParse(bad).success).toBe(false); + }); + + it("rejects a rail with an invalid state", () => { + const bad = { ...validRail, state: "unknown-state" }; + expect(AnchorRailCapabilitySchema.safeParse(bad).success).toBe(false); + }); + + it("rejects a rail missing required fields", () => { + expect(AnchorRailCapabilitySchema.safeParse({ railId: "SEPA" }).success).toBe(false); + }); +}); + +// ─── AnchorAssetCapabilitySchema ────────────────────────────────────────────── + +describe("AnchorAssetCapabilitySchema", () => { + it("accepts a valid issued asset", () => { + expect(AnchorAssetCapabilitySchema.safeParse(validAsset).success).toBe(true); + }); + + it("accepts a native asset with null issuer", () => { + expect(AnchorAssetCapabilitySchema.safeParse(validNativeAsset).success).toBe(true); + }); + + it("accepts an asset with optional amount bounds and note", () => { + const asset = { + ...validAsset, + depositMinAmount: "5.00", + depositMaxAmount: "50000.00", + withdrawalMinAmount: "5.00", + withdrawalMaxAmount: "50000.00", + note: "Some note.", + }; + expect(AnchorAssetCapabilitySchema.safeParse(asset).success).toBe(true); + }); + + it("rejects an asset with an empty code", () => { + const bad = { ...validAsset, code: "" }; + expect(AnchorAssetCapabilitySchema.safeParse(bad).success).toBe(false); + }); + + it("rejects an asset with a code longer than 12 chars", () => { + const bad = { ...validAsset, code: "VERYLONGCODE1" }; + expect(AnchorAssetCapabilitySchema.safeParse(bad).success).toBe(false); + }); + + it("rejects an asset missing required boolean fields", () => { + const bad = { code: "XLM", issuer: null }; + expect(AnchorAssetCapabilitySchema.safeParse(bad).success).toBe(false); + }); +}); + +// ─── AnchorCapabilityMatrixSchema ───────────────────────────────────────────── + +describe("AnchorCapabilityMatrixSchema", () => { + it("accepts a minimal valid matrix", () => { + expect(AnchorCapabilityMatrixSchema.safeParse(validMatrix).success).toBe(true); + }); + + it("accepts a matrix with experimental and disabled behaviours", () => { + const full = { + ...validMatrix, + experimentalBehaviours: { wire_deposit: "Experimental wire deposit." }, + disabledBehaviours: { card_payments: "Cards unavailable." }, + docsHref: "/docs#anchors", + }; + expect(AnchorCapabilityMatrixSchema.safeParse(full).success).toBe(true); + }); + + it("accepts an empty rails array", () => { + const m = { ...validMatrix, rails: [] }; + expect(AnchorCapabilityMatrixSchema.safeParse(m).success).toBe(true); + }); + + it("accepts an empty assets array", () => { + const m = { ...validMatrix, assets: [] }; + expect(AnchorCapabilityMatrixSchema.safeParse(m).success).toBe(true); + }); + + it("rejects a matrix with an empty anchor name", () => { + const bad = { ...validMatrix, anchorName: "" }; + expect(AnchorCapabilityMatrixSchema.safeParse(bad).success).toBe(false); + }); + + it("rejects a matrix with an invalid overallState", () => { + const bad = { ...validMatrix, overallState: "bogus" }; + expect(AnchorCapabilityMatrixSchema.safeParse(bad).success).toBe(false); + }); + + it("rejects a matrix with an invalid rail inside rails array", () => { + const bad = { + ...validMatrix, + rails: [{ railId: "", name: "Bad", state: "mock", depositSupported: true, withdrawalSupported: true, currencies: [], countries: [] }], + }; + expect(AnchorCapabilityMatrixSchema.safeParse(bad).success).toBe(false); + }); + + it("rejects when anchorName is missing", () => { + const { anchorName: _, ...bad } = validMatrix; + expect(AnchorCapabilityMatrixSchema.safeParse(bad).success).toBe(false); + }); +});