@arcenpay/node 0.0.1
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/README.md +117 -0
- package/dist/chunk-SKFD6TSD.mjs +266 -0
- package/dist/index.d.mts +1083 -0
- package/dist/index.d.ts +1083 -0
- package/dist/index.js +3581 -0
- package/dist/index.mjs +3317 -0
- package/dist/tableland.d.mts +46 -0
- package/dist/tableland.d.ts +46 -0
- package/dist/tableland.js +268 -0
- package/dist/tableland.mjs +6 -0
- package/package.json +74 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3317 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TablelandService,
|
|
3
|
+
__esm,
|
|
4
|
+
__export,
|
|
5
|
+
__toCommonJS
|
|
6
|
+
} from "./chunk-SKFD6TSD.mjs";
|
|
7
|
+
|
|
8
|
+
// src/services/nonce-store.ts
|
|
9
|
+
var nonce_store_exports = {};
|
|
10
|
+
__export(nonce_store_exports, {
|
|
11
|
+
InMemoryNonceStore: () => InMemoryNonceStore,
|
|
12
|
+
RedisNonceStore: () => RedisNonceStore
|
|
13
|
+
});
|
|
14
|
+
var InMemoryNonceStore, RedisNonceStore;
|
|
15
|
+
var init_nonce_store = __esm({
|
|
16
|
+
"src/services/nonce-store.ts"() {
|
|
17
|
+
"use strict";
|
|
18
|
+
InMemoryNonceStore = class {
|
|
19
|
+
seen = /* @__PURE__ */ new Map();
|
|
20
|
+
cleanupTimer;
|
|
21
|
+
constructor(cleanupIntervalMs = 6e4) {
|
|
22
|
+
this.cleanupTimer = setInterval(() => {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
for (const [key, expiresAt] of this.seen) {
|
|
25
|
+
if (now >= expiresAt) {
|
|
26
|
+
this.seen.delete(key);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}, cleanupIntervalMs);
|
|
30
|
+
if (typeof this.cleanupTimer === "object" && "unref" in this.cleanupTimer) {
|
|
31
|
+
this.cleanupTimer.unref();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async markUsed(key, ttlMs) {
|
|
35
|
+
if (this.seen.has(key)) {
|
|
36
|
+
const expiresAt = this.seen.get(key);
|
|
37
|
+
if (Date.now() < expiresAt) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.seen.set(key, Date.now() + ttlMs);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
async has(key) {
|
|
45
|
+
if (!this.seen.has(key)) return false;
|
|
46
|
+
const expiresAt = this.seen.get(key);
|
|
47
|
+
if (Date.now() >= expiresAt) {
|
|
48
|
+
this.seen.delete(key);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
stop() {
|
|
54
|
+
clearInterval(this.cleanupTimer);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
RedisNonceStore = class {
|
|
58
|
+
redis;
|
|
59
|
+
prefix;
|
|
60
|
+
constructor(redis, prefix = "meap:nonce:") {
|
|
61
|
+
this.redis = redis;
|
|
62
|
+
this.prefix = prefix;
|
|
63
|
+
}
|
|
64
|
+
async markUsed(key, ttlMs) {
|
|
65
|
+
const ttlSeconds = Math.max(1, Math.ceil(ttlMs / 1e3));
|
|
66
|
+
const result = await this.redis.set(
|
|
67
|
+
`${this.prefix}${key}`,
|
|
68
|
+
"1",
|
|
69
|
+
"EX",
|
|
70
|
+
ttlSeconds,
|
|
71
|
+
"NX"
|
|
72
|
+
);
|
|
73
|
+
return result === "OK";
|
|
74
|
+
}
|
|
75
|
+
async has(key) {
|
|
76
|
+
const result = await this.redis.get(`${this.prefix}${key}`);
|
|
77
|
+
return result !== null;
|
|
78
|
+
}
|
|
79
|
+
stop() {
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// src/client.ts
|
|
86
|
+
import { resolveArcenPayBaseUrl } from "@arcenpay/sdk";
|
|
87
|
+
var ArcenClient = class {
|
|
88
|
+
apiKey;
|
|
89
|
+
baseUrl;
|
|
90
|
+
timeoutMs;
|
|
91
|
+
session = null;
|
|
92
|
+
constructor(config) {
|
|
93
|
+
if (!config.apiKey.startsWith("api_")) {
|
|
94
|
+
throw new Error('ArcenClient: apiKey must start with "api_"');
|
|
95
|
+
}
|
|
96
|
+
this.apiKey = config.apiKey;
|
|
97
|
+
this.baseUrl = resolveArcenPayBaseUrl({ explicit: config.baseUrl });
|
|
98
|
+
this.timeoutMs = config.timeoutMs ?? 1e4;
|
|
99
|
+
}
|
|
100
|
+
buildAuthHeader() {
|
|
101
|
+
return this.session ? `Bearer ${this.session.token}` : `Bearer ${this.apiKey}`;
|
|
102
|
+
}
|
|
103
|
+
async request(method, path2, options = {}) {
|
|
104
|
+
const url = new URL(`${this.baseUrl}${path2}`);
|
|
105
|
+
if (options.query) {
|
|
106
|
+
for (const [k, v] of Object.entries(options.query)) {
|
|
107
|
+
url.searchParams.set(k, v);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const headers = {
|
|
111
|
+
Authorization: options.useApiKey ? `Bearer ${this.apiKey}` : this.buildAuthHeader(),
|
|
112
|
+
"Content-Type": "application/json"
|
|
113
|
+
};
|
|
114
|
+
if (!this.session || options.useApiKey) {
|
|
115
|
+
if (options.companyKeys) {
|
|
116
|
+
const parts = [];
|
|
117
|
+
if (options.companyKeys.id) parts.push(`id=${options.companyKeys.id}`);
|
|
118
|
+
if (options.companyKeys.wallet)
|
|
119
|
+
parts.push(`wallet=${options.companyKeys.wallet}`);
|
|
120
|
+
if (options.companyKeys.email)
|
|
121
|
+
parts.push(`email=${options.companyKeys.email}`);
|
|
122
|
+
if (parts.length > 0) headers["X-Arcen-Company-Keys"] = parts.join(",");
|
|
123
|
+
}
|
|
124
|
+
if (options.userKeys) {
|
|
125
|
+
const parts = [];
|
|
126
|
+
if (options.userKeys.id) parts.push(`id=${options.userKeys.id}`);
|
|
127
|
+
if (options.userKeys.clerkUserId)
|
|
128
|
+
parts.push(`clerk_user_id=${options.userKeys.clerkUserId}`);
|
|
129
|
+
if (options.userKeys.wallet)
|
|
130
|
+
parts.push(`wallet=${options.userKeys.wallet}`);
|
|
131
|
+
if (parts.length > 0) headers["X-Arcen-User-Keys"] = parts.join(",");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(url.toString(), {
|
|
138
|
+
method,
|
|
139
|
+
headers,
|
|
140
|
+
body: options.body !== void 0 ? JSON.stringify(options.body) : void 0,
|
|
141
|
+
signal: controller.signal
|
|
142
|
+
});
|
|
143
|
+
let json;
|
|
144
|
+
try {
|
|
145
|
+
json = await res.json();
|
|
146
|
+
} catch {
|
|
147
|
+
throw new ArcenApiError(
|
|
148
|
+
`Invalid JSON response from ${method} ${path2}`,
|
|
149
|
+
res.status
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (!res.ok) {
|
|
153
|
+
throw new ArcenApiError(json.error ?? `HTTP ${res.status}`, res.status);
|
|
154
|
+
}
|
|
155
|
+
return json;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err instanceof ArcenApiError) throw err;
|
|
158
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
159
|
+
throw new ArcenApiError(
|
|
160
|
+
`Request timed out after ${this.timeoutMs}ms`,
|
|
161
|
+
408
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
throw new ArcenApiError(
|
|
165
|
+
err instanceof Error ? err.message : "Network request failed",
|
|
166
|
+
0
|
|
167
|
+
);
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async identify(input) {
|
|
173
|
+
const res = await this.request(
|
|
174
|
+
"POST",
|
|
175
|
+
"/api/v1/access-tokens",
|
|
176
|
+
{
|
|
177
|
+
useApiKey: true,
|
|
178
|
+
body: {
|
|
179
|
+
company: input.company,
|
|
180
|
+
user: input.user ? {
|
|
181
|
+
id: input.user.id,
|
|
182
|
+
clerk_user_id: input.user.clerkUserId,
|
|
183
|
+
wallet: input.user.wallet,
|
|
184
|
+
name: input.user.name,
|
|
185
|
+
email: input.user.email
|
|
186
|
+
} : void 0,
|
|
187
|
+
expires_in: input.expiresIn ?? 3600
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
const data = res.data;
|
|
192
|
+
this.session = {
|
|
193
|
+
token: data.token,
|
|
194
|
+
companyId: data.company_id,
|
|
195
|
+
userId: data.user_id
|
|
196
|
+
};
|
|
197
|
+
return {
|
|
198
|
+
token: data.token,
|
|
199
|
+
companyId: data.company_id,
|
|
200
|
+
userId: data.user_id,
|
|
201
|
+
expiresAt: data.expires_at
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
async track(event) {
|
|
205
|
+
const input = typeof event === "string" ? { name: event } : event;
|
|
206
|
+
await this.request("POST", "/api/v1/events", {
|
|
207
|
+
useApiKey: this.session === null,
|
|
208
|
+
body: {
|
|
209
|
+
event_type: "track",
|
|
210
|
+
name: input.name,
|
|
211
|
+
...input.company ? { company: input.company } : {},
|
|
212
|
+
...input.user ? { user: input.user } : {},
|
|
213
|
+
traits: input.traits,
|
|
214
|
+
...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async consumeEntitlement(input) {
|
|
219
|
+
const normalized = typeof input === "string" ? { featureKey: input } : input;
|
|
220
|
+
return this.request(
|
|
221
|
+
"POST",
|
|
222
|
+
"/api/v1/usage/consume",
|
|
223
|
+
{
|
|
224
|
+
useApiKey: this.session === null,
|
|
225
|
+
companyKeys: normalized.company,
|
|
226
|
+
userKeys: normalized.user,
|
|
227
|
+
body: {
|
|
228
|
+
featureKey: normalized.featureKey,
|
|
229
|
+
traits: normalized.traits,
|
|
230
|
+
...normalized.idempotencyKey ? { idempotencyKey: normalized.idempotencyKey } : {}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
async checkFlag(key, companyKeys, userKeys) {
|
|
236
|
+
return this.request("GET", "/api/v1/check", {
|
|
237
|
+
query: { key },
|
|
238
|
+
companyKeys,
|
|
239
|
+
userKeys
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
async checkEntitlement(key, companyKeys, userKeys) {
|
|
243
|
+
return this.request("GET", "/api/v1/check", {
|
|
244
|
+
query: { key },
|
|
245
|
+
companyKeys,
|
|
246
|
+
userKeys
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async checkFlags(featureKeys, companyKeys, userKeys) {
|
|
250
|
+
const results = await Promise.all(
|
|
251
|
+
featureKeys.map((key) => this.checkFlag(key, companyKeys, userKeys))
|
|
252
|
+
);
|
|
253
|
+
return Object.fromEntries(results.map((r) => [r.key, r]));
|
|
254
|
+
}
|
|
255
|
+
async listCompanies(options = {}) {
|
|
256
|
+
const query = {};
|
|
257
|
+
if (options.limit) query.limit = String(options.limit);
|
|
258
|
+
if (options.search) query.search = options.search;
|
|
259
|
+
if (options.cursor) query.cursor = options.cursor;
|
|
260
|
+
return this.request("GET", "/api/v1/companies", { useApiKey: true, query });
|
|
261
|
+
}
|
|
262
|
+
async getCompany(id) {
|
|
263
|
+
return this.request("GET", `/api/v1/companies/${id}`, { useApiKey: true });
|
|
264
|
+
}
|
|
265
|
+
async createCompany(data) {
|
|
266
|
+
return this.request("POST", "/api/v1/companies", {
|
|
267
|
+
useApiKey: true,
|
|
268
|
+
body: data
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
async updateCompany(id, data) {
|
|
272
|
+
return this.request("PATCH", `/api/v1/companies/${id}`, {
|
|
273
|
+
useApiKey: true,
|
|
274
|
+
body: data
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async setCompanyOverride(companyId, featureKey, value, reason) {
|
|
278
|
+
await this.request("POST", `/api/v1/companies/${companyId}/overrides`, {
|
|
279
|
+
useApiKey: true,
|
|
280
|
+
body: { featureKey, value, reason }
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
async activateSubscription(input) {
|
|
284
|
+
const res = await this.request(
|
|
285
|
+
"POST",
|
|
286
|
+
"/api/v1/subscriptions/activate",
|
|
287
|
+
{
|
|
288
|
+
useApiKey: true,
|
|
289
|
+
body: {
|
|
290
|
+
planId: typeof input.planId === "bigint" ? input.planId.toString() : String(input.planId),
|
|
291
|
+
paymentAccount: input.paymentAccount,
|
|
292
|
+
company: input.company,
|
|
293
|
+
...input.subscriberEmail ? { subscriberEmail: input.subscriberEmail } : {}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
return res.data;
|
|
298
|
+
}
|
|
299
|
+
/** @deprecated Use identify() instead */
|
|
300
|
+
async createAccessToken(companyKeys, userKeys, expiresIn = 3600) {
|
|
301
|
+
const res = await this.request(
|
|
302
|
+
"POST",
|
|
303
|
+
"/api/v1/access-tokens",
|
|
304
|
+
{
|
|
305
|
+
useApiKey: true,
|
|
306
|
+
body: {
|
|
307
|
+
company: companyKeys,
|
|
308
|
+
user: userKeys ? {
|
|
309
|
+
id: userKeys.id,
|
|
310
|
+
clerk_user_id: userKeys.clerkUserId,
|
|
311
|
+
wallet: userKeys.wallet
|
|
312
|
+
} : void 0,
|
|
313
|
+
expires_in: expiresIn
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
return {
|
|
318
|
+
token: res.data.token,
|
|
319
|
+
expiresAt: res.data.expires_at
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
var ArcenApiError = class extends Error {
|
|
324
|
+
constructor(message, statusCode) {
|
|
325
|
+
super(message);
|
|
326
|
+
this.statusCode = statusCode;
|
|
327
|
+
this.name = "ArcenApiError";
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// src/middleware/x402.ts
|
|
332
|
+
import {
|
|
333
|
+
X402_VERSION,
|
|
334
|
+
X402_SCHEME,
|
|
335
|
+
SessionVaultABI,
|
|
336
|
+
getChainEnvironment
|
|
337
|
+
} from "@arcenpay/sdk";
|
|
338
|
+
import {
|
|
339
|
+
createPublicClient,
|
|
340
|
+
http,
|
|
341
|
+
recoverTypedDataAddress,
|
|
342
|
+
isAddress,
|
|
343
|
+
parseUnits
|
|
344
|
+
} from "viem";
|
|
345
|
+
import { sepolia, baseSepolia, base } from "viem/chains";
|
|
346
|
+
var PAYMENT_DOMAIN = {
|
|
347
|
+
name: "MEAP x402 Payment",
|
|
348
|
+
version: "1"
|
|
349
|
+
};
|
|
350
|
+
var PAYMENT_TYPES = {
|
|
351
|
+
Payment: [
|
|
352
|
+
{ name: "from", type: "address" },
|
|
353
|
+
{ name: "amount", type: "uint256" },
|
|
354
|
+
{ name: "resource", type: "string" },
|
|
355
|
+
{ name: "sessionId", type: "string" },
|
|
356
|
+
{ name: "nonce", type: "uint256" },
|
|
357
|
+
{ name: "timestamp", type: "uint256" }
|
|
358
|
+
]
|
|
359
|
+
};
|
|
360
|
+
var CHAIN_MAP = {
|
|
361
|
+
11155111: sepolia,
|
|
362
|
+
84532: baseSepolia,
|
|
363
|
+
8453: base
|
|
364
|
+
};
|
|
365
|
+
var CHAIN_ID_TO_NETWORK = {
|
|
366
|
+
11155111: "ethereum-sepolia",
|
|
367
|
+
84532: "base-sepolia",
|
|
368
|
+
8453: "base-mainnet"
|
|
369
|
+
};
|
|
370
|
+
function coerceBigInt(value, fallback) {
|
|
371
|
+
try {
|
|
372
|
+
if (typeof value === "bigint") return value;
|
|
373
|
+
if (typeof value === "number") {
|
|
374
|
+
if (!Number.isFinite(value) || value < 0) return fallback;
|
|
375
|
+
return BigInt(Math.trunc(value));
|
|
376
|
+
}
|
|
377
|
+
if (typeof value === "string" && value.trim()) return BigInt(value.trim());
|
|
378
|
+
return fallback;
|
|
379
|
+
} catch {
|
|
380
|
+
return fallback;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function normalizeSessionId(value) {
|
|
384
|
+
if (typeof value !== "string") return "default";
|
|
385
|
+
const trimmed = value.trim();
|
|
386
|
+
return trimmed.length > 0 ? trimmed : "default";
|
|
387
|
+
}
|
|
388
|
+
function normalizeAmount(value, fallback) {
|
|
389
|
+
return coerceBigInt(value, fallback);
|
|
390
|
+
}
|
|
391
|
+
function normalizeNonce(value) {
|
|
392
|
+
return coerceBigInt(value, 0n);
|
|
393
|
+
}
|
|
394
|
+
function normalizeTimestamp(value) {
|
|
395
|
+
return coerceBigInt(value, BigInt(Date.now()));
|
|
396
|
+
}
|
|
397
|
+
function normalizePaymentTimestampMs(timestamp) {
|
|
398
|
+
if (timestamp < 1000000000000n) {
|
|
399
|
+
return timestamp * 1000n;
|
|
400
|
+
}
|
|
401
|
+
return timestamp;
|
|
402
|
+
}
|
|
403
|
+
function normalizeResourceUrl(value) {
|
|
404
|
+
try {
|
|
405
|
+
const parsed = new URL(value);
|
|
406
|
+
return `${parsed.origin}${parsed.pathname}${parsed.search}`;
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function buildRequestResource(req) {
|
|
412
|
+
return `${req.protocol}://${req.get("host")}${req.originalUrl}`;
|
|
413
|
+
}
|
|
414
|
+
function sendRejection(res, reason, message, details) {
|
|
415
|
+
res.setHeader("x-meap-rejection-reason", reason);
|
|
416
|
+
res.status(402).json({
|
|
417
|
+
ok: false,
|
|
418
|
+
rejectionReason: reason,
|
|
419
|
+
message,
|
|
420
|
+
...details ? { details } : {}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
function x402Middleware(options) {
|
|
424
|
+
const {
|
|
425
|
+
planId,
|
|
426
|
+
ratePerCall,
|
|
427
|
+
chainId = 84532,
|
|
428
|
+
network,
|
|
429
|
+
rpcUrl,
|
|
430
|
+
payTo,
|
|
431
|
+
settlePayment,
|
|
432
|
+
failOpenOnBalanceCheck = false,
|
|
433
|
+
maxPaymentAgeMs = 3e5,
|
|
434
|
+
// 5 minutes
|
|
435
|
+
nonceTtlMs = 6e5,
|
|
436
|
+
// 10 minutes
|
|
437
|
+
nonceStore: externalNonceStore
|
|
438
|
+
} = options;
|
|
439
|
+
const inferredNetwork = CHAIN_ID_TO_NETWORK[chainId];
|
|
440
|
+
if (network && inferredNetwork && network !== inferredNetwork) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`[x402] network "${network}" does not match chainId ${chainId} (${inferredNetwork})`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
const resolvedNetwork = network ?? inferredNetwork ?? "ethereum-sepolia";
|
|
446
|
+
let nonceStore;
|
|
447
|
+
if (externalNonceStore) {
|
|
448
|
+
nonceStore = externalNonceStore;
|
|
449
|
+
} else {
|
|
450
|
+
if (process.env.NODE_ENV === "production") {
|
|
451
|
+
console.warn(
|
|
452
|
+
"[x402] WARNING: Using InMemoryNonceStore in production. This does not survive process restarts and is unsafe for multi-instance deployments. Pass a RedisNonceStore (or compatible NonceStore) via the `nonceStore` option."
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
const { InMemoryNonceStore: InMemoryNonceStore2 } = (init_nonce_store(), __toCommonJS(nonce_store_exports));
|
|
456
|
+
nonceStore = new InMemoryNonceStore2();
|
|
457
|
+
}
|
|
458
|
+
const rateAtomicUnits = parseUnits(ratePerCall, 6);
|
|
459
|
+
const chainEnv = getChainEnvironment(chainId);
|
|
460
|
+
const contracts = chainEnv.contracts;
|
|
461
|
+
const payToAddress = payTo || contracts.feeCollector || "0x0000000000000000000000000000000000000000";
|
|
462
|
+
const chain = CHAIN_MAP[chainId] || sepolia;
|
|
463
|
+
const publicClient = createPublicClient({
|
|
464
|
+
chain,
|
|
465
|
+
transport: http(rpcUrl || chainEnv.services.rpcUrl || chain.rpcUrls.default.http[0])
|
|
466
|
+
});
|
|
467
|
+
return async (req, res, next) => {
|
|
468
|
+
const paymentHeader = req.headers["x-payment"];
|
|
469
|
+
if (!paymentHeader) {
|
|
470
|
+
const x402Response = {
|
|
471
|
+
ok: false,
|
|
472
|
+
version: X402_VERSION,
|
|
473
|
+
accepts: [
|
|
474
|
+
{
|
|
475
|
+
scheme: X402_SCHEME,
|
|
476
|
+
network: resolvedNetwork,
|
|
477
|
+
maxAmountRequired: rateAtomicUnits.toString(),
|
|
478
|
+
resource: `${req.protocol}://${req.get("host")}${req.originalUrl}`,
|
|
479
|
+
description: `Premium API access \u2014 Plan: ${planId}`,
|
|
480
|
+
mimeType: "application/json",
|
|
481
|
+
payTo: payToAddress
|
|
482
|
+
}
|
|
483
|
+
],
|
|
484
|
+
error: "Payment required for this resource",
|
|
485
|
+
rejectionReason: "MISSING_PAYMENT_HEADER"
|
|
486
|
+
};
|
|
487
|
+
res.status(402).json(x402Response);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
let paymentData;
|
|
492
|
+
try {
|
|
493
|
+
const normalizedHeader = paymentHeader.trim();
|
|
494
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(normalizedHeader)) {
|
|
495
|
+
sendRejection(
|
|
496
|
+
res,
|
|
497
|
+
"INVALID_PAYMENT_HEADER_ENCODING",
|
|
498
|
+
"X-PAYMENT header must be valid base64-encoded JSON"
|
|
499
|
+
);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const decoded = Buffer.from(paymentHeader, "base64").toString("utf-8");
|
|
503
|
+
paymentData = JSON.parse(decoded);
|
|
504
|
+
} catch {
|
|
505
|
+
sendRejection(
|
|
506
|
+
res,
|
|
507
|
+
"INVALID_PAYMENT_HEADER_FORMAT",
|
|
508
|
+
"X-PAYMENT header must decode to a valid JSON object"
|
|
509
|
+
);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const { signature, payload: payloadRaw } = paymentData;
|
|
513
|
+
if (typeof signature !== "string" || !signature.startsWith("0x") || !payloadRaw) {
|
|
514
|
+
sendRejection(
|
|
515
|
+
res,
|
|
516
|
+
"MALFORMED_PAYMENT_PAYLOAD",
|
|
517
|
+
"Payment must include valid signature and payload fields"
|
|
518
|
+
);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
let payload;
|
|
522
|
+
try {
|
|
523
|
+
const parsedPayload = typeof payloadRaw === "string" ? JSON.parse(payloadRaw) : payloadRaw;
|
|
524
|
+
if (!parsedPayload || typeof parsedPayload !== "object" || Array.isArray(parsedPayload)) {
|
|
525
|
+
throw new Error("payload must be an object");
|
|
526
|
+
}
|
|
527
|
+
payload = parsedPayload;
|
|
528
|
+
} catch {
|
|
529
|
+
sendRejection(
|
|
530
|
+
res,
|
|
531
|
+
"MALFORMED_PAYMENT_PAYLOAD",
|
|
532
|
+
"Payment payload must be a valid JSON object"
|
|
533
|
+
);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const signerClaimed = typeof payload.from === "string" ? payload.from : "";
|
|
537
|
+
if (!isAddress(signerClaimed)) {
|
|
538
|
+
sendRejection(
|
|
539
|
+
res,
|
|
540
|
+
"MALFORMED_PAYMENT_PAYLOAD",
|
|
541
|
+
'Payment payload is missing a valid "from" address'
|
|
542
|
+
);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const requestedAmount = normalizeAmount(payload.amount, rateAtomicUnits);
|
|
546
|
+
const sessionId = normalizeSessionId(payload.sessionId);
|
|
547
|
+
const expectedResource = normalizeResourceUrl(buildRequestResource(req));
|
|
548
|
+
const paymentResourceRaw = typeof payload.resource === "string" ? payload.resource : "";
|
|
549
|
+
const paymentResource = normalizeResourceUrl(paymentResourceRaw);
|
|
550
|
+
if (!expectedResource || !paymentResource || paymentResource !== expectedResource) {
|
|
551
|
+
sendRejection(
|
|
552
|
+
res,
|
|
553
|
+
"RESOURCE_MISMATCH",
|
|
554
|
+
"Payment resource does not match the requested endpoint",
|
|
555
|
+
{
|
|
556
|
+
paymentResource: paymentResourceRaw || null,
|
|
557
|
+
expectedResource: expectedResource || null
|
|
558
|
+
}
|
|
559
|
+
);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (requestedAmount < rateAtomicUnits) {
|
|
563
|
+
sendRejection(
|
|
564
|
+
res,
|
|
565
|
+
"PAYMENT_AMOUNT_TOO_LOW",
|
|
566
|
+
"Payment amount is below the required endpoint rate",
|
|
567
|
+
{
|
|
568
|
+
providedAmount: requestedAmount.toString(),
|
|
569
|
+
requiredAmount: rateAtomicUnits.toString()
|
|
570
|
+
}
|
|
571
|
+
);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
let recoveredSigner;
|
|
575
|
+
try {
|
|
576
|
+
recoveredSigner = await recoverTypedDataAddress({
|
|
577
|
+
domain: { ...PAYMENT_DOMAIN, chainId: BigInt(chainId) },
|
|
578
|
+
types: PAYMENT_TYPES,
|
|
579
|
+
primaryType: "Payment",
|
|
580
|
+
message: {
|
|
581
|
+
from: signerClaimed,
|
|
582
|
+
amount: requestedAmount,
|
|
583
|
+
resource: String(payload.resource || ""),
|
|
584
|
+
sessionId,
|
|
585
|
+
nonce: normalizeNonce(payload.nonce),
|
|
586
|
+
timestamp: normalizeTimestamp(payload.timestamp)
|
|
587
|
+
},
|
|
588
|
+
signature
|
|
589
|
+
});
|
|
590
|
+
} catch {
|
|
591
|
+
sendRejection(
|
|
592
|
+
res,
|
|
593
|
+
"INVALID_SIGNATURE",
|
|
594
|
+
"Unable to validate the payment signature"
|
|
595
|
+
);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (recoveredSigner.toLowerCase() !== signerClaimed.toLowerCase()) {
|
|
599
|
+
sendRejection(
|
|
600
|
+
res,
|
|
601
|
+
"SIGNER_MISMATCH",
|
|
602
|
+
"Recovered signer does not match the payment sender"
|
|
603
|
+
);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const verifiedSigner = recoveredSigner;
|
|
607
|
+
const rawPaymentTimestamp = normalizeTimestamp(payload.timestamp);
|
|
608
|
+
const paymentTimestamp = normalizePaymentTimestampMs(rawPaymentTimestamp);
|
|
609
|
+
const paymentAgeMs = Date.now() - Number(paymentTimestamp);
|
|
610
|
+
if (paymentAgeMs > maxPaymentAgeMs || paymentAgeMs < -6e4) {
|
|
611
|
+
sendRejection(
|
|
612
|
+
res,
|
|
613
|
+
"PAYMENT_EXPIRED",
|
|
614
|
+
`Payment timestamp is outside the acceptable window (${Math.round(maxPaymentAgeMs / 1e3)}s)`,
|
|
615
|
+
{
|
|
616
|
+
rawPaymentTimestamp: rawPaymentTimestamp.toString(),
|
|
617
|
+
paymentTimestamp: paymentTimestamp.toString(),
|
|
618
|
+
serverTime: Date.now().toString()
|
|
619
|
+
}
|
|
620
|
+
);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const nonce = normalizeNonce(payload.nonce);
|
|
624
|
+
const nonceKey = `${verifiedSigner.toLowerCase()}:${nonce.toString()}`;
|
|
625
|
+
const isFresh = await nonceStore.markUsed(nonceKey, nonceTtlMs);
|
|
626
|
+
if (!isFresh) {
|
|
627
|
+
sendRejection(
|
|
628
|
+
res,
|
|
629
|
+
"NONCE_ALREADY_USED",
|
|
630
|
+
"This payment nonce has already been used. Use a fresh nonce for each request."
|
|
631
|
+
);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (verifiedSigner && contracts.sessionVault) {
|
|
635
|
+
try {
|
|
636
|
+
const balance = await publicClient.readContract({
|
|
637
|
+
address: contracts.sessionVault,
|
|
638
|
+
abi: SessionVaultABI,
|
|
639
|
+
functionName: "getAgentBalance",
|
|
640
|
+
args: [verifiedSigner]
|
|
641
|
+
});
|
|
642
|
+
if (balance < requestedAmount) {
|
|
643
|
+
sendRejection(
|
|
644
|
+
res,
|
|
645
|
+
"INSUFFICIENT_SESSION_BALANCE",
|
|
646
|
+
"Session balance is below the required amount",
|
|
647
|
+
{
|
|
648
|
+
balance: balance.toString(),
|
|
649
|
+
required: requestedAmount.toString()
|
|
650
|
+
}
|
|
651
|
+
);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
} catch (err) {
|
|
655
|
+
if (failOpenOnBalanceCheck) {
|
|
656
|
+
console.warn(
|
|
657
|
+
"[x402] Session vault balance check failed (fail-open enabled):",
|
|
658
|
+
err
|
|
659
|
+
);
|
|
660
|
+
} else {
|
|
661
|
+
sendRejection(
|
|
662
|
+
res,
|
|
663
|
+
"BALANCE_CHECK_FAILED",
|
|
664
|
+
"Unable to verify session balance on-chain"
|
|
665
|
+
);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
let settledAmount = requestedAmount;
|
|
671
|
+
let settlementTxHash = null;
|
|
672
|
+
if (settlePayment) {
|
|
673
|
+
try {
|
|
674
|
+
const settlement = await settlePayment({
|
|
675
|
+
signer: verifiedSigner,
|
|
676
|
+
amount: requestedAmount,
|
|
677
|
+
sessionId,
|
|
678
|
+
planId,
|
|
679
|
+
paymentHeader,
|
|
680
|
+
payload,
|
|
681
|
+
request: req
|
|
682
|
+
});
|
|
683
|
+
settledAmount = coerceBigInt(
|
|
684
|
+
settlement.settledAmount,
|
|
685
|
+
requestedAmount
|
|
686
|
+
);
|
|
687
|
+
settlementTxHash = settlement.settlementTxHash || null;
|
|
688
|
+
} catch (err) {
|
|
689
|
+
sendRejection(
|
|
690
|
+
res,
|
|
691
|
+
"SETTLEMENT_FAILED",
|
|
692
|
+
err?.message || "Payment verification succeeded but settlement failed"
|
|
693
|
+
);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const verifiedContext = {
|
|
698
|
+
verified: true,
|
|
699
|
+
signer: verifiedSigner,
|
|
700
|
+
planId,
|
|
701
|
+
ratePerCall,
|
|
702
|
+
amount: requestedAmount.toString(),
|
|
703
|
+
settledAmount: settledAmount.toString(),
|
|
704
|
+
settlementTxHash,
|
|
705
|
+
sessionId,
|
|
706
|
+
paymentHeader,
|
|
707
|
+
timestamp: Date.now(),
|
|
708
|
+
rejectionReason: null
|
|
709
|
+
};
|
|
710
|
+
req.meapPayment = verifiedContext;
|
|
711
|
+
next();
|
|
712
|
+
} catch (error) {
|
|
713
|
+
console.error("[x402] Payment processing error:", error);
|
|
714
|
+
sendRejection(
|
|
715
|
+
res,
|
|
716
|
+
"PAYMENT_PROCESSING_FAILED",
|
|
717
|
+
error.message || "Payment processing failed"
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// src/services/usage.ts
|
|
724
|
+
import { createHash } from "crypto";
|
|
725
|
+
import fs from "fs";
|
|
726
|
+
import path from "path";
|
|
727
|
+
import { pathToFileURL } from "url";
|
|
728
|
+
import { createPublicClient as createPublicClient2, createWalletClient, http as http2 } from "viem";
|
|
729
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
730
|
+
import { sepolia as sepolia2, baseSepolia as baseSepolia2, base as base2 } from "viem/chains";
|
|
731
|
+
import {
|
|
732
|
+
DEFAULT_CHAIN_ID,
|
|
733
|
+
ZKUsageVerifierABI,
|
|
734
|
+
getContractAddresses
|
|
735
|
+
} from "@arcenpay/sdk";
|
|
736
|
+
var UsageProofErrorCodes = {
|
|
737
|
+
TOOLING_UNAVAILABLE: "TOOLING_UNAVAILABLE",
|
|
738
|
+
ARTIFACTS_MISSING: "ARTIFACTS_MISSING",
|
|
739
|
+
ARTIFACT_VERSION_MISMATCH: "ARTIFACT_VERSION_MISMATCH",
|
|
740
|
+
INVALID_VERIFICATION_KEY: "INVALID_VERIFICATION_KEY",
|
|
741
|
+
INVALID_INPUT: "INVALID_INPUT",
|
|
742
|
+
PROOF_GENERATION_FAILED: "PROOF_GENERATION_FAILED",
|
|
743
|
+
PROOF_SIGNAL_MISMATCH: "PROOF_SIGNAL_MISMATCH"
|
|
744
|
+
};
|
|
745
|
+
var UsageProofError = class extends Error {
|
|
746
|
+
code;
|
|
747
|
+
cause;
|
|
748
|
+
constructor(code, message, cause) {
|
|
749
|
+
super(message);
|
|
750
|
+
this.name = "UsageProofError";
|
|
751
|
+
this.code = code;
|
|
752
|
+
this.cause = cause;
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
var CHAIN_MAP2 = {
|
|
756
|
+
11155111: sepolia2,
|
|
757
|
+
84532: baseSepolia2,
|
|
758
|
+
8453: base2
|
|
759
|
+
};
|
|
760
|
+
var MAX_ENTRIES = 128;
|
|
761
|
+
var ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
762
|
+
var UsageService = class {
|
|
763
|
+
logs = /* @__PURE__ */ new Map();
|
|
764
|
+
config;
|
|
765
|
+
signerAccount;
|
|
766
|
+
publicClient;
|
|
767
|
+
walletClient;
|
|
768
|
+
runtimeValidated = false;
|
|
769
|
+
constructor(config = {}) {
|
|
770
|
+
const chainId = config.chainId ?? DEFAULT_CHAIN_ID;
|
|
771
|
+
const chain = CHAIN_MAP2[chainId] || baseSepolia2;
|
|
772
|
+
const rpcUrl = config.rpcUrl || chain.rpcUrls.default.http[0];
|
|
773
|
+
const proofMode = config.proofMode ?? (process.env.NODE_ENV === "development" ? "dev" : "strict");
|
|
774
|
+
const circuitsRootDir = this.resolveCircuitsRoot(config.circuitsRootDir);
|
|
775
|
+
const buildDir = config.circuitBuildDir || path.join(circuitsRootDir, "build");
|
|
776
|
+
this.config = {
|
|
777
|
+
chainId,
|
|
778
|
+
rpcUrl,
|
|
779
|
+
proofMode,
|
|
780
|
+
circuitsRootDir,
|
|
781
|
+
circuitBuildDir: buildDir,
|
|
782
|
+
circuitWasmPath: config.circuitWasmPath || path.join(buildDir, "UsageBilling_js", "UsageBilling.wasm"),
|
|
783
|
+
zkeyPath: config.zkeyPath || path.join(buildDir, "circuit_final.zkey"),
|
|
784
|
+
verificationKeyPath: config.verificationKeyPath || path.join(buildDir, "verification_key.json"),
|
|
785
|
+
proveScriptPath: config.proveScriptPath || path.join(circuitsRootDir, "scripts", "prove.js"),
|
|
786
|
+
artifactVersion: config.artifactVersion || process.env.MEAP_PROOF_ARTIFACT_VERSION || "",
|
|
787
|
+
artifactManifestPath: config.artifactManifestPath || process.env.MEAP_PROOF_ARTIFACT_MANIFEST_PATH || path.join(circuitsRootDir, "artifact-manifest.json"),
|
|
788
|
+
signerPrivateKey: config.signerPrivateKey
|
|
789
|
+
};
|
|
790
|
+
if (config.signerPrivateKey) {
|
|
791
|
+
const key = config.signerPrivateKey.startsWith("0x") ? config.signerPrivateKey : `0x${config.signerPrivateKey}`;
|
|
792
|
+
this.signerAccount = privateKeyToAccount(key);
|
|
793
|
+
} else {
|
|
794
|
+
this.signerAccount = null;
|
|
795
|
+
}
|
|
796
|
+
this.publicClient = createPublicClient2({
|
|
797
|
+
chain,
|
|
798
|
+
transport: http2(rpcUrl)
|
|
799
|
+
});
|
|
800
|
+
if (this.signerAccount) {
|
|
801
|
+
this.walletClient = createWalletClient({
|
|
802
|
+
account: this.signerAccount,
|
|
803
|
+
chain,
|
|
804
|
+
transport: http2(rpcUrl)
|
|
805
|
+
});
|
|
806
|
+
} else {
|
|
807
|
+
this.walletClient = null;
|
|
808
|
+
}
|
|
809
|
+
if (this.config.proofMode === "strict") {
|
|
810
|
+
this.assertProofRuntimeReady();
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
getProofMode() {
|
|
814
|
+
return this.config.proofMode;
|
|
815
|
+
}
|
|
816
|
+
getProofRuntimeConfig() {
|
|
817
|
+
return {
|
|
818
|
+
proofMode: this.config.proofMode,
|
|
819
|
+
proveScriptPath: this.config.proveScriptPath,
|
|
820
|
+
circuitWasmPath: this.config.circuitWasmPath,
|
|
821
|
+
zkeyPath: this.config.zkeyPath,
|
|
822
|
+
verificationKeyPath: this.config.verificationKeyPath,
|
|
823
|
+
artifactVersion: this.config.artifactVersion,
|
|
824
|
+
artifactManifestPath: this.config.artifactManifestPath
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
assertProofRuntimeReady() {
|
|
828
|
+
this.assertProofTooling();
|
|
829
|
+
this.assertProofArtifacts();
|
|
830
|
+
this.assertVerificationKey();
|
|
831
|
+
this.runtimeValidated = true;
|
|
832
|
+
}
|
|
833
|
+
async logUsage(sessionId, callData) {
|
|
834
|
+
const message = JSON.stringify({
|
|
835
|
+
sessionId,
|
|
836
|
+
endpoint: callData.endpoint,
|
|
837
|
+
method: callData.method,
|
|
838
|
+
timestamp: callData.timestamp,
|
|
839
|
+
responseSize: callData.responseSize ?? 0
|
|
840
|
+
});
|
|
841
|
+
let signature = "";
|
|
842
|
+
if (this.signerAccount) {
|
|
843
|
+
try {
|
|
844
|
+
signature = await this.signerAccount.signMessage({ message });
|
|
845
|
+
} catch (err) {
|
|
846
|
+
console.error("[UsageService] Failed to sign log entry:", err);
|
|
847
|
+
signature = createHash("sha256").update(message).digest("hex");
|
|
848
|
+
}
|
|
849
|
+
} else {
|
|
850
|
+
signature = createHash("sha256").update(message).digest("hex");
|
|
851
|
+
}
|
|
852
|
+
const entry = { sessionId, callData, signature };
|
|
853
|
+
const existing = this.logs.get(sessionId) || [];
|
|
854
|
+
existing.push(entry);
|
|
855
|
+
this.logs.set(sessionId, existing);
|
|
856
|
+
}
|
|
857
|
+
getUsageCount(sessionId) {
|
|
858
|
+
return (this.logs.get(sessionId) || []).length;
|
|
859
|
+
}
|
|
860
|
+
getAllSessions() {
|
|
861
|
+
return Array.from(this.logs.keys());
|
|
862
|
+
}
|
|
863
|
+
aggregateUsage(sessionId, windowEnd) {
|
|
864
|
+
const logs = this.logs.get(sessionId) || [];
|
|
865
|
+
const windowLogs = logs.filter((entry) => entry.callData.timestamp <= windowEnd);
|
|
866
|
+
const windowStart = windowLogs.length > 0 ? Math.min(...windowLogs.map((entry) => entry.callData.timestamp)) : 0;
|
|
867
|
+
return {
|
|
868
|
+
sessionId,
|
|
869
|
+
callCount: windowLogs.length,
|
|
870
|
+
windowStart,
|
|
871
|
+
windowEnd,
|
|
872
|
+
logs: windowLogs
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
async generateProof(sessionId, windowEnd) {
|
|
876
|
+
const aggregation = this.aggregateUsage(sessionId, windowEnd);
|
|
877
|
+
if (aggregation.callCount <= 0) {
|
|
878
|
+
throw new UsageProofError(
|
|
879
|
+
UsageProofErrorCodes.INVALID_INPUT,
|
|
880
|
+
`No usage entries available for session ${sessionId}.`
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
if (aggregation.callCount > MAX_ENTRIES) {
|
|
884
|
+
throw new UsageProofError(
|
|
885
|
+
UsageProofErrorCodes.INVALID_INPUT,
|
|
886
|
+
`Usage batch too large (${aggregation.callCount} > ${MAX_ENTRIES}).`
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
if (aggregation.windowEnd <= aggregation.windowStart) {
|
|
890
|
+
throw new UsageProofError(
|
|
891
|
+
UsageProofErrorCodes.INVALID_INPUT,
|
|
892
|
+
"Billing window end must be greater than window start."
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
const sessionIdBytes32 = this.normalizeBytes32(sessionId);
|
|
896
|
+
const agentAddress = this.signerAccount?.address || ZERO_ADDRESS;
|
|
897
|
+
const agentAddressBytes32 = this.addressToBytes32(agentAddress);
|
|
898
|
+
const logHashes = aggregation.logs.map((log) => this.hashUsageLog(log));
|
|
899
|
+
const logTimestamps = aggregation.logs.map(
|
|
900
|
+
(log) => String(log.callData.timestamp)
|
|
901
|
+
);
|
|
902
|
+
const circuitInput = {
|
|
903
|
+
agentAddress,
|
|
904
|
+
sessionId: sessionIdBytes32,
|
|
905
|
+
callCount: aggregation.callCount,
|
|
906
|
+
windowStart: aggregation.windowStart,
|
|
907
|
+
windowEnd: aggregation.windowEnd,
|
|
908
|
+
logHashes,
|
|
909
|
+
logTimestamps
|
|
910
|
+
};
|
|
911
|
+
let proofResult = null;
|
|
912
|
+
let publicSignals = null;
|
|
913
|
+
try {
|
|
914
|
+
this.assertProofRuntimeReady();
|
|
915
|
+
const prover = await this.loadCircuitProver();
|
|
916
|
+
proofResult = await prover.prove(circuitInput);
|
|
917
|
+
publicSignals = proofResult.publicSignals;
|
|
918
|
+
this.assertPublicSignals(publicSignals, aggregation, agentAddress, sessionIdBytes32, prover);
|
|
919
|
+
} catch (err) {
|
|
920
|
+
if (this.config.proofMode === "strict") {
|
|
921
|
+
throw this.wrapProofError(err);
|
|
922
|
+
}
|
|
923
|
+
console.warn(
|
|
924
|
+
"[UsageService] Proof generation unavailable in dev mode, using zero-proof envelope:",
|
|
925
|
+
err
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
const proof = proofResult ? {
|
|
929
|
+
a: [
|
|
930
|
+
BigInt(proofResult.proof.pi_a[0]),
|
|
931
|
+
BigInt(proofResult.proof.pi_a[1])
|
|
932
|
+
],
|
|
933
|
+
b: [
|
|
934
|
+
[
|
|
935
|
+
BigInt(proofResult.proof.pi_b[0][1]),
|
|
936
|
+
BigInt(proofResult.proof.pi_b[0][0])
|
|
937
|
+
],
|
|
938
|
+
[
|
|
939
|
+
BigInt(proofResult.proof.pi_b[1][1]),
|
|
940
|
+
BigInt(proofResult.proof.pi_b[1][0])
|
|
941
|
+
]
|
|
942
|
+
],
|
|
943
|
+
c: [
|
|
944
|
+
BigInt(proofResult.proof.pi_c[0]),
|
|
945
|
+
BigInt(proofResult.proof.pi_c[1])
|
|
946
|
+
]
|
|
947
|
+
} : {
|
|
948
|
+
a: [0n, 0n],
|
|
949
|
+
b: [
|
|
950
|
+
[0n, 0n],
|
|
951
|
+
[0n, 0n]
|
|
952
|
+
],
|
|
953
|
+
c: [0n, 0n]
|
|
954
|
+
};
|
|
955
|
+
const fallbackMerkleRoot = this.normalizeBytes32(logHashes.join(","));
|
|
956
|
+
const fallbackNullifier = this.normalizeBytes32(
|
|
957
|
+
`${sessionIdBytes32}:${aggregation.windowEnd}`
|
|
958
|
+
);
|
|
959
|
+
const publicInputs = publicSignals ? {
|
|
960
|
+
agentAddress: this.fieldToBytes32(publicSignals[0]),
|
|
961
|
+
sessionId: this.fieldToBytes32(publicSignals[1]),
|
|
962
|
+
callCount: BigInt(publicSignals[2]),
|
|
963
|
+
windowStart: BigInt(publicSignals[3]),
|
|
964
|
+
windowEnd: BigInt(publicSignals[4]),
|
|
965
|
+
merkleRoot: this.fieldToBytes32(publicSignals[5]),
|
|
966
|
+
nullifier: this.fieldToBytes32(publicSignals[6])
|
|
967
|
+
} : {
|
|
968
|
+
agentAddress: agentAddressBytes32,
|
|
969
|
+
sessionId: sessionIdBytes32,
|
|
970
|
+
callCount: BigInt(aggregation.callCount),
|
|
971
|
+
windowStart: BigInt(aggregation.windowStart),
|
|
972
|
+
windowEnd: BigInt(aggregation.windowEnd),
|
|
973
|
+
merkleRoot: fallbackMerkleRoot,
|
|
974
|
+
nullifier: fallbackNullifier
|
|
975
|
+
};
|
|
976
|
+
return { proof, publicInputs };
|
|
977
|
+
}
|
|
978
|
+
async submitProof(proof, publicInputs) {
|
|
979
|
+
if (!this.walletClient) {
|
|
980
|
+
throw new Error(
|
|
981
|
+
"UsageService: Cannot submit proof \u2014 no signer private key configured. Set signerPrivateKey in UsageServiceConfig."
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
const chainId = this.config.chainId ?? DEFAULT_CHAIN_ID;
|
|
985
|
+
const contracts = getContractAddresses(chainId);
|
|
986
|
+
const verifierAddress = contracts.zkUsageVerifier;
|
|
987
|
+
if (!verifierAddress || verifierAddress === "0x0000000000000000000000000000000000000000") {
|
|
988
|
+
throw new Error(
|
|
989
|
+
"UsageService: ZKUsageVerifier contract address not configured for this chain"
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
const onChainProof = {
|
|
993
|
+
a: proof.a,
|
|
994
|
+
b: proof.b,
|
|
995
|
+
c: proof.c
|
|
996
|
+
};
|
|
997
|
+
const onChainInputs = {
|
|
998
|
+
agentAddress: publicInputs.agentAddress,
|
|
999
|
+
sessionId: publicInputs.sessionId,
|
|
1000
|
+
callCount: publicInputs.callCount,
|
|
1001
|
+
windowStart: publicInputs.windowStart,
|
|
1002
|
+
windowEnd: publicInputs.windowEnd,
|
|
1003
|
+
merkleRoot: publicInputs.merkleRoot,
|
|
1004
|
+
nullifier: publicInputs.nullifier
|
|
1005
|
+
};
|
|
1006
|
+
const txHash = await this.walletClient.writeContract({
|
|
1007
|
+
address: verifierAddress,
|
|
1008
|
+
abi: ZKUsageVerifierABI,
|
|
1009
|
+
functionName: "submitProof",
|
|
1010
|
+
args: [onChainProof, onChainInputs]
|
|
1011
|
+
});
|
|
1012
|
+
const receipt = await this.publicClient.waitForTransactionReceipt({
|
|
1013
|
+
hash: txHash
|
|
1014
|
+
});
|
|
1015
|
+
return {
|
|
1016
|
+
sessionId: publicInputs.sessionId,
|
|
1017
|
+
agentAddress: publicInputs.agentAddress,
|
|
1018
|
+
callCount: publicInputs.callCount,
|
|
1019
|
+
settlementAmount: 0n,
|
|
1020
|
+
windowStart: publicInputs.windowStart,
|
|
1021
|
+
windowEnd: publicInputs.windowEnd,
|
|
1022
|
+
transactionHash: receipt.transactionHash
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
clearLogs(sessionId, windowEnd) {
|
|
1026
|
+
const logs = this.logs.get(sessionId) || [];
|
|
1027
|
+
this.logs.set(
|
|
1028
|
+
sessionId,
|
|
1029
|
+
logs.filter((entry) => entry.callData.timestamp > windowEnd)
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
resolveCircuitsRoot(explicitRoot) {
|
|
1033
|
+
if (explicitRoot) return path.resolve(explicitRoot);
|
|
1034
|
+
const candidates = [
|
|
1035
|
+
process.env.MEAP_CIRCUITS_DIR,
|
|
1036
|
+
path.resolve(process.cwd(), "circuits"),
|
|
1037
|
+
path.resolve(process.cwd(), "..", "..", "circuits"),
|
|
1038
|
+
path.resolve(__dirname, "../../../../../circuits")
|
|
1039
|
+
].filter(Boolean);
|
|
1040
|
+
for (const candidate of candidates) {
|
|
1041
|
+
if (fs.existsSync(path.join(candidate, "UsageBilling.circom"))) {
|
|
1042
|
+
return candidate;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return path.resolve(process.cwd(), "circuits");
|
|
1046
|
+
}
|
|
1047
|
+
assertProofTooling() {
|
|
1048
|
+
if (!fs.existsSync(this.config.proveScriptPath)) {
|
|
1049
|
+
throw new UsageProofError(
|
|
1050
|
+
UsageProofErrorCodes.TOOLING_UNAVAILABLE,
|
|
1051
|
+
`Circuit prover script missing: ${this.config.proveScriptPath}`
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
assertProofArtifacts() {
|
|
1056
|
+
const required = [
|
|
1057
|
+
this.config.circuitWasmPath,
|
|
1058
|
+
this.config.zkeyPath,
|
|
1059
|
+
this.config.verificationKeyPath
|
|
1060
|
+
];
|
|
1061
|
+
const missing = required.filter((artifactPath) => !fs.existsSync(artifactPath));
|
|
1062
|
+
if (missing.length > 0) {
|
|
1063
|
+
throw new UsageProofError(
|
|
1064
|
+
UsageProofErrorCodes.ARTIFACTS_MISSING,
|
|
1065
|
+
`Missing proof artifact(s): ${missing.join(", ")}`
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
this.assertArtifactVersion();
|
|
1069
|
+
}
|
|
1070
|
+
assertArtifactVersion() {
|
|
1071
|
+
const expectedVersion = this.config.artifactVersion?.trim();
|
|
1072
|
+
const manifestPath = this.config.artifactManifestPath;
|
|
1073
|
+
if (!fs.existsSync(manifestPath)) {
|
|
1074
|
+
if (expectedVersion && this.config.proofMode === "strict") {
|
|
1075
|
+
throw new UsageProofError(
|
|
1076
|
+
UsageProofErrorCodes.ARTIFACTS_MISSING,
|
|
1077
|
+
`Artifact version set (${expectedVersion}) but manifest missing: ${manifestPath}`
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
let manifestVersion = "";
|
|
1083
|
+
try {
|
|
1084
|
+
const raw = fs.readFileSync(manifestPath, "utf8");
|
|
1085
|
+
const parsed = JSON.parse(raw);
|
|
1086
|
+
manifestVersion = String(parsed.version || "").trim();
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
throw new UsageProofError(
|
|
1089
|
+
UsageProofErrorCodes.ARTIFACTS_MISSING,
|
|
1090
|
+
`Invalid artifact manifest JSON: ${manifestPath}`,
|
|
1091
|
+
err
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
if (!expectedVersion) return;
|
|
1095
|
+
if (!manifestVersion || manifestVersion !== expectedVersion) {
|
|
1096
|
+
throw new UsageProofError(
|
|
1097
|
+
UsageProofErrorCodes.ARTIFACT_VERSION_MISMATCH,
|
|
1098
|
+
`Artifact version mismatch (expected ${expectedVersion}, got ${manifestVersion || "missing"})`
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
assertVerificationKey() {
|
|
1103
|
+
try {
|
|
1104
|
+
const raw = fs.readFileSync(this.config.verificationKeyPath, "utf8");
|
|
1105
|
+
const vkey = JSON.parse(raw);
|
|
1106
|
+
if (vkey.protocol !== "groth16") {
|
|
1107
|
+
throw new Error(`Unsupported protocol: ${String(vkey.protocol || "unknown")}`);
|
|
1108
|
+
}
|
|
1109
|
+
if (typeof vkey.nPublic === "number" && vkey.nPublic !== 7) {
|
|
1110
|
+
throw new Error(`Expected 7 public inputs, received ${vkey.nPublic}`);
|
|
1111
|
+
}
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
throw new UsageProofError(
|
|
1114
|
+
UsageProofErrorCodes.INVALID_VERIFICATION_KEY,
|
|
1115
|
+
`Invalid verification key: ${this.config.verificationKeyPath}`,
|
|
1116
|
+
err
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
wrapProofError(err) {
|
|
1121
|
+
if (err instanceof UsageProofError) {
|
|
1122
|
+
return err;
|
|
1123
|
+
}
|
|
1124
|
+
return new UsageProofError(
|
|
1125
|
+
UsageProofErrorCodes.PROOF_GENERATION_FAILED,
|
|
1126
|
+
"Failed to generate Groth16 proof.",
|
|
1127
|
+
err
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
async loadCircuitProver() {
|
|
1131
|
+
const moduleUrl = pathToFileURL(this.config.proveScriptPath).href;
|
|
1132
|
+
const mod = await import(moduleUrl);
|
|
1133
|
+
if (typeof mod.prove !== "function") {
|
|
1134
|
+
throw new UsageProofError(
|
|
1135
|
+
UsageProofErrorCodes.TOOLING_UNAVAILABLE,
|
|
1136
|
+
"Circuit prover module does not export `prove()`."
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
return mod;
|
|
1140
|
+
}
|
|
1141
|
+
assertPublicSignals(actual, aggregation, agentAddress, sessionIdBytes32, prover) {
|
|
1142
|
+
if (actual.length !== 7) {
|
|
1143
|
+
throw new UsageProofError(
|
|
1144
|
+
UsageProofErrorCodes.PROOF_SIGNAL_MISMATCH,
|
|
1145
|
+
`Public signal length mismatch (${actual.length} !== 7).`
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
const expectedAgentField = BigInt(agentAddress).toString();
|
|
1149
|
+
const expectedSessionField = typeof prover.sessionIdToField === "function" ? prover.sessionIdToField(sessionIdBytes32) : this.toFieldElement(sessionIdBytes32);
|
|
1150
|
+
if (BigInt(actual[0]) !== BigInt(expectedAgentField)) {
|
|
1151
|
+
throw new UsageProofError(
|
|
1152
|
+
UsageProofErrorCodes.PROOF_SIGNAL_MISMATCH,
|
|
1153
|
+
"Public signal mismatch at index 0 (agentAddress)."
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
if (BigInt(actual[1]) !== BigInt(expectedSessionField)) {
|
|
1157
|
+
throw new UsageProofError(
|
|
1158
|
+
UsageProofErrorCodes.PROOF_SIGNAL_MISMATCH,
|
|
1159
|
+
"Public signal mismatch at index 1 (sessionId)."
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
if (BigInt(actual[2]) !== BigInt(aggregation.callCount)) {
|
|
1163
|
+
throw new UsageProofError(
|
|
1164
|
+
UsageProofErrorCodes.PROOF_SIGNAL_MISMATCH,
|
|
1165
|
+
"Public signal mismatch at index 2 (callCount)."
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
if (BigInt(actual[3]) !== BigInt(aggregation.windowStart)) {
|
|
1169
|
+
throw new UsageProofError(
|
|
1170
|
+
UsageProofErrorCodes.PROOF_SIGNAL_MISMATCH,
|
|
1171
|
+
"Public signal mismatch at index 3 (windowStart)."
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
if (BigInt(actual[4]) !== BigInt(aggregation.windowEnd)) {
|
|
1175
|
+
throw new UsageProofError(
|
|
1176
|
+
UsageProofErrorCodes.PROOF_SIGNAL_MISMATCH,
|
|
1177
|
+
"Public signal mismatch at index 4 (windowEnd)."
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
hashUsageLog(log) {
|
|
1182
|
+
const digest = createHash("sha256").update(JSON.stringify({ ...log.callData, sig: log.signature })).digest("hex");
|
|
1183
|
+
return `0x${digest}`;
|
|
1184
|
+
}
|
|
1185
|
+
normalizeBytes32(value) {
|
|
1186
|
+
if (/^0x[a-fA-F0-9]{64}$/.test(value)) {
|
|
1187
|
+
return value.toLowerCase();
|
|
1188
|
+
}
|
|
1189
|
+
const hash = createHash("sha256").update(value).digest("hex");
|
|
1190
|
+
return `0x${hash}`;
|
|
1191
|
+
}
|
|
1192
|
+
addressToBytes32(address) {
|
|
1193
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(address)) {
|
|
1194
|
+
throw new UsageProofError(
|
|
1195
|
+
UsageProofErrorCodes.INVALID_INPUT,
|
|
1196
|
+
`Invalid address: ${address}`
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
return `0x${address.slice(2).padStart(64, "0")}`.toLowerCase();
|
|
1200
|
+
}
|
|
1201
|
+
fieldToBytes32(field) {
|
|
1202
|
+
const value = BigInt(field);
|
|
1203
|
+
if (value < 0n || value >= 2n ** 256n) {
|
|
1204
|
+
throw new UsageProofError(
|
|
1205
|
+
UsageProofErrorCodes.INVALID_INPUT,
|
|
1206
|
+
`Field value out of bytes32 range: ${field}`
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
return `0x${value.toString(16).padStart(64, "0")}`.toLowerCase();
|
|
1210
|
+
}
|
|
1211
|
+
toFieldElement(input) {
|
|
1212
|
+
const hex = Buffer.isBuffer(input) ? input.toString("hex") : input.replace(/^0x/, "");
|
|
1213
|
+
const normalized = hex.length === 0 ? "0" : hex;
|
|
1214
|
+
const truncated = normalized.slice(0, 62);
|
|
1215
|
+
return BigInt(`0x${truncated || "0"}`).toString();
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
// src/services/settlement.ts
|
|
1220
|
+
import { createPublicClient as createPublicClient3, http as http3 } from "viem";
|
|
1221
|
+
import { sepolia as sepolia3, baseSepolia as baseSepolia3, base as base3 } from "viem/chains";
|
|
1222
|
+
import {
|
|
1223
|
+
DEFAULT_CHAIN_ID as DEFAULT_CHAIN_ID2,
|
|
1224
|
+
SessionVaultABI as SessionVaultABI2,
|
|
1225
|
+
getContractAddresses as getContractAddresses2
|
|
1226
|
+
} from "@arcenpay/sdk";
|
|
1227
|
+
var CHAIN_MAP3 = {
|
|
1228
|
+
11155111: sepolia3,
|
|
1229
|
+
84532: baseSepolia3,
|
|
1230
|
+
8453: base3
|
|
1231
|
+
};
|
|
1232
|
+
var SettlementService = class {
|
|
1233
|
+
rpcUrl;
|
|
1234
|
+
chainId;
|
|
1235
|
+
zkVerifierAddress;
|
|
1236
|
+
sessionVaultAddress;
|
|
1237
|
+
watchers = [];
|
|
1238
|
+
pollInterval = null;
|
|
1239
|
+
lastBlock = 0n;
|
|
1240
|
+
publicClient;
|
|
1241
|
+
constructor(config) {
|
|
1242
|
+
this.rpcUrl = config.rpcUrl;
|
|
1243
|
+
this.chainId = config.chainId ?? DEFAULT_CHAIN_ID2;
|
|
1244
|
+
const contracts = getContractAddresses2(this.chainId);
|
|
1245
|
+
this.zkVerifierAddress = config.zkVerifierAddress || contracts.zkUsageVerifier;
|
|
1246
|
+
this.sessionVaultAddress = config.sessionVaultAddress || contracts.sessionVault;
|
|
1247
|
+
const chain = CHAIN_MAP3[this.chainId] || baseSepolia3;
|
|
1248
|
+
this.publicClient = createPublicClient3({
|
|
1249
|
+
chain,
|
|
1250
|
+
transport: http3(this.rpcUrl)
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Queries the SessionVault for remaining balance
|
|
1255
|
+
*/
|
|
1256
|
+
async getSessionBalance(agentAddress) {
|
|
1257
|
+
try {
|
|
1258
|
+
const balance = await this.publicClient.readContract({
|
|
1259
|
+
address: this.sessionVaultAddress,
|
|
1260
|
+
abi: SessionVaultABI2,
|
|
1261
|
+
functionName: "getAgentBalance",
|
|
1262
|
+
args: [agentAddress]
|
|
1263
|
+
});
|
|
1264
|
+
return balance;
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
console.error("[SettlementService] Balance query failed:", err);
|
|
1267
|
+
return 0n;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Gets details for a specific session
|
|
1272
|
+
*/
|
|
1273
|
+
async getSessionDetails(sessionId) {
|
|
1274
|
+
try {
|
|
1275
|
+
const session = await this.publicClient.readContract({
|
|
1276
|
+
address: this.sessionVaultAddress,
|
|
1277
|
+
abi: SessionVaultABI2,
|
|
1278
|
+
functionName: "getSession",
|
|
1279
|
+
args: [sessionId]
|
|
1280
|
+
});
|
|
1281
|
+
return session;
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
console.error("[SettlementService] Session query failed:", err);
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Subscribes to on-chain BillingSettled events
|
|
1289
|
+
*/
|
|
1290
|
+
watchSettlements(callback, intervalMs = 1e4) {
|
|
1291
|
+
this.watchers.push(callback);
|
|
1292
|
+
if (!this.pollInterval) {
|
|
1293
|
+
this.initBlockNumber().then(() => {
|
|
1294
|
+
this.pollInterval = setInterval(async () => {
|
|
1295
|
+
await this.pollSettlements();
|
|
1296
|
+
}, intervalMs);
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
return () => {
|
|
1300
|
+
this.watchers = this.watchers.filter((w) => w !== callback);
|
|
1301
|
+
if (this.watchers.length === 0 && this.pollInterval) {
|
|
1302
|
+
clearInterval(this.pollInterval);
|
|
1303
|
+
this.pollInterval = null;
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Initialize the starting block number for event polling
|
|
1309
|
+
*/
|
|
1310
|
+
async initBlockNumber() {
|
|
1311
|
+
try {
|
|
1312
|
+
this.lastBlock = await this.publicClient.getBlockNumber();
|
|
1313
|
+
} catch {
|
|
1314
|
+
this.lastBlock = 0n;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Polls for new BillingSettled events from ZKUsageVerifier
|
|
1319
|
+
*/
|
|
1320
|
+
async pollSettlements() {
|
|
1321
|
+
try {
|
|
1322
|
+
const currentBlock = await this.publicClient.getBlockNumber();
|
|
1323
|
+
if (currentBlock <= this.lastBlock) return;
|
|
1324
|
+
const logs = await this.publicClient.getLogs({
|
|
1325
|
+
address: this.zkVerifierAddress,
|
|
1326
|
+
event: {
|
|
1327
|
+
type: "event",
|
|
1328
|
+
name: "BillingSettled",
|
|
1329
|
+
inputs: [
|
|
1330
|
+
{ name: "sessionId", type: "bytes32", indexed: true },
|
|
1331
|
+
{ name: "agentAddress", type: "bytes32", indexed: true },
|
|
1332
|
+
{ name: "callCount", type: "uint256", indexed: false },
|
|
1333
|
+
{ name: "settlementAmount", type: "uint256", indexed: false },
|
|
1334
|
+
{ name: "windowStart", type: "uint64", indexed: false },
|
|
1335
|
+
{ name: "windowEnd", type: "uint64", indexed: false }
|
|
1336
|
+
]
|
|
1337
|
+
},
|
|
1338
|
+
fromBlock: this.lastBlock + 1n,
|
|
1339
|
+
toBlock: currentBlock
|
|
1340
|
+
});
|
|
1341
|
+
for (const log of logs) {
|
|
1342
|
+
const settlement = {
|
|
1343
|
+
sessionId: log.args.sessionId,
|
|
1344
|
+
agentAddress: log.args.agentAddress,
|
|
1345
|
+
callCount: log.args.callCount,
|
|
1346
|
+
settlementAmount: log.args.settlementAmount,
|
|
1347
|
+
windowStart: log.args.windowStart,
|
|
1348
|
+
windowEnd: log.args.windowEnd,
|
|
1349
|
+
transactionHash: log.transactionHash
|
|
1350
|
+
};
|
|
1351
|
+
this.watchers.forEach((cb) => cb(settlement));
|
|
1352
|
+
}
|
|
1353
|
+
this.lastBlock = currentBlock;
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
console.error("[SettlementService] Polling error:", err);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Stops all settlement watchers
|
|
1360
|
+
*/
|
|
1361
|
+
stop() {
|
|
1362
|
+
if (this.pollInterval) {
|
|
1363
|
+
clearInterval(this.pollInterval);
|
|
1364
|
+
this.pollInterval = null;
|
|
1365
|
+
}
|
|
1366
|
+
this.watchers = [];
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
// src/services/settlement-writer.ts
|
|
1371
|
+
import { createPublicClient as createPublicClient4, createWalletClient as createWalletClient2, http as http4, isAddress as isAddress2 } from "viem";
|
|
1372
|
+
import { privateKeyToAccount as privateKeyToAccount2 } from "viem/accounts";
|
|
1373
|
+
import { sepolia as sepolia4, baseSepolia as baseSepolia4, base as base4 } from "viem/chains";
|
|
1374
|
+
import {
|
|
1375
|
+
DEFAULT_CHAIN_ID as DEFAULT_CHAIN_ID3,
|
|
1376
|
+
SessionVaultABI as SessionVaultABI3,
|
|
1377
|
+
FeeCollectorABI,
|
|
1378
|
+
getContractAddresses as getContractAddresses3
|
|
1379
|
+
} from "@arcenpay/sdk";
|
|
1380
|
+
var CHAIN_MAP4 = {
|
|
1381
|
+
11155111: sepolia4,
|
|
1382
|
+
84532: baseSepolia4,
|
|
1383
|
+
8453: base4
|
|
1384
|
+
};
|
|
1385
|
+
function isBytes32(value) {
|
|
1386
|
+
return /^0x[a-fA-F0-9]{64}$/.test(value);
|
|
1387
|
+
}
|
|
1388
|
+
function normalizeSession(raw) {
|
|
1389
|
+
const value = raw;
|
|
1390
|
+
const agent = value?.agent ?? value?.[0] ?? "0x0000000000000000000000000000000000000000";
|
|
1391
|
+
const token = value?.token ?? value?.[1] ?? "0x0000000000000000000000000000000000000000";
|
|
1392
|
+
const balance = BigInt(value?.balance ?? value?.[2] ?? 0n);
|
|
1393
|
+
const totalFunded = BigInt(value?.totalFunded ?? value?.[3] ?? 0n);
|
|
1394
|
+
const totalSettled = BigInt(value?.totalSettled ?? value?.[4] ?? 0n);
|
|
1395
|
+
const active = Boolean(value?.active ?? value?.[5] ?? false);
|
|
1396
|
+
const createdAt = BigInt(value?.createdAt ?? value?.[6] ?? 0n);
|
|
1397
|
+
return {
|
|
1398
|
+
agent,
|
|
1399
|
+
token,
|
|
1400
|
+
balance,
|
|
1401
|
+
totalFunded,
|
|
1402
|
+
totalSettled,
|
|
1403
|
+
active,
|
|
1404
|
+
createdAt
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
var SettlementWriterService = class {
|
|
1408
|
+
chainId;
|
|
1409
|
+
sessionVaultAddress;
|
|
1410
|
+
feeCollectorAddress;
|
|
1411
|
+
providerAddress;
|
|
1412
|
+
signerAddress;
|
|
1413
|
+
waitForReceipt;
|
|
1414
|
+
publicClient;
|
|
1415
|
+
walletClient;
|
|
1416
|
+
constructor(config) {
|
|
1417
|
+
this.chainId = config.chainId ?? DEFAULT_CHAIN_ID3;
|
|
1418
|
+
const chain = CHAIN_MAP4[this.chainId] || baseSepolia4;
|
|
1419
|
+
const contracts = getContractAddresses3(this.chainId);
|
|
1420
|
+
this.sessionVaultAddress = config.sessionVaultAddress || contracts.sessionVault;
|
|
1421
|
+
this.feeCollectorAddress = config.feeCollectorAddress || contracts.feeCollector;
|
|
1422
|
+
const account = privateKeyToAccount2(config.privateKey);
|
|
1423
|
+
this.signerAddress = account.address;
|
|
1424
|
+
this.providerAddress = config.providerAddress || account.address;
|
|
1425
|
+
this.waitForReceipt = config.waitForReceipt ?? true;
|
|
1426
|
+
if (!isAddress2(this.sessionVaultAddress)) {
|
|
1427
|
+
throw new Error("Invalid SessionVault address for SettlementWriterService");
|
|
1428
|
+
}
|
|
1429
|
+
if (!isAddress2(this.providerAddress)) {
|
|
1430
|
+
throw new Error("Invalid provider address for SettlementWriterService");
|
|
1431
|
+
}
|
|
1432
|
+
this.publicClient = createPublicClient4({
|
|
1433
|
+
chain,
|
|
1434
|
+
transport: http4(config.rpcUrl || chain.rpcUrls.default.http[0])
|
|
1435
|
+
});
|
|
1436
|
+
this.walletClient = createWalletClient2({
|
|
1437
|
+
account,
|
|
1438
|
+
chain,
|
|
1439
|
+
transport: http4(config.rpcUrl || chain.rpcUrls.default.http[0])
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
getSignerAddress() {
|
|
1443
|
+
return this.signerAddress;
|
|
1444
|
+
}
|
|
1445
|
+
getSessionVaultAddress() {
|
|
1446
|
+
return this.sessionVaultAddress;
|
|
1447
|
+
}
|
|
1448
|
+
async isSignerAuthorized(address = this.signerAddress) {
|
|
1449
|
+
if (!isAddress2(address)) {
|
|
1450
|
+
return false;
|
|
1451
|
+
}
|
|
1452
|
+
const authorized = await this.publicClient.readContract({
|
|
1453
|
+
address: this.sessionVaultAddress,
|
|
1454
|
+
abi: SessionVaultABI3,
|
|
1455
|
+
functionName: "authorizedSettlers",
|
|
1456
|
+
args: [address]
|
|
1457
|
+
});
|
|
1458
|
+
return Boolean(authorized);
|
|
1459
|
+
}
|
|
1460
|
+
async getSession(sessionId) {
|
|
1461
|
+
const rawSession = await this.publicClient.readContract({
|
|
1462
|
+
address: this.sessionVaultAddress,
|
|
1463
|
+
abi: SessionVaultABI3,
|
|
1464
|
+
functionName: "getSession",
|
|
1465
|
+
args: [sessionId]
|
|
1466
|
+
});
|
|
1467
|
+
return normalizeSession(rawSession);
|
|
1468
|
+
}
|
|
1469
|
+
async resolveSessionId(sessionIdHint, signer) {
|
|
1470
|
+
const trimmedHint = sessionIdHint.trim();
|
|
1471
|
+
if (isBytes32(trimmedHint)) {
|
|
1472
|
+
return trimmedHint;
|
|
1473
|
+
}
|
|
1474
|
+
const sessionIds = await this.publicClient.readContract({
|
|
1475
|
+
address: this.sessionVaultAddress,
|
|
1476
|
+
abi: SessionVaultABI3,
|
|
1477
|
+
functionName: "getAgentSessions",
|
|
1478
|
+
args: [signer]
|
|
1479
|
+
});
|
|
1480
|
+
if (!sessionIds.length) {
|
|
1481
|
+
throw new Error("No billing session found for signer");
|
|
1482
|
+
}
|
|
1483
|
+
for (let idx = sessionIds.length - 1; idx >= 0; idx -= 1) {
|
|
1484
|
+
const candidate = sessionIds[idx];
|
|
1485
|
+
const session = await this.getSession(candidate);
|
|
1486
|
+
if (session.active && session.balance > 0n) {
|
|
1487
|
+
return candidate;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
throw new Error("No active billing session found for signer");
|
|
1491
|
+
}
|
|
1492
|
+
async settleForSigner(input) {
|
|
1493
|
+
if (!isAddress2(input.signer)) {
|
|
1494
|
+
throw new Error("Invalid signer address for settlement");
|
|
1495
|
+
}
|
|
1496
|
+
if (input.amount <= 0n) {
|
|
1497
|
+
throw new Error("Settlement amount must be greater than zero");
|
|
1498
|
+
}
|
|
1499
|
+
const resolvedSessionId = await this.resolveSessionId(input.sessionIdHint, input.signer);
|
|
1500
|
+
const session = await this.getSession(resolvedSessionId);
|
|
1501
|
+
if (!session.active) {
|
|
1502
|
+
throw new Error("Session is not active");
|
|
1503
|
+
}
|
|
1504
|
+
if (session.agent.toLowerCase() !== input.signer.toLowerCase()) {
|
|
1505
|
+
throw new Error("Session owner does not match payment signer");
|
|
1506
|
+
}
|
|
1507
|
+
if (session.balance < input.amount) {
|
|
1508
|
+
throw new Error(
|
|
1509
|
+
`Session balance too low for settlement (balance=${session.balance.toString()}, required=${input.amount.toString()})`
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
const providerAddress = input.providerAddress || this.providerAddress;
|
|
1513
|
+
if (!isAddress2(providerAddress)) {
|
|
1514
|
+
throw new Error("Invalid settlement provider address");
|
|
1515
|
+
}
|
|
1516
|
+
const txHash = await this.walletClient.writeContract({
|
|
1517
|
+
address: this.sessionVaultAddress,
|
|
1518
|
+
abi: SessionVaultABI3,
|
|
1519
|
+
functionName: "settle",
|
|
1520
|
+
args: [resolvedSessionId, input.amount, providerAddress]
|
|
1521
|
+
});
|
|
1522
|
+
if (this.waitForReceipt) {
|
|
1523
|
+
await this.publicClient.waitForTransactionReceipt({ hash: txHash });
|
|
1524
|
+
}
|
|
1525
|
+
if (isAddress2(this.feeCollectorAddress)) {
|
|
1526
|
+
try {
|
|
1527
|
+
const feeTxHash = await this.walletClient.writeContract({
|
|
1528
|
+
address: this.feeCollectorAddress,
|
|
1529
|
+
abi: FeeCollectorABI,
|
|
1530
|
+
functionName: "collectSettlementFeeFor",
|
|
1531
|
+
args: [input.amount, providerAddress]
|
|
1532
|
+
});
|
|
1533
|
+
if (this.waitForReceipt) {
|
|
1534
|
+
await this.publicClient.waitForTransactionReceipt({ hash: feeTxHash });
|
|
1535
|
+
}
|
|
1536
|
+
} catch (err) {
|
|
1537
|
+
console.warn("[SettlementWriter] Fee collection failed (non-fatal):", err);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
return {
|
|
1541
|
+
transactionHash: txHash,
|
|
1542
|
+
settledAmount: input.amount,
|
|
1543
|
+
sessionId: resolvedSessionId,
|
|
1544
|
+
providerAddress
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
// src/services/events.ts
|
|
1550
|
+
import { createPublicClient as createPublicClient5, http as http5 } from "viem";
|
|
1551
|
+
import { sepolia as sepolia5, baseSepolia as baseSepolia5, base as base5 } from "viem/chains";
|
|
1552
|
+
import { DEFAULT_CHAIN_ID as DEFAULT_CHAIN_ID4, getContractAddresses as getContractAddresses4 } from "@arcenpay/sdk";
|
|
1553
|
+
var CHAIN_MAP5 = {
|
|
1554
|
+
11155111: sepolia5,
|
|
1555
|
+
84532: baseSepolia5,
|
|
1556
|
+
8453: base5
|
|
1557
|
+
};
|
|
1558
|
+
var EventListenerService = class {
|
|
1559
|
+
publicClient;
|
|
1560
|
+
registryAddress;
|
|
1561
|
+
autopayModuleAddress;
|
|
1562
|
+
callbacks = /* @__PURE__ */ new Map();
|
|
1563
|
+
pollInterval = null;
|
|
1564
|
+
lastBlock = 0n;
|
|
1565
|
+
constructor(config) {
|
|
1566
|
+
const chainId = config.chainId ?? DEFAULT_CHAIN_ID4;
|
|
1567
|
+
const chain = CHAIN_MAP5[chainId] || baseSepolia5;
|
|
1568
|
+
const contracts = getContractAddresses4(chainId);
|
|
1569
|
+
this.registryAddress = config.registryAddress || contracts.subscriptionRegistry;
|
|
1570
|
+
this.autopayModuleAddress = contracts.autopayModule;
|
|
1571
|
+
this.publicClient = createPublicClient5({
|
|
1572
|
+
chain,
|
|
1573
|
+
transport: http5(config.rpcUrl)
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Register a callback for a specific event type (or '*' for all events)
|
|
1578
|
+
*/
|
|
1579
|
+
on(eventType, callback) {
|
|
1580
|
+
const existing = this.callbacks.get(eventType) || [];
|
|
1581
|
+
existing.push(callback);
|
|
1582
|
+
this.callbacks.set(eventType, existing);
|
|
1583
|
+
return () => {
|
|
1584
|
+
const cbs = this.callbacks.get(eventType) || [];
|
|
1585
|
+
this.callbacks.set(eventType, cbs.filter((cb) => cb !== callback));
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Start polling for events at the given interval
|
|
1590
|
+
*/
|
|
1591
|
+
async start(intervalMs = 12e3) {
|
|
1592
|
+
try {
|
|
1593
|
+
this.lastBlock = await this.publicClient.getBlockNumber();
|
|
1594
|
+
} catch {
|
|
1595
|
+
this.lastBlock = 0n;
|
|
1596
|
+
}
|
|
1597
|
+
console.log(`[EventListener] Watching SubscriptionRegistry at ${this.registryAddress} from block ${this.lastBlock}`);
|
|
1598
|
+
this.pollInterval = setInterval(async () => {
|
|
1599
|
+
await this.poll();
|
|
1600
|
+
}, intervalMs);
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Stop polling
|
|
1604
|
+
*/
|
|
1605
|
+
stop() {
|
|
1606
|
+
if (this.pollInterval) {
|
|
1607
|
+
clearInterval(this.pollInterval);
|
|
1608
|
+
this.pollInterval = null;
|
|
1609
|
+
}
|
|
1610
|
+
console.log("[EventListener] Stopped");
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Poll for new events since lastBlock
|
|
1614
|
+
*/
|
|
1615
|
+
async poll() {
|
|
1616
|
+
try {
|
|
1617
|
+
const currentBlock = await this.publicClient.getBlockNumber();
|
|
1618
|
+
if (currentBlock <= this.lastBlock) return;
|
|
1619
|
+
const addr = this.registryAddress;
|
|
1620
|
+
const from = this.lastBlock + 1n;
|
|
1621
|
+
const [mintedLogs, renewedLogs, cancelledLogs, planChangedLogs, billingLogs] = await Promise.all([
|
|
1622
|
+
this.publicClient.getLogs({
|
|
1623
|
+
address: addr,
|
|
1624
|
+
event: {
|
|
1625
|
+
type: "event",
|
|
1626
|
+
name: "SubscriptionMinted",
|
|
1627
|
+
inputs: [
|
|
1628
|
+
{ name: "subscriber", type: "address", indexed: true },
|
|
1629
|
+
{ name: "tokenId", type: "uint256", indexed: true },
|
|
1630
|
+
{ name: "planId", type: "uint256", indexed: true },
|
|
1631
|
+
{ name: "expiration", type: "uint64", indexed: false }
|
|
1632
|
+
]
|
|
1633
|
+
},
|
|
1634
|
+
fromBlock: from,
|
|
1635
|
+
toBlock: currentBlock
|
|
1636
|
+
}),
|
|
1637
|
+
this.publicClient.getLogs({
|
|
1638
|
+
address: addr,
|
|
1639
|
+
event: {
|
|
1640
|
+
type: "event",
|
|
1641
|
+
name: "SubscriptionRenewed",
|
|
1642
|
+
inputs: [
|
|
1643
|
+
{ name: "tokenId", type: "uint256", indexed: true },
|
|
1644
|
+
{ name: "newExpiration", type: "uint64", indexed: false },
|
|
1645
|
+
{ name: "amountPaid", type: "uint256", indexed: false }
|
|
1646
|
+
]
|
|
1647
|
+
},
|
|
1648
|
+
fromBlock: from,
|
|
1649
|
+
toBlock: currentBlock
|
|
1650
|
+
}),
|
|
1651
|
+
this.publicClient.getLogs({
|
|
1652
|
+
address: addr,
|
|
1653
|
+
event: {
|
|
1654
|
+
type: "event",
|
|
1655
|
+
name: "SubscriptionCancelled",
|
|
1656
|
+
inputs: [
|
|
1657
|
+
{ name: "tokenId", type: "uint256", indexed: true }
|
|
1658
|
+
]
|
|
1659
|
+
},
|
|
1660
|
+
fromBlock: from,
|
|
1661
|
+
toBlock: currentBlock
|
|
1662
|
+
}),
|
|
1663
|
+
this.publicClient.getLogs({
|
|
1664
|
+
address: addr,
|
|
1665
|
+
event: {
|
|
1666
|
+
type: "event",
|
|
1667
|
+
name: "SubscriptionPlanChanged",
|
|
1668
|
+
inputs: [
|
|
1669
|
+
{ name: "tokenId", type: "uint256", indexed: true },
|
|
1670
|
+
{ name: "previousPlanId", type: "uint256", indexed: true },
|
|
1671
|
+
{ name: "newPlanId", type: "uint256", indexed: true },
|
|
1672
|
+
{ name: "expiration", type: "uint64", indexed: false }
|
|
1673
|
+
]
|
|
1674
|
+
},
|
|
1675
|
+
fromBlock: from,
|
|
1676
|
+
toBlock: currentBlock
|
|
1677
|
+
}),
|
|
1678
|
+
this.publicClient.getLogs({
|
|
1679
|
+
address: this.autopayModuleAddress,
|
|
1680
|
+
event: {
|
|
1681
|
+
type: "event",
|
|
1682
|
+
name: "BillingExecuted",
|
|
1683
|
+
inputs: [
|
|
1684
|
+
{ name: "account", type: "address", indexed: true },
|
|
1685
|
+
{ name: "merchant", type: "address", indexed: true },
|
|
1686
|
+
{ name: "tokenId", type: "uint256", indexed: true },
|
|
1687
|
+
{ name: "planId", type: "uint256", indexed: false },
|
|
1688
|
+
{ name: "reason", type: "uint8", indexed: false },
|
|
1689
|
+
{ name: "grossAmount", type: "uint256", indexed: false },
|
|
1690
|
+
{ name: "merchantAmount", type: "uint256", indexed: false },
|
|
1691
|
+
{ name: "protocolFee", type: "uint256", indexed: false },
|
|
1692
|
+
{ name: "timestamp", type: "uint256", indexed: false }
|
|
1693
|
+
]
|
|
1694
|
+
},
|
|
1695
|
+
fromBlock: from,
|
|
1696
|
+
toBlock: currentBlock
|
|
1697
|
+
})
|
|
1698
|
+
]);
|
|
1699
|
+
for (const log of mintedLogs) {
|
|
1700
|
+
await this.emit({
|
|
1701
|
+
type: "minted",
|
|
1702
|
+
tokenId: log.args.tokenId,
|
|
1703
|
+
subscriber: log.args.subscriber,
|
|
1704
|
+
planId: log.args.planId,
|
|
1705
|
+
expiration: log.args.expiration,
|
|
1706
|
+
transactionHash: log.transactionHash,
|
|
1707
|
+
blockNumber: log.blockNumber,
|
|
1708
|
+
timestamp: Date.now()
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
for (const log of renewedLogs) {
|
|
1712
|
+
await this.emit({
|
|
1713
|
+
type: "renewed",
|
|
1714
|
+
tokenId: log.args.tokenId,
|
|
1715
|
+
expiration: log.args.newExpiration,
|
|
1716
|
+
amountPaid: log.args.amountPaid,
|
|
1717
|
+
transactionHash: log.transactionHash,
|
|
1718
|
+
blockNumber: log.blockNumber,
|
|
1719
|
+
timestamp: Date.now()
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
for (const log of cancelledLogs) {
|
|
1723
|
+
await this.emit({
|
|
1724
|
+
type: "cancelled",
|
|
1725
|
+
tokenId: log.args.tokenId,
|
|
1726
|
+
transactionHash: log.transactionHash,
|
|
1727
|
+
blockNumber: log.blockNumber,
|
|
1728
|
+
timestamp: Date.now()
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
for (const log of planChangedLogs) {
|
|
1732
|
+
await this.emit({
|
|
1733
|
+
type: "plan_changed",
|
|
1734
|
+
tokenId: log.args.tokenId,
|
|
1735
|
+
planId: log.args.newPlanId,
|
|
1736
|
+
previousPlanId: log.args.previousPlanId,
|
|
1737
|
+
expiration: log.args.expiration,
|
|
1738
|
+
transactionHash: log.transactionHash,
|
|
1739
|
+
blockNumber: log.blockNumber,
|
|
1740
|
+
timestamp: Date.now()
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
for (const log of billingLogs) {
|
|
1744
|
+
await this.emit({
|
|
1745
|
+
type: "billing_executed",
|
|
1746
|
+
tokenId: log.args.tokenId,
|
|
1747
|
+
account: log.args.account,
|
|
1748
|
+
merchant: log.args.merchant,
|
|
1749
|
+
planId: log.args.planId,
|
|
1750
|
+
billingReason: log.args.reason,
|
|
1751
|
+
amountPaid: log.args.grossAmount,
|
|
1752
|
+
merchantAmount: log.args.merchantAmount,
|
|
1753
|
+
protocolFee: log.args.protocolFee,
|
|
1754
|
+
transactionHash: log.transactionHash,
|
|
1755
|
+
blockNumber: log.blockNumber,
|
|
1756
|
+
timestamp: Number(log.args.timestamp ?? Date.now())
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
this.lastBlock = currentBlock;
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
console.error("[EventListener] Polling error:", err);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Dispatch event to registered callbacks
|
|
1766
|
+
*/
|
|
1767
|
+
async emit(event) {
|
|
1768
|
+
console.log(`[EventListener] ${event.type} \u2014 tokenId=${event.tokenId} tx=${event.transactionHash}`);
|
|
1769
|
+
const typeCallbacks = this.callbacks.get(event.type) || [];
|
|
1770
|
+
const wildcardCallbacks = this.callbacks.get("*") || [];
|
|
1771
|
+
for (const cb of [...typeCallbacks, ...wildcardCallbacks]) {
|
|
1772
|
+
try {
|
|
1773
|
+
await cb(event);
|
|
1774
|
+
} catch (err) {
|
|
1775
|
+
console.error(`[EventListener] Callback error for ${event.type}:`, err);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
// src/services/webhooks.ts
|
|
1782
|
+
import { createHmac } from "crypto";
|
|
1783
|
+
var DEFAULT_RETRY_DELAYS = [1e3, 5e3, 3e4, 3e5, 18e5];
|
|
1784
|
+
var WebhookService = class {
|
|
1785
|
+
config;
|
|
1786
|
+
deliveryLog = [];
|
|
1787
|
+
pendingRetries = /* @__PURE__ */ new Map();
|
|
1788
|
+
constructor(config) {
|
|
1789
|
+
this.config = {
|
|
1790
|
+
maxRetries: 5,
|
|
1791
|
+
retryDelays: DEFAULT_RETRY_DELAYS,
|
|
1792
|
+
...config
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Deliver a webhook for a subscription event
|
|
1797
|
+
*/
|
|
1798
|
+
async deliverEvent(event) {
|
|
1799
|
+
const payload = {
|
|
1800
|
+
event: `subscription.${event.type}`,
|
|
1801
|
+
timestamp: event.timestamp,
|
|
1802
|
+
data: {
|
|
1803
|
+
tokenId: event.tokenId?.toString(),
|
|
1804
|
+
subscriber: event.subscriber,
|
|
1805
|
+
planId: event.planId?.toString(),
|
|
1806
|
+
expiration: event.expiration?.toString(),
|
|
1807
|
+
amountPaid: event.amountPaid?.toString(),
|
|
1808
|
+
txHash: event.transactionHash,
|
|
1809
|
+
blockNumber: event.blockNumber?.toString()
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
await this.deliver(payload);
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* Deliver an arbitrary webhook payload
|
|
1816
|
+
*/
|
|
1817
|
+
async deliver(payload, attempt = 1) {
|
|
1818
|
+
const id = `${payload.event}-${payload.timestamp}-${attempt}`;
|
|
1819
|
+
const body = JSON.stringify(payload);
|
|
1820
|
+
const signature = this.sign(body);
|
|
1821
|
+
try {
|
|
1822
|
+
const response = await fetch(this.config.url, {
|
|
1823
|
+
method: "POST",
|
|
1824
|
+
headers: {
|
|
1825
|
+
"Content-Type": "application/json",
|
|
1826
|
+
"X-MEAP-Signature": signature,
|
|
1827
|
+
"X-MEAP-Delivery": id,
|
|
1828
|
+
"User-Agent": "MEAP-Webhook/1.0"
|
|
1829
|
+
},
|
|
1830
|
+
body,
|
|
1831
|
+
signal: AbortSignal.timeout(1e4)
|
|
1832
|
+
});
|
|
1833
|
+
this.log({
|
|
1834
|
+
id,
|
|
1835
|
+
event: payload.event,
|
|
1836
|
+
url: this.config.url,
|
|
1837
|
+
statusCode: response.status,
|
|
1838
|
+
attemptNumber: attempt,
|
|
1839
|
+
deliveredAt: Date.now(),
|
|
1840
|
+
success: response.ok
|
|
1841
|
+
});
|
|
1842
|
+
await this.persistDelivery(this.deliveryLog[this.deliveryLog.length - 1], payload);
|
|
1843
|
+
if (!response.ok && attempt < this.config.maxRetries) {
|
|
1844
|
+
await this.scheduleRetry(payload, attempt);
|
|
1845
|
+
}
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
this.log({
|
|
1848
|
+
id,
|
|
1849
|
+
event: payload.event,
|
|
1850
|
+
url: this.config.url,
|
|
1851
|
+
statusCode: null,
|
|
1852
|
+
attemptNumber: attempt,
|
|
1853
|
+
deliveredAt: Date.now(),
|
|
1854
|
+
success: false,
|
|
1855
|
+
error: err.message
|
|
1856
|
+
});
|
|
1857
|
+
await this.persistDelivery(this.deliveryLog[this.deliveryLog.length - 1], payload);
|
|
1858
|
+
if (attempt < this.config.maxRetries) {
|
|
1859
|
+
await this.scheduleRetry(payload, attempt);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* HMAC-SHA256 signature of the payload body
|
|
1865
|
+
*/
|
|
1866
|
+
sign(body) {
|
|
1867
|
+
return createHmac("sha256", this.config.secret).update(body).digest("hex");
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Schedule a retry with exponential backoff
|
|
1871
|
+
*/
|
|
1872
|
+
async scheduleRetry(payload, currentAttempt) {
|
|
1873
|
+
const delay = this.config.retryDelays[currentAttempt - 1] || 18e5;
|
|
1874
|
+
const retryId = `retry-${payload.event}-${currentAttempt}`;
|
|
1875
|
+
console.log(`[Webhook] Scheduling retry ${currentAttempt + 1} in ${delay}ms for ${payload.event}`);
|
|
1876
|
+
const timer = setTimeout(() => {
|
|
1877
|
+
this.deliver(payload, currentAttempt + 1);
|
|
1878
|
+
this.pendingRetries.delete(retryId);
|
|
1879
|
+
}, delay);
|
|
1880
|
+
this.pendingRetries.set(retryId, timer);
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Log a delivery attempt
|
|
1884
|
+
*/
|
|
1885
|
+
log(entry) {
|
|
1886
|
+
this.deliveryLog.push(entry);
|
|
1887
|
+
if (this.deliveryLog.length > 1e3) {
|
|
1888
|
+
this.deliveryLog = this.deliveryLog.slice(-500);
|
|
1889
|
+
}
|
|
1890
|
+
console.log(`[Webhook] ${entry.success ? "\u2705" : "\u274C"} ${entry.event} \u2192 ${entry.statusCode} (attempt ${entry.attemptNumber})`);
|
|
1891
|
+
}
|
|
1892
|
+
async persistDelivery(entry, payload) {
|
|
1893
|
+
if (!entry || !this.config.onDelivery) return;
|
|
1894
|
+
try {
|
|
1895
|
+
await this.config.onDelivery(entry, payload);
|
|
1896
|
+
} catch (err) {
|
|
1897
|
+
console.error("[Webhook] onDelivery callback failed:", err);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Get the delivery log for dashboard display
|
|
1902
|
+
*/
|
|
1903
|
+
getDeliveryLog(limit = 50) {
|
|
1904
|
+
return this.deliveryLog.slice(-limit).reverse();
|
|
1905
|
+
}
|
|
1906
|
+
/**
|
|
1907
|
+
* Verify an incoming webhook signature (for consumers).
|
|
1908
|
+
*
|
|
1909
|
+
* @example
|
|
1910
|
+
* ```ts
|
|
1911
|
+
* import { verifyWebhookSignature } from '@arcenpay/node';
|
|
1912
|
+
*
|
|
1913
|
+
* app.post('/webhooks/meap', (req, res) => {
|
|
1914
|
+
* const raw = JSON.stringify(req.body);
|
|
1915
|
+
* const sig = req.headers['x-meap-signature'] as string;
|
|
1916
|
+
* if (!verifyWebhookSignature(raw, sig, process.env.MEAP_WEBHOOK_SECRET!)) {
|
|
1917
|
+
* return res.status(401).json({ error: 'Invalid signature' });
|
|
1918
|
+
* }
|
|
1919
|
+
* });
|
|
1920
|
+
* ```
|
|
1921
|
+
*/
|
|
1922
|
+
static verifySignature(body, signature, secret) {
|
|
1923
|
+
const expected = createHmac("sha256", secret).update(body).digest("hex");
|
|
1924
|
+
return expected === signature;
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Cancel all pending retries and clean up
|
|
1928
|
+
*/
|
|
1929
|
+
stop() {
|
|
1930
|
+
for (const timer of this.pendingRetries.values()) {
|
|
1931
|
+
clearTimeout(timer);
|
|
1932
|
+
}
|
|
1933
|
+
this.pendingRetries.clear();
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
function verifyWebhookSignature(body, signature, secret) {
|
|
1937
|
+
return WebhookService.verifySignature(body, signature, secret);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// src/services/email.ts
|
|
1941
|
+
var EmailService = class {
|
|
1942
|
+
apiKey;
|
|
1943
|
+
fromAddress;
|
|
1944
|
+
brandName;
|
|
1945
|
+
constructor(config) {
|
|
1946
|
+
this.apiKey = config.apiKey;
|
|
1947
|
+
this.fromAddress = config.fromAddress;
|
|
1948
|
+
this.brandName = config.brandName || "ArcenPay";
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* Send an email using a pre-defined template
|
|
1952
|
+
*/
|
|
1953
|
+
async sendTemplate(template, recipient, data) {
|
|
1954
|
+
const payload = this.buildEmail(template, recipient, data);
|
|
1955
|
+
try {
|
|
1956
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
1957
|
+
method: "POST",
|
|
1958
|
+
headers: {
|
|
1959
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
1960
|
+
"Content-Type": "application/json"
|
|
1961
|
+
},
|
|
1962
|
+
body: JSON.stringify({
|
|
1963
|
+
from: `${this.brandName} <${this.fromAddress}>`,
|
|
1964
|
+
to: [payload.to],
|
|
1965
|
+
subject: payload.subject,
|
|
1966
|
+
html: payload.html
|
|
1967
|
+
})
|
|
1968
|
+
});
|
|
1969
|
+
if (!response.ok) {
|
|
1970
|
+
const err = await response.text();
|
|
1971
|
+
console.error(`[Email] Failed to send ${template}:`, err);
|
|
1972
|
+
return { success: false, error: err };
|
|
1973
|
+
}
|
|
1974
|
+
const result = await response.json();
|
|
1975
|
+
console.log(`[Email] \u2705 Sent ${template} to ${recipient.email} (${result.id})`);
|
|
1976
|
+
return { success: true, messageId: result.id };
|
|
1977
|
+
} catch (err) {
|
|
1978
|
+
console.error(`[Email] Error sending ${template}:`, err.message);
|
|
1979
|
+
return { success: false, error: err.message };
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* Build the email payload for a given template
|
|
1984
|
+
*/
|
|
1985
|
+
buildEmail(template, recipient, data) {
|
|
1986
|
+
const shortAddr = `${data.walletAddress?.slice(0, 6)}...${data.walletAddress?.slice(-4)}`;
|
|
1987
|
+
const templates = {
|
|
1988
|
+
welcome: {
|
|
1989
|
+
subject: `Welcome to ${this.brandName}!`,
|
|
1990
|
+
body: `
|
|
1991
|
+
<h2>Welcome to ${this.brandName}! \u{1F389}</h2>
|
|
1992
|
+
<p>Your subscription is now active.</p>
|
|
1993
|
+
<table style="border-collapse:collapse;width:100%;max-width:400px">
|
|
1994
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><strong>Plan</strong></td><td style="padding:8px;border-bottom:1px solid #eee">${data.planTier || "Pro"}</td></tr>
|
|
1995
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><strong>Wallet</strong></td><td style="padding:8px;border-bottom:1px solid #eee"><code>${shortAddr}</code></td></tr>
|
|
1996
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><strong>Expires</strong></td><td style="padding:8px;border-bottom:1px solid #eee">${data.expiresAt || "N/A"}</td></tr>
|
|
1997
|
+
<tr><td style="padding:8px"><strong>Token ID</strong></td><td style="padding:8px">#${data.tokenId || "\u2014"}</td></tr>
|
|
1998
|
+
</table>
|
|
1999
|
+
<p style="margin-top:16px;color:#888">Transaction: <a href="${data.explorerUrl || "#"}">${data.txHash?.slice(0, 16) || ""}...</a></p>
|
|
2000
|
+
`
|
|
2001
|
+
},
|
|
2002
|
+
renewal_receipt: {
|
|
2003
|
+
subject: `${this.brandName} \u2014 Subscription Renewed`,
|
|
2004
|
+
body: `
|
|
2005
|
+
<h2>Subscription Renewed \u2705</h2>
|
|
2006
|
+
<p>Your subscription has been automatically renewed.</p>
|
|
2007
|
+
<table style="border-collapse:collapse;width:100%;max-width:400px">
|
|
2008
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><strong>Amount</strong></td><td style="padding:8px;border-bottom:1px solid #eee">${data.amount || "0"} USDC</td></tr>
|
|
2009
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><strong>New Expiry</strong></td><td style="padding:8px;border-bottom:1px solid #eee">${data.expiresAt || "N/A"}</td></tr>
|
|
2010
|
+
<tr><td style="padding:8px"><strong>Wallet</strong></td><td style="padding:8px"><code>${shortAddr}</code></td></tr>
|
|
2011
|
+
</table>
|
|
2012
|
+
<p style="margin-top:16px;color:#888">Transaction: <a href="${data.explorerUrl || "#"}">${data.txHash?.slice(0, 16) || ""}...</a></p>
|
|
2013
|
+
`
|
|
2014
|
+
},
|
|
2015
|
+
payment_failed: {
|
|
2016
|
+
subject: `\u26A0\uFE0F ${this.brandName} \u2014 Payment Failed`,
|
|
2017
|
+
body: `
|
|
2018
|
+
<h2>Payment Failed \u26A0\uFE0F</h2>
|
|
2019
|
+
<p>We were unable to process your subscription renewal.</p>
|
|
2020
|
+
<p><strong>Reason:</strong> ${data.reason || "Insufficient balance"}</p>
|
|
2021
|
+
<p><strong>Wallet:</strong> <code>${shortAddr}</code></p>
|
|
2022
|
+
<p>Please ensure your smart account has sufficient USDC balance to avoid service interruption.</p>
|
|
2023
|
+
`
|
|
2024
|
+
},
|
|
2025
|
+
subscription_expiring: {
|
|
2026
|
+
subject: `${this.brandName} \u2014 Subscription Expiring Soon`,
|
|
2027
|
+
body: `
|
|
2028
|
+
<h2>Subscription Expiring Soon \u23F0</h2>
|
|
2029
|
+
<p>Your subscription will expire on <strong>${data.expiresAt || "soon"}</strong>.</p>
|
|
2030
|
+
<p>Please ensure your wallet has sufficient balance for automatic renewal, or renew manually.</p>
|
|
2031
|
+
<p><strong>Wallet:</strong> <code>${shortAddr}</code></p>
|
|
2032
|
+
`
|
|
2033
|
+
},
|
|
2034
|
+
access_revoked: {
|
|
2035
|
+
subject: `${this.brandName} \u2014 Access Revoked`,
|
|
2036
|
+
body: `
|
|
2037
|
+
<h2>Access Revoked</h2>
|
|
2038
|
+
<p>Your subscription has been cancelled and access has been revoked.</p>
|
|
2039
|
+
<p><strong>Token ID:</strong> #${data.tokenId || "\u2014"}</p>
|
|
2040
|
+
<p><strong>Wallet:</strong> <code>${shortAddr}</code></p>
|
|
2041
|
+
<p>You can re-subscribe at any time to restore access.</p>
|
|
2042
|
+
`
|
|
2043
|
+
},
|
|
2044
|
+
low_session_balance: {
|
|
2045
|
+
subject: `\u26A0\uFE0F ${this.brandName} \u2014 Low Session Balance`,
|
|
2046
|
+
body: `
|
|
2047
|
+
<h2>Low Session Balance \u26A0\uFE0F</h2>
|
|
2048
|
+
<p>Your ZKVUB session vault balance is running low.</p>
|
|
2049
|
+
<table style="border-collapse:collapse;width:100%;max-width:400px">
|
|
2050
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><strong>Remaining</strong></td><td style="padding:8px;border-bottom:1px solid #eee">${data.remaining || "0"} USDC</td></tr>
|
|
2051
|
+
<tr><td style="padding:8px"><strong>Session ID</strong></td><td style="padding:8px"><code>${data.sessionId?.slice(0, 16) || ""}...</code></td></tr>
|
|
2052
|
+
</table>
|
|
2053
|
+
<p>Please deposit additional USDC to avoid service interruption.</p>
|
|
2054
|
+
`
|
|
2055
|
+
}
|
|
2056
|
+
};
|
|
2057
|
+
const tpl = templates[template];
|
|
2058
|
+
return {
|
|
2059
|
+
to: recipient.email,
|
|
2060
|
+
subject: tpl.subject,
|
|
2061
|
+
html: this.wrapInLayout(tpl.body)
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Wrap template body in a consistent email layout
|
|
2066
|
+
*/
|
|
2067
|
+
wrapInLayout(body) {
|
|
2068
|
+
return `
|
|
2069
|
+
<!DOCTYPE html>
|
|
2070
|
+
<html>
|
|
2071
|
+
<head><meta charset="utf-8"></head>
|
|
2072
|
+
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a2e;background:#fafafa">
|
|
2073
|
+
<div style="background:#fff;border-radius:12px;padding:32px;box-shadow:0 2px 8px rgba(0,0,0,0.06)">
|
|
2074
|
+
${body}
|
|
2075
|
+
</div>
|
|
2076
|
+
<div style="text-align:center;margin-top:24px;font-size:12px;color:#999">
|
|
2077
|
+
<p>${this.brandName} \u2014 MEAP + ZKVUB Protocol</p>
|
|
2078
|
+
<p>Powered by on-chain entitlements</p>
|
|
2079
|
+
</div>
|
|
2080
|
+
</body>
|
|
2081
|
+
</html>
|
|
2082
|
+
`;
|
|
2083
|
+
}
|
|
2084
|
+
};
|
|
2085
|
+
|
|
2086
|
+
// src/services/lit.ts
|
|
2087
|
+
import { LIT_NETWORK } from "@lit-protocol/constants";
|
|
2088
|
+
import { decryptToString, encryptString } from "@lit-protocol/encryption";
|
|
2089
|
+
import { LitNodeClient } from "@lit-protocol/lit-node-client";
|
|
2090
|
+
var LitProtocolService = class {
|
|
2091
|
+
config;
|
|
2092
|
+
litClient = null;
|
|
2093
|
+
constructor(config) {
|
|
2094
|
+
this.config = config;
|
|
2095
|
+
}
|
|
2096
|
+
async connect() {
|
|
2097
|
+
const litNetwork = (() => {
|
|
2098
|
+
switch (this.config.network) {
|
|
2099
|
+
case "habanero":
|
|
2100
|
+
case "datil":
|
|
2101
|
+
return LIT_NETWORK.Datil;
|
|
2102
|
+
case "datil-test":
|
|
2103
|
+
case "manzano":
|
|
2104
|
+
return LIT_NETWORK.DatilTest;
|
|
2105
|
+
case "datil-dev":
|
|
2106
|
+
default:
|
|
2107
|
+
return LIT_NETWORK.DatilDev;
|
|
2108
|
+
}
|
|
2109
|
+
})();
|
|
2110
|
+
this.litClient = new LitNodeClient({
|
|
2111
|
+
litNetwork,
|
|
2112
|
+
debug: false
|
|
2113
|
+
});
|
|
2114
|
+
await this.litClient.connect();
|
|
2115
|
+
console.log(`[LitProtocol] Connected to ${litNetwork}`);
|
|
2116
|
+
}
|
|
2117
|
+
buildSubscriptionACC(_walletAddress) {
|
|
2118
|
+
return [
|
|
2119
|
+
{
|
|
2120
|
+
contractAddress: this.config.registryAddress,
|
|
2121
|
+
standardContractType: "Custom",
|
|
2122
|
+
chain: this.config.chain,
|
|
2123
|
+
method: "hasActiveSubscription",
|
|
2124
|
+
parameters: [":userAddress"],
|
|
2125
|
+
returnValueTest: {
|
|
2126
|
+
comparator: "=",
|
|
2127
|
+
value: "true"
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
];
|
|
2131
|
+
}
|
|
2132
|
+
buildTierACC(minimumTier) {
|
|
2133
|
+
const tierOrder = { starter: 1, pro: 2, enterprise: 3 };
|
|
2134
|
+
const minValue = tierOrder[minimumTier] || 1;
|
|
2135
|
+
return [
|
|
2136
|
+
{
|
|
2137
|
+
contractAddress: this.config.registryAddress,
|
|
2138
|
+
standardContractType: "Custom",
|
|
2139
|
+
chain: this.config.chain,
|
|
2140
|
+
method: "hasActiveSubscription",
|
|
2141
|
+
parameters: [":userAddress"],
|
|
2142
|
+
returnValueTest: {
|
|
2143
|
+
comparator: "=",
|
|
2144
|
+
value: "true"
|
|
2145
|
+
}
|
|
2146
|
+
},
|
|
2147
|
+
{
|
|
2148
|
+
contractAddress: this.config.registryAddress,
|
|
2149
|
+
standardContractType: "Custom",
|
|
2150
|
+
chain: this.config.chain,
|
|
2151
|
+
method: "getWalletSubscription",
|
|
2152
|
+
parameters: [":userAddress"],
|
|
2153
|
+
returnValueTest: {
|
|
2154
|
+
comparator: ">=",
|
|
2155
|
+
value: String(minValue > 0 ? 1 : 0)
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
];
|
|
2159
|
+
}
|
|
2160
|
+
async encrypt(content, accessConditions) {
|
|
2161
|
+
this.ensureConnected();
|
|
2162
|
+
const result = await encryptString(
|
|
2163
|
+
{
|
|
2164
|
+
accessControlConditions: accessConditions,
|
|
2165
|
+
chain: this.config.chain,
|
|
2166
|
+
dataToEncrypt: content
|
|
2167
|
+
},
|
|
2168
|
+
this.litClient
|
|
2169
|
+
);
|
|
2170
|
+
return {
|
|
2171
|
+
ciphertext: result.ciphertext,
|
|
2172
|
+
dataToEncryptHash: result.dataToEncryptHash
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
async decrypt(ciphertext, dataToEncryptHash, accessConditions, auth) {
|
|
2176
|
+
this.ensureConnected();
|
|
2177
|
+
if (!auth?.authSig && !auth?.sessionSigs) {
|
|
2178
|
+
throw new Error(
|
|
2179
|
+
"[LitProtocol] Missing auth input. Provide authSig or sessionSigs for decryption."
|
|
2180
|
+
);
|
|
2181
|
+
}
|
|
2182
|
+
const decrypted = await decryptToString(
|
|
2183
|
+
{
|
|
2184
|
+
accessControlConditions: accessConditions,
|
|
2185
|
+
chain: this.config.chain,
|
|
2186
|
+
ciphertext,
|
|
2187
|
+
dataToEncryptHash,
|
|
2188
|
+
...auth.authSig ? { authSig: auth.authSig } : {},
|
|
2189
|
+
...auth.sessionSigs ? { sessionSigs: auth.sessionSigs } : {}
|
|
2190
|
+
},
|
|
2191
|
+
this.litClient
|
|
2192
|
+
);
|
|
2193
|
+
return decrypted;
|
|
2194
|
+
}
|
|
2195
|
+
async disconnect() {
|
|
2196
|
+
if (this.litClient) {
|
|
2197
|
+
await this.litClient.disconnect();
|
|
2198
|
+
this.litClient = null;
|
|
2199
|
+
}
|
|
2200
|
+
console.log("[LitProtocol] Disconnected");
|
|
2201
|
+
}
|
|
2202
|
+
ensureConnected() {
|
|
2203
|
+
if (!this.litClient) {
|
|
2204
|
+
throw new Error("[LitProtocol] Not connected. Call connect() first.");
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
};
|
|
2208
|
+
|
|
2209
|
+
// src/services/aggregator.ts
|
|
2210
|
+
import { createHash as createHash2 } from "crypto";
|
|
2211
|
+
var AggregatorService = class {
|
|
2212
|
+
logs = /* @__PURE__ */ new Map();
|
|
2213
|
+
merkleRoots = /* @__PURE__ */ new Map();
|
|
2214
|
+
settlementThreshold;
|
|
2215
|
+
constructor(config) {
|
|
2216
|
+
this.settlementThreshold = config?.settlementThreshold || 100;
|
|
2217
|
+
}
|
|
2218
|
+
/**
|
|
2219
|
+
* Append a signed usage entry
|
|
2220
|
+
*/
|
|
2221
|
+
addEntry(entry) {
|
|
2222
|
+
const existing = this.logs.get(entry.sessionId) || [];
|
|
2223
|
+
existing.push(entry);
|
|
2224
|
+
this.logs.set(entry.sessionId, existing);
|
|
2225
|
+
const thresholdReached = existing.length >= this.settlementThreshold;
|
|
2226
|
+
if (thresholdReached) {
|
|
2227
|
+
console.log(`[Aggregator] Threshold reached for session ${entry.sessionId} (${existing.length} entries)`);
|
|
2228
|
+
}
|
|
2229
|
+
return { totalEntries: existing.length, thresholdReached };
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* Build a Merkle tree from session's usage logs
|
|
2233
|
+
* Returns the Merkle root hash
|
|
2234
|
+
*/
|
|
2235
|
+
buildMerkleTree(sessionId) {
|
|
2236
|
+
const entries = this.logs.get(sessionId) || [];
|
|
2237
|
+
if (entries.length === 0) return "0x" + "0".repeat(64);
|
|
2238
|
+
const leaves = entries.map(
|
|
2239
|
+
(entry) => this.hashEntry(entry)
|
|
2240
|
+
);
|
|
2241
|
+
const root = this.computeMerkleRoot(leaves);
|
|
2242
|
+
this.merkleRoots.set(sessionId, root);
|
|
2243
|
+
console.log(`[Aggregator] Merkle root for ${sessionId}: ${root} (${entries.length} leaves)`);
|
|
2244
|
+
return root;
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Get circuit inputs for zk-SNARK proving
|
|
2248
|
+
* These inputs are passed to the Circom circuit
|
|
2249
|
+
*/
|
|
2250
|
+
getCircuitInputs(sessionId) {
|
|
2251
|
+
const entries = this.logs.get(sessionId) || [];
|
|
2252
|
+
const merkleRoot = this.merkleRoots.get(sessionId) || this.buildMerkleTree(sessionId);
|
|
2253
|
+
const timestamps = entries.map((e) => e.timestamp);
|
|
2254
|
+
const windowStart = timestamps.length > 0 ? Math.min(...timestamps) : 0;
|
|
2255
|
+
const windowEnd = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
|
2256
|
+
return {
|
|
2257
|
+
callCount: entries.length,
|
|
2258
|
+
merkleRoot,
|
|
2259
|
+
windowStart,
|
|
2260
|
+
windowEnd,
|
|
2261
|
+
logHashes: entries.map((e) => this.hashEntry(e))
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
/**
|
|
2265
|
+
* Clear entries for a settled session window
|
|
2266
|
+
*/
|
|
2267
|
+
clearSettledEntries(sessionId, windowEnd) {
|
|
2268
|
+
const entries = this.logs.get(sessionId) || [];
|
|
2269
|
+
this.logs.set(
|
|
2270
|
+
sessionId,
|
|
2271
|
+
entries.filter((e) => e.timestamp > windowEnd)
|
|
2272
|
+
);
|
|
2273
|
+
this.merkleRoots.delete(sessionId);
|
|
2274
|
+
console.log(`[Aggregator] Cleared entries for ${sessionId} before ${windowEnd}`);
|
|
2275
|
+
}
|
|
2276
|
+
/**
|
|
2277
|
+
* Get aggregation stats for monitoring
|
|
2278
|
+
*/
|
|
2279
|
+
getStats() {
|
|
2280
|
+
let totalEntries = 0;
|
|
2281
|
+
let pendingSettlements = 0;
|
|
2282
|
+
for (const [, entries] of this.logs) {
|
|
2283
|
+
totalEntries += entries.length;
|
|
2284
|
+
if (entries.length >= this.settlementThreshold) {
|
|
2285
|
+
pendingSettlements++;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
return {
|
|
2289
|
+
sessions: this.logs.size,
|
|
2290
|
+
totalEntries,
|
|
2291
|
+
pendingSettlements
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Hash a single usage entry (leaf in the Merkle tree)
|
|
2296
|
+
*/
|
|
2297
|
+
hashEntry(entry) {
|
|
2298
|
+
const data = `${entry.sessionId}|${entry.endpoint}|${entry.method}|${entry.timestamp}|${entry.responseSize}|${entry.signature}`;
|
|
2299
|
+
return "0x" + createHash2("sha256").update(data).digest("hex");
|
|
2300
|
+
}
|
|
2301
|
+
/**
|
|
2302
|
+
* Compute Merkle root from leaf hashes
|
|
2303
|
+
*/
|
|
2304
|
+
computeMerkleRoot(leaves) {
|
|
2305
|
+
if (leaves.length === 0) return "0x" + "0".repeat(64);
|
|
2306
|
+
if (leaves.length === 1) return leaves[0];
|
|
2307
|
+
while (leaves.length & leaves.length - 1) {
|
|
2308
|
+
leaves.push("0x" + "0".repeat(64));
|
|
2309
|
+
}
|
|
2310
|
+
let currentLevel = leaves;
|
|
2311
|
+
while (currentLevel.length > 1) {
|
|
2312
|
+
const nextLevel = [];
|
|
2313
|
+
for (let i = 0; i < currentLevel.length; i += 2) {
|
|
2314
|
+
const combined = currentLevel[i] + currentLevel[i + 1].slice(2);
|
|
2315
|
+
nextLevel.push("0x" + createHash2("sha256").update(combined).digest("hex"));
|
|
2316
|
+
}
|
|
2317
|
+
currentLevel = nextLevel;
|
|
2318
|
+
}
|
|
2319
|
+
return currentLevel[0];
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
|
|
2323
|
+
// src/services/redis-usage.ts
|
|
2324
|
+
import { createClient } from "redis";
|
|
2325
|
+
function sessionKey(prefix, sessionId) {
|
|
2326
|
+
return `${prefix}:usage:${sessionId}`;
|
|
2327
|
+
}
|
|
2328
|
+
function settlementKey(prefix) {
|
|
2329
|
+
return `${prefix}:settlement:latest`;
|
|
2330
|
+
}
|
|
2331
|
+
var RedisUsageStore = class {
|
|
2332
|
+
config;
|
|
2333
|
+
client;
|
|
2334
|
+
connected = false;
|
|
2335
|
+
constructor(config) {
|
|
2336
|
+
this.config = {
|
|
2337
|
+
keyPrefix: "meap",
|
|
2338
|
+
ttlSeconds: 7 * 24 * 60 * 60,
|
|
2339
|
+
...config
|
|
2340
|
+
};
|
|
2341
|
+
this.client = createClient({ url: this.config.url });
|
|
2342
|
+
this.client.on("error", (err) => {
|
|
2343
|
+
console.error("[RedisUsageStore] Redis error:", err);
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
async connect() {
|
|
2347
|
+
if (this.connected) return;
|
|
2348
|
+
await this.client.connect();
|
|
2349
|
+
this.connected = true;
|
|
2350
|
+
}
|
|
2351
|
+
async appendUsage(entry) {
|
|
2352
|
+
if (!this.connected) {
|
|
2353
|
+
await this.connect();
|
|
2354
|
+
}
|
|
2355
|
+
const key = sessionKey(this.config.keyPrefix, entry.sessionId);
|
|
2356
|
+
await this.client.rPush(key, JSON.stringify(entry));
|
|
2357
|
+
await this.client.expire(key, this.config.ttlSeconds);
|
|
2358
|
+
}
|
|
2359
|
+
async getUsageCount(sessionId) {
|
|
2360
|
+
if (!this.connected) {
|
|
2361
|
+
await this.connect();
|
|
2362
|
+
}
|
|
2363
|
+
return this.client.lLen(sessionKey(this.config.keyPrefix, sessionId));
|
|
2364
|
+
}
|
|
2365
|
+
async getUsageEntries(sessionId, limit = 100) {
|
|
2366
|
+
if (!this.connected) {
|
|
2367
|
+
await this.connect();
|
|
2368
|
+
}
|
|
2369
|
+
const upper = Math.max(0, limit - 1);
|
|
2370
|
+
const values = await this.client.lRange(sessionKey(this.config.keyPrefix, sessionId), 0, upper);
|
|
2371
|
+
return values.map((value) => {
|
|
2372
|
+
try {
|
|
2373
|
+
return JSON.parse(value);
|
|
2374
|
+
} catch {
|
|
2375
|
+
return null;
|
|
2376
|
+
}
|
|
2377
|
+
}).filter((value) => value !== null);
|
|
2378
|
+
}
|
|
2379
|
+
async setLatestSettlementTx(sessionId, txHash) {
|
|
2380
|
+
if (!this.connected) {
|
|
2381
|
+
await this.connect();
|
|
2382
|
+
}
|
|
2383
|
+
await this.client.hSet(settlementKey(this.config.keyPrefix), sessionId, txHash);
|
|
2384
|
+
await this.client.expire(settlementKey(this.config.keyPrefix), this.config.ttlSeconds);
|
|
2385
|
+
}
|
|
2386
|
+
async getLatestSettlementTx(sessionId) {
|
|
2387
|
+
if (!this.connected) {
|
|
2388
|
+
await this.connect();
|
|
2389
|
+
}
|
|
2390
|
+
const txHash = await this.client.hGet(settlementKey(this.config.keyPrefix), sessionId);
|
|
2391
|
+
return txHash ?? null;
|
|
2392
|
+
}
|
|
2393
|
+
async stop() {
|
|
2394
|
+
if (this.connected) {
|
|
2395
|
+
await this.client.quit();
|
|
2396
|
+
this.connected = false;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
};
|
|
2400
|
+
|
|
2401
|
+
// src/services/proof-orchestrator.ts
|
|
2402
|
+
var ProofOrchestrator = class {
|
|
2403
|
+
usageService;
|
|
2404
|
+
config;
|
|
2405
|
+
/**
|
|
2406
|
+
* Nullifiers that have been submitted — prevents double-settlement.
|
|
2407
|
+
* Key: nullifier hash, Value: settlement timestamp
|
|
2408
|
+
*/
|
|
2409
|
+
settledNullifiers = /* @__PURE__ */ new Map();
|
|
2410
|
+
/** Active proof jobs indexed by `sessionId:windowEnd` */
|
|
2411
|
+
activeJobs = /* @__PURE__ */ new Map();
|
|
2412
|
+
/** Completed job history (last 200) */
|
|
2413
|
+
jobHistory = [];
|
|
2414
|
+
/** Interval for auto-submit checks */
|
|
2415
|
+
autoCheckInterval = null;
|
|
2416
|
+
constructor(config = {}) {
|
|
2417
|
+
this.usageService = new UsageService(config);
|
|
2418
|
+
this.config = {
|
|
2419
|
+
maxRetries: config.maxRetries ?? 3,
|
|
2420
|
+
retryDelays: config.retryDelays ?? [2e3, 5e3, 15e3],
|
|
2421
|
+
autoSubmitThreshold: config.autoSubmitThreshold ?? 50,
|
|
2422
|
+
windowDurationSeconds: config.windowDurationSeconds ?? 3600
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
/**
|
|
2426
|
+
* Records usage and auto-triggers proof+submission when threshold is reached
|
|
2427
|
+
*/
|
|
2428
|
+
async recordUsage(sessionId, callData) {
|
|
2429
|
+
await this.usageService.logUsage(sessionId, callData);
|
|
2430
|
+
const count = this.usageService.getUsageCount(sessionId);
|
|
2431
|
+
if (count >= this.config.autoSubmitThreshold) {
|
|
2432
|
+
void this.generateAndSubmit(sessionId).catch(
|
|
2433
|
+
(err) => console.error(
|
|
2434
|
+
`[ProofOrchestrator] Auto-submit failed for ${sessionId}:`,
|
|
2435
|
+
err
|
|
2436
|
+
)
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* Manually triggers proof generation + submission for a session
|
|
2442
|
+
*/
|
|
2443
|
+
async generateAndSubmit(sessionId) {
|
|
2444
|
+
const windowEnd = Math.floor(Date.now() / 1e3);
|
|
2445
|
+
const jobKey = `${sessionId}:${windowEnd}`;
|
|
2446
|
+
const existingJob = this.activeJobs.get(jobKey);
|
|
2447
|
+
if (existingJob && existingJob.status !== "failed") {
|
|
2448
|
+
return existingJob;
|
|
2449
|
+
}
|
|
2450
|
+
const job = {
|
|
2451
|
+
sessionId,
|
|
2452
|
+
windowEnd,
|
|
2453
|
+
windowStart: null,
|
|
2454
|
+
callCount: null,
|
|
2455
|
+
settlementAmount: null,
|
|
2456
|
+
status: "pending",
|
|
2457
|
+
attempts: 0,
|
|
2458
|
+
nullifier: null,
|
|
2459
|
+
txHash: null,
|
|
2460
|
+
error: null,
|
|
2461
|
+
createdAt: Date.now(),
|
|
2462
|
+
lastAttemptAt: 0
|
|
2463
|
+
};
|
|
2464
|
+
this.activeJobs.set(jobKey, job);
|
|
2465
|
+
return this.executeJob(job);
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* Execute a proof job with deterministic retry
|
|
2469
|
+
*/
|
|
2470
|
+
async executeJob(job) {
|
|
2471
|
+
const jobKey = `${job.sessionId}:${job.windowEnd}`;
|
|
2472
|
+
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
2473
|
+
job.attempts = attempt + 1;
|
|
2474
|
+
job.lastAttemptAt = Date.now();
|
|
2475
|
+
if (attempt > 0) {
|
|
2476
|
+
const delay = this.config.retryDelays[attempt - 1] ?? 15e3;
|
|
2477
|
+
console.log(
|
|
2478
|
+
`[ProofOrchestrator] Retry ${attempt}/${this.config.maxRetries} in ${delay}ms for ${job.sessionId}`
|
|
2479
|
+
);
|
|
2480
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2481
|
+
}
|
|
2482
|
+
try {
|
|
2483
|
+
job.status = "generating";
|
|
2484
|
+
const { proof, publicInputs } = await this.usageService.generateProof(
|
|
2485
|
+
job.sessionId,
|
|
2486
|
+
job.windowEnd
|
|
2487
|
+
);
|
|
2488
|
+
job.windowStart = Number(publicInputs.windowStart);
|
|
2489
|
+
job.callCount = publicInputs.callCount.toString();
|
|
2490
|
+
const nullifierKey = publicInputs.nullifier;
|
|
2491
|
+
if (this.settledNullifiers.has(nullifierKey)) {
|
|
2492
|
+
console.warn(
|
|
2493
|
+
`[ProofOrchestrator] Nullifier already settled: ${nullifierKey}`
|
|
2494
|
+
);
|
|
2495
|
+
job.status = "settled";
|
|
2496
|
+
job.nullifier = nullifierKey;
|
|
2497
|
+
job.error = "Nullifier already settled (idempotent skip)";
|
|
2498
|
+
this.archiveJob(jobKey, job);
|
|
2499
|
+
return job;
|
|
2500
|
+
}
|
|
2501
|
+
job.status = "submitting";
|
|
2502
|
+
const settlement = await this.usageService.submitProof(
|
|
2503
|
+
proof,
|
|
2504
|
+
publicInputs
|
|
2505
|
+
);
|
|
2506
|
+
job.status = "settled";
|
|
2507
|
+
job.nullifier = nullifierKey;
|
|
2508
|
+
job.txHash = settlement.transactionHash;
|
|
2509
|
+
job.settlementAmount = settlement.settlementAmount.toString();
|
|
2510
|
+
job.error = null;
|
|
2511
|
+
this.settledNullifiers.set(nullifierKey, Date.now());
|
|
2512
|
+
this.usageService.clearLogs(job.sessionId, job.windowEnd);
|
|
2513
|
+
console.log(
|
|
2514
|
+
`[ProofOrchestrator] \u2705 Settled ${job.sessionId} \u2192 ${settlement.transactionHash}`
|
|
2515
|
+
);
|
|
2516
|
+
this.archiveJob(jobKey, job);
|
|
2517
|
+
return job;
|
|
2518
|
+
} catch (err) {
|
|
2519
|
+
job.error = err?.message || String(err);
|
|
2520
|
+
job.status = "failed";
|
|
2521
|
+
console.error(
|
|
2522
|
+
`[ProofOrchestrator] Attempt ${attempt + 1} failed for ${job.sessionId}:`,
|
|
2523
|
+
err?.message
|
|
2524
|
+
);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
console.error(
|
|
2528
|
+
`[ProofOrchestrator] \u274C All ${this.config.maxRetries + 1} attempts failed for ${job.sessionId}`
|
|
2529
|
+
);
|
|
2530
|
+
this.archiveJob(jobKey, job);
|
|
2531
|
+
return job;
|
|
2532
|
+
}
|
|
2533
|
+
/**
|
|
2534
|
+
* Move a job from active to history
|
|
2535
|
+
*/
|
|
2536
|
+
archiveJob(jobKey, job) {
|
|
2537
|
+
this.activeJobs.delete(jobKey);
|
|
2538
|
+
this.jobHistory.push({ ...job });
|
|
2539
|
+
if (this.jobHistory.length > 200) {
|
|
2540
|
+
this.jobHistory.splice(0, this.jobHistory.length - 200);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Start auto-submit monitoring (polls every 30s)
|
|
2545
|
+
*/
|
|
2546
|
+
startAutoSubmit() {
|
|
2547
|
+
if (this.autoCheckInterval) return;
|
|
2548
|
+
console.log(
|
|
2549
|
+
`[ProofOrchestrator] Auto-submit started (threshold: ${this.config.autoSubmitThreshold} calls)`
|
|
2550
|
+
);
|
|
2551
|
+
this.autoCheckInterval = setInterval(async () => {
|
|
2552
|
+
try {
|
|
2553
|
+
const sessions = this.usageService.getAllSessions();
|
|
2554
|
+
for (const sessionId of sessions) {
|
|
2555
|
+
const count = this.usageService.getUsageCount(sessionId);
|
|
2556
|
+
if (count >= this.config.autoSubmitThreshold) {
|
|
2557
|
+
const jobKey = Array.from(this.activeJobs.keys()).find(
|
|
2558
|
+
(k) => k.startsWith(`${sessionId}:`)
|
|
2559
|
+
);
|
|
2560
|
+
if (!jobKey) {
|
|
2561
|
+
console.log(
|
|
2562
|
+
`[ProofOrchestrator] Auto-submit triggered for ${sessionId} (${count} calls)`
|
|
2563
|
+
);
|
|
2564
|
+
void this.generateAndSubmit(sessionId).catch(
|
|
2565
|
+
(err) => console.error(
|
|
2566
|
+
`[ProofOrchestrator] Auto-submit failed for ${sessionId}:`,
|
|
2567
|
+
err
|
|
2568
|
+
)
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
} catch (err) {
|
|
2574
|
+
console.error("[ProofOrchestrator] Auto-submit poll error:", err);
|
|
2575
|
+
}
|
|
2576
|
+
}, 3e4);
|
|
2577
|
+
if (typeof this.autoCheckInterval === "object" && "unref" in this.autoCheckInterval) {
|
|
2578
|
+
this.autoCheckInterval.unref();
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
/**
|
|
2582
|
+
* Stop auto-submit monitoring
|
|
2583
|
+
*/
|
|
2584
|
+
stop() {
|
|
2585
|
+
if (this.autoCheckInterval) {
|
|
2586
|
+
clearInterval(this.autoCheckInterval);
|
|
2587
|
+
this.autoCheckInterval = null;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
/**
|
|
2591
|
+
* Get current active proof jobs
|
|
2592
|
+
*/
|
|
2593
|
+
getActiveJobs() {
|
|
2594
|
+
return Array.from(this.activeJobs.values());
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Get completed job history
|
|
2598
|
+
*/
|
|
2599
|
+
getJobHistory(limit = 50) {
|
|
2600
|
+
return this.jobHistory.slice(-limit).reverse();
|
|
2601
|
+
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Check if a nullifier has already been settled
|
|
2604
|
+
*/
|
|
2605
|
+
isNullifierSettled(nullifier) {
|
|
2606
|
+
return this.settledNullifiers.has(nullifier);
|
|
2607
|
+
}
|
|
2608
|
+
/**
|
|
2609
|
+
* Get the underlying UsageService for direct access
|
|
2610
|
+
*/
|
|
2611
|
+
getUsageService() {
|
|
2612
|
+
return this.usageService;
|
|
2613
|
+
}
|
|
2614
|
+
};
|
|
2615
|
+
|
|
2616
|
+
// src/services/transport-axelar.ts
|
|
2617
|
+
import {
|
|
2618
|
+
createPublicClient as createPublicClient6,
|
|
2619
|
+
createWalletClient as createWalletClient3,
|
|
2620
|
+
http as http6
|
|
2621
|
+
} from "viem";
|
|
2622
|
+
import { privateKeyToAccount as privateKeyToAccount3 } from "viem/accounts";
|
|
2623
|
+
import { sepolia as sepolia6, baseSepolia as baseSepolia6, base as base6 } from "viem/chains";
|
|
2624
|
+
import { DEFAULT_CHAIN_ID as DEFAULT_CHAIN_ID5 } from "@arcenpay/sdk";
|
|
2625
|
+
var CHAIN_MAP6 = {
|
|
2626
|
+
11155111: sepolia6,
|
|
2627
|
+
84532: baseSepolia6,
|
|
2628
|
+
8453: base6
|
|
2629
|
+
};
|
|
2630
|
+
var AXELAR_CHAIN_NAMES = {
|
|
2631
|
+
1: "ethereum",
|
|
2632
|
+
11155111: "ethereum-sepolia",
|
|
2633
|
+
137: "polygon",
|
|
2634
|
+
42161: "arbitrum",
|
|
2635
|
+
10: "optimism",
|
|
2636
|
+
8453: "base",
|
|
2637
|
+
84532: "base-sepolia",
|
|
2638
|
+
43114: "avalanche"
|
|
2639
|
+
};
|
|
2640
|
+
var AXELAR_GATEWAY_ABI = [
|
|
2641
|
+
{
|
|
2642
|
+
name: "callContract",
|
|
2643
|
+
type: "function",
|
|
2644
|
+
stateMutability: "nonpayable",
|
|
2645
|
+
inputs: [
|
|
2646
|
+
{ name: "destinationChain", type: "string" },
|
|
2647
|
+
{ name: "contractAddress", type: "string" },
|
|
2648
|
+
{ name: "payload", type: "bytes" }
|
|
2649
|
+
],
|
|
2650
|
+
outputs: []
|
|
2651
|
+
}
|
|
2652
|
+
];
|
|
2653
|
+
var AXELAR_GAS_SERVICE_ABI = [
|
|
2654
|
+
{
|
|
2655
|
+
name: "payNativeGasForContractCall",
|
|
2656
|
+
type: "function",
|
|
2657
|
+
stateMutability: "payable",
|
|
2658
|
+
inputs: [
|
|
2659
|
+
{ name: "sender", type: "address" },
|
|
2660
|
+
{ name: "destinationChain", type: "string" },
|
|
2661
|
+
{ name: "destinationAddress", type: "string" },
|
|
2662
|
+
{ name: "payload", type: "bytes" },
|
|
2663
|
+
{ name: "refundAddress", type: "address" }
|
|
2664
|
+
],
|
|
2665
|
+
outputs: []
|
|
2666
|
+
},
|
|
2667
|
+
{
|
|
2668
|
+
name: "estimateGasFee",
|
|
2669
|
+
type: "function",
|
|
2670
|
+
stateMutability: "view",
|
|
2671
|
+
inputs: [
|
|
2672
|
+
{ name: "destinationChain", type: "string" },
|
|
2673
|
+
{ name: "destinationAddress", type: "string" },
|
|
2674
|
+
{ name: "payload", type: "bytes" }
|
|
2675
|
+
],
|
|
2676
|
+
outputs: [{ name: "", type: "uint256" }]
|
|
2677
|
+
}
|
|
2678
|
+
];
|
|
2679
|
+
var AxelarTransport = class {
|
|
2680
|
+
config;
|
|
2681
|
+
chainId;
|
|
2682
|
+
publicClient;
|
|
2683
|
+
walletClient;
|
|
2684
|
+
signerAddress;
|
|
2685
|
+
constructor(config) {
|
|
2686
|
+
this.config = config;
|
|
2687
|
+
this.chainId = config.chainId ?? DEFAULT_CHAIN_ID5;
|
|
2688
|
+
const chain = CHAIN_MAP6[this.chainId] || baseSepolia6;
|
|
2689
|
+
const account = privateKeyToAccount3(config.privateKey);
|
|
2690
|
+
this.signerAddress = account.address;
|
|
2691
|
+
this.publicClient = createPublicClient6({
|
|
2692
|
+
chain,
|
|
2693
|
+
transport: http6(config.rpcUrl)
|
|
2694
|
+
});
|
|
2695
|
+
this.walletClient = createWalletClient3({
|
|
2696
|
+
account,
|
|
2697
|
+
chain,
|
|
2698
|
+
transport: http6(config.rpcUrl)
|
|
2699
|
+
});
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Dispatch a cross-chain mirror update via Axelar GMP
|
|
2703
|
+
*/
|
|
2704
|
+
async dispatch(destinationChainId, payload, refundAddress, destinationAddressOverride) {
|
|
2705
|
+
const destChainName = AXELAR_CHAIN_NAMES[destinationChainId];
|
|
2706
|
+
if (!destChainName) {
|
|
2707
|
+
throw new Error(
|
|
2708
|
+
`Unsupported destination chain ID: ${destinationChainId}`
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2711
|
+
const destAddress = destinationAddressOverride || this.config.mirrorRegistryAddress;
|
|
2712
|
+
let gasEstimate = 0n;
|
|
2713
|
+
try {
|
|
2714
|
+
gasEstimate = await this.publicClient.readContract({
|
|
2715
|
+
address: this.config.gasServiceAddress,
|
|
2716
|
+
abi: AXELAR_GAS_SERVICE_ABI,
|
|
2717
|
+
functionName: "estimateGasFee",
|
|
2718
|
+
args: [destChainName, destAddress, payload]
|
|
2719
|
+
});
|
|
2720
|
+
} catch {
|
|
2721
|
+
gasEstimate = 2000000000000000n;
|
|
2722
|
+
}
|
|
2723
|
+
const gasBudget = gasEstimate * 120n / 100n;
|
|
2724
|
+
const gasPayTx = await this.walletClient.writeContract({
|
|
2725
|
+
address: this.config.gasServiceAddress,
|
|
2726
|
+
abi: AXELAR_GAS_SERVICE_ABI,
|
|
2727
|
+
functionName: "payNativeGasForContractCall",
|
|
2728
|
+
args: [
|
|
2729
|
+
this.signerAddress,
|
|
2730
|
+
destChainName,
|
|
2731
|
+
destAddress,
|
|
2732
|
+
payload,
|
|
2733
|
+
refundAddress
|
|
2734
|
+
],
|
|
2735
|
+
value: gasBudget
|
|
2736
|
+
});
|
|
2737
|
+
await this.publicClient.waitForTransactionReceipt({ hash: gasPayTx });
|
|
2738
|
+
const dispatchTx = await this.walletClient.writeContract({
|
|
2739
|
+
address: this.config.gatewayAddress,
|
|
2740
|
+
abi: AXELAR_GATEWAY_ABI,
|
|
2741
|
+
functionName: "callContract",
|
|
2742
|
+
args: [destChainName, destAddress, payload]
|
|
2743
|
+
});
|
|
2744
|
+
const receipt = await this.publicClient.waitForTransactionReceipt({
|
|
2745
|
+
hash: dispatchTx
|
|
2746
|
+
});
|
|
2747
|
+
console.log(
|
|
2748
|
+
`[AxelarTransport] \u2705 Dispatched to ${destChainName} \u2192 tx: ${dispatchTx}`
|
|
2749
|
+
);
|
|
2750
|
+
return {
|
|
2751
|
+
transactionHash: dispatchTx,
|
|
2752
|
+
messageId: `axelar-${dispatchTx}`,
|
|
2753
|
+
route: "axelar",
|
|
2754
|
+
destinationChain: destChainName,
|
|
2755
|
+
gasEstimate: gasBudget
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
/**
|
|
2759
|
+
* Get the estimated gas cost for a dispatch
|
|
2760
|
+
*/
|
|
2761
|
+
async estimateGas(destinationChainId, payload, destinationAddressOverride) {
|
|
2762
|
+
const destChainName = AXELAR_CHAIN_NAMES[destinationChainId];
|
|
2763
|
+
if (!destChainName) return 0n;
|
|
2764
|
+
const destAddress = destinationAddressOverride || this.config.mirrorRegistryAddress;
|
|
2765
|
+
try {
|
|
2766
|
+
return await this.publicClient.readContract({
|
|
2767
|
+
address: this.config.gasServiceAddress,
|
|
2768
|
+
abi: AXELAR_GAS_SERVICE_ABI,
|
|
2769
|
+
functionName: "estimateGasFee",
|
|
2770
|
+
args: [destChainName, destAddress, payload]
|
|
2771
|
+
});
|
|
2772
|
+
} catch {
|
|
2773
|
+
return 2000000000000000n;
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
/**
|
|
2777
|
+
* Get signer's gas balance (for alert monitoring)
|
|
2778
|
+
*/
|
|
2779
|
+
async getSignerBalance() {
|
|
2780
|
+
return this.publicClient.getBalance({ address: this.signerAddress });
|
|
2781
|
+
}
|
|
2782
|
+
};
|
|
2783
|
+
|
|
2784
|
+
// src/services/transport-ccip.ts
|
|
2785
|
+
import { createPublicClient as createPublicClient7, createWalletClient as createWalletClient4, http as http7 } from "viem";
|
|
2786
|
+
import { privateKeyToAccount as privateKeyToAccount4 } from "viem/accounts";
|
|
2787
|
+
import { sepolia as sepolia7, baseSepolia as baseSepolia7, base as base7 } from "viem/chains";
|
|
2788
|
+
import { DEFAULT_CHAIN_ID as DEFAULT_CHAIN_ID6 } from "@arcenpay/sdk";
|
|
2789
|
+
var CHAIN_MAP7 = {
|
|
2790
|
+
11155111: sepolia7,
|
|
2791
|
+
84532: baseSepolia7,
|
|
2792
|
+
8453: base7
|
|
2793
|
+
};
|
|
2794
|
+
var CCIP_CHAIN_SELECTORS = {
|
|
2795
|
+
1: 5009297550715157269n,
|
|
2796
|
+
// Ethereum Mainnet
|
|
2797
|
+
11155111: 16015286601757825753n,
|
|
2798
|
+
// Sepolia
|
|
2799
|
+
137: 4051577828743386545n,
|
|
2800
|
+
// Polygon
|
|
2801
|
+
42161: 4949039107694359620n,
|
|
2802
|
+
// Arbitrum
|
|
2803
|
+
10: 3734403246176062136n,
|
|
2804
|
+
// Optimism
|
|
2805
|
+
8453: 15971525489660198786n,
|
|
2806
|
+
// Base
|
|
2807
|
+
84532: 10344971235874465080n,
|
|
2808
|
+
// Base Sepolia
|
|
2809
|
+
43114: 6433500567565415381n
|
|
2810
|
+
// Avalanche
|
|
2811
|
+
};
|
|
2812
|
+
var CCIP_ROUTER_ABI = [
|
|
2813
|
+
{
|
|
2814
|
+
name: "ccipSend",
|
|
2815
|
+
type: "function",
|
|
2816
|
+
stateMutability: "payable",
|
|
2817
|
+
inputs: [
|
|
2818
|
+
{ name: "destinationChainSelector", type: "uint64" },
|
|
2819
|
+
{
|
|
2820
|
+
name: "message",
|
|
2821
|
+
type: "tuple",
|
|
2822
|
+
components: [
|
|
2823
|
+
{ name: "receiver", type: "bytes" },
|
|
2824
|
+
{ name: "data", type: "bytes" },
|
|
2825
|
+
{
|
|
2826
|
+
name: "tokenAmounts",
|
|
2827
|
+
type: "tuple[]",
|
|
2828
|
+
components: [
|
|
2829
|
+
{ name: "token", type: "address" },
|
|
2830
|
+
{ name: "amount", type: "uint256" }
|
|
2831
|
+
]
|
|
2832
|
+
},
|
|
2833
|
+
{ name: "feeToken", type: "address" },
|
|
2834
|
+
{ name: "extraArgs", type: "bytes" }
|
|
2835
|
+
]
|
|
2836
|
+
}
|
|
2837
|
+
],
|
|
2838
|
+
outputs: [{ name: "messageId", type: "bytes32" }]
|
|
2839
|
+
},
|
|
2840
|
+
{
|
|
2841
|
+
name: "getFee",
|
|
2842
|
+
type: "function",
|
|
2843
|
+
stateMutability: "view",
|
|
2844
|
+
inputs: [
|
|
2845
|
+
{ name: "destinationChainSelector", type: "uint64" },
|
|
2846
|
+
{
|
|
2847
|
+
name: "message",
|
|
2848
|
+
type: "tuple",
|
|
2849
|
+
components: [
|
|
2850
|
+
{ name: "receiver", type: "bytes" },
|
|
2851
|
+
{ name: "data", type: "bytes" },
|
|
2852
|
+
{
|
|
2853
|
+
name: "tokenAmounts",
|
|
2854
|
+
type: "tuple[]",
|
|
2855
|
+
components: [
|
|
2856
|
+
{ name: "token", type: "address" },
|
|
2857
|
+
{ name: "amount", type: "uint256" }
|
|
2858
|
+
]
|
|
2859
|
+
},
|
|
2860
|
+
{ name: "feeToken", type: "address" },
|
|
2861
|
+
{ name: "extraArgs", type: "bytes" }
|
|
2862
|
+
]
|
|
2863
|
+
}
|
|
2864
|
+
],
|
|
2865
|
+
outputs: [{ name: "fee", type: "uint256" }]
|
|
2866
|
+
}
|
|
2867
|
+
];
|
|
2868
|
+
var CCIPTransport = class {
|
|
2869
|
+
config;
|
|
2870
|
+
chainId;
|
|
2871
|
+
publicClient;
|
|
2872
|
+
walletClient;
|
|
2873
|
+
signerAddress;
|
|
2874
|
+
gasLimit;
|
|
2875
|
+
constructor(config) {
|
|
2876
|
+
this.config = config;
|
|
2877
|
+
this.chainId = config.chainId ?? DEFAULT_CHAIN_ID6;
|
|
2878
|
+
this.gasLimit = config.gasLimit || 200000n;
|
|
2879
|
+
const chain = CHAIN_MAP7[this.chainId] || baseSepolia7;
|
|
2880
|
+
const account = privateKeyToAccount4(config.privateKey);
|
|
2881
|
+
this.signerAddress = account.address;
|
|
2882
|
+
this.publicClient = createPublicClient7({
|
|
2883
|
+
chain,
|
|
2884
|
+
transport: http7(config.rpcUrl)
|
|
2885
|
+
});
|
|
2886
|
+
this.walletClient = createWalletClient4({
|
|
2887
|
+
account,
|
|
2888
|
+
chain,
|
|
2889
|
+
transport: http7(config.rpcUrl)
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
/**
|
|
2893
|
+
* Dispatch a cross-chain mirror update via Chainlink CCIP
|
|
2894
|
+
*/
|
|
2895
|
+
async dispatch(destinationChainId, payload, refundAddress, destinationAddressOverride) {
|
|
2896
|
+
const destSelector = CCIP_CHAIN_SELECTORS[destinationChainId];
|
|
2897
|
+
if (!destSelector) {
|
|
2898
|
+
throw new Error(
|
|
2899
|
+
`Unsupported CCIP destination chain ID: ${destinationChainId}`
|
|
2900
|
+
);
|
|
2901
|
+
}
|
|
2902
|
+
const message = this.buildMessage(payload, destinationAddressOverride);
|
|
2903
|
+
let fee = 0n;
|
|
2904
|
+
try {
|
|
2905
|
+
fee = await this.publicClient.readContract({
|
|
2906
|
+
address: this.config.routerAddress,
|
|
2907
|
+
abi: CCIP_ROUTER_ABI,
|
|
2908
|
+
functionName: "getFee",
|
|
2909
|
+
args: [destSelector, message]
|
|
2910
|
+
});
|
|
2911
|
+
} catch {
|
|
2912
|
+
fee = 5000000000000000n;
|
|
2913
|
+
}
|
|
2914
|
+
const feeBudget = fee * 115n / 100n;
|
|
2915
|
+
const txHash = await this.walletClient.writeContract({
|
|
2916
|
+
address: this.config.routerAddress,
|
|
2917
|
+
abi: CCIP_ROUTER_ABI,
|
|
2918
|
+
functionName: "ccipSend",
|
|
2919
|
+
args: [destSelector, message],
|
|
2920
|
+
value: feeBudget
|
|
2921
|
+
});
|
|
2922
|
+
const receipt = await this.publicClient.waitForTransactionReceipt({
|
|
2923
|
+
hash: txHash
|
|
2924
|
+
});
|
|
2925
|
+
console.log(
|
|
2926
|
+
`[CCIPTransport] \u2705 Dispatched to selector ${destSelector} \u2192 tx: ${txHash}`
|
|
2927
|
+
);
|
|
2928
|
+
return {
|
|
2929
|
+
transactionHash: txHash,
|
|
2930
|
+
messageId: `ccip-${txHash}`,
|
|
2931
|
+
route: "ccip",
|
|
2932
|
+
destinationChainSelector: destSelector,
|
|
2933
|
+
fee: feeBudget
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Build a CCIP EVM2AnyMessage
|
|
2938
|
+
*/
|
|
2939
|
+
buildMessage(payload, destinationAddressOverride) {
|
|
2940
|
+
const extraArgs = `0x97a657c9${this.gasLimit.toString(16).padStart(64, "0")}`;
|
|
2941
|
+
return {
|
|
2942
|
+
receiver: destinationAddressOverride || this.config.mirrorRegistryAddress,
|
|
2943
|
+
data: payload,
|
|
2944
|
+
tokenAmounts: [],
|
|
2945
|
+
feeToken: "0x0000000000000000000000000000000000000000",
|
|
2946
|
+
// Native gas
|
|
2947
|
+
extraArgs
|
|
2948
|
+
};
|
|
2949
|
+
}
|
|
2950
|
+
/**
|
|
2951
|
+
* Estimate the CCIP fee for a dispatch
|
|
2952
|
+
*/
|
|
2953
|
+
async estimateFee(destinationChainId, payload, destinationAddressOverride) {
|
|
2954
|
+
const destSelector = CCIP_CHAIN_SELECTORS[destinationChainId];
|
|
2955
|
+
if (!destSelector) return 0n;
|
|
2956
|
+
try {
|
|
2957
|
+
return await this.publicClient.readContract({
|
|
2958
|
+
address: this.config.routerAddress,
|
|
2959
|
+
abi: CCIP_ROUTER_ABI,
|
|
2960
|
+
functionName: "getFee",
|
|
2961
|
+
args: [destSelector, this.buildMessage(payload, destinationAddressOverride)]
|
|
2962
|
+
});
|
|
2963
|
+
} catch {
|
|
2964
|
+
return 5000000000000000n;
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
/**
|
|
2968
|
+
* Get signer's gas balance (for alert monitoring)
|
|
2969
|
+
*/
|
|
2970
|
+
async getSignerBalance() {
|
|
2971
|
+
return this.publicClient.getBalance({ address: this.signerAddress });
|
|
2972
|
+
}
|
|
2973
|
+
};
|
|
2974
|
+
|
|
2975
|
+
// src/services/keeper.ts
|
|
2976
|
+
import { createPublicClient as createPublicClient8, createWalletClient as createWalletClient5, http as http8 } from "viem";
|
|
2977
|
+
import { privateKeyToAccount as privateKeyToAccount5 } from "viem/accounts";
|
|
2978
|
+
import { sepolia as sepolia8, baseSepolia as baseSepolia8, base as base8 } from "viem/chains";
|
|
2979
|
+
import {
|
|
2980
|
+
DEFAULT_CHAIN_ID as DEFAULT_CHAIN_ID7,
|
|
2981
|
+
SubscriptionRegistryABI,
|
|
2982
|
+
ERC7579AutopayModuleABI,
|
|
2983
|
+
getContractAddresses as getContractAddresses5
|
|
2984
|
+
} from "@arcenpay/sdk";
|
|
2985
|
+
var CHAIN_MAP8 = {
|
|
2986
|
+
11155111: sepolia8,
|
|
2987
|
+
84532: baseSepolia8,
|
|
2988
|
+
8453: base8
|
|
2989
|
+
};
|
|
2990
|
+
var BillingKeeper = class {
|
|
2991
|
+
chainId;
|
|
2992
|
+
publicClient;
|
|
2993
|
+
walletClient;
|
|
2994
|
+
signerAddress;
|
|
2995
|
+
intervalMs;
|
|
2996
|
+
batchSize;
|
|
2997
|
+
renewalBufferSeconds;
|
|
2998
|
+
registryAddress;
|
|
2999
|
+
autopayModuleAddress;
|
|
3000
|
+
pollTimer = null;
|
|
3001
|
+
processing = false;
|
|
3002
|
+
renewalLog = [];
|
|
3003
|
+
callbacks = [];
|
|
3004
|
+
constructor(config) {
|
|
3005
|
+
this.chainId = config.chainId ?? DEFAULT_CHAIN_ID7;
|
|
3006
|
+
const chain = CHAIN_MAP8[this.chainId] || baseSepolia8;
|
|
3007
|
+
const contracts = getContractAddresses5(this.chainId);
|
|
3008
|
+
this.registryAddress = contracts.subscriptionRegistry;
|
|
3009
|
+
this.autopayModuleAddress = contracts.autopayModule;
|
|
3010
|
+
this.intervalMs = config.intervalMs || 6e4;
|
|
3011
|
+
this.batchSize = config.batchSize || 20;
|
|
3012
|
+
this.renewalBufferSeconds = config.renewalBufferSeconds || 86400;
|
|
3013
|
+
const account = privateKeyToAccount5(config.privateKey);
|
|
3014
|
+
this.signerAddress = account.address;
|
|
3015
|
+
this.publicClient = createPublicClient8({
|
|
3016
|
+
chain,
|
|
3017
|
+
transport: http8(config.rpcUrl)
|
|
3018
|
+
});
|
|
3019
|
+
this.walletClient = createWalletClient5({
|
|
3020
|
+
account,
|
|
3021
|
+
chain,
|
|
3022
|
+
transport: http8(config.rpcUrl)
|
|
3023
|
+
});
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* Register a callback for renewal events
|
|
3027
|
+
*/
|
|
3028
|
+
onRenewal(callback) {
|
|
3029
|
+
this.callbacks.push(callback);
|
|
3030
|
+
return () => {
|
|
3031
|
+
this.callbacks = this.callbacks.filter((cb) => cb !== callback);
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
/**
|
|
3035
|
+
* Start the keeper polling loop
|
|
3036
|
+
*/
|
|
3037
|
+
start() {
|
|
3038
|
+
if (this.pollTimer) return;
|
|
3039
|
+
console.log(
|
|
3040
|
+
`[BillingKeeper] Started \u2014 polling every ${this.intervalMs / 1e3}s, renewal buffer ${this.renewalBufferSeconds}s, batch size ${this.batchSize}`
|
|
3041
|
+
);
|
|
3042
|
+
void this.poll();
|
|
3043
|
+
this.pollTimer = setInterval(() => {
|
|
3044
|
+
void this.poll();
|
|
3045
|
+
}, this.intervalMs);
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* Stop the keeper
|
|
3049
|
+
*/
|
|
3050
|
+
stop() {
|
|
3051
|
+
if (this.pollTimer) {
|
|
3052
|
+
clearInterval(this.pollTimer);
|
|
3053
|
+
this.pollTimer = null;
|
|
3054
|
+
}
|
|
3055
|
+
console.log("[BillingKeeper] Stopped");
|
|
3056
|
+
}
|
|
3057
|
+
/**
|
|
3058
|
+
* Get renewal history
|
|
3059
|
+
*/
|
|
3060
|
+
getRenewalLog(limit = 50) {
|
|
3061
|
+
return this.renewalLog.slice(-limit).reverse();
|
|
3062
|
+
}
|
|
3063
|
+
/**
|
|
3064
|
+
* Get keeper stats
|
|
3065
|
+
*/
|
|
3066
|
+
getStats() {
|
|
3067
|
+
const successful = this.renewalLog.filter((r) => r.success).length;
|
|
3068
|
+
return {
|
|
3069
|
+
running: this.pollTimer !== null,
|
|
3070
|
+
totalRenewals: this.renewalLog.length,
|
|
3071
|
+
successfulRenewals: successful,
|
|
3072
|
+
failedRenewals: this.renewalLog.length - successful
|
|
3073
|
+
};
|
|
3074
|
+
}
|
|
3075
|
+
/**
|
|
3076
|
+
* Poll for subscriptions needing renewal
|
|
3077
|
+
*/
|
|
3078
|
+
async poll() {
|
|
3079
|
+
if (this.processing) return;
|
|
3080
|
+
this.processing = true;
|
|
3081
|
+
try {
|
|
3082
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
3083
|
+
const renewalDeadline = now + this.renewalBufferSeconds;
|
|
3084
|
+
const expiringTokenIds = await this.findExpiringSubscriptions(
|
|
3085
|
+
BigInt(now),
|
|
3086
|
+
BigInt(renewalDeadline)
|
|
3087
|
+
);
|
|
3088
|
+
if (expiringTokenIds.length === 0) {
|
|
3089
|
+
this.processing = false;
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
console.log(
|
|
3093
|
+
`[BillingKeeper] Found ${expiringTokenIds.length} subscriptions nearing expiry`
|
|
3094
|
+
);
|
|
3095
|
+
const batch = expiringTokenIds.slice(0, this.batchSize);
|
|
3096
|
+
for (const tokenId of batch) {
|
|
3097
|
+
await this.processRenewal(tokenId);
|
|
3098
|
+
}
|
|
3099
|
+
} catch (err) {
|
|
3100
|
+
console.error("[BillingKeeper] Poll error:", err);
|
|
3101
|
+
} finally {
|
|
3102
|
+
this.processing = false;
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
/**
|
|
3106
|
+
* Find subscriptions expiring between now and the renewal deadline
|
|
3107
|
+
*/
|
|
3108
|
+
async findExpiringSubscriptions(now, deadline) {
|
|
3109
|
+
try {
|
|
3110
|
+
const totalSupply = await this.publicClient.readContract({
|
|
3111
|
+
address: this.registryAddress,
|
|
3112
|
+
abi: SubscriptionRegistryABI,
|
|
3113
|
+
functionName: "totalSupply"
|
|
3114
|
+
});
|
|
3115
|
+
const tokenIds = [];
|
|
3116
|
+
for (let index = 0n; index < totalSupply; index += 1n) {
|
|
3117
|
+
try {
|
|
3118
|
+
const tokenId = await this.publicClient.readContract({
|
|
3119
|
+
address: this.registryAddress,
|
|
3120
|
+
abi: SubscriptionRegistryABI,
|
|
3121
|
+
functionName: "tokenByIndex",
|
|
3122
|
+
args: [index]
|
|
3123
|
+
});
|
|
3124
|
+
const expiresAt = await this.publicClient.readContract({
|
|
3125
|
+
address: this.registryAddress,
|
|
3126
|
+
abi: SubscriptionRegistryABI,
|
|
3127
|
+
functionName: "expiresAt",
|
|
3128
|
+
args: [tokenId]
|
|
3129
|
+
});
|
|
3130
|
+
if (expiresAt > now && expiresAt <= deadline) {
|
|
3131
|
+
tokenIds.push(tokenId);
|
|
3132
|
+
}
|
|
3133
|
+
} catch (err) {
|
|
3134
|
+
console.warn(
|
|
3135
|
+
`[BillingKeeper] Skipping token index ${index.toString()} while scanning expiring subscriptions:`,
|
|
3136
|
+
err
|
|
3137
|
+
);
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
return tokenIds;
|
|
3141
|
+
} catch (err) {
|
|
3142
|
+
console.error("[BillingKeeper] Error finding expiring subscriptions:", err);
|
|
3143
|
+
return [];
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
/**
|
|
3147
|
+
* Process a single subscription renewal
|
|
3148
|
+
*/
|
|
3149
|
+
async processRenewal(tokenId) {
|
|
3150
|
+
try {
|
|
3151
|
+
const subscriber = await this.publicClient.readContract({
|
|
3152
|
+
address: this.registryAddress,
|
|
3153
|
+
abi: SubscriptionRegistryABI,
|
|
3154
|
+
functionName: "ownerOf",
|
|
3155
|
+
args: [tokenId]
|
|
3156
|
+
});
|
|
3157
|
+
const isInstalled = await this.checkAutopayInstalled(subscriber);
|
|
3158
|
+
if (!isInstalled) {
|
|
3159
|
+
console.log(
|
|
3160
|
+
`[BillingKeeper] Skipping tokenId=${tokenId} \u2014 no AutopayModule installed`
|
|
3161
|
+
);
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
const config = await this.publicClient.readContract({
|
|
3165
|
+
address: this.autopayModuleAddress,
|
|
3166
|
+
abi: ERC7579AutopayModuleABI,
|
|
3167
|
+
functionName: "getConfig",
|
|
3168
|
+
args: [subscriber]
|
|
3169
|
+
});
|
|
3170
|
+
const merchant = String(config?.merchant ?? config?.[0] ?? "").toLowerCase();
|
|
3171
|
+
const amount = BigInt(config?.maxAmount ?? config?.[1] ?? 0);
|
|
3172
|
+
if (amount <= 0n) {
|
|
3173
|
+
console.log(
|
|
3174
|
+
`[BillingKeeper] Skipping tokenId=${tokenId} \u2014 autopay maxAmount is not configured`
|
|
3175
|
+
);
|
|
3176
|
+
return;
|
|
3177
|
+
}
|
|
3178
|
+
if (merchant !== this.signerAddress.toLowerCase()) {
|
|
3179
|
+
throw new Error(
|
|
3180
|
+
`Keeper signer ${this.signerAddress} does not match autopay merchant ${merchant || "unknown"}. Renewals must be executed by the configured merchant wallet.`
|
|
3181
|
+
);
|
|
3182
|
+
}
|
|
3183
|
+
const txHash = await this.walletClient.writeContract({
|
|
3184
|
+
address: this.autopayModuleAddress,
|
|
3185
|
+
abi: ERC7579AutopayModuleABI,
|
|
3186
|
+
functionName: "execute",
|
|
3187
|
+
args: [subscriber, amount]
|
|
3188
|
+
});
|
|
3189
|
+
await this.publicClient.waitForTransactionReceipt({ hash: txHash });
|
|
3190
|
+
const result = {
|
|
3191
|
+
tokenId,
|
|
3192
|
+
subscriber,
|
|
3193
|
+
txHash,
|
|
3194
|
+
success: true
|
|
3195
|
+
};
|
|
3196
|
+
this.logRenewal(result);
|
|
3197
|
+
console.log(
|
|
3198
|
+
`[BillingKeeper] \u2705 Renewed tokenId=${tokenId} for ${subscriber} \u2014 tx: ${txHash}`
|
|
3199
|
+
);
|
|
3200
|
+
} catch (err) {
|
|
3201
|
+
const result = {
|
|
3202
|
+
tokenId,
|
|
3203
|
+
subscriber: "unknown",
|
|
3204
|
+
txHash: "0x",
|
|
3205
|
+
success: false,
|
|
3206
|
+
error: err?.message || String(err)
|
|
3207
|
+
};
|
|
3208
|
+
this.logRenewal(result);
|
|
3209
|
+
console.error(
|
|
3210
|
+
`[BillingKeeper] \u274C Failed to renew tokenId=${tokenId}:`,
|
|
3211
|
+
err?.message
|
|
3212
|
+
);
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
/**
|
|
3216
|
+
* Check if AutopayModule is installed for a subscriber
|
|
3217
|
+
*/
|
|
3218
|
+
async checkAutopayInstalled(subscriber) {
|
|
3219
|
+
try {
|
|
3220
|
+
const config = await this.publicClient.readContract({
|
|
3221
|
+
address: this.autopayModuleAddress,
|
|
3222
|
+
abi: ERC7579AutopayModuleABI,
|
|
3223
|
+
functionName: "getConfig",
|
|
3224
|
+
args: [subscriber]
|
|
3225
|
+
});
|
|
3226
|
+
const maxAmount = BigInt(config?.maxAmount ?? config?.[0] ?? 0);
|
|
3227
|
+
return maxAmount > 0n;
|
|
3228
|
+
} catch {
|
|
3229
|
+
return false;
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
/**
|
|
3233
|
+
* Log a renewal result
|
|
3234
|
+
*/
|
|
3235
|
+
logRenewal(result) {
|
|
3236
|
+
this.renewalLog.push(result);
|
|
3237
|
+
if (this.renewalLog.length > 1e3) {
|
|
3238
|
+
this.renewalLog = this.renewalLog.slice(-500);
|
|
3239
|
+
}
|
|
3240
|
+
for (const cb of this.callbacks) {
|
|
3241
|
+
try {
|
|
3242
|
+
cb(result);
|
|
3243
|
+
} catch (err) {
|
|
3244
|
+
console.error("[BillingKeeper] Callback error:", err);
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
};
|
|
3249
|
+
|
|
3250
|
+
// src/index.ts
|
|
3251
|
+
init_nonce_store();
|
|
3252
|
+
|
|
3253
|
+
// src/services/event-contracts.ts
|
|
3254
|
+
var PROTOCOL_EVENT_SCHEMA_VERSION = "2026-03-09.1";
|
|
3255
|
+
function normalizePart(value) {
|
|
3256
|
+
return value.trim().replace(/\s+/g, "_").replace(/[:/]/g, "-");
|
|
3257
|
+
}
|
|
3258
|
+
function buildEventIdempotencyKey(name, uniqueParts) {
|
|
3259
|
+
const suffix = uniqueParts.map((part) => normalizePart(String(part))).filter(Boolean).join(":");
|
|
3260
|
+
return suffix ? `${name}:${suffix}` : name;
|
|
3261
|
+
}
|
|
3262
|
+
function createProtocolEvent(name, payload, options) {
|
|
3263
|
+
const occurred = options.occurredAt instanceof Date ? options.occurredAt.toISOString() : typeof options.occurredAt === "number" ? new Date(options.occurredAt).toISOString() : options.occurredAt || (/* @__PURE__ */ new Date()).toISOString();
|
|
3264
|
+
return {
|
|
3265
|
+
schemaVersion: PROTOCOL_EVENT_SCHEMA_VERSION,
|
|
3266
|
+
name,
|
|
3267
|
+
source: options.source,
|
|
3268
|
+
idempotencyKey: options.idempotencyKey,
|
|
3269
|
+
chainId: options.chainId,
|
|
3270
|
+
occurredAt: occurred,
|
|
3271
|
+
payload
|
|
3272
|
+
};
|
|
3273
|
+
}
|
|
3274
|
+
function mapProtocolEventToBillingType(name) {
|
|
3275
|
+
switch (name) {
|
|
3276
|
+
case "subscription.minted":
|
|
3277
|
+
return "SUBSCRIPTION_MINTED";
|
|
3278
|
+
case "subscription.renewed":
|
|
3279
|
+
return "SUBSCRIPTION_RENEWED";
|
|
3280
|
+
case "subscription.renewal_failed":
|
|
3281
|
+
return "PAYMENT_FAILED";
|
|
3282
|
+
case "subscription.cancelled":
|
|
3283
|
+
return "SUBSCRIPTION_CANCELLED";
|
|
3284
|
+
default:
|
|
3285
|
+
throw new Error(
|
|
3286
|
+
`[mapProtocolEventToBillingType] Unsupported event name for billing bridge: ${name}`
|
|
3287
|
+
);
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
export {
|
|
3291
|
+
AggregatorService,
|
|
3292
|
+
ArcenApiError,
|
|
3293
|
+
ArcenClient,
|
|
3294
|
+
AxelarTransport,
|
|
3295
|
+
BillingKeeper,
|
|
3296
|
+
CCIPTransport,
|
|
3297
|
+
EmailService,
|
|
3298
|
+
EventListenerService,
|
|
3299
|
+
InMemoryNonceStore,
|
|
3300
|
+
LitProtocolService,
|
|
3301
|
+
PROTOCOL_EVENT_SCHEMA_VERSION,
|
|
3302
|
+
ProofOrchestrator,
|
|
3303
|
+
RedisNonceStore,
|
|
3304
|
+
RedisUsageStore,
|
|
3305
|
+
SettlementService,
|
|
3306
|
+
SettlementWriterService,
|
|
3307
|
+
TablelandService,
|
|
3308
|
+
UsageProofError,
|
|
3309
|
+
UsageProofErrorCodes,
|
|
3310
|
+
UsageService,
|
|
3311
|
+
WebhookService,
|
|
3312
|
+
buildEventIdempotencyKey,
|
|
3313
|
+
createProtocolEvent,
|
|
3314
|
+
mapProtocolEventToBillingType,
|
|
3315
|
+
verifyWebhookSignature,
|
|
3316
|
+
x402Middleware
|
|
3317
|
+
};
|