@vera-pay/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1359 @@
1
+ // src/client.ts
2
+ import { ethers } from "ethers";
3
+
4
+ // src/abi.ts
5
+ var VERA_PAY_ABI = [
6
+ {
7
+ type: "constructor",
8
+ inputs: [
9
+ { name: "_feeRecipient", type: "address" },
10
+ { name: "_feeBps", type: "uint256" }
11
+ ],
12
+ stateMutability: "nonpayable"
13
+ },
14
+ {
15
+ type: "function",
16
+ name: "createPlan",
17
+ inputs: [
18
+ { name: "_paymentToken", type: "address" },
19
+ { name: "_amount", type: "uint256" },
20
+ { name: "_interval", type: "uint256" },
21
+ { name: "_name", type: "string" },
22
+ { name: "_metadataURI", type: "string" }
23
+ ],
24
+ outputs: [{ name: "planId", type: "uint256" }],
25
+ stateMutability: "nonpayable"
26
+ },
27
+ {
28
+ type: "function",
29
+ name: "togglePlan",
30
+ inputs: [{ name: "_planId", type: "uint256" }],
31
+ outputs: [],
32
+ stateMutability: "nonpayable"
33
+ },
34
+ {
35
+ type: "function",
36
+ name: "subscribe",
37
+ inputs: [{ name: "_planId", type: "uint256" }],
38
+ outputs: [{ name: "subId", type: "uint256" }],
39
+ stateMutability: "nonpayable"
40
+ },
41
+ {
42
+ type: "function",
43
+ name: "cancelSubscription",
44
+ inputs: [{ name: "_subId", type: "uint256" }],
45
+ outputs: [],
46
+ stateMutability: "nonpayable"
47
+ },
48
+ {
49
+ type: "function",
50
+ name: "processPayment",
51
+ inputs: [{ name: "_subId", type: "uint256" }],
52
+ outputs: [],
53
+ stateMutability: "nonpayable"
54
+ },
55
+ {
56
+ type: "function",
57
+ name: "batchProcessPayments",
58
+ inputs: [{ name: "_subIds", type: "uint256[]" }],
59
+ outputs: [],
60
+ stateMutability: "nonpayable"
61
+ },
62
+ {
63
+ type: "function",
64
+ name: "getPlan",
65
+ inputs: [{ name: "_planId", type: "uint256" }],
66
+ outputs: [
67
+ {
68
+ name: "",
69
+ type: "tuple",
70
+ components: [
71
+ { name: "merchant", type: "address" },
72
+ { name: "paymentToken", type: "address" },
73
+ { name: "amount", type: "uint256" },
74
+ { name: "interval", type: "uint256" },
75
+ { name: "name", type: "string" },
76
+ { name: "metadataURI", type: "string" },
77
+ { name: "active", type: "bool" }
78
+ ]
79
+ }
80
+ ],
81
+ stateMutability: "view"
82
+ },
83
+ {
84
+ type: "function",
85
+ name: "getSubscription",
86
+ inputs: [{ name: "_subId", type: "uint256" }],
87
+ outputs: [
88
+ {
89
+ name: "",
90
+ type: "tuple",
91
+ components: [
92
+ { name: "planId", type: "uint256" },
93
+ { name: "subscriber", type: "address" },
94
+ { name: "startTime", type: "uint256" },
95
+ { name: "lastPaymentTime", type: "uint256" },
96
+ { name: "paymentsCount", type: "uint256" },
97
+ { name: "active", type: "bool" }
98
+ ]
99
+ }
100
+ ],
101
+ stateMutability: "view"
102
+ },
103
+ {
104
+ type: "function",
105
+ name: "getMerchantPlans",
106
+ inputs: [{ name: "_merchant", type: "address" }],
107
+ outputs: [{ name: "", type: "uint256[]" }],
108
+ stateMutability: "view"
109
+ },
110
+ {
111
+ type: "function",
112
+ name: "getSubscriberSubscriptions",
113
+ inputs: [{ name: "_subscriber", type: "address" }],
114
+ outputs: [{ name: "", type: "uint256[]" }],
115
+ stateMutability: "view"
116
+ },
117
+ {
118
+ type: "function",
119
+ name: "isPaymentDue",
120
+ inputs: [{ name: "_subId", type: "uint256" }],
121
+ outputs: [{ name: "", type: "bool" }],
122
+ stateMutability: "view"
123
+ },
124
+ {
125
+ type: "function",
126
+ name: "getDuePayments",
127
+ inputs: [{ name: "_subIds", type: "uint256[]" }],
128
+ outputs: [{ name: "due", type: "uint256[]" }],
129
+ stateMutability: "view"
130
+ },
131
+ {
132
+ type: "function",
133
+ name: "nextPlanId",
134
+ inputs: [],
135
+ outputs: [{ name: "", type: "uint256" }],
136
+ stateMutability: "view"
137
+ },
138
+ {
139
+ type: "function",
140
+ name: "nextSubscriptionId",
141
+ inputs: [],
142
+ outputs: [{ name: "", type: "uint256" }],
143
+ stateMutability: "view"
144
+ },
145
+ {
146
+ type: "function",
147
+ name: "protocolFeeBps",
148
+ inputs: [],
149
+ outputs: [{ name: "", type: "uint256" }],
150
+ stateMutability: "view"
151
+ },
152
+ {
153
+ type: "function",
154
+ name: "activeSub",
155
+ inputs: [
156
+ { name: "planId", type: "uint256" },
157
+ { name: "subscriber", type: "address" }
158
+ ],
159
+ outputs: [{ name: "subId", type: "uint256" }],
160
+ stateMutability: "view"
161
+ },
162
+ {
163
+ type: "event",
164
+ name: "PlanCreated",
165
+ inputs: [
166
+ { name: "planId", type: "uint256", indexed: true },
167
+ { name: "merchant", type: "address", indexed: true },
168
+ { name: "paymentToken", type: "address", indexed: false },
169
+ { name: "amount", type: "uint256", indexed: false },
170
+ { name: "interval", type: "uint256", indexed: false },
171
+ { name: "name", type: "string", indexed: false },
172
+ { name: "metadataURI", type: "string", indexed: false }
173
+ ],
174
+ anonymous: false
175
+ },
176
+ {
177
+ type: "event",
178
+ name: "PlanToggled",
179
+ inputs: [
180
+ { name: "planId", type: "uint256", indexed: true },
181
+ { name: "active", type: "bool", indexed: false }
182
+ ],
183
+ anonymous: false
184
+ },
185
+ {
186
+ type: "event",
187
+ name: "Subscribed",
188
+ inputs: [
189
+ { name: "subscriptionId", type: "uint256", indexed: true },
190
+ { name: "planId", type: "uint256", indexed: true },
191
+ { name: "subscriber", type: "address", indexed: true }
192
+ ],
193
+ anonymous: false
194
+ },
195
+ {
196
+ type: "event",
197
+ name: "PaymentProcessed",
198
+ inputs: [
199
+ { name: "subscriptionId", type: "uint256", indexed: true },
200
+ { name: "planId", type: "uint256", indexed: true },
201
+ { name: "subscriber", type: "address", indexed: true },
202
+ { name: "merchant", type: "address", indexed: false },
203
+ { name: "amount", type: "uint256", indexed: false },
204
+ { name: "protocolFee", type: "uint256", indexed: false },
205
+ { name: "timestamp", type: "uint256", indexed: false }
206
+ ],
207
+ anonymous: false
208
+ },
209
+ {
210
+ type: "event",
211
+ name: "SubscriptionCancelled",
212
+ inputs: [
213
+ { name: "subscriptionId", type: "uint256", indexed: true },
214
+ { name: "subscriber", type: "address", indexed: true }
215
+ ],
216
+ anonymous: false
217
+ }
218
+ ];
219
+ var ERC20_ABI = [
220
+ {
221
+ type: "function",
222
+ name: "approve",
223
+ inputs: [
224
+ { name: "spender", type: "address" },
225
+ { name: "amount", type: "uint256" }
226
+ ],
227
+ outputs: [{ name: "", type: "bool" }],
228
+ stateMutability: "nonpayable"
229
+ },
230
+ {
231
+ type: "function",
232
+ name: "allowance",
233
+ inputs: [
234
+ { name: "owner", type: "address" },
235
+ { name: "spender", type: "address" }
236
+ ],
237
+ outputs: [{ name: "", type: "uint256" }],
238
+ stateMutability: "view"
239
+ },
240
+ {
241
+ type: "function",
242
+ name: "balanceOf",
243
+ inputs: [{ name: "account", type: "address" }],
244
+ outputs: [{ name: "", type: "uint256" }],
245
+ stateMutability: "view"
246
+ },
247
+ {
248
+ type: "function",
249
+ name: "decimals",
250
+ inputs: [],
251
+ outputs: [{ name: "", type: "uint8" }],
252
+ stateMutability: "view"
253
+ },
254
+ {
255
+ type: "function",
256
+ name: "symbol",
257
+ inputs: [],
258
+ outputs: [{ name: "", type: "string" }],
259
+ stateMutability: "view"
260
+ },
261
+ {
262
+ type: "function",
263
+ name: "mint",
264
+ inputs: [
265
+ { name: "to", type: "address" },
266
+ { name: "amount", type: "uint256" }
267
+ ],
268
+ outputs: [],
269
+ stateMutability: "nonpayable"
270
+ }
271
+ ];
272
+
273
+ // src/constants.ts
274
+ var NETWORKS = {
275
+ "flow-testnet": {
276
+ chainId: 545,
277
+ rpcUrl: "https://testnet.evm.nodes.onflow.org",
278
+ blockExplorer: "https://testnet.explorer.flow.com",
279
+ evmBlockExplorer: "https://testnet.evm.flow.com",
280
+ name: "Flow EVM Testnet"
281
+ },
282
+ "flow-mainnet": {
283
+ chainId: 747,
284
+ rpcUrl: "https://mainnet.evm.nodes.onflow.org",
285
+ blockExplorer: "https://explorer.flow.com",
286
+ evmBlockExplorer: "https://evm.flow.com",
287
+ name: "Flow EVM Mainnet"
288
+ }
289
+ };
290
+ var DEPLOYED_CONTRACTS = {
291
+ "flow-testnet": "0x24730C8387C11e6031f692Bf0B14000D93271766"
292
+ };
293
+ var KNOWN_TOKENS = {
294
+ "flow-testnet": {
295
+ USDC: "0x9C080703256BDF9Ea1b485aE72f13E31f74C558b",
296
+ "USDC.e": "0x9B7550D337bB449b89C6f9C926C3b976b6f4095b"
297
+ },
298
+ "flow-mainnet": {
299
+ USDT: "0x674843C06FF83502ddb4D37c2E09C01cdA38cbc8"
300
+ }
301
+ };
302
+ var CADENCE_HANDLERS = {
303
+ "flow-testnet": "7c0bf27829276c6b"
304
+ };
305
+ var DEFAULT_IPFS_GATEWAY = "https://storacha.link/ipfs";
306
+
307
+ // src/ipfs.ts
308
+ function createKuboAdapter(apiUrl) {
309
+ return {
310
+ async uploadJson(data) {
311
+ const blob = new Blob([JSON.stringify(data)], {
312
+ type: "application/json"
313
+ });
314
+ const form = new FormData();
315
+ form.append("file", blob, "receipt.json");
316
+ const res = await fetch(`${apiUrl}/api/v0/add?pin=true`, {
317
+ method: "POST",
318
+ body: form
319
+ });
320
+ if (!res.ok) throw new Error(`IPFS upload failed: ${res.statusText}`);
321
+ const result = await res.json();
322
+ return result.Hash;
323
+ },
324
+ async fetchJson(cid) {
325
+ const res = await fetch(`${DEFAULT_IPFS_GATEWAY}/${cid}`);
326
+ if (!res.ok) throw new Error(`IPFS fetch failed: ${res.statusText}`);
327
+ return res.json();
328
+ }
329
+ };
330
+ }
331
+ function createStorachaAdapter(clientOrConfig) {
332
+ let resolvedClient = null;
333
+ let initPromise = null;
334
+ async function getClient() {
335
+ if (resolvedClient) return resolvedClient;
336
+ if ("uploadFile" in clientOrConfig) {
337
+ resolvedClient = clientOrConfig;
338
+ return resolvedClient;
339
+ }
340
+ if (!initPromise) {
341
+ initPromise = (async () => {
342
+ const { key, proof } = clientOrConfig;
343
+ const Client = await import("@storacha/client");
344
+ const ed25519 = await import("@storacha/client/principal/ed25519");
345
+ const { StoreMemory } = await import("@storacha/client/stores/memory");
346
+ const Proof = await import("@storacha/client/proof");
347
+ const principal = ed25519.parse(key);
348
+ const client = await Client.create({ principal, store: new StoreMemory() });
349
+ const space = await client.addSpace(await Proof.parse(proof));
350
+ await client.setCurrentSpace(space.did());
351
+ resolvedClient = client;
352
+ return client;
353
+ })();
354
+ }
355
+ return initPromise;
356
+ }
357
+ return {
358
+ async uploadJson(data) {
359
+ const client = await getClient();
360
+ if (!client) throw new Error("Storacha client failed to initialize");
361
+ const blob = new Blob([JSON.stringify(data, null, 2)], {
362
+ type: "application/json"
363
+ });
364
+ const cid = await client.uploadFile(blob);
365
+ return cid.toString();
366
+ },
367
+ async fetchJson(cid) {
368
+ const res = await fetch(`${DEFAULT_IPFS_GATEWAY}/${cid}`);
369
+ if (!res.ok) throw new Error(`IPFS fetch failed: ${res.statusText}`);
370
+ return res.json();
371
+ }
372
+ };
373
+ }
374
+ function createMemoryAdapter() {
375
+ const store = /* @__PURE__ */ new Map();
376
+ let counter = 0;
377
+ return {
378
+ async uploadJson(data) {
379
+ const json = JSON.stringify(data);
380
+ const cid = `mem_${++counter}_${Date.now()}`;
381
+ store.set(cid, json);
382
+ return cid;
383
+ },
384
+ async fetchJson(cid) {
385
+ const json = store.get(cid);
386
+ if (!json) throw new Error(`CID not found in memory store: ${cid}`);
387
+ return JSON.parse(json);
388
+ }
389
+ };
390
+ }
391
+ function buildPaymentReceipt(event, txHash, blockNumber, chainId) {
392
+ return {
393
+ subscriptionId: event.subscriptionId.toString(),
394
+ planId: event.planId.toString(),
395
+ subscriber: event.subscriber,
396
+ merchant: event.merchant,
397
+ amount: event.amount.toString(),
398
+ protocolFee: event.protocolFee.toString(),
399
+ timestamp: Number(event.timestamp),
400
+ txHash,
401
+ blockNumber,
402
+ chainId
403
+ };
404
+ }
405
+ function ipfsGatewayUrl(cid) {
406
+ return `${DEFAULT_IPFS_GATEWAY}/${cid}`;
407
+ }
408
+
409
+ // src/client.ts
410
+ var VeraPayClient = class _VeraPayClient {
411
+ contract;
412
+ provider;
413
+ config;
414
+ signer;
415
+ ipfs;
416
+ constructor(config2, signerOrProvider, ipfsAdapter) {
417
+ this.config = config2;
418
+ this.ipfs = ipfsAdapter;
419
+ if ("getAddress" in signerOrProvider) {
420
+ this.signer = signerOrProvider;
421
+ this.provider = signerOrProvider.provider;
422
+ } else {
423
+ this.provider = signerOrProvider;
424
+ }
425
+ this.contract = new ethers.Contract(
426
+ config2.contractAddress,
427
+ VERA_PAY_ABI,
428
+ this.signer ?? this.provider
429
+ );
430
+ }
431
+ /**
432
+ * Convenience factory that auto-configures for a known Flow network.
433
+ */
434
+ static fromNetwork(network, contractAddress, signerOrProvider, ipfsAdapter) {
435
+ const net = NETWORKS[network];
436
+ return new _VeraPayClient(
437
+ { contractAddress, rpcUrl: net.rpcUrl, chainId: net.chainId },
438
+ signerOrProvider,
439
+ ipfsAdapter
440
+ );
441
+ }
442
+ // ── Merchant API ─────────────────────────────────────────────────────
443
+ async createPlan(params) {
444
+ this.requireSigner();
445
+ const tx2 = await this.contract.createPlan(
446
+ params.paymentToken,
447
+ params.amount,
448
+ params.interval,
449
+ params.name,
450
+ params.metadataURI ?? ""
451
+ );
452
+ const receipt = await tx2.wait();
453
+ const log = receipt.logs.find(
454
+ (l) => l.topics[0] === ethers.id(
455
+ "PlanCreated(uint256,address,address,uint256,uint256,string,string)"
456
+ )
457
+ );
458
+ const planId = log ? BigInt(log.topics[1]) : 0n;
459
+ return { planId, tx: tx2 };
460
+ }
461
+ async togglePlan(planId) {
462
+ this.requireSigner();
463
+ return this.contract.togglePlan(planId);
464
+ }
465
+ async getMerchantPlans(merchant) {
466
+ return this.contract.getMerchantPlans(merchant);
467
+ }
468
+ // ── Subscriber API ───────────────────────────────────────────────────
469
+ /**
470
+ * Approve the VeraPay contract to pull `amount` of the given ERC-20 token,
471
+ * then subscribe to the plan. Returns subscription ID and the payment receipt
472
+ * (optionally pinned to IPFS).
473
+ */
474
+ async subscribeWithApproval(planId, options) {
475
+ this.requireSigner();
476
+ const plan = await this.getPlan(planId);
477
+ const token = new ethers.Contract(
478
+ plan.paymentToken,
479
+ ERC20_ABI,
480
+ this.signer
481
+ );
482
+ const approveAmount = options?.approveMax ? ethers.MaxUint256 : plan.amount * 12n;
483
+ const approveTx = await token.approve(
484
+ this.config.contractAddress,
485
+ approveAmount
486
+ );
487
+ await approveTx.wait();
488
+ return this.subscribe(planId);
489
+ }
490
+ async subscribe(planId) {
491
+ this.requireSigner();
492
+ const tx2 = await this.contract.subscribe(planId);
493
+ const txReceipt = await tx2.wait();
494
+ const paymentReceipt = await this.extractPaymentReceipt(txReceipt);
495
+ const subLog = txReceipt.logs.find(
496
+ (l) => l.topics[0] === ethers.id("Subscribed(uint256,uint256,address)")
497
+ );
498
+ const subscriptionId = subLog ? BigInt(subLog.topics[1]) : 0n;
499
+ return { subscriptionId, receipt: paymentReceipt, tx: tx2 };
500
+ }
501
+ async cancelSubscription(subscriptionId) {
502
+ this.requireSigner();
503
+ return this.contract.cancelSubscription(subscriptionId);
504
+ }
505
+ async getSubscriberSubscriptions(subscriber) {
506
+ return this.contract.getSubscriberSubscriptions(subscriber);
507
+ }
508
+ // ── Keeper / Relayer API ─────────────────────────────────────────────
509
+ async processPayment(subscriptionId) {
510
+ this.requireSigner();
511
+ const tx2 = await this.contract.processPayment(subscriptionId);
512
+ const txReceipt = await tx2.wait();
513
+ const receipt = await this.extractPaymentReceipt(txReceipt);
514
+ return { receipt, tx: tx2 };
515
+ }
516
+ async batchProcessPayments(subscriptionIds) {
517
+ this.requireSigner();
518
+ return this.contract.batchProcessPayments(subscriptionIds);
519
+ }
520
+ async isPaymentDue(subscriptionId) {
521
+ return this.contract.isPaymentDue(subscriptionId);
522
+ }
523
+ async getDuePayments(subscriptionIds) {
524
+ return this.contract.getDuePayments(subscriptionIds);
525
+ }
526
+ /**
527
+ * Start a polling loop that checks for due payments and processes them.
528
+ * Returns a cleanup function to stop the loop.
529
+ */
530
+ startKeeper(subscriptionIds, intervalMs = 6e4, onPayment, onError) {
531
+ let running = true;
532
+ const tick = async () => {
533
+ if (!running) return;
534
+ try {
535
+ const due = await this.getDuePayments(subscriptionIds);
536
+ for (const subId of due) {
537
+ const { receipt } = await this.processPayment(subId);
538
+ onPayment?.(receipt);
539
+ }
540
+ } catch (err) {
541
+ onError?.(err);
542
+ }
543
+ if (running) setTimeout(tick, intervalMs);
544
+ };
545
+ tick();
546
+ return () => {
547
+ running = false;
548
+ };
549
+ }
550
+ // ── Read helpers ─────────────────────────────────────────────────────
551
+ async getNextPlanId() {
552
+ return this.contract.nextPlanId();
553
+ }
554
+ async getNextSubscriptionId() {
555
+ return this.contract.nextSubscriptionId();
556
+ }
557
+ async listActivePlans() {
558
+ const nextId = await this.getNextPlanId();
559
+ const plans = [];
560
+ for (let i = 0n; i < nextId; i++) {
561
+ try {
562
+ const plan = await this.getPlan(i);
563
+ if (plan.active) plans.push(plan);
564
+ } catch {
565
+ }
566
+ }
567
+ return plans;
568
+ }
569
+ async getPlan(planId) {
570
+ const raw = await this.contract.getPlan(planId);
571
+ return {
572
+ planId,
573
+ merchant: raw.merchant,
574
+ paymentToken: raw.paymentToken,
575
+ amount: raw.amount,
576
+ interval: raw.interval,
577
+ name: raw.name,
578
+ metadataURI: raw.metadataURI,
579
+ active: raw.active
580
+ };
581
+ }
582
+ async getSubscription(subscriptionId) {
583
+ const raw = await this.contract.getSubscription(subscriptionId);
584
+ return {
585
+ subscriptionId,
586
+ planId: raw.planId,
587
+ subscriber: raw.subscriber,
588
+ startTime: raw.startTime,
589
+ lastPaymentTime: raw.lastPaymentTime,
590
+ paymentsCount: raw.paymentsCount,
591
+ active: raw.active
592
+ };
593
+ }
594
+ async getProtocolFeeBps() {
595
+ return this.contract.protocolFeeBps();
596
+ }
597
+ // ── IPFS ─────────────────────────────────────────────────────────────
598
+ get hasIPFS() {
599
+ return !!this.ipfs;
600
+ }
601
+ get contractAddress() {
602
+ return this.config.contractAddress;
603
+ }
604
+ /**
605
+ * Pin a payment receipt to IPFS and return the CID.
606
+ */
607
+ async pinReceipt(receipt) {
608
+ if (!this.ipfs) throw new Error("No IPFS adapter configured");
609
+ const cid = await this.ipfs.uploadJson(receipt);
610
+ receipt.ipfsCid = cid;
611
+ return cid;
612
+ }
613
+ async fetchReceipt(cid) {
614
+ if (!this.ipfs) throw new Error("No IPFS adapter configured");
615
+ return this.ipfs.fetchJson(cid);
616
+ }
617
+ setIPFSAdapter(adapter) {
618
+ this.ipfs = adapter;
619
+ }
620
+ // ── Listeners ────────────────────────────────────────────────────────
621
+ onPaymentProcessed(callback, filter) {
622
+ const eventFilter = this.contract.filters.PaymentProcessed(
623
+ filter?.subscriptionId ?? null,
624
+ filter?.planId ?? null,
625
+ filter?.subscriber ?? null
626
+ );
627
+ this.contract.on(
628
+ eventFilter,
629
+ async (subscriptionId, planId, subscriber, merchant, amount, protocolFee, timestamp, event) => {
630
+ const receipt = buildPaymentReceipt(
631
+ { subscriptionId, planId, subscriber, merchant, amount, protocolFee, timestamp },
632
+ event.transactionHash,
633
+ event.blockNumber,
634
+ this.config.chainId ?? 0
635
+ );
636
+ if (this.ipfs) {
637
+ try {
638
+ receipt.ipfsCid = await this.ipfs.uploadJson(receipt);
639
+ } catch {
640
+ }
641
+ }
642
+ callback(receipt);
643
+ }
644
+ );
645
+ return this.contract;
646
+ }
647
+ removeAllListeners() {
648
+ this.contract.removeAllListeners();
649
+ }
650
+ // ── Internal ─────────────────────────────────────────────────────────
651
+ requireSigner() {
652
+ if (!this.signer) {
653
+ throw new Error("A signer is required for write operations");
654
+ }
655
+ }
656
+ async extractPaymentReceipt(txReceipt) {
657
+ const iface = new ethers.Interface(VERA_PAY_ABI);
658
+ const paymentTopic = ethers.id(
659
+ "PaymentProcessed(uint256,uint256,address,address,uint256,uint256,uint256)"
660
+ );
661
+ for (const log of txReceipt.logs) {
662
+ if (log.topics[0] === paymentTopic) {
663
+ const parsed = iface.parseLog({
664
+ topics: [...log.topics],
665
+ data: log.data
666
+ });
667
+ if (!parsed) continue;
668
+ const receipt = buildPaymentReceipt(
669
+ {
670
+ subscriptionId: BigInt(log.topics[1]),
671
+ planId: BigInt(log.topics[2]),
672
+ subscriber: ethers.getAddress("0x" + log.topics[3].slice(26)),
673
+ merchant: parsed.args.merchant,
674
+ amount: parsed.args.amount,
675
+ protocolFee: parsed.args.protocolFee,
676
+ timestamp: parsed.args.timestamp
677
+ },
678
+ txReceipt.hash,
679
+ txReceipt.blockNumber,
680
+ this.config.chainId ?? 0
681
+ );
682
+ if (this.ipfs) {
683
+ try {
684
+ receipt.ipfsCid = await this.ipfs.uploadJson(receipt);
685
+ } catch {
686
+ }
687
+ }
688
+ return receipt;
689
+ }
690
+ }
691
+ return {
692
+ subscriptionId: "0",
693
+ planId: "0",
694
+ subscriber: "",
695
+ merchant: "",
696
+ amount: "0",
697
+ protocolFee: "0",
698
+ timestamp: 0,
699
+ txHash: txReceipt.hash,
700
+ blockNumber: txReceipt.blockNumber,
701
+ chainId: this.config.chainId ?? 0
702
+ };
703
+ }
704
+ };
705
+
706
+ // src/scheduler.ts
707
+ import * as fcl from "@onflow/fcl";
708
+ var NETWORK_CONFIG = {
709
+ testnet: {
710
+ accessNode: "https://rest-testnet.onflow.org",
711
+ discoveryWallet: "https://fcl-discovery.onflow.org/testnet/authn",
712
+ flowNetwork: "testnet",
713
+ contracts: {
714
+ FlowTransactionScheduler: "0x8c5303eaa26202d6",
715
+ FlowTransactionSchedulerUtils: "0x8c5303eaa26202d6",
716
+ FlowToken: "0x7e60df042a9c0868",
717
+ FungibleToken: "0x9a0766d93b6608b7",
718
+ EVM: "0x8c5303eaa26202d6"
719
+ }
720
+ },
721
+ mainnet: {
722
+ accessNode: "https://rest-mainnet.onflow.org",
723
+ discoveryWallet: "https://fcl-discovery.onflow.org/authn",
724
+ flowNetwork: "mainnet",
725
+ contracts: {
726
+ FlowTransactionScheduler: "0xe467b9dd11fa00df",
727
+ FlowTransactionSchedulerUtils: "0xe467b9dd11fa00df",
728
+ FlowToken: "0x1654653399040a61",
729
+ FungibleToken: "0xf233dcee88fe0abe",
730
+ EVM: "0xe467b9dd11fa00df"
731
+ }
732
+ }
733
+ };
734
+ function schedulePaymentCdc(c) {
735
+ return `
736
+ import FlowTransactionScheduler from ${c.FlowTransactionScheduler}
737
+ import FlowTransactionSchedulerUtils from ${c.FlowTransactionSchedulerUtils}
738
+ import FlowToken from ${c.FlowToken}
739
+ import FungibleToken from ${c.FungibleToken}
740
+
741
+ transaction(
742
+ subscriptionId: UInt256,
743
+ delaySeconds: UFix64,
744
+ priority: UInt8,
745
+ executionEffort: UInt64
746
+ ) {
747
+ prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, GetStorageCapabilityController) &Account) {
748
+ let future = getCurrentBlock().timestamp + delaySeconds
749
+
750
+ let pr = priority == 0
751
+ ? FlowTransactionScheduler.Priority.High
752
+ : priority == 1
753
+ ? FlowTransactionScheduler.Priority.Medium
754
+ : FlowTransactionScheduler.Priority.Low
755
+
756
+ var handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>? = nil
757
+ let controllers = signer.capabilities.storage.getControllers(forPath: /storage/VeraPayScheduledPaymentHandler)
758
+ for controller in controllers {
759
+ if let cap = controller.capability as? Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}> {
760
+ handlerCap = cap
761
+ break
762
+ }
763
+ }
764
+
765
+ assert(handlerCap != nil, message: "No handler capability found. Run InitVeraPayHandler first.")
766
+
767
+ if signer.storage.borrow<&AnyResource>(from: FlowTransactionSchedulerUtils.managerStoragePath) == nil {
768
+ let manager <- FlowTransactionSchedulerUtils.createManager()
769
+ signer.storage.save(<-manager, to: FlowTransactionSchedulerUtils.managerStoragePath)
770
+
771
+ let managerCap = signer.capabilities.storage.issue<&{FlowTransactionSchedulerUtils.Manager}>(
772
+ FlowTransactionSchedulerUtils.managerStoragePath
773
+ )
774
+ signer.capabilities.publish(managerCap, at: FlowTransactionSchedulerUtils.managerPublicPath)
775
+ }
776
+
777
+ let manager = signer.storage.borrow<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>(
778
+ from: FlowTransactionSchedulerUtils.managerStoragePath
779
+ ) ?? panic("Could not borrow Manager")
780
+
781
+ let est = FlowTransactionScheduler.estimate(
782
+ data: subscriptionId as AnyStruct,
783
+ timestamp: future,
784
+ priority: pr,
785
+ executionEffort: executionEffort
786
+ )
787
+
788
+ assert(
789
+ est.timestamp != nil || pr == FlowTransactionScheduler.Priority.Low,
790
+ message: est.error ?? "Fee estimation failed"
791
+ )
792
+
793
+ let vault = signer.storage.borrow<auth(FungibleToken.Withdraw) &FlowToken.Vault>(
794
+ from: /storage/flowTokenVault
795
+ ) ?? panic("Could not borrow FlowToken vault")
796
+
797
+ let fees <- vault.withdraw(amount: est.flowFee ?? 0.0) as! @FlowToken.Vault
798
+
799
+ let transactionId = manager.schedule(
800
+ handlerCap: handlerCap!,
801
+ data: subscriptionId,
802
+ timestamp: future,
803
+ priority: pr,
804
+ executionEffort: executionEffort,
805
+ fees: <-fees
806
+ )
807
+
808
+ log("Scheduled payment for sub #".concat(subscriptionId.toString())
809
+ .concat(" | txId: ").concat(transactionId.toString())
810
+ .concat(" | at: ").concat(future.toString()))
811
+ }
812
+ }
813
+ `;
814
+ }
815
+ function cancelPaymentCdc(c) {
816
+ return `
817
+ import FlowTransactionScheduler from ${c.FlowTransactionScheduler}
818
+ import FlowTransactionSchedulerUtils from ${c.FlowTransactionSchedulerUtils}
819
+ import FlowToken from ${c.FlowToken}
820
+ import FungibleToken from ${c.FungibleToken}
821
+
822
+ transaction(transactionId: UInt64) {
823
+ prepare(signer: auth(BorrowValue) &Account) {
824
+ let manager = signer.storage.borrow<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>(
825
+ from: FlowTransactionSchedulerUtils.managerStoragePath
826
+ ) ?? panic("Could not borrow Manager")
827
+
828
+ let vault = signer.storage.borrow<auth(FungibleToken.Withdraw) &FlowToken.Vault>(
829
+ from: /storage/flowTokenVault
830
+ ) ?? panic("Could not borrow FlowToken vault")
831
+
832
+ let refund <- manager.cancel(id: transactionId)
833
+ vault.deposit(from: <-refund)
834
+
835
+ log("Cancelled scheduled transaction #".concat(transactionId.toString()))
836
+ }
837
+ }
838
+ `;
839
+ }
840
+ function setupCoaCdc(c) {
841
+ return `
842
+ import EVM from ${c.EVM}
843
+ import FlowToken from ${c.FlowToken}
844
+ import FungibleToken from ${c.FungibleToken}
845
+
846
+ transaction(fundAmount: UFix64) {
847
+ prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability) &Account) {
848
+ if signer.storage.borrow<&EVM.CadenceOwnedAccount>(from: /storage/evm) != nil {
849
+ log("COA already exists at /storage/evm")
850
+ return
851
+ }
852
+
853
+ let coa <- EVM.createCadenceOwnedAccount()
854
+
855
+ if fundAmount > 0.0 {
856
+ let vault = signer.storage.borrow<auth(FungibleToken.Withdraw) &FlowToken.Vault>(
857
+ from: /storage/flowTokenVault
858
+ ) ?? panic("Could not borrow FlowToken vault")
859
+
860
+ let tokens <- vault.withdraw(amount: fundAmount) as! @FlowToken.Vault
861
+ coa.deposit(from: <-tokens)
862
+ }
863
+
864
+ signer.storage.save(<-coa, to: /storage/evm)
865
+
866
+ let callCap = signer.capabilities.storage.issue<auth(EVM.Call) &EVM.CadenceOwnedAccount>(/storage/evm)
867
+ let publicCap = signer.capabilities.storage.issue<&EVM.CadenceOwnedAccount>(/storage/evm)
868
+ signer.capabilities.publish(publicCap, at: /public/evm)
869
+
870
+ log("COA created and funded with ".concat(fundAmount.toString()).concat(" FLOW"))
871
+ }
872
+ }
873
+ `;
874
+ }
875
+ function initHandlerCdc(c, handlerAddr) {
876
+ return `
877
+ import VeraPayScheduledPaymentHandler from 0x${handlerAddr}
878
+ import FlowTransactionScheduler from ${c.FlowTransactionScheduler}
879
+ import EVM from ${c.EVM}
880
+
881
+ transaction(verapayEvmAddress: String) {
882
+ prepare(signer: auth(BorrowValue, SaveValue, LoadValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability, GetStorageCapabilityController) &Account) {
883
+ // 1. Destroy old handler resource if it exists
884
+ if let oldHandler <- signer.storage.load<@AnyResource>(from: /storage/VeraPayScheduledPaymentHandler) {
885
+ destroy oldHandler
886
+ log("Destroyed old VeraPay handler")
887
+ }
888
+
889
+ // 2. Unpublish old public capability
890
+ signer.capabilities.unpublish(/public/VeraPayScheduledPaymentHandler)
891
+
892
+ // 3. Delete all old capability controllers for the handler storage path
893
+ let oldControllers = signer.capabilities.storage.getControllers(forPath: /storage/VeraPayScheduledPaymentHandler)
894
+ for controller in oldControllers {
895
+ controller.delete()
896
+ }
897
+
898
+ // 4. Get or create COA capability
899
+ let evmAddr = EVM.addressFromString(verapayEvmAddress)
900
+
901
+ var coaCap: Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount>? = nil
902
+ let controllers = signer.capabilities.storage.getControllers(forPath: /storage/evm)
903
+ for controller in controllers {
904
+ if let cap = controller.capability as? Capability<auth(EVM.Call) &EVM.CadenceOwnedAccount> {
905
+ coaCap = cap
906
+ break
907
+ }
908
+ }
909
+
910
+ if coaCap == nil {
911
+ coaCap = signer.capabilities.storage.issue<auth(EVM.Call) &EVM.CadenceOwnedAccount>(/storage/evm)
912
+ }
913
+
914
+ // 5. Create fresh handler with new address
915
+ let handler <- VeraPayScheduledPaymentHandler.createHandler(
916
+ coaCapability: coaCap!,
917
+ verapayAddress: evmAddr
918
+ )
919
+ signer.storage.save(<-handler, to: /storage/VeraPayScheduledPaymentHandler)
920
+
921
+ // 6. Issue fresh capabilities
922
+ signer.capabilities.storage.issue<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>(
923
+ /storage/VeraPayScheduledPaymentHandler
924
+ )
925
+
926
+ let publicCap = signer.capabilities.storage.issue<&{FlowTransactionScheduler.TransactionHandler}>(
927
+ /storage/VeraPayScheduledPaymentHandler
928
+ )
929
+ signer.capabilities.publish(publicCap, at: /public/VeraPayScheduledPaymentHandler)
930
+
931
+ log("VeraPay handler initialized for contract: ".concat(verapayEvmAddress))
932
+ }
933
+ }
934
+ `;
935
+ }
936
+ var FlowScheduler = class _FlowScheduler {
937
+ constructor(config2) {
938
+ this.config = config2;
939
+ this.ipfs = config2.ipfsAdapter;
940
+ }
941
+ configured = false;
942
+ ipfs;
943
+ get hasIPFS() {
944
+ return !!this.ipfs;
945
+ }
946
+ setIPFSAdapter(adapter) {
947
+ this.ipfs = adapter;
948
+ }
949
+ ensureConfigured() {
950
+ if (this.configured) return;
951
+ const net = NETWORK_CONFIG[this.config.network];
952
+ fcl.config().put("accessNode.api", net.accessNode).put("discovery.wallet", net.discoveryWallet).put("flow.network", net.flowNetwork);
953
+ this.configured = true;
954
+ }
955
+ /** Trigger FCL wallet authentication (Blocto, Lilico, etc.) */
956
+ async authenticate() {
957
+ this.ensureConfigured();
958
+ const user = await fcl.authenticate();
959
+ return { addr: user.addr ?? "" };
960
+ }
961
+ /** Disconnect the current wallet */
962
+ async unauthenticate() {
963
+ await fcl.unauthenticate();
964
+ }
965
+ /** Get the currently authenticated Flow address, or null */
966
+ async currentUser() {
967
+ this.ensureConfigured();
968
+ const snapshot = await fcl.currentUser.snapshot();
969
+ return snapshot.addr ?? null;
970
+ }
971
+ /**
972
+ * Create a Cadence Owned Account (COA) and fund it with FLOW for EVM gas.
973
+ * Idempotent — skips if COA already exists. Must be called before initHandler.
974
+ * Returns the Flow transaction ID.
975
+ */
976
+ async setupCOA(fundAmount = "1.0") {
977
+ this.ensureConfigured();
978
+ const contracts = NETWORK_CONFIG[this.config.network].contracts;
979
+ const txId = await fcl.mutate({
980
+ cadence: setupCoaCdc(contracts),
981
+ args: (arg, t) => [arg(fundAmount, t.UFix64)],
982
+ limit: 9999
983
+ });
984
+ return txId;
985
+ }
986
+ /**
987
+ * Initialize the VeraPay scheduled payment handler on the connected account.
988
+ * Idempotent — skips if already initialized. Requires a COA (call setupCOA first).
989
+ * Returns the Flow transaction ID.
990
+ */
991
+ async initHandler() {
992
+ this.ensureConfigured();
993
+ const contracts = NETWORK_CONFIG[this.config.network].contracts;
994
+ const evmAddr = this.config.evmContractAddress.startsWith("0x") ? this.config.evmContractAddress : "0x" + this.config.evmContractAddress;
995
+ const txId = await fcl.mutate({
996
+ cadence: initHandlerCdc(contracts, this.config.handlerAddress),
997
+ args: (arg, t) => [arg(evmAddr, t.String)],
998
+ limit: 9999
999
+ });
1000
+ return txId;
1001
+ }
1002
+ /**
1003
+ * Convenience method: runs setupCOA + initHandler in sequence.
1004
+ * Both are idempotent so it's safe to call on every session.
1005
+ */
1006
+ async setup(fundAmount = "1.0") {
1007
+ const coaTxId = await this.setupCOA(fundAmount);
1008
+ await this.waitForTransaction(coaTxId);
1009
+ const handlerTxId = await this.initHandler();
1010
+ await this.waitForTransaction(handlerTxId);
1011
+ return { coaTxId, handlerTxId };
1012
+ }
1013
+ /**
1014
+ * Schedule a subscription payment to be executed at a future time.
1015
+ * The connected Flow wallet pays the scheduling fees.
1016
+ * Returns the Flow transaction ID.
1017
+ */
1018
+ async schedulePayment(params) {
1019
+ this.ensureConfigured();
1020
+ const contracts = NETWORK_CONFIG[this.config.network].contracts;
1021
+ const txId = await fcl.mutate({
1022
+ cadence: schedulePaymentCdc(contracts),
1023
+ args: (arg, t) => [
1024
+ arg(params.subscriptionId, t.UInt256),
1025
+ arg(params.delaySeconds, t.UFix64),
1026
+ arg(String(params.priority), t.UInt8),
1027
+ arg(String(params.executionEffort), t.UInt64)
1028
+ ],
1029
+ limit: 9999
1030
+ });
1031
+ return txId;
1032
+ }
1033
+ /**
1034
+ * Cancel a previously scheduled payment and receive a partial fee refund.
1035
+ * Returns the Flow transaction ID.
1036
+ */
1037
+ async cancelScheduledPayment(scheduledTransactionId) {
1038
+ this.ensureConfigured();
1039
+ const contracts = NETWORK_CONFIG[this.config.network].contracts;
1040
+ const txId = await fcl.mutate({
1041
+ cadence: cancelPaymentCdc(contracts),
1042
+ args: (arg, t) => [
1043
+ arg(scheduledTransactionId, t.UInt64)
1044
+ ],
1045
+ limit: 9999
1046
+ });
1047
+ return txId;
1048
+ }
1049
+ /**
1050
+ * Make the COA approve an EVM spender (e.g., VeraPay) to transfer ERC20 tokens.
1051
+ * This is required before the scheduled payment can pull tokens from the COA.
1052
+ * Returns the Flow transaction ID.
1053
+ */
1054
+ async approveERC20(tokenAddress, amount = "115792089237316195423570985008687907853269984665640564039457584007913129639935") {
1055
+ this.ensureConfigured();
1056
+ const contracts = NETWORK_CONFIG[this.config.network].contracts;
1057
+ const token = tokenAddress.startsWith("0x") ? tokenAddress : "0x" + tokenAddress;
1058
+ const spender = this.config.evmContractAddress.startsWith("0x") ? this.config.evmContractAddress : "0x" + this.config.evmContractAddress;
1059
+ const txId = await fcl.mutate({
1060
+ cadence: `
1061
+ import EVM from ${contracts.EVM}
1062
+
1063
+ transaction(tokenAddress: String, spenderAddress: String, amount: UInt256) {
1064
+ prepare(signer: auth(BorrowValue) &Account) {
1065
+ let coa = signer.storage.borrow<auth(EVM.Call) &EVM.CadenceOwnedAccount>(from: /storage/evm)
1066
+ ?? panic("No COA found. Run Setup first.")
1067
+
1068
+ let calldata = EVM.encodeABIWithSignature(
1069
+ "approve(address,uint256)",
1070
+ [EVM.addressFromString(spenderAddress), amount]
1071
+ )
1072
+
1073
+ let result = coa.call(
1074
+ to: EVM.addressFromString(tokenAddress),
1075
+ data: calldata,
1076
+ gasLimit: 100_000,
1077
+ value: EVM.Balance(attoflow: 0)
1078
+ )
1079
+
1080
+ assert(result.status == EVM.Status.successful, message: "ERC20 approve failed")
1081
+ }
1082
+ }
1083
+ `,
1084
+ args: (arg, t) => [
1085
+ arg(token, t.String),
1086
+ arg(spender, t.String),
1087
+ arg(amount, t.UInt256)
1088
+ ],
1089
+ limit: 9999
1090
+ });
1091
+ return txId;
1092
+ }
1093
+ /**
1094
+ * Subscribe the COA to a VeraPay plan AND schedule the next recurring payment
1095
+ * in a single Cadence transaction.
1096
+ *
1097
+ * 1. COA calls subscribe(planId) on the EVM contract (first payment is charged immediately)
1098
+ * 2. Decodes the returned subscription ID from the EVM call
1099
+ * 3. Schedules the next payment via FlowTransactionScheduler with the plan's interval as delay
1100
+ *
1101
+ * If an IPFS adapter is configured, the payment receipt is automatically
1102
+ * pinned after the transaction seals.
1103
+ *
1104
+ * Returns the Flow tx ID, and optionally the receipt + IPFS CID.
1105
+ */
1106
+ async subscribeAndSchedule(params) {
1107
+ this.ensureConfigured();
1108
+ const contracts = NETWORK_CONFIG[this.config.network].contracts;
1109
+ const verapay = this.config.evmContractAddress.startsWith("0x") ? this.config.evmContractAddress : "0x" + this.config.evmContractAddress;
1110
+ const txId = await fcl.mutate({
1111
+ cadence: `
1112
+ import EVM from ${contracts.EVM}
1113
+ import FlowTransactionScheduler from ${contracts.FlowTransactionScheduler}
1114
+ import FlowTransactionSchedulerUtils from ${contracts.FlowTransactionSchedulerUtils}
1115
+ import FlowToken from ${contracts.FlowToken}
1116
+ import FungibleToken from ${contracts.FungibleToken}
1117
+
1118
+ transaction(
1119
+ verapayAddress: String,
1120
+ planId: UInt256,
1121
+ intervalSeconds: UFix64,
1122
+ priority: UInt8,
1123
+ executionEffort: UInt64
1124
+ ) {
1125
+ prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, GetStorageCapabilityController) &Account) {
1126
+ // 1. Subscribe via COA
1127
+ let coa = signer.storage.borrow<auth(EVM.Call) &EVM.CadenceOwnedAccount>(from: /storage/evm)
1128
+ ?? panic("No COA found. Run Setup first.")
1129
+
1130
+ let calldata = EVM.encodeABIWithSignature(
1131
+ "subscribe(uint256)",
1132
+ [planId]
1133
+ )
1134
+
1135
+ let result = coa.call(
1136
+ to: EVM.addressFromString(verapayAddress),
1137
+ data: calldata,
1138
+ gasLimit: 300_000,
1139
+ value: EVM.Balance(attoflow: 0)
1140
+ )
1141
+
1142
+ assert(result.status == EVM.Status.successful,
1143
+ message: "VeraPay subscribe failed: ".concat(result.errorCode.toString()))
1144
+
1145
+ // 2. Decode the returned subscription ID (uint256)
1146
+ let decoded = EVM.decodeABI(types: [Type<UInt256>()], data: result.data)
1147
+ let subscriptionId = decoded[0] as! UInt256
1148
+
1149
+ log("Subscribed! Sub ID: ".concat(subscriptionId.toString()))
1150
+
1151
+ // 3. Schedule next payment
1152
+ let future = getCurrentBlock().timestamp + intervalSeconds
1153
+
1154
+ let pr = priority == 0
1155
+ ? FlowTransactionScheduler.Priority.High
1156
+ : priority == 1
1157
+ ? FlowTransactionScheduler.Priority.Medium
1158
+ : FlowTransactionScheduler.Priority.Low
1159
+
1160
+ var handlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>? = nil
1161
+ let controllers = signer.capabilities.storage.getControllers(forPath: /storage/VeraPayScheduledPaymentHandler)
1162
+ for controller in controllers {
1163
+ if let cap = controller.capability as? Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}> {
1164
+ handlerCap = cap
1165
+ break
1166
+ }
1167
+ }
1168
+
1169
+ assert(handlerCap != nil, message: "No handler capability found. Run InitVeraPayHandler first.")
1170
+
1171
+ if signer.storage.borrow<&AnyResource>(from: FlowTransactionSchedulerUtils.managerStoragePath) == nil {
1172
+ let manager <- FlowTransactionSchedulerUtils.createManager()
1173
+ signer.storage.save(<-manager, to: FlowTransactionSchedulerUtils.managerStoragePath)
1174
+
1175
+ let managerCap = signer.capabilities.storage.issue<&{FlowTransactionSchedulerUtils.Manager}>(
1176
+ FlowTransactionSchedulerUtils.managerStoragePath
1177
+ )
1178
+ signer.capabilities.publish(managerCap, at: FlowTransactionSchedulerUtils.managerPublicPath)
1179
+ }
1180
+
1181
+ let manager = signer.storage.borrow<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>(
1182
+ from: FlowTransactionSchedulerUtils.managerStoragePath
1183
+ ) ?? panic("Could not borrow Manager")
1184
+
1185
+ let est = FlowTransactionScheduler.estimate(
1186
+ data: subscriptionId as AnyStruct,
1187
+ timestamp: future,
1188
+ priority: pr,
1189
+ executionEffort: executionEffort
1190
+ )
1191
+
1192
+ assert(
1193
+ est.timestamp != nil || pr == FlowTransactionScheduler.Priority.Low,
1194
+ message: est.error ?? "Fee estimation failed"
1195
+ )
1196
+
1197
+ let vault = signer.storage.borrow<auth(FungibleToken.Withdraw) &FlowToken.Vault>(
1198
+ from: /storage/flowTokenVault
1199
+ ) ?? panic("Could not borrow FlowToken vault")
1200
+
1201
+ let fees <- vault.withdraw(amount: est.flowFee ?? 0.0) as! @FlowToken.Vault
1202
+
1203
+ let transactionId = manager.schedule(
1204
+ handlerCap: handlerCap!,
1205
+ data: subscriptionId,
1206
+ timestamp: future,
1207
+ priority: pr,
1208
+ executionEffort: executionEffort,
1209
+ fees: <-fees
1210
+ )
1211
+
1212
+ log("Scheduled next payment for sub #".concat(subscriptionId.toString())
1213
+ .concat(" | txId: ").concat(transactionId.toString())
1214
+ .concat(" | at: ").concat(future.toString()))
1215
+ }
1216
+ }
1217
+ `,
1218
+ args: (arg, t) => [
1219
+ arg(verapay, t.String),
1220
+ arg(String(params.planId), t.UInt256),
1221
+ arg(params.intervalSeconds, t.UFix64),
1222
+ arg(String(params.priority ?? 1), t.UInt8),
1223
+ arg(String(params.executionEffort ?? 1e3), t.UInt64)
1224
+ ],
1225
+ limit: 9999
1226
+ });
1227
+ const result = { flowTxId: txId };
1228
+ try {
1229
+ const sealed = await this.waitForTransaction(txId);
1230
+ result.scheduledTxId = _FlowScheduler.extractScheduledTxId(sealed.events);
1231
+ if (this.ipfs) {
1232
+ try {
1233
+ const receipt = {
1234
+ subscriptionId: "pending",
1235
+ planId: String(params.planId),
1236
+ subscriber: await this.currentUser() ?? "",
1237
+ merchant: "",
1238
+ amount: "0",
1239
+ protocolFee: "0",
1240
+ timestamp: Math.floor(Date.now() / 1e3),
1241
+ txHash: txId,
1242
+ blockNumber: 0,
1243
+ chainId: this.config.network === "testnet" ? 545 : 747
1244
+ };
1245
+ const cid = await this.ipfs.uploadJson(receipt);
1246
+ receipt.ipfsCid = cid;
1247
+ result.receipt = receipt;
1248
+ result.ipfsCid = cid;
1249
+ } catch {
1250
+ }
1251
+ }
1252
+ } catch {
1253
+ }
1254
+ return result;
1255
+ }
1256
+ /**
1257
+ * Subscribe the COA to a VeraPay plan via an EVM call (without scheduling).
1258
+ * The COA must have already approved the VeraPay contract to spend its tokens.
1259
+ * Returns the Flow transaction ID.
1260
+ */
1261
+ async subscribeToPlan(planId) {
1262
+ this.ensureConfigured();
1263
+ const contracts = NETWORK_CONFIG[this.config.network].contracts;
1264
+ const verapay = this.config.evmContractAddress.startsWith("0x") ? this.config.evmContractAddress : "0x" + this.config.evmContractAddress;
1265
+ const txId = await fcl.mutate({
1266
+ cadence: `
1267
+ import EVM from ${contracts.EVM}
1268
+
1269
+ transaction(verapayAddress: String, planId: UInt256) {
1270
+ prepare(signer: auth(BorrowValue) &Account) {
1271
+ let coa = signer.storage.borrow<auth(EVM.Call) &EVM.CadenceOwnedAccount>(from: /storage/evm)
1272
+ ?? panic("No COA found. Run Setup first.")
1273
+
1274
+ let calldata = EVM.encodeABIWithSignature(
1275
+ "subscribe(uint256)",
1276
+ [planId]
1277
+ )
1278
+
1279
+ let result = coa.call(
1280
+ to: EVM.addressFromString(verapayAddress),
1281
+ data: calldata,
1282
+ gasLimit: 300_000,
1283
+ value: EVM.Balance(attoflow: 0)
1284
+ )
1285
+
1286
+ assert(result.status == EVM.Status.successful, message: "VeraPay subscribe failed: ".concat(result.errorCode.toString()))
1287
+ }
1288
+ }
1289
+ `,
1290
+ args: (arg, t) => [
1291
+ arg(verapay, t.String),
1292
+ arg(String(planId), t.UInt256)
1293
+ ],
1294
+ limit: 9999
1295
+ });
1296
+ return txId;
1297
+ }
1298
+ /**
1299
+ * Query the COA's EVM address for a given Flow account.
1300
+ * Returns the hex EVM address (0x...) or null if no COA exists.
1301
+ */
1302
+ async getCoaEvmAddress(flowAddress) {
1303
+ this.ensureConfigured();
1304
+ const addr = flowAddress ?? await this.currentUser();
1305
+ if (!addr) return null;
1306
+ const contracts = NETWORK_CONFIG[this.config.network].contracts;
1307
+ try {
1308
+ const result = await fcl.query({
1309
+ cadence: `
1310
+ import EVM from ${contracts.EVM}
1311
+
1312
+ access(all) fun main(flowAddress: Address): String? {
1313
+ if let acc = getAuthAccount<auth(BorrowValue) &Account>(flowAddress)
1314
+ .storage.borrow<&EVM.CadenceOwnedAccount>(from: /storage/evm) {
1315
+ return "0x".concat(acc.address().toString())
1316
+ }
1317
+ return nil
1318
+ }
1319
+ `,
1320
+ args: (arg, t) => [arg(addr, t.Address)]
1321
+ });
1322
+ return result;
1323
+ } catch {
1324
+ return null;
1325
+ }
1326
+ }
1327
+ /** Wait for a Flow transaction to be sealed. Returns the transaction result with events. */
1328
+ async waitForTransaction(txId) {
1329
+ this.ensureConfigured();
1330
+ const result = await fcl.tx(txId).onceSealed();
1331
+ return result;
1332
+ }
1333
+ /**
1334
+ * Extract the scheduled transaction ID from sealed tx events.
1335
+ * Looks for the FlowTransactionScheduler.TransactionScheduled event.
1336
+ */
1337
+ static extractScheduledTxId(events) {
1338
+ const evt = events.find((e) => e.type.includes("FlowTransactionScheduler") && e.type.includes("TransactionScheduled"));
1339
+ if (!evt) return void 0;
1340
+ const id = evt.data.id ?? evt.data.transactionId ?? evt.data.scheduledTransactionId;
1341
+ return id != null ? String(id) : void 0;
1342
+ }
1343
+ };
1344
+ export {
1345
+ CADENCE_HANDLERS,
1346
+ DEFAULT_IPFS_GATEWAY,
1347
+ DEPLOYED_CONTRACTS,
1348
+ ERC20_ABI,
1349
+ FlowScheduler,
1350
+ KNOWN_TOKENS,
1351
+ NETWORKS,
1352
+ VERA_PAY_ABI,
1353
+ VeraPayClient,
1354
+ buildPaymentReceipt,
1355
+ createKuboAdapter,
1356
+ createMemoryAdapter,
1357
+ createStorachaAdapter,
1358
+ ipfsGatewayUrl
1359
+ };