@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.
- package/dist/src/charge/client.d.ts +10 -6
- package/dist/src/charge/client.d.ts.map +1 -1
- package/dist/src/charge/client.js +117 -102
- package/dist/src/charge/common.d.ts +3 -3
- package/dist/src/charge/server.d.ts +18 -7
- package/dist/src/charge/server.d.ts.map +1 -1
- package/dist/src/charge/server.js +22 -24
- package/dist/src/compat.d.ts +38 -0
- package/dist/src/compat.d.ts.map +1 -0
- package/dist/src/compat.js +86 -0
- package/dist/src/compat.test.d.ts +3 -0
- package/dist/src/compat.test.d.ts.map +1 -0
- package/dist/src/compat.test.js +70 -0
- package/dist/src/exact/client.d.ts +18 -15
- package/dist/src/exact/client.d.ts.map +1 -1
- package/dist/src/exact/client.js +124 -96
- package/dist/src/exact/common.d.ts +1 -1
- package/dist/src/exact/facilitator.d.ts +19 -12
- package/dist/src/exact/facilitator.d.ts.map +1 -1
- package/dist/src/exact/facilitator.js +19 -18
- package/dist/src/exact/memo.d.ts +0 -2
- package/dist/src/exact/memo.d.ts.map +1 -1
- package/dist/src/exact/memo.js +0 -9
- package/dist/src/exact/verify.d.ts +5 -1
- package/dist/src/exact/verify.d.ts.map +1 -1
- package/dist/src/exact/verify.js +8 -2
- package/dist/src/exact/verify.test.js +80 -3
- package/dist/src/flex/client/handler.d.ts +31 -0
- package/dist/src/flex/client/handler.d.ts.map +1 -0
- package/dist/src/flex/client/handler.js +104 -0
- package/dist/src/flex/client/index.d.ts +3 -0
- package/dist/src/flex/client/index.d.ts.map +1 -0
- package/dist/src/flex/client/index.js +1 -0
- package/dist/src/flex/common.d.ts +15 -0
- package/dist/src/flex/common.d.ts.map +1 -0
- package/dist/src/flex/common.js +7 -0
- package/dist/src/flex/facilitator/handler.d.ts +48 -0
- package/dist/src/flex/facilitator/handler.d.ts.map +1 -0
- package/dist/src/flex/facilitator/handler.js +705 -0
- package/dist/src/flex/facilitator/index.d.ts +5 -0
- package/dist/src/flex/facilitator/index.d.ts.map +1 -0
- package/dist/src/flex/facilitator/index.js +2 -0
- package/dist/src/flex/hono/index.d.ts +3 -0
- package/dist/src/flex/hono/index.d.ts.map +1 -0
- package/dist/src/flex/hono/index.js +1 -0
- package/dist/src/flex/hono/upto-handler.d.ts +20 -0
- package/dist/src/flex/hono/upto-handler.d.ts.map +1 -0
- package/dist/src/flex/hono/upto-handler.js +72 -0
- package/dist/src/flex/hono/upto-handler.test.d.ts +3 -0
- package/dist/src/flex/hono/upto-handler.test.d.ts.map +1 -0
- package/dist/src/flex/hono/upto-handler.test.js +381 -0
- package/dist/src/flex/index.d.ts +4 -0
- package/dist/src/flex/index.d.ts.map +1 -0
- package/dist/src/flex/index.js +3 -0
- package/dist/src/flex/logger.d.ts +2 -0
- package/dist/src/flex/logger.d.ts.map +1 -0
- package/dist/src/flex/logger.js +2 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- 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
|
+
};
|