@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/dist/index.cjs +660 -0
- package/dist/index.d.cts +284 -0
- package/dist/index.d.ts +284 -0
- package/dist/index.js +609 -0
- package/package.json +36 -0
- package/src/budget.ts +182 -0
- package/src/call.ts +164 -0
- package/src/client.ts +90 -0
- package/src/eip3009.ts +99 -0
- package/src/errors.ts +62 -0
- package/src/index.ts +33 -0
- package/src/provider.ts +75 -0
- package/src/recommend.ts +21 -0
- package/src/telemetry.ts +72 -0
- package/src/types.ts +136 -0
- package/src/wallet/types.ts +1 -0
- package/src/wallet/viem.ts +57 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +10 -0
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";
|
package/src/provider.ts
ADDED
|
@@ -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
|
+
}
|