Skip to content

Commit d77cc1d

Browse files
authored
Merge pull request #597 from Ebuka042-pixel/feature/548-token-gate-controller
feat(#548): add TokenGateController for token-gated invoice access
2 parents 96982a5 + fd86175 commit d77cc1d

4 files changed

Lines changed: 462 additions & 2 deletions

File tree

src/client.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2419,6 +2419,11 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
24192419

24202420
/**
24212421
* Fetch an invoice by ID. Returns cached result if within TTL.
2422+
*
2423+
* When the invoice has an `accessPolicy` set and the client was constructed
2424+
* with a `tokenGateController`, the caller's token balance is verified before
2425+
* the invoice data is returned. Throws {@link TokenGateAccessDeniedError} when
2426+
* the caller does not meet the balance requirement (and `strict !== false`).
24222427
*/
24232428
async getInvoice(
24242429
invoiceId: string,
@@ -2438,15 +2443,27 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
24382443
const useDedupe = opts?.dedupe !== false;
24392444
const effectiveRetry =
24402445
opts?.retry ?? (this._retryOptions ? {} : undefined);
2446+
2447+
let invoice: Invoice;
24412448
if (this._retryOptions && effectiveRetry !== undefined) {
2442-
return await executeWithRetry(
2449+
invoice = await executeWithRetry(
24432450
() =>
24442451
useDedupe ? this._dedup.dedupe(invoiceId, fetcher) : fetcher(),
24452452
this._retryOptions,
24462453
opts?.retry,
24472454
);
2455+
} else {
2456+
invoice = await (useDedupe ? this._dedup.dedupe(invoiceId, fetcher) : fetcher());
24482457
}
2449-
return useDedupe ? this._dedup.dedupe(invoiceId, fetcher) : fetcher();
2458+
2459+
// Token-gate access check: verify caller balance when policy is set.
2460+
const gateController = this.config.tokenGateController;
2461+
const callerId = this.config.callerAccountId;
2462+
if (gateController && callerId && invoice.accessPolicy) {
2463+
await gateController.verify(callerId, invoice.accessPolicy);
2464+
}
2465+
2466+
return invoice;
24502467
});
24512468
}
24522469

