@chain-lens/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/budget.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import type { BudgetConfig } from "./types.js";
4
+
5
+ const DEFAULTS: BudgetConfig = {
6
+ perCallMaxUsdc: 1,
7
+ dailyMaxUsdc: 50,
8
+ monthlyMaxUsdc: 500,
9
+ };
10
+
11
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
12
+ const MS_PER_MONTH = 30 * MS_PER_DAY;
13
+
14
+ interface SpendRecord {
15
+ ts: number;
16
+ amount: number;
17
+ idempotencyKey?: string;
18
+ }
19
+
20
+ /**
21
+ * Off-chain rolling-window budget controller.
22
+ *
23
+ * Persists spend records to LevelDB under ~/.chainlens/budget/<wallet>/.
24
+ * Falls back to in-memory when LevelDB is unavailable (browser / ephemeral).
25
+ */
26
+ export class BudgetController {
27
+ private readonly cfg: BudgetConfig;
28
+ private db: BudgetDB | null = null;
29
+ private readonly walletAddress: string;
30
+ private initPromise: Promise<void> | null = null;
31
+
32
+ constructor(walletAddress: string, cfg: Partial<BudgetConfig> = {}) {
33
+ this.walletAddress = walletAddress;
34
+ this.cfg = { ...DEFAULTS, ...cfg };
35
+ }
36
+
37
+ private async getDb(): Promise<BudgetDB> {
38
+ if (this.db) return this.db;
39
+ if (!this.initPromise) {
40
+ this.initPromise = this.openDb();
41
+ }
42
+ await this.initPromise;
43
+ return this.db!;
44
+ }
45
+
46
+ private async openDb(): Promise<void> {
47
+ try {
48
+ const { Level } = await import("level");
49
+ const dbPath = join(
50
+ homedir(),
51
+ ".chainlens",
52
+ "budget",
53
+ sanitizeAddress(this.walletAddress),
54
+ );
55
+ const level = new Level<string, string>(dbPath, { valueEncoding: "json" });
56
+ await level.open();
57
+ this.db = new LevelDB(level);
58
+ } catch {
59
+ // LevelDB unavailable (browser/ephemeral) — use in-memory
60
+ process.stderr.write(
61
+ "chain-lens SDK: LevelDB unavailable, using in-memory budget storage.\n",
62
+ );
63
+ this.db = new InMemoryDB();
64
+ }
65
+ }
66
+
67
+ async canSpend(amount: number): Promise<{ ok: true } | { ok: false; reason: string }> {
68
+ if (amount > this.cfg.perCallMaxUsdc) {
69
+ return { ok: false, reason: `per-call cap: $${amount} > $${this.cfg.perCallMaxUsdc}` };
70
+ }
71
+
72
+ const db = await this.getDb();
73
+ const now = Date.now();
74
+ const records = await db.readAll();
75
+ const alive = records.filter((r) => now - r.ts < MS_PER_MONTH);
76
+
77
+ const dailySpend = alive
78
+ .filter((r) => now - r.ts < MS_PER_DAY)
79
+ .reduce((s, r) => s + r.amount, 0);
80
+
81
+ const monthlySpend = alive.reduce((s, r) => s + r.amount, 0);
82
+
83
+ if (dailySpend + amount > this.cfg.dailyMaxUsdc) {
84
+ return {
85
+ ok: false,
86
+ reason: `daily cap: $${(dailySpend + amount).toFixed(4)} > $${this.cfg.dailyMaxUsdc}`,
87
+ };
88
+ }
89
+ if (monthlySpend + amount > this.cfg.monthlyMaxUsdc) {
90
+ return {
91
+ ok: false,
92
+ reason: `monthly cap: $${(monthlySpend + amount).toFixed(4)} > $${this.cfg.monthlyMaxUsdc}`,
93
+ };
94
+ }
95
+ return { ok: true };
96
+ }
97
+
98
+ async debit(amount: number, idempotencyKey?: string): Promise<void> {
99
+ const db = await this.getDb();
100
+ if (idempotencyKey) {
101
+ const existing = await db.readAll();
102
+ if (existing.some((r) => r.idempotencyKey === idempotencyKey)) return;
103
+ }
104
+ await db.append({ ts: Date.now(), amount, idempotencyKey });
105
+ // Evict records older than 30 days
106
+ await db.evictBefore(Date.now() - MS_PER_MONTH);
107
+ }
108
+
109
+ async currentSpend(): Promise<{ dailyUsdc: number; monthlyUsdc: number }> {
110
+ const db = await this.getDb();
111
+ const now = Date.now();
112
+ const records = await db.readAll();
113
+ const dailyUsdc = records
114
+ .filter((r) => now - r.ts < MS_PER_DAY)
115
+ .reduce((s, r) => s + r.amount, 0);
116
+ const monthlyUsdc = records
117
+ .filter((r) => now - r.ts < MS_PER_MONTH)
118
+ .reduce((s, r) => s + r.amount, 0);
119
+ return { dailyUsdc, monthlyUsdc };
120
+ }
121
+ }
122
+
123
+ // ─── storage backends ─────────────────────────────────────────────────
124
+
125
+ interface BudgetDB {
126
+ readAll(): Promise<SpendRecord[]>;
127
+ append(record: SpendRecord): Promise<void>;
128
+ evictBefore(ts: number): Promise<void>;
129
+ }
130
+
131
+ class InMemoryDB implements BudgetDB {
132
+ private records: SpendRecord[] = [];
133
+
134
+ async readAll() { return [...this.records]; }
135
+
136
+ async append(record: SpendRecord) { this.records.push(record); }
137
+
138
+ async evictBefore(ts: number) {
139
+ this.records = this.records.filter((r) => r.ts >= ts);
140
+ }
141
+ }
142
+
143
+ class LevelDB implements BudgetDB {
144
+ constructor(
145
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
+ private readonly level: any,
147
+ ) {}
148
+
149
+ async readAll(): Promise<SpendRecord[]> {
150
+ const records: SpendRecord[] = [];
151
+ for await (const value of this.level.values()) {
152
+ try {
153
+ records.push(JSON.parse(value) as SpendRecord);
154
+ } catch {
155
+ // skip corrupt entries
156
+ }
157
+ }
158
+ return records;
159
+ }
160
+
161
+ async append(record: SpendRecord): Promise<void> {
162
+ const key = `${record.ts}-${Math.random().toString(36).slice(2)}`;
163
+ await this.level.put(key, JSON.stringify(record));
164
+ }
165
+
166
+ async evictBefore(ts: number): Promise<void> {
167
+ const batch = this.level.batch();
168
+ for await (const [key, value] of this.level.iterator()) {
169
+ try {
170
+ const record = JSON.parse(value) as SpendRecord;
171
+ if (record.ts < ts) batch.del(key);
172
+ } catch {
173
+ batch.del(key);
174
+ }
175
+ }
176
+ await batch.write();
177
+ }
178
+ }
179
+
180
+ function sanitizeAddress(addr: string): string {
181
+ return addr.toLowerCase().replace(/[^a-f0-9x]/g, "").slice(0, 42);
182
+ }
package/src/call.ts ADDED
@@ -0,0 +1,164 @@
1
+ import {
2
+ usdcToAtomic,
3
+ atomicToUsdc,
4
+ signReceiveWithAuthorization,
5
+ CHAIN_LENS_MARKET_ADDRESSES,
6
+ } from "./eip3009.js";
7
+ import type { ChainLensConfig, CallOptions, CallResult, ListingInfo } from "./types.js";
8
+ import type { BudgetController } from "./budget.js";
9
+ import type { TelemetryRecorder } from "./telemetry.js";
10
+ import { hashParams } from "./telemetry.js";
11
+ import {
12
+ ChainLensResolveError,
13
+ BudgetExceededError,
14
+ ChainLensSignError,
15
+ ChainLensGatewayError,
16
+ ChainLensCallError,
17
+ } from "./errors.js";
18
+
19
+ export async function fetchListingInfo(
20
+ gatewayUrl: string,
21
+ listingId: number,
22
+ ): Promise<ListingInfo> {
23
+ const res = await fetch(`${gatewayUrl}/v1/listings/${listingId}`);
24
+ if (!res.ok) {
25
+ const body = await res.text().catch(() => "");
26
+ throw new ChainLensResolveError(
27
+ `Failed to fetch listing ${listingId}: ${res.status} ${body}`,
28
+ );
29
+ }
30
+ return res.json() as Promise<ListingInfo>;
31
+ }
32
+
33
+ export async function executeCall(
34
+ cfg: ChainLensConfig & { gatewayUrl: string },
35
+ budget: BudgetController,
36
+ telemetry: TelemetryRecorder,
37
+ listingId: number,
38
+ params: unknown,
39
+ options: CallOptions = {},
40
+ ): Promise<CallResult> {
41
+ const t0 = Date.now();
42
+
43
+ // Step 1+2: Resolve listing and fetch pricing
44
+ let listing: ListingInfo;
45
+ try {
46
+ listing = await fetchListingInfo(cfg.gatewayUrl, listingId);
47
+ } catch (err) {
48
+ throw err instanceof ChainLensResolveError
49
+ ? err
50
+ : new ChainLensResolveError(String(err));
51
+ }
52
+
53
+ const priceUsdc = listing.priceAtomic ? atomicToUsdc(listing.priceAtomic) : 0;
54
+ const effectiveMaxUsdc = options.maxUsdc ?? priceUsdc;
55
+
56
+ // Step 3+4: Budget checks — must happen before signing
57
+ const budgetCheck = await budget.canSpend(effectiveMaxUsdc);
58
+ if (!budgetCheck.ok) {
59
+ throw new BudgetExceededError(budgetCheck.reason);
60
+ }
61
+
62
+ // Step 5: Sign EIP-3009 authorization
63
+ const marketAddress = CHAIN_LENS_MARKET_ADDRESSES[cfg.chainId];
64
+ if (!marketAddress) throw new ChainLensResolveError(`No market address for chainId=${cfg.chainId}`);
65
+ const amountAtomic = usdcToAtomic(effectiveMaxUsdc);
66
+
67
+ let auth: Awaited<ReturnType<typeof signReceiveWithAuthorization>>;
68
+ try {
69
+ auth = await signReceiveWithAuthorization({
70
+ wallet: cfg.wallet,
71
+ chainId: cfg.chainId,
72
+ amount: amountAtomic,
73
+ to: marketAddress,
74
+ signal: options.signal,
75
+ });
76
+ } catch (err) {
77
+ throw new ChainLensSignError(String(err), err);
78
+ }
79
+
80
+ // Step 6: POST /v1/call
81
+ let res: Response;
82
+ try {
83
+ res = await fetch(`${cfg.gatewayUrl}/v1/call`, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ signal: options.signal,
87
+ body: JSON.stringify({
88
+ listingId,
89
+ params,
90
+ auth: {
91
+ from: auth.from,
92
+ to: auth.to,
93
+ amount: auth.amount,
94
+ validAfter: auth.validAfter,
95
+ validBefore: auth.validBefore,
96
+ nonce: auth.nonce,
97
+ v: auth.v,
98
+ r: auth.r,
99
+ s: auth.s,
100
+ },
101
+ }),
102
+ });
103
+ } catch (err) {
104
+ const latencyMs = Date.now() - t0;
105
+ await telemetry.record({
106
+ ts: t0,
107
+ listingId,
108
+ amountUsdc: effectiveMaxUsdc,
109
+ latencyMs,
110
+ ok: false,
111
+ failure: { kind: "unknown", hint: String(err) },
112
+ paramsHash: hashParams(params),
113
+ });
114
+ throw new ChainLensGatewayError(`Network error: ${String(err)}`, 0, err);
115
+ }
116
+
117
+ const latencyMs = Date.now() - t0;
118
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
119
+ const body = (await res.json().catch(() => null)) as any;
120
+
121
+ // Step 8: On failure, record telemetry and throw typed error
122
+ if (!res.ok || !body?.ok) {
123
+ const failure = body?.failure ?? { kind: "unknown", hint: `HTTP ${res.status}` };
124
+ await telemetry.record({
125
+ ts: t0,
126
+ listingId,
127
+ amountUsdc: effectiveMaxUsdc,
128
+ latencyMs,
129
+ ok: false,
130
+ failure,
131
+ paramsHash: hashParams(params),
132
+ });
133
+ throw new ChainLensCallError(failure);
134
+ }
135
+
136
+ // Step 7: On success, debit budget and record telemetry
137
+ const amountUsdc = body.amount ? atomicToUsdc(body.amount) : effectiveMaxUsdc;
138
+ const feeUsdc = body.fee ? atomicToUsdc(body.fee) : 0;
139
+ const netUsdc = body.net ? atomicToUsdc(body.net) : amountUsdc - feeUsdc;
140
+
141
+ await budget.debit(amountUsdc, options.idempotencyKey);
142
+
143
+ await telemetry.record({
144
+ ts: t0,
145
+ listingId,
146
+ amountUsdc,
147
+ latencyMs,
148
+ ok: true,
149
+ txHash: body.settlement?.txHash,
150
+ paramsHash: hashParams(params),
151
+ });
152
+
153
+ return {
154
+ ok: true,
155
+ data: body.response,
156
+ listingId,
157
+ amountUsdc,
158
+ feeUsdc,
159
+ netUsdc,
160
+ settlement: body.settlement,
161
+ latencyMs,
162
+ attemptIndex: 0,
163
+ };
164
+ }
package/src/client.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { ChainLensConfig, CallOptions, CallResult, RankedListing } from "./types.js";
2
+ import { BudgetController } from "./budget.js";
3
+ import { TelemetryRecorder } from "./telemetry.js";
4
+ import { executeCall } from "./call.js";
5
+ import { fetchRecommendations } from "./recommend.js";
6
+ import { ProviderClient } from "./provider.js";
7
+ import { ChainLensCallError } from "./errors.js";
8
+ import type { FailureMetadata } from "./types.js";
9
+
10
+ const DEFAULT_GATEWAY = "https://chainlens.pelicanlab.dev";
11
+
12
+ const RETRYABLE_KINDS: ReadonlySet<FailureMetadata["kind"]> = new Set([
13
+ "http_5xx",
14
+ "timeout",
15
+ "schema_mismatch",
16
+ ]);
17
+
18
+ export class ChainLens {
19
+ private readonly cfg: ChainLensConfig & { gatewayUrl: string };
20
+ private budget: BudgetController | null = null;
21
+ private telemetry: TelemetryRecorder | null = null;
22
+ private walletAddress: string | null = null;
23
+ readonly provider: ProviderClient;
24
+
25
+ constructor(cfg: ChainLensConfig) {
26
+ this.cfg = { gatewayUrl: DEFAULT_GATEWAY, ...cfg };
27
+ this.provider = new ProviderClient(this.cfg.gatewayUrl, cfg.wallet);
28
+ }
29
+
30
+ private async init(): Promise<{ budget: BudgetController; telemetry: TelemetryRecorder }> {
31
+ if (!this.walletAddress) {
32
+ this.walletAddress = await this.cfg.wallet.address();
33
+ }
34
+ if (!this.budget) {
35
+ this.budget = new BudgetController(this.walletAddress, this.cfg.budget);
36
+ }
37
+ if (!this.telemetry) {
38
+ this.telemetry = new TelemetryRecorder({
39
+ enabled: this.cfg.telemetry?.enabled ?? true,
40
+ upload: this.cfg.telemetry?.upload ?? false,
41
+ bufferMaxEntries: this.cfg.telemetry?.bufferMaxEntries ?? 1000,
42
+ gatewayUrl: this.cfg.gatewayUrl,
43
+ walletAddress: this.walletAddress,
44
+ });
45
+ }
46
+ return { budget: this.budget, telemetry: this.telemetry };
47
+ }
48
+
49
+ async call<T = unknown>(
50
+ listingId: number,
51
+ params: unknown,
52
+ options: CallOptions = {},
53
+ ): Promise<CallResult<T>> {
54
+ const { budget, telemetry } = await this.init();
55
+
56
+ const maxAttempts =
57
+ options.fallback !== false && this.cfg.fallback?.enabled
58
+ ? (this.cfg.fallback.maxAttempts ?? 2)
59
+ : 1;
60
+
61
+ let lastErr: unknown;
62
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
63
+ try {
64
+ const result = await executeCall(this.cfg, budget, telemetry, listingId, params, options);
65
+ return { ...result, attemptIndex: attempt } as CallResult<T>;
66
+ } catch (err) {
67
+ lastErr = err;
68
+ if (
69
+ attempt < maxAttempts - 1 &&
70
+ err instanceof ChainLensCallError &&
71
+ RETRYABLE_KINDS.has(err.failure.kind)
72
+ ) {
73
+ continue;
74
+ }
75
+ break;
76
+ }
77
+ }
78
+
79
+ throw lastErr;
80
+ }
81
+
82
+ async recommend(task: string, maxResults = 5): Promise<RankedListing[]> {
83
+ return fetchRecommendations(this.cfg.gatewayUrl, task, maxResults);
84
+ }
85
+
86
+ async currentSpend(): Promise<{ dailyUsdc: number; monthlyUsdc: number }> {
87
+ const { budget } = await this.init();
88
+ return budget.currentSpend();
89
+ }
90
+ }
package/src/eip3009.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type { WalletAdapter, TypedData } from "./types.js";
3
+
4
+ /** Chain-specific USDC contract addresses. */
5
+ export const USDC_ADDRESSES: Record<number, `0x${string}`> = {
6
+ 84532: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // Base Sepolia
7
+ 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // Base Mainnet
8
+ };
9
+
10
+ /** ChainLensMarket addresses per chain. */
11
+ export const CHAIN_LENS_MARKET_ADDRESSES: Record<number, `0x${string}`> = {
12
+ 84532: "0x45bB56fDB0E6bb14d178E417b67Ed7B3323ffFf7", // Base Sepolia
13
+ 8453: "0x0000000000000000000000000000000000000000", // placeholder — not deployed yet
14
+ };
15
+
16
+ export const RECEIVE_WITH_AUTHORIZATION_TYPES = {
17
+ ReceiveWithAuthorization: [
18
+ { name: "from", type: "address" },
19
+ { name: "to", type: "address" },
20
+ { name: "value", type: "uint256" },
21
+ { name: "validAfter", type: "uint256" },
22
+ { name: "validBefore", type: "uint256" },
23
+ { name: "nonce", type: "bytes32" },
24
+ ],
25
+ } as const;
26
+
27
+ export interface Eip3009Auth {
28
+ from: `0x${string}`;
29
+ to: `0x${string}`;
30
+ amount: string;
31
+ validAfter: string;
32
+ validBefore: string;
33
+ nonce: `0x${string}`;
34
+ v: number;
35
+ r: `0x${string}`;
36
+ s: `0x${string}`;
37
+ }
38
+
39
+ export async function signReceiveWithAuthorization(opts: {
40
+ wallet: WalletAdapter;
41
+ chainId: number;
42
+ amount: bigint;
43
+ to: `0x${string}`;
44
+ signal?: AbortSignal;
45
+ }): Promise<Eip3009Auth> {
46
+ const { wallet, chainId, amount, to } = opts;
47
+
48
+ const usdcAddress = USDC_ADDRESSES[chainId];
49
+ if (!usdcAddress) throw new Error(`No USDC address for chainId=${chainId}`);
50
+
51
+ const from = await wallet.address();
52
+ const now = Math.floor(Date.now() / 1000);
53
+ const validAfter = BigInt(now - 60);
54
+ const validBefore = BigInt(now + 300);
55
+ const nonce = ("0x" + randomBytes(32).toString("hex")) as `0x${string}`;
56
+
57
+ const typedData: TypedData = {
58
+ domain: {
59
+ name: "USD Coin",
60
+ version: "2",
61
+ chainId,
62
+ verifyingContract: usdcAddress,
63
+ },
64
+ types: RECEIVE_WITH_AUTHORIZATION_TYPES as unknown as Record<string, Array<{ name: string; type: string }>>,
65
+ primaryType: "ReceiveWithAuthorization",
66
+ message: {
67
+ from,
68
+ to,
69
+ value: amount,
70
+ validAfter,
71
+ validBefore,
72
+ nonce,
73
+ },
74
+ };
75
+
76
+ const sig = await wallet.signTypedData(typedData);
77
+
78
+ return {
79
+ from,
80
+ to,
81
+ amount: amount.toString(),
82
+ validAfter: validAfter.toString(),
83
+ validBefore: validBefore.toString(),
84
+ nonce,
85
+ v: sig.v,
86
+ r: sig.r,
87
+ s: sig.s,
88
+ };
89
+ }
90
+
91
+ /** Convert USDC display units (e.g. 0.05) to atomic units (50000). */
92
+ export function usdcToAtomic(usdc: number): bigint {
93
+ return BigInt(Math.round(usdc * 1_000_000));
94
+ }
95
+
96
+ /** Convert atomic USDC units to display units. */
97
+ export function atomicToUsdc(atomic: bigint | string): number {
98
+ return Number(BigInt(atomic)) / 1_000_000;
99
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { FailureMetadata } from "./types.js";
2
+
3
+ export class ChainLensError extends Error {
4
+ readonly code: string;
5
+ readonly cause?: unknown;
6
+
7
+ constructor(message: string, code: string, cause?: unknown) {
8
+ super(message);
9
+ this.name = "ChainLensError";
10
+ this.code = code;
11
+ this.cause = cause;
12
+ }
13
+ }
14
+
15
+ /** Listing not found or task query produced no match. */
16
+ export class ChainLensResolveError extends ChainLensError {
17
+ constructor(message: string, cause?: unknown) {
18
+ super(message, "RESOLVE", cause);
19
+ this.name = "ChainLensResolveError";
20
+ }
21
+ }
22
+
23
+ /** Off-chain budget limit exceeded before any signature was produced. */
24
+ export class BudgetExceededError extends ChainLensError {
25
+ readonly reason: string;
26
+
27
+ constructor(reason: string) {
28
+ super(`Budget exceeded: ${reason}`, "BUDGET");
29
+ this.name = "BudgetExceededError";
30
+ this.reason = reason;
31
+ }
32
+ }
33
+
34
+ /** EIP-3009 signing failed. */
35
+ export class ChainLensSignError extends ChainLensError {
36
+ constructor(message: string, cause?: unknown) {
37
+ super(message, "SIGN", cause);
38
+ this.name = "ChainLensSignError";
39
+ }
40
+ }
41
+
42
+ /** Gateway returned a non-200 HTTP status. */
43
+ export class ChainLensGatewayError extends ChainLensError {
44
+ readonly status: number;
45
+
46
+ constructor(message: string, status: number, cause?: unknown) {
47
+ super(message, "GATEWAY", cause);
48
+ this.name = "ChainLensGatewayError";
49
+ this.status = status;
50
+ }
51
+ }
52
+
53
+ /** The call completed but the provider reported failure (no settlement). */
54
+ export class ChainLensCallError extends ChainLensError {
55
+ readonly failure: FailureMetadata;
56
+
57
+ constructor(failure: FailureMetadata) {
58
+ super(`Call failed: ${failure.kind} — ${failure.hint}`, "CALL");
59
+ this.name = "ChainLensCallError";
60
+ this.failure = failure;
61
+ }
62
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ export { ChainLens } from "./client.js";
2
+ export { ViemWallet } from "./wallet/viem.js";
3
+ export { BudgetController } from "./budget.js";
4
+ export { ProviderClient } from "./provider.js";
5
+ export {
6
+ ChainLensError,
7
+ ChainLensResolveError,
8
+ BudgetExceededError,
9
+ ChainLensSignError,
10
+ ChainLensGatewayError,
11
+ ChainLensCallError,
12
+ } from "./errors.js";
13
+ export {
14
+ signReceiveWithAuthorization,
15
+ usdcToAtomic,
16
+ atomicToUsdc,
17
+ USDC_ADDRESSES,
18
+ CHAIN_LENS_MARKET_ADDRESSES,
19
+ } from "./eip3009.js";
20
+ export type {
21
+ ChainLensConfig,
22
+ WalletAdapter,
23
+ TypedData,
24
+ TxRequest,
25
+ CallOptions,
26
+ CallResult,
27
+ RankedListing,
28
+ FailureMetadata,
29
+ ListingInfo,
30
+ BudgetConfig,
31
+ } from "./types.js";
32
+ export type { TelemetryEntry, TelemetryConfig } from "./telemetry.js";
33
+ export type { ClaimableResult, ListingDashboard } from "./provider.js";
@@ -0,0 +1,75 @@
1
+ import type { WalletAdapter } from "./types.js";
2
+ import { ChainLensResolveError, ChainLensGatewayError } from "./errors.js";
3
+
4
+ export interface ClaimableResult {
5
+ totalUsdc: number;
6
+ atomicBalance: string;
7
+ }
8
+
9
+ export interface ListingDashboard {
10
+ listingId: number;
11
+ name: string | null;
12
+ totalEarnedUsdc: number;
13
+ claimableUsdc: number;
14
+ callCount: number;
15
+ successRate: number;
16
+ p50LatencyMs: number;
17
+ }
18
+
19
+ export class ProviderClient {
20
+ constructor(
21
+ private readonly gatewayUrl: string,
22
+ private readonly wallet: WalletAdapter,
23
+ ) {}
24
+
25
+ async claimable(): Promise<ClaimableResult> {
26
+ const address = await this.wallet.address();
27
+ const res = await fetch(
28
+ `${this.gatewayUrl}/v1/provider/claimable?address=${address}`,
29
+ );
30
+ if (!res.ok) {
31
+ throw new ChainLensGatewayError(
32
+ `claimable fetch failed: ${res.status}`,
33
+ res.status,
34
+ );
35
+ }
36
+ return res.json() as Promise<ClaimableResult>;
37
+ }
38
+
39
+ async claim(): Promise<{ txHash: `0x${string}` } | { skipped: true }> {
40
+ const claimable = await this.claimable();
41
+ if (BigInt(claimable.atomicBalance) === 0n) {
42
+ return { skipped: true };
43
+ }
44
+ const address = await this.wallet.address();
45
+ const res = await fetch(`${this.gatewayUrl}/v1/provider/claim`, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify({ address }),
49
+ });
50
+ if (!res.ok) {
51
+ const body = await res.text().catch(() => "");
52
+ throw new ChainLensGatewayError(`claim failed: ${res.status} ${body}`, res.status);
53
+ }
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ const data = (await res.json()) as any;
56
+ return { txHash: data.txHash as `0x${string}` };
57
+ }
58
+
59
+ async listingDashboard(listingId: number): Promise<ListingDashboard> {
60
+ const address = await this.wallet.address();
61
+ const res = await fetch(
62
+ `${this.gatewayUrl}/v1/provider/listing/${listingId}?address=${address}`,
63
+ );
64
+ if (!res.ok) {
65
+ if (res.status === 403) {
66
+ throw new ChainLensResolveError(`Not authorized to view listing ${listingId}`);
67
+ }
68
+ throw new ChainLensGatewayError(
69
+ `dashboard fetch failed: ${res.status}`,
70
+ res.status,
71
+ );
72
+ }
73
+ return res.json() as Promise<ListingDashboard>;
74
+ }
75
+ }