@faremeter/payment-solana 0.19.0 → 0.20.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/README.md +70 -0
- package/dist/src/charge/client.d.ts +18 -0
- package/dist/src/charge/client.d.ts.map +1 -0
- package/dist/src/charge/client.js +205 -0
- package/dist/src/charge/common.d.ts +44 -0
- package/dist/src/charge/common.d.ts.map +1 -0
- package/dist/src/charge/common.js +29 -0
- package/dist/src/charge/index.d.ts +7 -0
- package/dist/src/charge/index.d.ts.map +1 -0
- package/dist/src/charge/index.js +3 -0
- package/dist/src/charge/logger.d.ts +2 -0
- package/dist/src/charge/logger.d.ts.map +1 -0
- package/dist/src/charge/logger.js +2 -0
- package/dist/src/charge/replay.d.ts +12 -0
- package/dist/src/charge/replay.d.ts.map +1 -0
- package/dist/src/charge/replay.js +24 -0
- package/dist/src/charge/server.d.ts +31 -0
- package/dist/src/charge/server.d.ts.map +1 -0
- package/dist/src/charge/server.js +395 -0
- package/dist/src/charge/verify.d.ts +40 -0
- package/dist/src/charge/verify.d.ts.map +1 -0
- package/dist/src/charge/verify.js +185 -0
- package/dist/src/common.d.ts +12 -0
- package/dist/src/common.d.ts.map +1 -0
- package/dist/src/common.js +1 -0
- package/dist/src/exact/client.d.ts.map +1 -1
- package/dist/src/exact/client.js +5 -2
- package/dist/src/exact/facilitator.d.ts +1 -0
- package/dist/src/exact/facilitator.d.ts.map +1 -1
- package/dist/src/exact/facilitator.js +6 -0
- package/dist/src/exact/index.d.ts +1 -1
- package/dist/src/exact/index.d.ts.map +1 -1
- package/dist/src/exact/memo.d.ts +4 -0
- package/dist/src/exact/memo.d.ts.map +1 -0
- package/dist/src/exact/memo.js +16 -0
- package/dist/src/exact/verify.d.ts +2 -1
- package/dist/src/exact/verify.d.ts.map +1 -1
- package/dist/src/exact/verify.js +48 -4
- package/dist/src/exact/verify.test.js +155 -16
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +6 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +21 -15
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { encodeBase64URL, canonicalizeSortedJSON, decodeBase64URL, } from "@faremeter/types/mpp";
|
|
2
|
+
import { isValidationError } from "@faremeter/types";
|
|
3
|
+
import { lookupX402Network, caip2ToCluster, } from "@faremeter/info/solana";
|
|
4
|
+
import { fetchMint } from "@solana-program/token";
|
|
5
|
+
import { address, decompileTransactionMessage, getBase64Encoder, getCompiledTransactionMessageDecoder, } from "@solana/kit";
|
|
6
|
+
import { getBase64EncodedWireTransaction, getTransactionDecoder, partiallySignTransaction, } from "@solana/transactions";
|
|
7
|
+
import { Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
|
|
8
|
+
import { mppChargeRequest, chargeCredentialPayload } from "./common.js";
|
|
9
|
+
import { verifyChargeTransaction, verifyNativeChargeTransaction, } from "./verify.js";
|
|
10
|
+
import { logger } from "./logger.js";
|
|
11
|
+
async function generateChallengeID(secret, params) {
|
|
12
|
+
const slots = [
|
|
13
|
+
params.realm,
|
|
14
|
+
params.method,
|
|
15
|
+
params.intent,
|
|
16
|
+
params.request,
|
|
17
|
+
params.expires ?? "",
|
|
18
|
+
params.digest ?? "",
|
|
19
|
+
params.opaque ?? "",
|
|
20
|
+
];
|
|
21
|
+
// Per spec: pipe-delimited. Safe because slot values are either
|
|
22
|
+
// server-controlled constants or base64url-encoded (no pipe chars).
|
|
23
|
+
const message = new TextEncoder().encode(slots.join("|"));
|
|
24
|
+
const keyData = new Uint8Array(secret);
|
|
25
|
+
const key = await crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
26
|
+
const sig = await crypto.subtle.sign("HMAC", key, message);
|
|
27
|
+
return encodeBase64URL(String.fromCharCode(...new Uint8Array(sig)));
|
|
28
|
+
}
|
|
29
|
+
async function verifyChallengeID(secret, params) {
|
|
30
|
+
const { id, ...rest } = params;
|
|
31
|
+
const computed = await generateChallengeID(secret, rest);
|
|
32
|
+
const encoder = new TextEncoder();
|
|
33
|
+
const a = encoder.encode(computed);
|
|
34
|
+
const b = encoder.encode(id);
|
|
35
|
+
if (a.byteLength !== b.byteLength)
|
|
36
|
+
return false;
|
|
37
|
+
const { timingSafeEqual } = await import("node:crypto");
|
|
38
|
+
return timingSafeEqual(a, b);
|
|
39
|
+
}
|
|
40
|
+
const sendTransaction = async (rpc, signedTransaction, maxRetries, retryDelayMs) => {
|
|
41
|
+
const base64EncodedTransaction = getBase64EncodedWireTransaction(signedTransaction);
|
|
42
|
+
const simResult = await rpc
|
|
43
|
+
.simulateTransaction(base64EncodedTransaction, {
|
|
44
|
+
encoding: "base64",
|
|
45
|
+
})
|
|
46
|
+
.send();
|
|
47
|
+
if (simResult.value.err) {
|
|
48
|
+
logger.error("transaction simulation failed", simResult.value);
|
|
49
|
+
return { success: false, error: "Transaction simulation failed" };
|
|
50
|
+
}
|
|
51
|
+
const signature = await rpc
|
|
52
|
+
.sendTransaction(base64EncodedTransaction, {
|
|
53
|
+
encoding: "base64",
|
|
54
|
+
})
|
|
55
|
+
.send();
|
|
56
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
57
|
+
const status = await rpc.getSignatureStatuses([signature]).send();
|
|
58
|
+
if (status.value[0]?.err) {
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
error: `Transaction failed: ${JSON.stringify(status.value[0].err)}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (status.value[0]?.confirmationStatus === "confirmed" ||
|
|
65
|
+
status.value[0]?.confirmationStatus === "finalized") {
|
|
66
|
+
return { success: true, signature };
|
|
67
|
+
}
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
69
|
+
}
|
|
70
|
+
return { success: false, error: "Transaction confirmation timeout" };
|
|
71
|
+
};
|
|
72
|
+
const fetchConfirmedTransaction = async (rpc, signature, maxRetries, retryDelayMs) => {
|
|
73
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
74
|
+
const result = await rpc
|
|
75
|
+
.getTransaction(signature, {
|
|
76
|
+
commitment: "confirmed",
|
|
77
|
+
maxSupportedTransactionVersion: 0,
|
|
78
|
+
encoding: "base64",
|
|
79
|
+
})
|
|
80
|
+
.send();
|
|
81
|
+
if (result !== null) {
|
|
82
|
+
if (result.meta?.err) {
|
|
83
|
+
throw new Error(`on-chain transaction failed: ${JSON.stringify(result.meta.err)}`);
|
|
84
|
+
}
|
|
85
|
+
const txData = result.transaction;
|
|
86
|
+
const txB64 = Array.isArray(txData) ? txData[0] : txData;
|
|
87
|
+
if (typeof txB64 !== "string") {
|
|
88
|
+
throw new Error("unexpected transaction encoding in RPC response");
|
|
89
|
+
}
|
|
90
|
+
const txBytes = getBase64Encoder().encode(txB64);
|
|
91
|
+
const decodedTx = getTransactionDecoder().decode(txBytes);
|
|
92
|
+
const compiledMessage = getCompiledTransactionMessageDecoder().decode(decodedTx.messageBytes);
|
|
93
|
+
return decompileTransactionMessage(compiledMessage);
|
|
94
|
+
}
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
const decodeWireTransaction = (base64Transaction) => {
|
|
100
|
+
const txBytes = getBase64Encoder().encode(base64Transaction);
|
|
101
|
+
const decodedTx = getTransactionDecoder().decode(txBytes);
|
|
102
|
+
const compiledMessage = getCompiledTransactionMessageDecoder().decode(decodedTx.messageBytes);
|
|
103
|
+
return {
|
|
104
|
+
transactionMessage: decompileTransactionMessage(compiledMessage),
|
|
105
|
+
decodedTx,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
export async function createMPPSolanaChargeHandler(args) {
|
|
109
|
+
const { network, rpc, feePayerKeypair, mint, replayStore, realm, secretKey, maxRetries = 30, retryDelayMs = 1000, maxPriorityFee = 100_000, } = args;
|
|
110
|
+
const solanaNetwork = lookupX402Network(network);
|
|
111
|
+
const mintAddress = mint.toBase58();
|
|
112
|
+
const hasFeePayerKeypair = feePayerKeypair !== undefined;
|
|
113
|
+
const feePayerAddress = feePayerKeypair?.publicKey.toBase58();
|
|
114
|
+
const mintInfo = await fetchMint(rpc, address(mintAddress));
|
|
115
|
+
const tokenProgram = mintInfo.programAddress;
|
|
116
|
+
const feePayerSigner = hasFeePayerKeypair
|
|
117
|
+
? await (async () => {
|
|
118
|
+
const { createKeyPairSignerFromBytes } = await import("@solana/kit");
|
|
119
|
+
return createKeyPairSignerFromBytes(feePayerKeypair.secretKey);
|
|
120
|
+
})()
|
|
121
|
+
: null;
|
|
122
|
+
const getChallenge = async (intent, pricing, _resourceURL, opts) => {
|
|
123
|
+
const methodDetails = {
|
|
124
|
+
network: caip2ToCluster(solanaNetwork.caip2) ?? solanaNetwork.caip2,
|
|
125
|
+
decimals: mintInfo.data.decimals,
|
|
126
|
+
tokenProgram: tokenProgram,
|
|
127
|
+
};
|
|
128
|
+
if (hasFeePayerKeypair && feePayerAddress) {
|
|
129
|
+
const latestBlockhash = await rpc.getLatestBlockhash().send();
|
|
130
|
+
methodDetails.feePayer = true;
|
|
131
|
+
methodDetails.feePayerKey = feePayerAddress;
|
|
132
|
+
methodDetails.recentBlockhash = latestBlockhash.value.blockhash;
|
|
133
|
+
}
|
|
134
|
+
const requestBody = {
|
|
135
|
+
amount: pricing.amount,
|
|
136
|
+
currency: mintAddress,
|
|
137
|
+
recipient: pricing.recipient,
|
|
138
|
+
...(pricing.description ? { description: pricing.description } : {}),
|
|
139
|
+
methodDetails,
|
|
140
|
+
};
|
|
141
|
+
const requestEncoded = encodeBase64URL(canonicalizeSortedJSON(requestBody));
|
|
142
|
+
const challengeTimeoutMs = 60_000;
|
|
143
|
+
const expiresAt = Date.now() + challengeTimeoutMs;
|
|
144
|
+
const paramsWithoutID = {
|
|
145
|
+
realm,
|
|
146
|
+
method: "solana",
|
|
147
|
+
intent,
|
|
148
|
+
request: requestEncoded,
|
|
149
|
+
expires: String(Math.floor(expiresAt / 1000)),
|
|
150
|
+
...(opts?.digest !== undefined ? { digest: opts.digest } : {}),
|
|
151
|
+
};
|
|
152
|
+
const id = await generateChallengeID(secretKey, paramsWithoutID);
|
|
153
|
+
await replayStore.add(id, expiresAt);
|
|
154
|
+
return { id, ...paramsWithoutID };
|
|
155
|
+
};
|
|
156
|
+
const handleSettle = async (credential) => {
|
|
157
|
+
const { challenge, payload } = credential;
|
|
158
|
+
if (challenge.method !== "solana")
|
|
159
|
+
return null;
|
|
160
|
+
if (challenge.intent !== "charge")
|
|
161
|
+
return null;
|
|
162
|
+
let requestBody;
|
|
163
|
+
try {
|
|
164
|
+
requestBody = JSON.parse(decodeBase64URL(challenge.request));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const request = mppChargeRequest(requestBody);
|
|
170
|
+
if (isValidationError(request))
|
|
171
|
+
return null;
|
|
172
|
+
if (request.currency === "sol")
|
|
173
|
+
return null;
|
|
174
|
+
const idValid = await verifyChallengeID(secretKey, challenge);
|
|
175
|
+
if (!idValid) {
|
|
176
|
+
throw new Error("invalid challenge ID");
|
|
177
|
+
}
|
|
178
|
+
if (challenge.expires !== undefined) {
|
|
179
|
+
const expiresAtMs = Number(challenge.expires) * 1000;
|
|
180
|
+
if (expiresAtMs > 0 && Date.now() > expiresAtMs) {
|
|
181
|
+
throw new Error("challenge expired");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const consumed = await replayStore.consume(challenge.id);
|
|
185
|
+
if (!consumed) {
|
|
186
|
+
throw new Error("challenge ID already consumed or expired");
|
|
187
|
+
}
|
|
188
|
+
const validatedPayload = chargeCredentialPayload(payload);
|
|
189
|
+
if (isValidationError(validatedPayload)) {
|
|
190
|
+
throw new Error(`invalid credential payload: ${validatedPayload.summary}`);
|
|
191
|
+
}
|
|
192
|
+
const verifyArgs = {
|
|
193
|
+
request,
|
|
194
|
+
feePayerAddress: feePayerAddress ?? "",
|
|
195
|
+
tokenProgram,
|
|
196
|
+
maxPriorityFee,
|
|
197
|
+
};
|
|
198
|
+
if (validatedPayload.type === "signature") {
|
|
199
|
+
if (request.methodDetails?.feePayer) {
|
|
200
|
+
throw new Error("push mode is not allowed with fee sponsorship");
|
|
201
|
+
}
|
|
202
|
+
const transactionMessage = await fetchConfirmedTransaction(rpc, validatedPayload.signature, maxRetries, retryDelayMs);
|
|
203
|
+
if (!transactionMessage) {
|
|
204
|
+
throw new Error("could not fetch confirmed transaction");
|
|
205
|
+
}
|
|
206
|
+
const verifyResult = await verifyChargeTransaction({
|
|
207
|
+
transactionMessage,
|
|
208
|
+
...verifyArgs,
|
|
209
|
+
});
|
|
210
|
+
if ("error" in verifyResult) {
|
|
211
|
+
throw new Error(`transaction verification failed: ${verifyResult.error}`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
status: "success",
|
|
215
|
+
method: "solana",
|
|
216
|
+
timestamp: new Date().toISOString(),
|
|
217
|
+
reference: validatedPayload.signature,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const { transactionMessage, decodedTx } = decodeWireTransaction(validatedPayload.transaction);
|
|
221
|
+
const verifyResult = await verifyChargeTransaction({
|
|
222
|
+
transactionMessage,
|
|
223
|
+
...verifyArgs,
|
|
224
|
+
});
|
|
225
|
+
if ("error" in verifyResult) {
|
|
226
|
+
throw new Error(`transaction verification failed: ${verifyResult.error}`);
|
|
227
|
+
}
|
|
228
|
+
if (!feePayerSigner) {
|
|
229
|
+
throw new Error("pull mode requires a fee payer keypair");
|
|
230
|
+
}
|
|
231
|
+
const signedTransaction = await partiallySignTransaction([feePayerSigner.keyPair], decodedTx);
|
|
232
|
+
const txResult = await sendTransaction(rpc, signedTransaction, maxRetries, retryDelayMs);
|
|
233
|
+
if (!txResult.success) {
|
|
234
|
+
throw new Error(`settlement failed: ${txResult.error}`);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
status: "success",
|
|
238
|
+
method: "solana",
|
|
239
|
+
timestamp: new Date().toISOString(),
|
|
240
|
+
reference: txResult.signature,
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
return {
|
|
244
|
+
method: "solana",
|
|
245
|
+
capabilities: {
|
|
246
|
+
networks: [solanaNetwork.caip2],
|
|
247
|
+
assets: [mintAddress],
|
|
248
|
+
},
|
|
249
|
+
getSupportedIntents: () => ["charge"],
|
|
250
|
+
getChallenge,
|
|
251
|
+
handleSettle,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const SOL_DECIMALS = Math.log10(LAMPORTS_PER_SOL);
|
|
255
|
+
export async function createMPPSolanaNativeChargeHandler(args) {
|
|
256
|
+
const { network, rpc, feePayerKeypair, replayStore, realm, secretKey, maxRetries = 30, retryDelayMs = 1000, maxPriorityFee = 100_000, } = args;
|
|
257
|
+
const solanaNetwork = lookupX402Network(network);
|
|
258
|
+
const hasFeePayerKeypair = feePayerKeypair !== undefined;
|
|
259
|
+
const feePayerAddress = feePayerKeypair?.publicKey.toBase58();
|
|
260
|
+
const feePayerSigner = hasFeePayerKeypair
|
|
261
|
+
? await (async () => {
|
|
262
|
+
const { createKeyPairSignerFromBytes } = await import("@solana/kit");
|
|
263
|
+
return createKeyPairSignerFromBytes(feePayerKeypair.secretKey);
|
|
264
|
+
})()
|
|
265
|
+
: null;
|
|
266
|
+
const getChallenge = async (intent, pricing, _resourceURL, opts) => {
|
|
267
|
+
const methodDetails = {
|
|
268
|
+
network: caip2ToCluster(solanaNetwork.caip2) ?? solanaNetwork.caip2,
|
|
269
|
+
decimals: SOL_DECIMALS,
|
|
270
|
+
};
|
|
271
|
+
if (hasFeePayerKeypair && feePayerAddress) {
|
|
272
|
+
const latestBlockhash = await rpc.getLatestBlockhash().send();
|
|
273
|
+
methodDetails.feePayer = true;
|
|
274
|
+
methodDetails.feePayerKey = feePayerAddress;
|
|
275
|
+
methodDetails.recentBlockhash = latestBlockhash.value.blockhash;
|
|
276
|
+
}
|
|
277
|
+
const requestBody = {
|
|
278
|
+
amount: pricing.amount,
|
|
279
|
+
currency: "sol",
|
|
280
|
+
recipient: pricing.recipient,
|
|
281
|
+
...(pricing.description ? { description: pricing.description } : {}),
|
|
282
|
+
methodDetails,
|
|
283
|
+
};
|
|
284
|
+
const requestEncoded = encodeBase64URL(canonicalizeSortedJSON(requestBody));
|
|
285
|
+
const challengeTimeoutMs = 60_000;
|
|
286
|
+
const expiresAt = Date.now() + challengeTimeoutMs;
|
|
287
|
+
const paramsWithoutID = {
|
|
288
|
+
realm,
|
|
289
|
+
method: "solana",
|
|
290
|
+
intent,
|
|
291
|
+
request: requestEncoded,
|
|
292
|
+
expires: String(Math.floor(expiresAt / 1000)),
|
|
293
|
+
...(opts?.digest !== undefined ? { digest: opts.digest } : {}),
|
|
294
|
+
};
|
|
295
|
+
const id = await generateChallengeID(secretKey, paramsWithoutID);
|
|
296
|
+
await replayStore.add(id, expiresAt);
|
|
297
|
+
return { id, ...paramsWithoutID };
|
|
298
|
+
};
|
|
299
|
+
const handleSettle = async (credential) => {
|
|
300
|
+
const { challenge, payload } = credential;
|
|
301
|
+
if (challenge.method !== "solana")
|
|
302
|
+
return null;
|
|
303
|
+
if (challenge.intent !== "charge")
|
|
304
|
+
return null;
|
|
305
|
+
let requestBody;
|
|
306
|
+
try {
|
|
307
|
+
requestBody = JSON.parse(decodeBase64URL(challenge.request));
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
const request = mppChargeRequest(requestBody);
|
|
313
|
+
if (isValidationError(request))
|
|
314
|
+
return null;
|
|
315
|
+
if (request.currency !== "sol")
|
|
316
|
+
return null;
|
|
317
|
+
const idValid = await verifyChallengeID(secretKey, challenge);
|
|
318
|
+
if (!idValid) {
|
|
319
|
+
throw new Error("invalid challenge ID");
|
|
320
|
+
}
|
|
321
|
+
if (challenge.expires !== undefined) {
|
|
322
|
+
const expiresAtMs = Number(challenge.expires) * 1000;
|
|
323
|
+
if (expiresAtMs > 0 && Date.now() > expiresAtMs) {
|
|
324
|
+
throw new Error("challenge expired");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const consumed = await replayStore.consume(challenge.id);
|
|
328
|
+
if (!consumed) {
|
|
329
|
+
throw new Error("challenge ID already consumed or expired");
|
|
330
|
+
}
|
|
331
|
+
const validatedPayload = chargeCredentialPayload(payload);
|
|
332
|
+
if (isValidationError(validatedPayload)) {
|
|
333
|
+
throw new Error(`invalid credential payload: ${validatedPayload.summary}`);
|
|
334
|
+
}
|
|
335
|
+
const verifyArgs = {
|
|
336
|
+
request,
|
|
337
|
+
feePayerAddress: feePayerAddress ?? "",
|
|
338
|
+
maxPriorityFee,
|
|
339
|
+
};
|
|
340
|
+
if (validatedPayload.type === "signature") {
|
|
341
|
+
if (request.methodDetails?.feePayer) {
|
|
342
|
+
throw new Error("push mode is not allowed with fee sponsorship");
|
|
343
|
+
}
|
|
344
|
+
const transactionMessage = await fetchConfirmedTransaction(rpc, validatedPayload.signature, maxRetries, retryDelayMs);
|
|
345
|
+
if (!transactionMessage) {
|
|
346
|
+
throw new Error("could not fetch confirmed transaction");
|
|
347
|
+
}
|
|
348
|
+
const verifyResult = await verifyNativeChargeTransaction({
|
|
349
|
+
transactionMessage,
|
|
350
|
+
...verifyArgs,
|
|
351
|
+
});
|
|
352
|
+
if ("error" in verifyResult) {
|
|
353
|
+
throw new Error(`transaction verification failed: ${verifyResult.error}`);
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
status: "success",
|
|
357
|
+
method: "solana",
|
|
358
|
+
timestamp: new Date().toISOString(),
|
|
359
|
+
reference: validatedPayload.signature,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const { transactionMessage, decodedTx } = decodeWireTransaction(validatedPayload.transaction);
|
|
363
|
+
const verifyResult = await verifyNativeChargeTransaction({
|
|
364
|
+
transactionMessage,
|
|
365
|
+
...verifyArgs,
|
|
366
|
+
});
|
|
367
|
+
if ("error" in verifyResult) {
|
|
368
|
+
throw new Error(`transaction verification failed: ${verifyResult.error}`);
|
|
369
|
+
}
|
|
370
|
+
if (!feePayerSigner) {
|
|
371
|
+
throw new Error("pull mode requires a fee payer keypair");
|
|
372
|
+
}
|
|
373
|
+
const signedTransaction = await partiallySignTransaction([feePayerSigner.keyPair], decodedTx);
|
|
374
|
+
const txResult = await sendTransaction(rpc, signedTransaction, maxRetries, retryDelayMs);
|
|
375
|
+
if (!txResult.success) {
|
|
376
|
+
throw new Error(`settlement failed: ${txResult.error}`);
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
status: "success",
|
|
380
|
+
method: "solana",
|
|
381
|
+
timestamp: new Date().toISOString(),
|
|
382
|
+
reference: txResult.signature,
|
|
383
|
+
};
|
|
384
|
+
};
|
|
385
|
+
return {
|
|
386
|
+
method: "solana",
|
|
387
|
+
capabilities: {
|
|
388
|
+
networks: [solanaNetwork.caip2],
|
|
389
|
+
assets: ["sol"],
|
|
390
|
+
},
|
|
391
|
+
getSupportedIntents: () => ["charge"],
|
|
392
|
+
getChallenge,
|
|
393
|
+
handleSettle,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Address } from "@solana/kit";
|
|
2
|
+
import type { mppChargeRequest } from "./common.js";
|
|
3
|
+
import type { CompilableTransactionMessage } from "../common.js";
|
|
4
|
+
export type VerifyChargeTransactionArgs = {
|
|
5
|
+
transactionMessage: CompilableTransactionMessage;
|
|
6
|
+
request: mppChargeRequest;
|
|
7
|
+
feePayerAddress: string;
|
|
8
|
+
tokenProgram: Address;
|
|
9
|
+
maxPriorityFee?: number;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Verifies that a client-submitted transaction matches the charge
|
|
13
|
+
* challenge. Scans the instruction list for a matching transferChecked
|
|
14
|
+
* and caps the priority fee from any compute budget instructions.
|
|
15
|
+
*
|
|
16
|
+
* Returns the payer (transfer authority) address on success, or a
|
|
17
|
+
* string error message on failure.
|
|
18
|
+
*/
|
|
19
|
+
export declare function verifyChargeTransaction(args: VerifyChargeTransactionArgs): Promise<{
|
|
20
|
+
payer: string;
|
|
21
|
+
} | {
|
|
22
|
+
error: string;
|
|
23
|
+
}>;
|
|
24
|
+
export type VerifyNativeChargeTransactionArgs = {
|
|
25
|
+
transactionMessage: CompilableTransactionMessage;
|
|
26
|
+
request: mppChargeRequest;
|
|
27
|
+
feePayerAddress: string;
|
|
28
|
+
maxPriorityFee?: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Verifies that a client-submitted transaction matches a native SOL
|
|
32
|
+
* charge challenge. Scans the instruction list for a matching System
|
|
33
|
+
* Program transferSol and caps the priority fee.
|
|
34
|
+
*/
|
|
35
|
+
export declare function verifyNativeChargeTransaction(args: VerifyNativeChargeTransactionArgs): Promise<{
|
|
36
|
+
payer: string;
|
|
37
|
+
} | {
|
|
38
|
+
error: string;
|
|
39
|
+
}>;
|
|
40
|
+
//# sourceMappingURL=verify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../../../src/charge/verify.ts"],"names":[],"mappings":"AASA,OAAO,EAAW,KAAK,OAAO,EAAoB,MAAM,aAAa,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,WAAW,CAAC;AAuD9D,MAAM,MAAM,2BAA2B,GAAG;IACxC,kBAAkB,EAAE,4BAA4B,CAAC;IACjD,OAAO,EAAE,gBAAgB,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,2BAA2B,GAChC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CA8EhD;AAED,MAAM,MAAM,iCAAiC,GAAG;IAC9C,kBAAkB,EAAE,4BAA4B,CAAC;IACjD,OAAO,EAAE,gBAAgB,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,6BAA6B,CACjD,IAAI,EAAE,iCAAiC,GACtC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CA0DhD"}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { parseSetComputeUnitLimitInstruction, parseSetComputeUnitPriceInstruction, } from "@solana-program/compute-budget";
|
|
2
|
+
import { parseTransferSolInstruction } from "@solana-program/system";
|
|
3
|
+
import { findAssociatedTokenPda, parseTransferCheckedInstruction, } from "@solana-program/token";
|
|
4
|
+
import { address } from "@solana/kit";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
6
|
+
const DEFAULT_COMPUTE_UNIT_LIMIT = 200_000;
|
|
7
|
+
/**
|
|
8
|
+
* Scans instructions for compute budget settings and calculates the
|
|
9
|
+
* effective priority fee. Returns 0 when no compute budget instructions
|
|
10
|
+
* are present. Uses the highest fee found when duplicates exist
|
|
11
|
+
* (conservative for cap enforcement).
|
|
12
|
+
*/
|
|
13
|
+
function calculatePriorityFee(instructions) {
|
|
14
|
+
let highestLimit = 0;
|
|
15
|
+
let highestMicroLamports = 0n;
|
|
16
|
+
let foundLimit = false;
|
|
17
|
+
let foundPrice = false;
|
|
18
|
+
for (const ix of instructions) {
|
|
19
|
+
if (!ix.data)
|
|
20
|
+
continue;
|
|
21
|
+
const data = new Uint8Array(ix.data);
|
|
22
|
+
try {
|
|
23
|
+
const limit = parseSetComputeUnitLimitInstruction({
|
|
24
|
+
programAddress: ix.programAddress,
|
|
25
|
+
data,
|
|
26
|
+
});
|
|
27
|
+
foundLimit = true;
|
|
28
|
+
if (limit.data.units > highestLimit) {
|
|
29
|
+
highestLimit = limit.data.units;
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// not a setComputeUnitLimit instruction
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const price = parseSetComputeUnitPriceInstruction({
|
|
38
|
+
programAddress: ix.programAddress,
|
|
39
|
+
data,
|
|
40
|
+
});
|
|
41
|
+
foundPrice = true;
|
|
42
|
+
if (price.data.microLamports > highestMicroLamports) {
|
|
43
|
+
highestMicroLamports = price.data.microLamports;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// not a setComputeUnitPrice instruction
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!foundPrice)
|
|
51
|
+
return 0;
|
|
52
|
+
const units = foundLimit ? highestLimit : DEFAULT_COMPUTE_UNIT_LIMIT;
|
|
53
|
+
return (units * Number(highestMicroLamports)) / 1_000_000;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Verifies that a client-submitted transaction matches the charge
|
|
57
|
+
* challenge. Scans the instruction list for a matching transferChecked
|
|
58
|
+
* and caps the priority fee from any compute budget instructions.
|
|
59
|
+
*
|
|
60
|
+
* Returns the payer (transfer authority) address on success, or a
|
|
61
|
+
* string error message on failure.
|
|
62
|
+
*/
|
|
63
|
+
export async function verifyChargeTransaction(args) {
|
|
64
|
+
const { transactionMessage, request, feePayerAddress, tokenProgram } = args;
|
|
65
|
+
const md = request.methodDetails;
|
|
66
|
+
if (md?.feePayer && transactionMessage.feePayer.address !== feePayerAddress) {
|
|
67
|
+
return { error: "fee payer does not match challenge" };
|
|
68
|
+
}
|
|
69
|
+
const instructions = transactionMessage.instructions;
|
|
70
|
+
const maxFee = args.maxPriorityFee ?? 100_000;
|
|
71
|
+
const priorityFee = calculatePriorityFee(instructions);
|
|
72
|
+
if (priorityFee > maxFee) {
|
|
73
|
+
return {
|
|
74
|
+
error: `priority fee ${priorityFee} exceeds maximum ${maxFee}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const [expectedATA] = await findAssociatedTokenPda({
|
|
78
|
+
mint: address(request.currency),
|
|
79
|
+
owner: address(request.recipient),
|
|
80
|
+
tokenProgram,
|
|
81
|
+
});
|
|
82
|
+
for (const ix of instructions) {
|
|
83
|
+
if (!ix.data || !ix.accounts)
|
|
84
|
+
continue;
|
|
85
|
+
let transfer;
|
|
86
|
+
try {
|
|
87
|
+
transfer = parseTransferCheckedInstruction({
|
|
88
|
+
accounts: ix.accounts,
|
|
89
|
+
programAddress: ix.programAddress,
|
|
90
|
+
data: new Uint8Array(ix.data),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (transfer.data.amount !== BigInt(request.amount)) {
|
|
97
|
+
logger.debug("transfer amount mismatch", {
|
|
98
|
+
expected: request.amount,
|
|
99
|
+
actual: transfer.data.amount.toString(),
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (transfer.accounts.mint.address !== request.currency) {
|
|
104
|
+
logger.debug("transfer mint mismatch", {
|
|
105
|
+
expected: request.currency,
|
|
106
|
+
actual: transfer.accounts.mint.address,
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (md?.decimals !== undefined && transfer.data.decimals !== md.decimals) {
|
|
111
|
+
logger.debug("transfer decimals mismatch", {
|
|
112
|
+
expected: md.decimals,
|
|
113
|
+
actual: transfer.data.decimals,
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (transfer.accounts.destination.address !== expectedATA) {
|
|
118
|
+
logger.debug("transfer destination mismatch", {
|
|
119
|
+
expected: expectedATA,
|
|
120
|
+
actual: transfer.accounts.destination.address,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (transfer.accounts.authority.address === feePayerAddress) {
|
|
125
|
+
return { error: "transfer authority must not be the fee payer" };
|
|
126
|
+
}
|
|
127
|
+
return { payer: transfer.accounts.authority.address };
|
|
128
|
+
}
|
|
129
|
+
return { error: "no matching transferChecked instruction found" };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Verifies that a client-submitted transaction matches a native SOL
|
|
133
|
+
* charge challenge. Scans the instruction list for a matching System
|
|
134
|
+
* Program transferSol and caps the priority fee.
|
|
135
|
+
*/
|
|
136
|
+
export async function verifyNativeChargeTransaction(args) {
|
|
137
|
+
const { transactionMessage, request, feePayerAddress } = args;
|
|
138
|
+
const md = request.methodDetails;
|
|
139
|
+
if (md?.feePayer && transactionMessage.feePayer.address !== feePayerAddress) {
|
|
140
|
+
return { error: "fee payer does not match challenge" };
|
|
141
|
+
}
|
|
142
|
+
const instructions = transactionMessage.instructions;
|
|
143
|
+
const maxFee = args.maxPriorityFee ?? 100_000;
|
|
144
|
+
const priorityFee = calculatePriorityFee(instructions);
|
|
145
|
+
if (priorityFee > maxFee) {
|
|
146
|
+
return {
|
|
147
|
+
error: `priority fee ${priorityFee} exceeds maximum ${maxFee}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const expectedRecipient = address(request.recipient);
|
|
151
|
+
for (const ix of instructions) {
|
|
152
|
+
if (!ix.data || !ix.accounts)
|
|
153
|
+
continue;
|
|
154
|
+
let transfer;
|
|
155
|
+
try {
|
|
156
|
+
transfer = parseTransferSolInstruction({
|
|
157
|
+
accounts: ix.accounts,
|
|
158
|
+
programAddress: ix.programAddress,
|
|
159
|
+
data: new Uint8Array(ix.data),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (transfer.data.amount !== BigInt(request.amount)) {
|
|
166
|
+
logger.debug("native transfer amount mismatch", {
|
|
167
|
+
expected: request.amount,
|
|
168
|
+
actual: transfer.data.amount.toString(),
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (transfer.accounts.destination.address !== expectedRecipient) {
|
|
173
|
+
logger.debug("native transfer destination mismatch", {
|
|
174
|
+
expected: expectedRecipient,
|
|
175
|
+
actual: transfer.accounts.destination.address,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (transfer.accounts.source.address === feePayerAddress) {
|
|
180
|
+
return { error: "transfer source must not be the fee payer" };
|
|
181
|
+
}
|
|
182
|
+
return { payer: transfer.accounts.source.address };
|
|
183
|
+
}
|
|
184
|
+
return { error: "no matching transferSol instruction found" };
|
|
185
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TransactionMessage, TransactionMessageWithFeePayer, TransactionMessageWithLifetime } from "@solana/kit";
|
|
2
|
+
/**
|
|
3
|
+
* A transaction message with everything required to be compiled and landed
|
|
4
|
+
* on the network: a fee payer and a lifetime constraint.
|
|
5
|
+
*
|
|
6
|
+
* `@solana/transaction-messages` used to expose this exact intersection as
|
|
7
|
+
* `CompilableTransactionMessage`. The named alias was removed in 6.x but the
|
|
8
|
+
* constituent types are still exported, so we re-declare it locally with the
|
|
9
|
+
* same shape.
|
|
10
|
+
*/
|
|
11
|
+
export type CompilableTransactionMessage = TransactionMessage & TransactionMessageWithFeePayer & TransactionMessageWithLifetime;
|
|
12
|
+
//# sourceMappingURL=common.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../src/common.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,8BAA8B,EAC9B,8BAA8B,EAC/B,MAAM,aAAa,CAAC;AAErB;;;;;;;;GAQG;AACH,MAAM,MAAM,4BAA4B,GAAG,kBAAkB,GAC3D,8BAA8B,GAC9B,8BAA8B,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/exact/client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,cAAc,EAEf,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAajE,OAAO,EAEL,UAAU,EACV,SAAS,EACT,sBAAsB,EAEtB,oBAAoB,EAErB,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/exact/client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,cAAc,EAEf,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAajE,OAAO,EAEL,UAAU,EACV,SAAS,EACT,sBAAsB,EAEtB,oBAAoB,EAErB,MAAM,iBAAiB,CAAC;AAMzB,MAAM,MAAM,MAAM,GAAG;IACnB,OAAO,EAAE,MAAM,GAAG,kBAAkB,CAAC;IACrC,SAAS,EAAE,SAAS,CAAC;IACrB,gBAAgB,CAAC,EAAE,CACjB,YAAY,EAAE,sBAAsB,EAAE,EACtC,eAAe,EAAE,MAAM,KACpB,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACnC,wBAAwB,CAAC,EAAE,CACzB,EAAE,EAAE,oBAAoB,KACrB,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACnC,iBAAiB,CAAC,EAAE,CAClB,EAAE,EAAE,oBAAoB,KACrB,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACnC,eAAe,CAAC,EAAE,CAAC,EAAE,EAAE,oBAAoB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACjE,CAAC;AAEF,UAAU,oCAAoC;IAC5C,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,wBAAwB,CAAC,EAAE,SAAS,CAAC;CACtC;AAyFD,UAAU,2BAA2B;IACnC,KAAK,CAAC,EAAE,oCAAoC,CAAC;IAC7C,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,EAAE;QACT,wBAAwB,CAAC,EAAE,OAAO,CAAC;KACpC,CAAC;CACH;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,SAAS,EACf,UAAU,CAAC,EAAE,UAAU,EACvB,OAAO,CAAC,EAAE,2BAA2B,GACpC,cAAc,CAyMhB"}
|