From e5e495c5146cf1e664f8fdb156899b4b46a635be Mon Sep 17 00:00:00 2001 From: Ebuka042-pixel Date: Thu, 30 Jul 2026 03:31:51 +0000 Subject: [PATCH] feat(#548): add TokenGateController for token-gated invoice access - Add src/tokenGateController.ts with TokenGateController class - verify(callerAccountId, policy) checks Horizon balance - throws TokenGateAccessDeniedError when strict + balance insufficient - non-strict mode warns instead of throwing - caches results via SimpleCache with configurable TTL (default 15s) - Add TokenGatePolicy interface to src/types.ts - Add accessPolicy field to Invoice interface in src/types.ts - Add TokenGateAccessDeniedError to src/errors.ts - Integrate into src/client.ts getInvoice(): verify called when accessPolicy set - Add tokenGateController + callerAccountId to StellarSplitClientConfig - Export from src/index.ts - Add unit tests in test/tokenGateController.test.ts --- src/client.ts | 32 ++++- src/errors.ts | 42 ++++++ src/index.ts | 17 +++ src/tokenGateController.ts | 222 +++++++++++++++++++++++++++++++ src/types.ts | 30 +++++ test/tokenGateController.test.ts | 216 ++++++++++++++++++++++++++++++ 6 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 src/tokenGateController.ts create mode 100644 test/tokenGateController.test.ts diff --git a/src/client.ts b/src/client.ts index d7b0d69..9a546e9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -487,6 +487,17 @@ export interface StellarSplitClientConfig { * for inspecting in-flight transaction envelopes. Defaults to false. */ debug?: boolean; + /** + * Optional token-gate controller for invoice access control. + * When provided, getInvoice() will call TokenGateController.verify() + * when the invoice has an accessPolicy set. + */ + tokenGateController?: import("./tokenGateController.js").TokenGateController; + /** + * The caller's account ID, used for token-gate verification in getInvoice(). + * Required when tokenGateController is set. + */ + callerAccountId?: string; } /** Network configuration. */ @@ -2252,6 +2263,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, @@ -2271,15 +2287,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()); + } + + // 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 useDedupe ? this._dedup.dedupe(invoiceId, fetcher) : fetcher(); + + return invoice; }); } diff --git a/src/errors.ts b/src/errors.ts index 47f1a5c..cc6193c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1699,3 +1699,45 @@ export class ClassifiedHorizonError extends StellarSplitError { export function isClassifiedHorizonError(err: unknown): err is ClassifiedHorizonError { return err instanceof ClassifiedHorizonError; } + +// --------------------------------------------------------------------------- +// Token-Gate Errors (#548) +// --------------------------------------------------------------------------- + +/** + * Thrown when a caller's token balance is below the minimum required by a + * {@link TokenGatePolicy}. + */ +export class TokenGateAccessDeniedError extends StellarSplitError { + readonly callerAccountId: string; + readonly assetCode: string; + readonly required: string; + readonly actual: string; + + constructor( + callerAccountId: string, + assetCode: string, + required: string, + actual: string, + raw?: string, + ) { + super( + `Token-gate access denied for ${callerAccountId}: requires ${required} ${assetCode}, has ${actual}`, + "TOKEN_GATE_ACCESS_DENIED", + { callerAccountId, assetCode, required, actual }, + raw, + ); + this.name = "TokenGateAccessDeniedError"; + this.callerAccountId = callerAccountId; + this.assetCode = assetCode; + this.required = required; + this.actual = actual; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isTokenGateAccessDeniedError( + err: unknown, +): err is TokenGateAccessDeniedError { + return err instanceof TokenGateAccessDeniedError; +} diff --git a/src/index.ts b/src/index.ts index 38d8fe1..bac9666 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1112,3 +1112,20 @@ export type { ScValPrimitive, ContractStorageExporterOptions, } from "./diagnostics/ContractStorageExporter.js"; + +// --------------------------------------------------------------------------- +// #548 — TokenGateController: token-gated invoice access control +// --------------------------------------------------------------------------- + +export { TokenGateController } from "./tokenGateController.js"; +export type { + TokenGateControllerOptions, + TokenGateVerifyResult, +} from "./tokenGateController.js"; + +export type { TokenGatePolicy } from "./types.js"; + +export { + TokenGateAccessDeniedError, + isTokenGateAccessDeniedError, +} from "./errors.js"; 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 fff3f03..9da115a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -260,6 +260,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; } /** @@ -1647,3 +1652,28 @@ export interface CursorStore { /** Delete a saved cursor. */ delete(key: string): Promise; } + +// --------------------------------------------------------------------------- +// Token-Gate Types (#548) +// --------------------------------------------------------------------------- + +/** + * Policy defining the minimum token balance required for a caller to access + * an invoice. Used by {@link TokenGateController}. + */ +export interface TokenGatePolicy { + /** + * The Stellar asset (code + issuer) that the caller must hold. + * Pass a string in "CODE:ISSUER" format for custom assets, or "native" for XLM. + */ + asset: string; + /** + * Minimum balance required (as a decimal string, e.g. "10.0000000"). + */ + minBalance: string; + /** + * When `false`, a balance shortfall only emits a warning instead of + * throwing {@link TokenGateAccessDeniedError}. Defaults to `true`. + */ + strict?: boolean; +} 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"); + }); + }); +});