diff --git a/src/client.ts b/src/client.ts index b7fe16f..7a775ea 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2419,6 +2419,11 @@ export class StellarSplitClient extends TypedEventEmitter { /** * Fetch an invoice by ID. Returns cached result if within TTL. + * + * When the invoice has an `accessPolicy` set and the client was constructed + * with a `tokenGateController`, the caller's token balance is verified before + * the invoice data is returned. Throws {@link TokenGateAccessDeniedError} when + * the caller does not meet the balance requirement (and `strict !== false`). */ async getInvoice( invoiceId: string, @@ -2438,15 +2443,27 @@ export class StellarSplitClient extends TypedEventEmitter { const useDedupe = opts?.dedupe !== false; const effectiveRetry = opts?.retry ?? (this._retryOptions ? {} : undefined); + + let invoice: Invoice; if (this._retryOptions && effectiveRetry !== undefined) { - return await executeWithRetry( + invoice = await executeWithRetry( () => useDedupe ? this._dedup.dedupe(invoiceId, fetcher) : fetcher(), this._retryOptions, opts?.retry, ); + } else { + invoice = await (useDedupe ? this._dedup.dedupe(invoiceId, fetcher) : fetcher()); } - return useDedupe ? this._dedup.dedupe(invoiceId, fetcher) : fetcher(); + + // Token-gate access check: verify caller balance when policy is set. + const gateController = this.config.tokenGateController; + const callerId = this.config.callerAccountId; + if (gateController && callerId && invoice.accessPolicy) { + await gateController.verify(callerId, invoice.accessPolicy); + } + + return invoice; }); } diff --git a/src/tokenGateController.ts b/src/tokenGateController.ts new file mode 100644 index 0000000..1de91d8 --- /dev/null +++ b/src/tokenGateController.ts @@ -0,0 +1,222 @@ +/** + * Token-Gated Invoice Access Controller + * + * Verifies that a caller holds the minimum balance of a specific Stellar asset + * before granting access to an invoice. Balance checks are cached with a short + * TTL to reduce Horizon calls. + * + * Integrates with src/client.ts getInvoice() and src/accessControl.ts. + */ + +import { Horizon } from "@stellar/stellar-sdk"; +import type { TokenGatePolicy } from "./types.js"; +import { TokenGateAccessDeniedError } from "./errors.js"; +import { SimpleCache } from "./cache.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Options for creating a {@link TokenGateController}. */ +export interface TokenGateControllerOptions { + /** + * Base URL for the Horizon server used to load account balances. + * @example "https://horizon-testnet.stellar.org" + */ + horizonUrl: string; + /** + * How long (in milliseconds) to cache a balance check result. + * @default 15_000 + */ + cacheTtlMs?: number; +} + +/** The result of a successful balance verification. */ +export interface TokenGateVerifyResult { + /** Whether the caller meets the balance requirement. */ + allowed: boolean; + /** The caller's current balance of the required asset. */ + actualBalance: string; + /** The required minimum balance. */ + requiredBalance: string; + /** Whether the result was served from the cache. */ + cached: boolean; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Parse a balance string like "123.4500000" to a comparable number. + * Stellar balances have up to 7 decimal places. + */ +function parseBalance(b: string): number { + return parseFloat(b); +} + +/** Build the cache key for a given caller + policy combination. */ +function cacheKey(callerAccountId: string, policy: TokenGatePolicy): string { + return `token-gate:${callerAccountId}:${policy.asset}`; +} + +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + +/** + * Verifies that a caller holds the minimum balance of a specific Stellar asset + * before granting access to an invoice. + * + * @example + * ```typescript + * const controller = new TokenGateController({ + * horizonUrl: "https://horizon-testnet.stellar.org", + * }); + * + * const policy: TokenGatePolicy = { + * asset: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + * minBalance: "10.0000000", + * }; + * + * // Resolves if caller has >= 10 USDC, throws TokenGateAccessDeniedError otherwise. + * await controller.verify("G...", policy); + * ``` + */ +export class TokenGateController { + private readonly horizonUrl: string; + private readonly cacheTtlMs: number; + private readonly _cache: SimpleCache; + + constructor(options: TokenGateControllerOptions) { + this.horizonUrl = options.horizonUrl.replace(/\/$/, ""); + this.cacheTtlMs = options.cacheTtlMs ?? 15_000; + + // Use SimpleCache with a default TTL + this._cache = new SimpleCache({ + enabled: true, + ttlMs: this.cacheTtlMs, + }); + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Verify that `callerAccountId` holds at least `policy.minBalance` of + * `policy.asset`. + * + * - Resolves with a {@link TokenGateVerifyResult} when the caller meets the + * requirement. + * - Throws {@link TokenGateAccessDeniedError} when `policy.strict !== false` + * and the balance is insufficient. + * - When `policy.strict === false`, logs a warning and resolves instead of + * throwing. + * + * @param callerAccountId - The Stellar public key (G…) of the caller. + * @param policy - The token-gate policy to evaluate. + */ + async verify( + callerAccountId: string, + policy: TokenGatePolicy, + ): Promise { + const key = cacheKey(callerAccountId, policy); + const cached = this._cache.get(key); + if (cached !== undefined) { + return { ...cached, cached: true }; + } + + const balance = await this._fetchBalance(callerAccountId, policy.asset); + const strict = policy.strict !== false; // default true + + const allowed = parseBalance(balance) >= parseBalance(policy.minBalance); + + const result: TokenGateVerifyResult = { + allowed, + actualBalance: balance, + requiredBalance: policy.minBalance, + cached: false, + }; + + // Cache regardless of pass/fail so repeated checks within the TTL window + // don't hammer Horizon. + this._cache.set(key, result); + + if (!allowed) { + const assetCode = policy.asset.split(":")[0] ?? policy.asset; + if (strict) { + throw new TokenGateAccessDeniedError( + callerAccountId, + assetCode, + policy.minBalance, + balance, + ); + } else { + console.warn( + `[TokenGateController] Non-strict warning: ${callerAccountId} has ${balance} ${assetCode} ` + + `(required ${policy.minBalance}). Access allowed in non-strict mode.`, + ); + } + } + + return result; + } + + /** + * Invalidate cached balance data for a specific caller and asset. + * + * @param callerAccountId - Caller whose cache entry should be invalidated. + * @param policy - Policy used as the cache key. + */ + invalidateCache(callerAccountId: string, policy: TokenGatePolicy): void { + const key = cacheKey(callerAccountId, policy); + this._cache.invalidate(key); + } + + /** + * Clear all cached balance check results. + */ + clearCache(): void { + this._cache.clear(); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Load the caller's account from Horizon and extract the balance for the + * specified asset. + * + * @param accountId - Stellar account public key. + * @param asset - "CODE:ISSUER" string, or "native" for XLM. + * @returns Balance as a decimal string (e.g. "42.5000000"), or "0.0000000" + * if the account has no trustline for the asset. + */ + async _fetchBalance(accountId: string, asset: string): Promise { + const server = new Horizon.Server(this.horizonUrl, { + allowHttp: this.horizonUrl.startsWith("http://"), + }); + + const account = await server.loadAccount(accountId); + + if (asset === "native" || asset.toUpperCase() === "XLM") { + const nativeBalance = account.balances.find( + (b: { asset_type: string }) => b.asset_type === "native", + ) as { balance: string } | undefined; + return nativeBalance?.balance ?? "0.0000000"; + } + + const [assetCode, assetIssuer] = asset.split(":"); + const found = account.balances.find( + (b: { asset_type: string; asset_code?: string; asset_issuer?: string }) => + b.asset_type === "credit_alphanum4" || + b.asset_type === "credit_alphanum12" + ? b.asset_code === assetCode && b.asset_issuer === assetIssuer + : false, + ) as { balance: string } | undefined; + + return found?.balance ?? "0.0000000"; + } +} diff --git a/src/types.ts b/src/types.ts index 22ed914..3f93aae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -301,6 +301,11 @@ export interface Invoice { auto_resolve_rules?: AutoResolveRule[]; /** ID of the single prerequisite invoice in this invoice's dependency chain. */ prerequisite_id?: string; + /** + * Optional token-gate policy. When set, callers must hold the specified + * asset balance to read or interact with this invoice. + */ + accessPolicy?: TokenGatePolicy; } /** diff --git a/test/tokenGateController.test.ts b/test/tokenGateController.test.ts new file mode 100644 index 0000000..c2edd31 --- /dev/null +++ b/test/tokenGateController.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { TokenGateController } from "../src/tokenGateController.js"; +import { TokenGateAccessDeniedError } from "../src/errors.js"; +import type { TokenGatePolicy } from "../src/types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const HORIZON_URL = "https://horizon-testnet.stellar.org"; +const CALLER = "GCZST3XVCDTUJ76ZAV2HA72KYTZ4KXX52HRXVWWRWXH2NBDXZWQS2FB2"; +const USDC_POLICY: TokenGatePolicy = { + asset: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + minBalance: "10.0000000", + strict: true, +}; + +/** + * Build a mock `_fetchBalance` implementation that returns `balance` for + * the given `accountId` and `asset`. + */ +function mockFetchBalance(balance: string) { + return vi.fn().mockResolvedValue(balance); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("TokenGateController", () => { + let controller: TokenGateController; + + beforeEach(() => { + controller = new TokenGateController({ + horizonUrl: HORIZON_URL, + cacheTtlMs: 5_000, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + controller.clearCache(); + }); + + // ── Balance above minimum ──────────────────────────────────────────────── + + describe("balance above minimum", () => { + it("resolves when caller balance meets the requirement", async () => { + vi.spyOn(controller as any, "_fetchBalance").mockResolvedValue("25.0000000"); + + const result = await controller.verify(CALLER, USDC_POLICY); + + expect(result.allowed).toBe(true); + expect(result.actualBalance).toBe("25.0000000"); + expect(result.requiredBalance).toBe("10.0000000"); + expect(result.cached).toBe(false); + }); + + it("resolves when caller balance exactly equals the minimum", async () => { + vi.spyOn(controller as any, "_fetchBalance").mockResolvedValue("10.0000000"); + + const result = await controller.verify(CALLER, USDC_POLICY); + + expect(result.allowed).toBe(true); + }); + }); + + // ── Balance below minimum ──────────────────────────────────────────────── + + describe("balance below minimum (strict mode)", () => { + it("throws TokenGateAccessDeniedError when balance is insufficient", async () => { + vi.spyOn(controller as any, "_fetchBalance").mockResolvedValue("1.5000000"); + + await expect(controller.verify(CALLER, USDC_POLICY)).rejects.toThrow( + TokenGateAccessDeniedError, + ); + }); + + it("error includes the caller account ID and balance info", async () => { + vi.spyOn(controller as any, "_fetchBalance").mockResolvedValue("0.0000000"); + + let error: TokenGateAccessDeniedError | null = null; + try { + await controller.verify(CALLER, USDC_POLICY); + } catch (e) { + error = e as TokenGateAccessDeniedError; + } + + expect(error).not.toBeNull(); + expect(error!.callerAccountId).toBe(CALLER); + expect(error!.required).toBe("10.0000000"); + expect(error!.actual).toBe("0.0000000"); + expect(error!.assetCode).toBe("USDC"); + }); + }); + + // ── Non-strict mode ────────────────────────────────────────────────────── + + describe("non-strict mode", () => { + it("warns but does NOT throw when strict is false and balance is low", async () => { + vi.spyOn(controller as any, "_fetchBalance").mockResolvedValue("0.5000000"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const nonStrictPolicy: TokenGatePolicy = { + ...USDC_POLICY, + strict: false, + }; + + const result = await controller.verify(CALLER, nonStrictPolicy); + + expect(result.allowed).toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Non-strict warning")); + }); + }); + + // ── Caching ────────────────────────────────────────────────────────────── + + describe("balance check caching", () => { + it("caches the result and skips Horizon on second call", async () => { + const fetchSpy = vi + .spyOn(controller as any, "_fetchBalance") + .mockResolvedValue("50.0000000"); + + // First call — hits Horizon + const first = await controller.verify(CALLER, USDC_POLICY); + expect(first.cached).toBe(false); + + // Second call — served from cache + const second = await controller.verify(CALLER, USDC_POLICY); + expect(second.cached).toBe(true); + + // Horizon should only have been called once + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("re-fetches after cache is invalidated", async () => { + const fetchSpy = vi + .spyOn(controller as any, "_fetchBalance") + .mockResolvedValue("30.0000000"); + + await controller.verify(CALLER, USDC_POLICY); + controller.invalidateCache(CALLER, USDC_POLICY); + await controller.verify(CALLER, USDC_POLICY); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("re-fetches after clearCache()", async () => { + const fetchSpy = vi + .spyOn(controller as any, "_fetchBalance") + .mockResolvedValue("20.0000000"); + + await controller.verify(CALLER, USDC_POLICY); + controller.clearCache(); + await controller.verify(CALLER, USDC_POLICY); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("caches even failed (access-denied) results to avoid hammering Horizon", async () => { + const fetchSpy = vi + .spyOn(controller as any, "_fetchBalance") + .mockResolvedValue("0.0000000"); + + // First attempt — hits Horizon and throws + await expect(controller.verify(CALLER, USDC_POLICY)).rejects.toThrow( + TokenGateAccessDeniedError, + ); + + // Second attempt — cache hit, still throws but no new Horizon call + await expect(controller.verify(CALLER, USDC_POLICY)).rejects.toThrow( + TokenGateAccessDeniedError, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + // ── Native / XLM asset ─────────────────────────────────────────────────── + + describe("native XLM policy", () => { + it("resolves when caller has enough XLM", async () => { + const fetchSpy = vi + .spyOn(controller as any, "_fetchBalance") + .mockResolvedValue("100.0000000"); + + const xlmPolicy: TokenGatePolicy = { + asset: "native", + minBalance: "50.0000000", + }; + + const result = await controller.verify(CALLER, xlmPolicy); + + expect(result.allowed).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith(CALLER, "native"); + }); + }); + + // ── Error code ─────────────────────────────────────────────────────────── + + describe("TokenGateAccessDeniedError", () => { + it("has the correct error code", async () => { + vi.spyOn(controller as any, "_fetchBalance").mockResolvedValue("0.0000000"); + + let err: TokenGateAccessDeniedError | null = null; + try { + await controller.verify(CALLER, USDC_POLICY); + } catch (e) { + err = e as TokenGateAccessDeniedError; + } + + expect(err?.code).toBe("TOKEN_GATE_ACCESS_DENIED"); + expect(err?.name).toBe("TokenGateAccessDeniedError"); + }); + }); +});