src/tokenGateController.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* Token-Gated Invoice Access Controller
3+
*
4+
* Verifies that a caller holds the minimum balance of a specific Stellar asset
5+
* before granting access to an invoice. Balance checks are cached with a short
6+
* TTL to reduce Horizon calls.
7+
*
8+
* Integrates with src/client.ts getInvoice() and src/accessControl.ts.
9+
*/
10+
11+
import { Horizon } from "@stellar/stellar-sdk";
12+
import type { TokenGatePolicy } from "./types.js";
13+
import { TokenGateAccessDeniedError } from "./errors.js";
14+
import { SimpleCache } from "./cache.js";
15+
16+
// ---------------------------------------------------------------------------
17+
// Types
18+
// ---------------------------------------------------------------------------
19+
20+
/** Options for creating a {@link TokenGateController}. */
21+
export interface TokenGateControllerOptions {
22+
/**
23+
* Base URL for the Horizon server used to load account balances.
24+
* @example "https://horizon-testnet.stellar.org"
25+
*/
26+
horizonUrl: string;
27+
/**
28+
* How long (in milliseconds) to cache a balance check result.
29+
* @default 15_000
30+
*/
31+
cacheTtlMs?: number;
32+
}
33+
34+
/** The result of a successful balance verification. */
35+
export interface TokenGateVerifyResult {
36+
/** Whether the caller meets the balance requirement. */
37+
allowed: boolean;
38+
/** The caller's current balance of the required asset. */
39+
actualBalance: string;
40+
/** The required minimum balance. */
41+
requiredBalance: string;
42+
/** Whether the result was served from the cache. */
43+
cached: boolean;
44+
}
45+
46+
// ---------------------------------------------------------------------------
47+
// Internal helpers
48+
// ---------------------------------------------------------------------------
49+
50+
/**
51+
* Parse a balance string like "123.4500000" to a comparable number.
52+
* Stellar balances have up to 7 decimal places.
53+
*/
54+
function parseBalance(b: string): number {
55+
return parseFloat(b);
56+
}
57+
58+
/** Build the cache key for a given caller + policy combination. */
59+
function cacheKey(callerAccountId: string, policy: TokenGatePolicy): string {
60+
return `token-gate:${callerAccountId}:${policy.asset}`;
61+
}
62+
63+
// ---------------------------------------------------------------------------
64+
// Controller
65+
// ---------------------------------------------------------------------------
66+
67+
/**
68+
* Verifies that a caller holds the minimum balance of a specific Stellar asset
69+
* before granting access to an invoice.
70+
*
71+
* @example
72+
* ```typescript
73+
* const controller = new TokenGateController({
74+
* horizonUrl: "https://horizon-testnet.stellar.org",
75+
* });
76+
*
77+
* const policy: TokenGatePolicy = {
78+
* asset: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
79+
* minBalance: "10.0000000",
80+
* };
81+
*
82+
* // Resolves if caller has >= 10 USDC, throws TokenGateAccessDeniedError otherwise.
83+
* await controller.verify("G...", policy);
84+
* ```
85+
*/
86+
export class TokenGateController {
87+
private readonly horizonUrl: string;
88+
private readonly cacheTtlMs: number;
89+
private readonly _cache: SimpleCache<TokenGateVerifyResult>;
90+
91+
constructor(options: TokenGateControllerOptions) {
92+
this.horizonUrl = options.horizonUrl.replace(/\/$/, "");
93+
this.cacheTtlMs = options.cacheTtlMs ?? 15_000;
94+
95+
// Use SimpleCache with a default TTL
96+
this._cache = new SimpleCache<TokenGateVerifyResult>({
97+
enabled: true,
98+
ttlMs: this.cacheTtlMs,
99+
});
100+
}
101+
102+
// ---------------------------------------------------------------------------
103+
// Public API
104+
// ---------------------------------------------------------------------------
105+
106+
/**
107+
* Verify that `callerAccountId` holds at least `policy.minBalance` of
108+
* `policy.asset`.
109+
*
110+
* - Resolves with a {@link TokenGateVerifyResult} when the caller meets the
111+
* requirement.
112+
* - Throws {@link TokenGateAccessDeniedError} when `policy.strict !== false`
113+
* and the balance is insufficient.
114+
* - When `policy.strict === false`, logs a warning and resolves instead of
115+
* throwing.
116+
*
117+
* @param callerAccountId - The Stellar public key (G…) of the caller.
118+
* @param policy - The token-gate policy to evaluate.
119+
*/
120+
async verify(
121+
callerAccountId: string,
122+
policy: TokenGatePolicy,
123+
): Promise<TokenGateVerifyResult> {
124+
const key = cacheKey(callerAccountId, policy);
125+
const cached = this._cache.get(key);
126+
if (cached !== undefined) {
127+
return { ...cached, cached: true };
128+
}
129+
130+
const balance = await this._fetchBalance(callerAccountId, policy.asset);
131+
const strict = policy.strict !== false; // default true
132+
133+
const allowed = parseBalance(balance) >= parseBalance(policy.minBalance);
134+
135+
const result: TokenGateVerifyResult = {
136+
allowed,
137+
actualBalance: balance,
138+
requiredBalance: policy.minBalance,
139+
cached: false,
140+
};
141+
142+
// Cache regardless of pass/fail so repeated checks within the TTL window
143+
// don't hammer Horizon.
144+
this._cache.set(key, result);
145+
146+
if (!allowed) {
147+
const assetCode = policy.asset.split(":")[0] ?? policy.asset;
148+
if (strict) {
149+
throw new TokenGateAccessDeniedError(
150+
callerAccountId,
151+
assetCode,
152+
policy.minBalance,
153+
balance,
154+
);
155+
} else {
156+
console.warn(
157+
`[TokenGateController] Non-strict warning: ${callerAccountId} has ${balance} ${assetCode} ` +
158+
`(required ${policy.minBalance}). Access allowed in non-strict mode.`,
159+
);
160+
}
161+
}
162+
163+
return result;
164+
}
165+
166+
/**
167+
* Invalidate cached balance data for a specific caller and asset.
168+
*
169+
* @param callerAccountId - Caller whose cache entry should be invalidated.
170+
* @param policy - Policy used as the cache key.
171+
*/
172+
invalidateCache(callerAccountId: string, policy: TokenGatePolicy): void {
173+
const key = cacheKey(callerAccountId, policy);
174+
this._cache.invalidate(key);
175+
}
176+
177+
/**
178+
* Clear all cached balance check results.
179+
*/
180+
clearCache(): void {
181+
this._cache.clear();
182+
}
183+
184+
// ---------------------------------------------------------------------------
185+
// Private helpers
186+
// ---------------------------------------------------------------------------
187+
188+
/**
189+
* Load the caller's account from Horizon and extract the balance for the
190+
* specified asset.
191+
*
192+
* @param accountId - Stellar account public key.
193+
* @param asset - "CODE:ISSUER" string, or "native" for XLM.
194+
* @returns Balance as a decimal string (e.g. "42.5000000"), or "0.0000000"
195+
* if the account has no trustline for the asset.
196+
*/
197+
async _fetchBalance(accountId: string, asset: string): Promise<string> {
198+
const server = new Horizon.Server(this.horizonUrl, {
199+
allowHttp: this.horizonUrl.startsWith("http://"),
200+
});
201+
202+
const account = await server.loadAccount(accountId);
203+
204+
if (asset === "native" || asset.toUpperCase() === "XLM") {
205+
const nativeBalance = account.balances.find(
206+
(b: { asset_type: string }) => b.asset_type === "native",
207+
) as { balance: string } | undefined;
208+
return nativeBalance?.balance ?? "0.0000000";
209+
}
210+
211+
const [assetCode, assetIssuer] = asset.split(":");
212+
const found = account.balances.find(
213+
(b: { asset_type: string; asset_code?: string; asset_issuer?: string }) =>
214+
b.asset_type === "credit_alphanum4" ||
215+
b.asset_type === "credit_alphanum12"
216+
? b.asset_code === assetCode && b.asset_issuer === assetIssuer
217+
: false,
218+
) as { balance: string } | undefined;
219+
220+
return found?.balance ?? "0.0000000";
221+
}
222+
}

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,11 @@ export interface Invoice {
301301
auto_resolve_rules?: AutoResolveRule[];
302302
/** ID of the single prerequisite invoice in this invoice's dependency chain. */
303303
prerequisite_id?: string;
304+
/**
305+
* Optional token-gate policy. When set, callers must hold the specified
306+
* asset balance to read or interact with this invoice.
307+
*/
308+
accessPolicy?: TokenGatePolicy;
304309
}
305310

306311
/**

0 commit comments

Comments
 (0)