Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2419,6 +2419,11 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {

/**
* 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,
Expand All @@ -2438,15 +2443,27 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
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;
});
}

Expand Down
222 changes: 222 additions & 0 deletions src/tokenGateController.ts
Original file line number Diff line number Diff line change
@@ -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<TokenGateVerifyResult>;

constructor(options: TokenGateControllerOptions) {
this.horizonUrl = options.horizonUrl.replace(/\/$/, "");
this.cacheTtlMs = options.cacheTtlMs ?? 15_000;

// Use SimpleCache with a default TTL
this._cache = new SimpleCache<TokenGateVerifyResult>({
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<TokenGateVerifyResult> {
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<string> {
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";
}
}
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading
Loading