@faremeter/payment-solana 0.20.0 → 0.21.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.
Files changed (62) hide show
  1. package/dist/src/charge/client.d.ts +10 -6
  2. package/dist/src/charge/client.d.ts.map +1 -1
  3. package/dist/src/charge/client.js +117 -102
  4. package/dist/src/charge/common.d.ts +3 -3
  5. package/dist/src/charge/server.d.ts +18 -7
  6. package/dist/src/charge/server.d.ts.map +1 -1
  7. package/dist/src/charge/server.js +22 -24
  8. package/dist/src/compat.d.ts +38 -0
  9. package/dist/src/compat.d.ts.map +1 -0
  10. package/dist/src/compat.js +86 -0
  11. package/dist/src/compat.test.d.ts +3 -0
  12. package/dist/src/compat.test.d.ts.map +1 -0
  13. package/dist/src/compat.test.js +70 -0
  14. package/dist/src/exact/client.d.ts +18 -15
  15. package/dist/src/exact/client.d.ts.map +1 -1
  16. package/dist/src/exact/client.js +124 -96
  17. package/dist/src/exact/common.d.ts +1 -1
  18. package/dist/src/exact/facilitator.d.ts +19 -12
  19. package/dist/src/exact/facilitator.d.ts.map +1 -1
  20. package/dist/src/exact/facilitator.js +19 -18
  21. package/dist/src/exact/memo.d.ts +0 -2
  22. package/dist/src/exact/memo.d.ts.map +1 -1
  23. package/dist/src/exact/memo.js +0 -9
  24. package/dist/src/exact/verify.d.ts +5 -1
  25. package/dist/src/exact/verify.d.ts.map +1 -1
  26. package/dist/src/exact/verify.js +8 -2
  27. package/dist/src/exact/verify.test.js +80 -3
  28. package/dist/src/flex/client/handler.d.ts +31 -0
  29. package/dist/src/flex/client/handler.d.ts.map +1 -0
  30. package/dist/src/flex/client/handler.js +104 -0
  31. package/dist/src/flex/client/index.d.ts +3 -0
  32. package/dist/src/flex/client/index.d.ts.map +1 -0
  33. package/dist/src/flex/client/index.js +1 -0
  34. package/dist/src/flex/common.d.ts +15 -0
  35. package/dist/src/flex/common.d.ts.map +1 -0
  36. package/dist/src/flex/common.js +7 -0
  37. package/dist/src/flex/facilitator/handler.d.ts +48 -0
  38. package/dist/src/flex/facilitator/handler.d.ts.map +1 -0
  39. package/dist/src/flex/facilitator/handler.js +705 -0
  40. package/dist/src/flex/facilitator/index.d.ts +5 -0
  41. package/dist/src/flex/facilitator/index.d.ts.map +1 -0
  42. package/dist/src/flex/facilitator/index.js +2 -0
  43. package/dist/src/flex/hono/index.d.ts +3 -0
  44. package/dist/src/flex/hono/index.d.ts.map +1 -0
  45. package/dist/src/flex/hono/index.js +1 -0
  46. package/dist/src/flex/hono/upto-handler.d.ts +20 -0
  47. package/dist/src/flex/hono/upto-handler.d.ts.map +1 -0
  48. package/dist/src/flex/hono/upto-handler.js +72 -0
  49. package/dist/src/flex/hono/upto-handler.test.d.ts +3 -0
  50. package/dist/src/flex/hono/upto-handler.test.d.ts.map +1 -0
  51. package/dist/src/flex/hono/upto-handler.test.js +381 -0
  52. package/dist/src/flex/index.d.ts +4 -0
  53. package/dist/src/flex/index.d.ts.map +1 -0
  54. package/dist/src/flex/index.js +3 -0
  55. package/dist/src/flex/logger.d.ts +2 -0
  56. package/dist/src/flex/logger.d.ts.map +1 -0
  57. package/dist/src/flex/logger.js +2 -0
  58. package/dist/src/index.d.ts +1 -0
  59. package/dist/src/index.d.ts.map +1 -1
  60. package/dist/src/index.js +1 -0
  61. package/dist/tsconfig.tsbuildinfo +1 -1
  62. package/package.json +23 -7
@@ -0,0 +1,705 @@
1
+ import { address, createTransactionMessage, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstructions, signTransactionMessageWithSigners, getSignatureFromTransaction, getBase64EncodedWireTransaction, getProgramDerivedAddress, getAddressEncoder, getU64Encoder, AccountRole, } from "@solana/kit";
2
+ import { lookupX402Network } from "@faremeter/info/solana";
3
+ import { isValidationError } from "@faremeter/types";
4
+ import { FlexPaymentPayload, serializePaymentAuthorization, createEd25519VerifyInstruction, fetchEscrowAccount, fetchSessionKey, findPendingSettlementsByEscrow, getSubmitAuthorizationInstructionAsync, getFinalizeInstructionDataEncoder, FLEX_PROGRAM_ADDRESS, } from "@faremeter/flex-solana";
5
+ import { generateMatcher, FLEX_SCHEME } from "../common.js";
6
+ import { logger } from "../logger.js";
7
+ import { createHoldManager, mergeSplits, } from "@faremeter/flex-solana/facilitator";
8
+ const MS_PER_SLOT = 400;
9
+ const DEFAULT_MIN_GRACE_PERIOD_SLOTS = 150n;
10
+ const DEFAULT_CONFIRMATION_BUFFER_SLOTS = 75n;
11
+ // Anchor error codes that indicate the authorization can never succeed.
12
+ // These map to FlexError variants in programs/flex/src/error.rs.
13
+ const PERMANENT_SUBMIT_ERRORS = new Set([
14
+ 6000, // SessionKeyExpired
15
+ 6001, // SessionKeyRevoked
16
+ 6002, // AuthorizationExpired
17
+ 6003, // InvalidSignature
18
+ 6019, // InvalidEd25519Instruction
19
+ 6022, // InvalidSplitCount
20
+ 6023, // InvalidSplitBps
21
+ 6024, // SplitBpsZero
22
+ 6025, // DuplicateSplitRecipient
23
+ 6028, // SettleExceedsMax
24
+ 6029, // SettleAmountZero
25
+ 6030, // ExpiryTooFar
26
+ ]);
27
+ function isPermanentSubmitError(error) {
28
+ if (!error)
29
+ return false;
30
+ const match = /"Custom":(\d+)/.exec(error);
31
+ if (!match?.[1])
32
+ return false;
33
+ return PERMANENT_SUBMIT_ERRORS.has(Number(match[1]));
34
+ }
35
+ const DEFAULT_SNAPSHOT_MAX_AGE_MS = 10_000;
36
+ /**
37
+ * Creates a facilitator handler that verifies Flex payment authorizations,
38
+ * manages in-memory holds, and submits/finalizes settlements on-chain.
39
+ *
40
+ * Starts a background interval that periodically flushes settled holds
41
+ * and finalizes confirmed transactions. Call `stop()` to clear it.
42
+ *
43
+ * @param network - Solana cluster name (e.g. "mainnet", "devnet")
44
+ * @param rpc - Solana RPC client
45
+ * @param facilitatorSigner - Transaction signer for the facilitator
46
+ * @param config - Supported mints, splits, and timing configuration
47
+ * @returns A `FlexFacilitator` with verify/settle/flush/stop methods
48
+ */
49
+ export const createFacilitatorHandler = async (network, rpc, facilitatorSigner, config) => {
50
+ const { maxSubmitRetries = 30, submitRetryDelayMs = 1000 } = config;
51
+ const minGracePeriodSlots = config.minGracePeriodSlots ?? DEFAULT_MIN_GRACE_PERIOD_SLOTS;
52
+ const confirmationBufferSlots = config.confirmationBufferSlots ?? DEFAULT_CONFIRMATION_BUFFER_SLOTS;
53
+ const snapshotMaxAgeMs = config.snapshotMaxAgeMs ?? DEFAULT_SNAPSHOT_MAX_AGE_MS;
54
+ const facilitatorAddress = facilitatorSigner.address;
55
+ const solanaNetwork = lookupX402Network(network);
56
+ const networkId = solanaNetwork.caip2;
57
+ const matchers = config.supportedMints.map((mint) => generateMatcher(network, mint));
58
+ const holdManager = createHoldManager();
59
+ const escrowSnapshots = new Map();
60
+ const sessionKeyCache = new Map();
61
+ let lastKnownSlot = 0n;
62
+ let lastSlotFetchedAtMs = 0;
63
+ async function refreshSlot() {
64
+ lastKnownSlot = await rpc.getSlot().send();
65
+ lastSlotFetchedAtMs = Date.now();
66
+ return lastKnownSlot;
67
+ }
68
+ function estimateCurrentSlot() {
69
+ if (lastKnownSlot === 0n)
70
+ throw new Error("Slot not yet fetched");
71
+ const elapsedMs = Date.now() - lastSlotFetchedAtMs;
72
+ return lastKnownSlot + BigInt(Math.floor(elapsedMs / MS_PER_SLOT));
73
+ }
74
+ async function getCachedSessionKey(pda) {
75
+ const cached = sessionKeyCache.get(pda);
76
+ if (cached && Date.now() - cached.fetchedAtMs < 30_000) {
77
+ return cached.data;
78
+ }
79
+ const data = await fetchSessionKey(rpc, pda);
80
+ if (data) {
81
+ sessionKeyCache.set(pda, { data, fetchedAtMs: Date.now() });
82
+ }
83
+ else {
84
+ sessionKeyCache.delete(pda);
85
+ }
86
+ return data;
87
+ }
88
+ function isSessionKeyUsable(key, atSlot) {
89
+ if (!key.active) {
90
+ if (key.revokedAtSlot === null) {
91
+ return { usable: false, reason: "Session key is not active" };
92
+ }
93
+ const graceEnd = key.revokedAtSlot + key.revocationGracePeriodSlots;
94
+ if (atSlot >= graceEnd) {
95
+ return {
96
+ usable: false,
97
+ reason: "Session key revocation grace period has expired",
98
+ };
99
+ }
100
+ }
101
+ if (key.expiresAtSlot !== null && atSlot >= key.expiresAtSlot) {
102
+ return { usable: false, reason: "Session key has expired" };
103
+ }
104
+ return { usable: true };
105
+ }
106
+ function computeHoldDeadline(key, expiresAtSlot, currentSlot) {
107
+ const deadlines = [expiresAtSlot];
108
+ if (key.expiresAtSlot !== null) {
109
+ deadlines.push(key.expiresAtSlot);
110
+ }
111
+ if (key.active) {
112
+ if (key.revocationGracePeriodSlots > 0n) {
113
+ deadlines.push(currentSlot + key.revocationGracePeriodSlots);
114
+ }
115
+ }
116
+ else if (key.revokedAtSlot !== null) {
117
+ deadlines.push(key.revokedAtSlot + key.revocationGracePeriodSlots);
118
+ }
119
+ const earliest = deadlines.reduce((a, b) => (a < b ? a : b));
120
+ return earliest - confirmationBufferSlots;
121
+ }
122
+ async function snapshotEscrow(escrowAddress) {
123
+ const [account, pendingResults] = await Promise.all([
124
+ fetchEscrowAccount(rpc, escrowAddress),
125
+ findPendingSettlementsByEscrow(rpc, escrowAddress),
126
+ refreshSlot(),
127
+ ]);
128
+ if (!account) {
129
+ throw new Error("Escrow account not found");
130
+ }
131
+ const vaultByMint = new Map();
132
+ const onChainCommittedByMint = new Map();
133
+ for (const p of pendingResults) {
134
+ const current = onChainCommittedByMint.get(p.account.mint) ?? 0n;
135
+ onChainCommittedByMint.set(p.account.mint, current + p.account.amount);
136
+ }
137
+ const balancePromises = config.supportedMints.map(async (mint) => {
138
+ const [vault] = await deriveVaultPDA(escrowAddress, mint);
139
+ try {
140
+ const balance = await rpc.getTokenAccountBalance(vault).send();
141
+ return { mint, amount: BigInt(balance.value.amount) };
142
+ }
143
+ catch {
144
+ return { mint, amount: 0n };
145
+ }
146
+ });
147
+ for (const { mint, amount } of await Promise.all(balancePromises)) {
148
+ vaultByMint.set(mint, amount);
149
+ }
150
+ const snapshot = {
151
+ account,
152
+ vaultByMint,
153
+ onChainCommittedByMint,
154
+ fetchedAtMs: Date.now(),
155
+ };
156
+ escrowSnapshots.set(escrowAddress, snapshot);
157
+ return snapshot;
158
+ }
159
+ async function buildSplits(payTo, mint) {
160
+ if (!payTo)
161
+ return config.defaultSplits;
162
+ const reservedBps = config.defaultSplits.reduce((sum, s) => sum + s.bps, 0);
163
+ const [payToATA] = await deriveATA(address(payTo), mint);
164
+ const receiverSplit = {
165
+ recipient: payToATA,
166
+ bps: 10_000 - reservedBps,
167
+ };
168
+ if (config.defaultSplits.length === 0)
169
+ return [receiverSplit];
170
+ const fixedSplits = await Promise.all(config.defaultSplits.map(async (s) => {
171
+ const [ata] = await deriveATA(address(s.recipient), mint);
172
+ return { recipient: ata, bps: s.bps };
173
+ }));
174
+ return mergeSplits([receiverSplit, ...fixedSplits]);
175
+ }
176
+ const isMatchingRequirement = (req) => matchers.some((m) => m.isMatchingRequirement(req));
177
+ const getSupported = () => [
178
+ Promise.resolve({
179
+ x402Version: 2,
180
+ scheme: FLEX_SCHEME,
181
+ network: networkId,
182
+ extra: {
183
+ facilitator: facilitatorAddress,
184
+ supportedMints: config.supportedMints,
185
+ splits: config.defaultSplits,
186
+ minGracePeriodSlots: minGracePeriodSlots.toString(),
187
+ },
188
+ }),
189
+ ];
190
+ const getRequirements = async (args) => {
191
+ const compatible = args.accepts.filter(isMatchingRequirement);
192
+ return Promise.all(compatible.map(async (r) => ({
193
+ ...r,
194
+ extra: {
195
+ facilitator: facilitatorAddress,
196
+ supportedMints: config.supportedMints,
197
+ splits: await buildSplits(r.payTo, address(r.asset)),
198
+ minGracePeriodSlots: minGracePeriodSlots.toString(),
199
+ },
200
+ })));
201
+ };
202
+ const addressEncoder = getAddressEncoder();
203
+ const u64Encoder = getU64Encoder();
204
+ const textEncoder = new TextEncoder();
205
+ const TOKEN_PROGRAM = address("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
206
+ const ASSOCIATED_TOKEN_PROGRAM = address("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
207
+ const deriveATA = (wallet, mint) => getProgramDerivedAddress({
208
+ programAddress: ASSOCIATED_TOKEN_PROGRAM,
209
+ seeds: [
210
+ addressEncoder.encode(wallet),
211
+ addressEncoder.encode(TOKEN_PROGRAM),
212
+ addressEncoder.encode(mint),
213
+ ],
214
+ });
215
+ const deriveSessionKeyPDA = (escrow, sessionKey) => getProgramDerivedAddress({
216
+ programAddress: FLEX_PROGRAM_ADDRESS,
217
+ seeds: [
218
+ textEncoder.encode("session"),
219
+ addressEncoder.encode(escrow),
220
+ addressEncoder.encode(sessionKey),
221
+ ],
222
+ });
223
+ const deriveVaultPDA = (escrow, mint) => getProgramDerivedAddress({
224
+ programAddress: FLEX_PROGRAM_ADDRESS,
225
+ seeds: [
226
+ textEncoder.encode("token"),
227
+ addressEncoder.encode(escrow),
228
+ addressEncoder.encode(mint),
229
+ ],
230
+ });
231
+ const derivePendingPDA = (escrow, authorizationId) => getProgramDerivedAddress({
232
+ programAddress: FLEX_PROGRAM_ADDRESS,
233
+ seeds: [
234
+ textEncoder.encode("pending"),
235
+ addressEncoder.encode(escrow),
236
+ u64Encoder.encode(authorizationId),
237
+ ],
238
+ });
239
+ const parseAndVerifyPayload = async (payment) => {
240
+ const parseResult = FlexPaymentPayload(payment.payload);
241
+ if (isValidationError(parseResult)) {
242
+ return { error: `Invalid flex payload: ${parseResult.summary}` };
243
+ }
244
+ const escrowAddress = address(parseResult.escrow);
245
+ const mint = address(parseResult.mint);
246
+ const maxAmount = BigInt(parseResult.maxAmount);
247
+ const authorizationId = BigInt(parseResult.authorizationId);
248
+ const expiresAtSlot = BigInt(parseResult.expiresAtSlot);
249
+ const sessionKeyAddress = address(parseResult.sessionKey);
250
+ const signatureBytes = Uint8Array.from(atob(parseResult.signature), (c) => c.charCodeAt(0));
251
+ const splits = parseResult.splits.map((s) => ({
252
+ recipient: address(s.recipient),
253
+ bps: s.bps,
254
+ }));
255
+ const cached = escrowSnapshots.get(escrowAddress);
256
+ const snapshot = cached && Date.now() - cached.fetchedAtMs < snapshotMaxAgeMs
257
+ ? cached
258
+ : await snapshotEscrow(escrowAddress);
259
+ if (snapshot.account.facilitator !== facilitatorAddress) {
260
+ return { error: "Escrow facilitator does not match" };
261
+ }
262
+ if (snapshot.account.refundTimeoutSlots <= confirmationBufferSlots) {
263
+ return {
264
+ error: `Escrow refund timeout (${snapshot.account.refundTimeoutSlots}) too short for confirmation buffer (${confirmationBufferSlots})`,
265
+ };
266
+ }
267
+ const [sessionKeyPDA] = await deriveSessionKeyPDA(escrowAddress, sessionKeyAddress);
268
+ const sessionKeyData = await getCachedSessionKey(sessionKeyPDA);
269
+ if (!sessionKeyData) {
270
+ return { error: "Session key not found" };
271
+ }
272
+ if (sessionKeyData.revocationGracePeriodSlots < minGracePeriodSlots) {
273
+ return {
274
+ error: `Session key grace period ${sessionKeyData.revocationGracePeriodSlots} below minimum ${minGracePeriodSlots}`,
275
+ };
276
+ }
277
+ const currentSlot = estimateCurrentSlot();
278
+ const usability = isSessionKeyUsable(sessionKeyData, currentSlot);
279
+ if (!usability.usable) {
280
+ sessionKeyCache.delete(sessionKeyPDA);
281
+ return { error: usability.reason };
282
+ }
283
+ if (currentSlot >= expiresAtSlot) {
284
+ return { error: "Authorization has already expired" };
285
+ }
286
+ if (expiresAtSlot < currentSlot + confirmationBufferSlots) {
287
+ return {
288
+ error: "Authorization expires too soon for on-chain submission",
289
+ };
290
+ }
291
+ if (expiresAtSlot > currentSlot + snapshot.account.refundTimeoutSlots) {
292
+ return { error: "Authorization expiry exceeds refund timeout" };
293
+ }
294
+ const validUntilSlot = computeHoldDeadline(sessionKeyData, expiresAtSlot, currentSlot);
295
+ if (currentSlot >= validUntilSlot) {
296
+ return {
297
+ error: "Session key validity window too short for settlement",
298
+ };
299
+ }
300
+ const [vault] = await deriveVaultPDA(escrowAddress, mint);
301
+ const vaultAmount = snapshot.vaultByMint.get(mint) ?? 0n;
302
+ const onChainCommitted = snapshot.onChainCommittedByMint.get(mint) ?? 0n;
303
+ const message = serializePaymentAuthorization({
304
+ programId: FLEX_PROGRAM_ADDRESS,
305
+ escrow: escrowAddress,
306
+ mint,
307
+ maxAmount,
308
+ authorizationId,
309
+ expiresAtSlot,
310
+ splits,
311
+ });
312
+ const publicKeyBytes = addressEncoder.encode(sessionKeyAddress);
313
+ const cryptoKey = await crypto.subtle.importKey("raw", publicKeyBytes, "Ed25519", false, ["verify"]);
314
+ const isValid = await crypto.subtle.verify("Ed25519", cryptoKey, signatureBytes, message);
315
+ if (!isValid) {
316
+ return { error: "Ed25519 signature verification failed" };
317
+ }
318
+ return {
319
+ escrowAddress,
320
+ escrowAccount: snapshot.account,
321
+ mint,
322
+ maxAmount,
323
+ authorizationId,
324
+ expiresAtSlot,
325
+ sessionKeyAddress,
326
+ sessionKeyPDA,
327
+ vault,
328
+ splits,
329
+ signatureBytes,
330
+ message,
331
+ payer: snapshot.account.owner,
332
+ vaultAmount,
333
+ onChainCommitted,
334
+ validUntilSlot,
335
+ };
336
+ };
337
+ const handleVerify = async (requirements, payment) => {
338
+ if (!isMatchingRequirement(requirements)) {
339
+ return null;
340
+ }
341
+ const result = await parseAndVerifyPayload(payment);
342
+ if ("error" in result) {
343
+ return { isValid: false, invalidReason: result.error };
344
+ }
345
+ // Create hold at maxAmount to reserve funds and prevent replay.
346
+ // The settle step will reduce settleAmount to the actual cost.
347
+ const holdResult = holdManager.tryHold({
348
+ escrow: result.escrowAddress,
349
+ mint: result.mint,
350
+ settleAmount: result.maxAmount,
351
+ maxAmount: result.maxAmount,
352
+ authorizationId: result.authorizationId,
353
+ expiresAtSlot: result.expiresAtSlot,
354
+ sessionKeyAddress: result.sessionKeyAddress,
355
+ sessionKeyPDA: result.sessionKeyPDA,
356
+ vault: result.vault,
357
+ splits: result.splits,
358
+ signatureBytes: result.signatureBytes,
359
+ message: result.message,
360
+ payer: result.payer,
361
+ validUntilSlot: result.validUntilSlot,
362
+ }, result.vaultAmount, result.onChainCommitted, result.escrowAccount.pendingCount);
363
+ if (!holdResult.ok) {
364
+ return { isValid: false, invalidReason: holdResult.reason };
365
+ }
366
+ return { isValid: true, payer: result.payer };
367
+ };
368
+ const handleSettle = async (requirements, payment) => {
369
+ if (!isMatchingRequirement(requirements)) {
370
+ return null;
371
+ }
372
+ const errorResponse = (msg) => {
373
+ logger.error(msg);
374
+ return {
375
+ success: false,
376
+ errorReason: msg,
377
+ transaction: "",
378
+ network: networkId,
379
+ };
380
+ };
381
+ const result = await parseAndVerifyPayload(payment);
382
+ if ("error" in result) {
383
+ return errorResponse(result.error);
384
+ }
385
+ const settleAmount = BigInt(requirements.amount);
386
+ if (settleAmount > result.maxAmount) {
387
+ return errorResponse("Settle amount exceeds client-authorized maxAmount");
388
+ }
389
+ // Update the hold created during verify with the actual settle amount.
390
+ // If no hold exists (settle called without prior verify), create one.
391
+ const updateResult = holdManager.updateSettleAmount(result.escrowAddress, result.authorizationId, settleAmount);
392
+ if (updateResult.ok) {
393
+ return {
394
+ success: true,
395
+ transaction: result.authorizationId.toString(),
396
+ network: networkId,
397
+ payer: result.payer,
398
+ };
399
+ }
400
+ if (!updateResult.ok && updateResult.reason !== "Hold not found") {
401
+ return errorResponse(updateResult.reason);
402
+ }
403
+ // No existing hold -- fall back to creating one (direct settle path)
404
+ const holdResult = holdManager.tryHold({
405
+ escrow: result.escrowAddress,
406
+ mint: result.mint,
407
+ settleAmount,
408
+ maxAmount: result.maxAmount,
409
+ authorizationId: result.authorizationId,
410
+ expiresAtSlot: result.expiresAtSlot,
411
+ sessionKeyAddress: result.sessionKeyAddress,
412
+ sessionKeyPDA: result.sessionKeyPDA,
413
+ vault: result.vault,
414
+ splits: result.splits,
415
+ signatureBytes: result.signatureBytes,
416
+ message: result.message,
417
+ payer: result.payer,
418
+ validUntilSlot: result.validUntilSlot,
419
+ }, result.vaultAmount, result.onChainCommitted, result.escrowAccount.pendingCount);
420
+ if (!holdResult.ok) {
421
+ return errorResponse(holdResult.reason);
422
+ }
423
+ holdManager.updateSettleAmount(result.escrowAddress, result.authorizationId, settleAmount);
424
+ return {
425
+ success: true,
426
+ transaction: result.authorizationId.toString(),
427
+ network: networkId,
428
+ payer: result.payer,
429
+ };
430
+ };
431
+ async function submitHold(hold) {
432
+ const [pending] = await derivePendingPDA(hold.escrow, hold.authorizationId);
433
+ const ed25519Ix = createEd25519VerifyInstruction({
434
+ publicKey: hold.sessionKeyAddress,
435
+ message: hold.message,
436
+ signature: hold.signatureBytes,
437
+ });
438
+ const submitIx = await getSubmitAuthorizationInstructionAsync({
439
+ escrow: hold.escrow,
440
+ facilitator: facilitatorSigner,
441
+ sessionKey: hold.sessionKeyPDA,
442
+ tokenAccount: hold.vault,
443
+ pending,
444
+ mint: hold.mint,
445
+ maxAmount: hold.maxAmount,
446
+ settleAmount: hold.settleAmount,
447
+ authorizationId: hold.authorizationId,
448
+ expiresAtSlot: hold.expiresAtSlot,
449
+ splits: hold.splits.map((s) => ({
450
+ recipient: s.recipient,
451
+ bps: s.bps,
452
+ })),
453
+ });
454
+ const { value: { blockhash, lastValidBlockHeight }, } = await rpc.getLatestBlockhash().send();
455
+ const txMessage = appendTransactionMessageInstructions([ed25519Ix, submitIx], setTransactionMessageLifetimeUsingBlockhash({ blockhash, lastValidBlockHeight }, setTransactionMessageFeePayerSigner(facilitatorSigner, createTransactionMessage({ version: 0 }))));
456
+ const signedTx = await signTransactionMessageWithSigners(txMessage);
457
+ const txSignature = getSignatureFromTransaction(signedTx);
458
+ const wireTransaction = getBase64EncodedWireTransaction(signedTx);
459
+ await rpc.sendTransaction(wireTransaction, { encoding: "base64" }).send();
460
+ for (let i = 0; i < maxSubmitRetries; i++) {
461
+ const status = await rpc.getSignatureStatuses([txSignature]).send();
462
+ const statusValue = status.value[0];
463
+ if (statusValue?.err) {
464
+ return {
465
+ authorizationId: hold.authorizationId,
466
+ success: false,
467
+ error: `Transaction failed: ${JSON.stringify(statusValue.err)}`,
468
+ };
469
+ }
470
+ if (statusValue?.confirmationStatus === "confirmed" ||
471
+ statusValue?.confirmationStatus === "finalized") {
472
+ return {
473
+ authorizationId: hold.authorizationId,
474
+ success: true,
475
+ transaction: txSignature,
476
+ };
477
+ }
478
+ await new Promise((resolve) => setTimeout(resolve, submitRetryDelayMs));
479
+ }
480
+ return {
481
+ authorizationId: hold.authorizationId,
482
+ success: false,
483
+ error: "Transaction confirmation timeout",
484
+ };
485
+ }
486
+ async function flush() {
487
+ const currentSlot = estimateCurrentSlot();
488
+ const expired = holdManager.sweepExpired(currentSlot);
489
+ for (const h of expired) {
490
+ logger.warning(`hold expired before flush: authorizationId=${h.authorizationId} validUntilSlot=${h.validUntilSlot}`);
491
+ }
492
+ const batch = holdManager.drainSubmittable(currentSlot);
493
+ if (batch.length === 0)
494
+ return [];
495
+ const escrowsToRefresh = new Set();
496
+ const settled = await Promise.allSettled(batch.map(async (hold) => {
497
+ try {
498
+ return await submitHold(hold);
499
+ }
500
+ catch (cause) {
501
+ return {
502
+ authorizationId: hold.authorizationId,
503
+ success: false,
504
+ error: cause instanceof Error
505
+ ? cause.message
506
+ : "Unknown submission error",
507
+ };
508
+ }
509
+ }));
510
+ const results = [];
511
+ for (const [i, hold] of batch.entries()) {
512
+ const outcome = settled[i];
513
+ const result = outcome?.status === "fulfilled"
514
+ ? outcome.value
515
+ : {
516
+ authorizationId: hold.authorizationId,
517
+ success: false,
518
+ error: outcome?.status === "rejected" &&
519
+ outcome.reason instanceof Error
520
+ ? outcome.reason.message
521
+ : "Unknown submission error",
522
+ };
523
+ results.push(result);
524
+ if (result.success) {
525
+ if (!holdManager.markSubmitted(hold.escrow, hold.authorizationId, currentSlot)) {
526
+ logger.warning(`markSubmitted returned false for authorizationId ${hold.authorizationId}`);
527
+ }
528
+ escrowsToRefresh.add(hold.escrow);
529
+ }
530
+ else {
531
+ logger.error(`submission failed for authorizationId ${hold.authorizationId}: ${result.error}`);
532
+ if (isPermanentSubmitError(result.error)) {
533
+ holdManager.releaseHold(hold.escrow, hold.authorizationId);
534
+ }
535
+ else {
536
+ const retries = holdManager.markFailed(hold.escrow, hold.authorizationId);
537
+ if (retries < 0 || retries >= maxSubmitRetries) {
538
+ if (retries >= maxSubmitRetries) {
539
+ logger.error(`giving up on authorizationId ${hold.authorizationId} after ${retries} attempts`);
540
+ }
541
+ holdManager.releaseHold(hold.escrow, hold.authorizationId);
542
+ }
543
+ }
544
+ escrowSnapshots.delete(hold.escrow);
545
+ }
546
+ }
547
+ await Promise.all([...escrowsToRefresh].map((escrow) => snapshotEscrow(escrow)));
548
+ return results;
549
+ }
550
+ async function finalizeHold(hold) {
551
+ const [pending] = await derivePendingPDA(hold.escrow, hold.authorizationId);
552
+ const ix = {
553
+ programAddress: FLEX_PROGRAM_ADDRESS,
554
+ data: getFinalizeInstructionDataEncoder().encode({}),
555
+ accounts: [
556
+ { address: hold.escrow, role: AccountRole.WRITABLE },
557
+ {
558
+ address: facilitatorSigner.address,
559
+ role: AccountRole.WRITABLE_SIGNER,
560
+ signer: facilitatorSigner,
561
+ },
562
+ { address: pending, role: AccountRole.WRITABLE },
563
+ { address: hold.vault, role: AccountRole.WRITABLE },
564
+ { address: TOKEN_PROGRAM, role: AccountRole.READONLY },
565
+ ...hold.splits.map((s) => ({
566
+ address: s.recipient,
567
+ role: AccountRole.WRITABLE,
568
+ })),
569
+ ],
570
+ };
571
+ const { value: { blockhash, lastValidBlockHeight }, } = await rpc.getLatestBlockhash().send();
572
+ const txMessage = appendTransactionMessageInstructions([ix], setTransactionMessageLifetimeUsingBlockhash({ blockhash, lastValidBlockHeight }, setTransactionMessageFeePayerSigner(facilitatorSigner, createTransactionMessage({ version: 0 }))));
573
+ const signedTx = await signTransactionMessageWithSigners(txMessage);
574
+ const txSignature = getSignatureFromTransaction(signedTx);
575
+ const wireTransaction = getBase64EncodedWireTransaction(signedTx);
576
+ await rpc.sendTransaction(wireTransaction, { encoding: "base64" }).send();
577
+ for (let i = 0; i < maxSubmitRetries; i++) {
578
+ const status = await rpc.getSignatureStatuses([txSignature]).send();
579
+ const statusValue = status.value[0];
580
+ if (statusValue?.err) {
581
+ return {
582
+ authorizationId: hold.authorizationId,
583
+ success: false,
584
+ error: `Transaction failed: ${JSON.stringify(statusValue.err)}`,
585
+ };
586
+ }
587
+ if (statusValue?.confirmationStatus === "confirmed" ||
588
+ statusValue?.confirmationStatus === "finalized") {
589
+ return {
590
+ authorizationId: hold.authorizationId,
591
+ success: true,
592
+ transaction: txSignature,
593
+ };
594
+ }
595
+ await new Promise((resolve) => setTimeout(resolve, submitRetryDelayMs));
596
+ }
597
+ return {
598
+ authorizationId: hold.authorizationId,
599
+ success: false,
600
+ error: "Transaction confirmation timeout",
601
+ };
602
+ }
603
+ function getRefundTimeout(escrow) {
604
+ const snapshot = escrowSnapshots.get(escrow);
605
+ if (!snapshot)
606
+ return null;
607
+ return snapshot.account.refundTimeoutSlots + confirmationBufferSlots;
608
+ }
609
+ function extractErrorMessage(cause) {
610
+ if (!(cause instanceof Error))
611
+ return String(cause);
612
+ const context = "context" in cause
613
+ ? ` context=${JSON.stringify(cause.context, (_k, v) => (typeof v === "bigint" ? v.toString() : v))}`
614
+ : "";
615
+ const causeChain = cause.cause instanceof Error ? ` cause=${cause.cause.message}` : "";
616
+ return `${cause.message}${context}${causeChain}`;
617
+ }
618
+ async function finalizeReady() {
619
+ const currentSlot = estimateCurrentSlot();
620
+ const batch = holdManager.drainFinalizable(currentSlot, getRefundTimeout);
621
+ if (batch.length === 0)
622
+ return [];
623
+ const settled = await Promise.allSettled(batch.map(async (hold) => {
624
+ try {
625
+ return await finalizeHold(hold);
626
+ }
627
+ catch (cause) {
628
+ return {
629
+ authorizationId: hold.authorizationId,
630
+ success: false,
631
+ error: extractErrorMessage(cause),
632
+ };
633
+ }
634
+ }));
635
+ const results = [];
636
+ for (const [i, hold] of batch.entries()) {
637
+ const outcome = settled[i];
638
+ const result = outcome?.status === "fulfilled"
639
+ ? outcome.value
640
+ : {
641
+ authorizationId: hold.authorizationId,
642
+ success: false,
643
+ error: outcome?.status === "rejected"
644
+ ? extractErrorMessage(outcome.reason)
645
+ : "Unknown finalization error",
646
+ };
647
+ results.push(result);
648
+ if (result.success) {
649
+ if (!holdManager.markFinalized(hold.escrow, hold.authorizationId)) {
650
+ logger.warning(`markFinalized returned false for authorizationId ${hold.authorizationId}`);
651
+ }
652
+ }
653
+ else {
654
+ logger.error(`finalization failed for authorizationId ${hold.authorizationId}: ${result.error}`);
655
+ holdManager.resetToSubmitted(hold.escrow, hold.authorizationId);
656
+ }
657
+ }
658
+ return results;
659
+ }
660
+ await refreshSlot();
661
+ const flushIntervalMs = config.flushIntervalMs ?? 2000;
662
+ let tickRunning = false;
663
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
664
+ const interval = setInterval(async () => {
665
+ if (tickRunning)
666
+ return;
667
+ tickRunning = true;
668
+ try {
669
+ const flushResults = await flush();
670
+ for (const r of flushResults) {
671
+ if (r.success) {
672
+ logger.info(`flushed authorizationId=${r.authorizationId} tx=${r.transaction}`);
673
+ }
674
+ else {
675
+ logger.error(`flush failed authorizationId=${r.authorizationId}: ${r.error}`);
676
+ }
677
+ }
678
+ const finalizeResults = await finalizeReady();
679
+ for (const r of finalizeResults) {
680
+ if (r.success) {
681
+ logger.info(`finalized authorizationId=${r.authorizationId} tx=${r.transaction}`);
682
+ }
683
+ else {
684
+ logger.error(`finalize failed authorizationId=${r.authorizationId}: ${r.error}`);
685
+ }
686
+ }
687
+ }
688
+ catch (cause) {
689
+ logger.error(`tick error: ${cause instanceof Error ? cause.message : cause}`);
690
+ }
691
+ finally {
692
+ tickRunning = false;
693
+ }
694
+ }, flushIntervalMs);
695
+ const stop = () => clearInterval(interval);
696
+ return {
697
+ getSupported,
698
+ getRequirements,
699
+ handleVerify,
700
+ handleSettle,
701
+ flush,
702
+ getHoldManager: () => holdManager,
703
+ stop,
704
+ };
705
+ };