mpp-test-sdk 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -32
- package/dist/index.d.mts +92 -37
- package/dist/index.d.ts +92 -37
- package/dist/index.js +273 -85
- package/dist/index.mjs +285 -85
- package/package.json +15 -7
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// src/client.ts
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
Keypair,
|
|
4
|
+
Connection,
|
|
5
|
+
PublicKey,
|
|
6
|
+
LAMPORTS_PER_SOL,
|
|
7
|
+
SystemProgram,
|
|
8
|
+
Transaction,
|
|
9
|
+
sendAndConfirmTransaction
|
|
10
|
+
} from "@solana/web3.js";
|
|
4
11
|
|
|
5
12
|
// src/errors.ts
|
|
6
13
|
var MppError = class extends Error {
|
|
@@ -12,7 +19,9 @@ var MppError = class extends Error {
|
|
|
12
19
|
var MppFaucetError = class extends MppError {
|
|
13
20
|
address;
|
|
14
21
|
constructor(address, cause) {
|
|
15
|
-
super(
|
|
22
|
+
super(
|
|
23
|
+
`Failed to airdrop SOL to wallet ${address}. The devnet/testnet faucet may be rate-limited. Wait 30s and retry, or pass a pre-funded secretKey to skip airdrop.`
|
|
24
|
+
);
|
|
16
25
|
this.name = "MppFaucetError";
|
|
17
26
|
this.address = address;
|
|
18
27
|
this.cause = cause;
|
|
@@ -33,95 +42,201 @@ var MppTimeoutError = class extends MppError {
|
|
|
33
42
|
url;
|
|
34
43
|
timeoutMs;
|
|
35
44
|
constructor(url, timeoutMs) {
|
|
36
|
-
super(`Request to ${url} timed out after ${timeoutMs}ms
|
|
45
|
+
super(`Request to ${url} timed out after ${timeoutMs}ms. Increase the timeout option or check your Solana RPC connection.`);
|
|
37
46
|
this.name = "MppTimeoutError";
|
|
38
47
|
this.url = url;
|
|
39
48
|
this.timeoutMs = timeoutMs;
|
|
40
49
|
}
|
|
41
50
|
};
|
|
51
|
+
var MppNetworkError = class extends MppError {
|
|
52
|
+
network;
|
|
53
|
+
constructor(network, message) {
|
|
54
|
+
super(
|
|
55
|
+
message ?? `Network configuration error for "${network}". Mainnet requires a pre-funded secretKey (no airdrop available).`
|
|
56
|
+
);
|
|
57
|
+
this.name = "MppNetworkError";
|
|
58
|
+
this.network = network;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
42
61
|
|
|
43
62
|
// src/client.ts
|
|
44
|
-
var
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
var NETWORK_RPC = {
|
|
64
|
+
devnet: "https://api.devnet.solana.com",
|
|
65
|
+
testnet: "https://api.testnet.solana.com",
|
|
66
|
+
mainnet: "https://api.mainnet-beta.solana.com"
|
|
67
|
+
};
|
|
68
|
+
var AIRDROP_NETWORKS = ["devnet", "testnet"];
|
|
69
|
+
async function airdropWithRetry(connection, publicKey, retries = 3) {
|
|
70
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
const sig = await connection.requestAirdrop(publicKey, 2 * LAMPORTS_PER_SOL);
|
|
73
|
+
await connection.confirmTransaction(sig, "confirmed");
|
|
74
|
+
return;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (attempt === retries - 1) {
|
|
77
|
+
throw new MppFaucetError(publicKey.toBase58(), err);
|
|
78
|
+
}
|
|
79
|
+
await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt)));
|
|
80
|
+
}
|
|
58
81
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
}
|
|
83
|
+
function parseHeaderParams(header) {
|
|
84
|
+
const params = {};
|
|
85
|
+
const parts = header.split(";").map((s) => s.trim());
|
|
86
|
+
for (const part of parts.slice(1)) {
|
|
87
|
+
const eqIdx = part.indexOf("=");
|
|
88
|
+
if (eqIdx > 0) {
|
|
89
|
+
const key = part.slice(0, eqIdx).trim().toLowerCase();
|
|
90
|
+
const val = part.slice(eqIdx + 1).trim().replace(/^"|"$/g, "");
|
|
91
|
+
params[key] = val;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return params;
|
|
95
|
+
}
|
|
96
|
+
function anySignal(signals) {
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
for (const signal of signals) {
|
|
99
|
+
if (signal.aborted) {
|
|
100
|
+
controller.abort(signal.reason);
|
|
101
|
+
return controller.signal;
|
|
102
|
+
}
|
|
103
|
+
signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
|
|
62
104
|
}
|
|
105
|
+
return controller.signal;
|
|
63
106
|
}
|
|
64
107
|
async function createTestClient(config) {
|
|
65
108
|
const emit = config?.onStep ?? (() => {
|
|
66
109
|
});
|
|
67
110
|
const timeout = config?.timeout ?? 3e4;
|
|
68
|
-
const
|
|
69
|
-
const
|
|
111
|
+
const network = config?.network ?? "devnet";
|
|
112
|
+
const rpcUrl = config?.rpcUrl ?? NETWORK_RPC[network];
|
|
113
|
+
if (network === "mainnet" && !config?.secretKey) {
|
|
114
|
+
throw new MppNetworkError(
|
|
115
|
+
"mainnet",
|
|
116
|
+
"createTestClient: mainnet requires a pre-funded secretKey. Airdrop is not available on mainnet. Pass your keypair's secretKey in the config."
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const keypair = config?.secretKey ? Keypair.fromSecretKey(config.secretKey) : Keypair.generate();
|
|
120
|
+
const connection = new Connection(rpcUrl, "confirmed");
|
|
121
|
+
const address = keypair.publicKey.toBase58();
|
|
70
122
|
emit({
|
|
71
123
|
type: "wallet-created",
|
|
72
|
-
message: `Wallet ${
|
|
73
|
-
data: { address
|
|
74
|
-
});
|
|
75
|
-
await fundWallet(account.address);
|
|
76
|
-
emit({ type: "funded", message: "Wallet funded on testnet" });
|
|
77
|
-
const mppxClient = Mppx.create({
|
|
78
|
-
methods: [tempo({ account, testnet: true })]
|
|
124
|
+
message: `Wallet ${address}`,
|
|
125
|
+
data: { address, network }
|
|
79
126
|
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
127
|
+
if (AIRDROP_NETWORKS.includes(network)) {
|
|
128
|
+
await airdropWithRetry(connection, keypair.publicKey);
|
|
129
|
+
emit({
|
|
130
|
+
type: "funded",
|
|
131
|
+
message: `Wallet funded via ${network} airdrop (2 SOL)`,
|
|
132
|
+
data: { network, amount: 2 }
|
|
133
|
+
});
|
|
134
|
+
} else {
|
|
135
|
+
emit({
|
|
136
|
+
type: "funded",
|
|
137
|
+
message: "Using pre-funded mainnet wallet",
|
|
138
|
+
data: { network }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const clientFetch = async (url, init) => {
|
|
142
|
+
emit({ type: "request", message: `\u2192 ${url}`, data: { url } });
|
|
143
|
+
const controller = new AbortController();
|
|
144
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
145
|
+
const signal = init?.signal ? anySignal([init.signal, controller.signal]) : controller.signal;
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch(url, { ...init, signal });
|
|
148
|
+
if (res.status !== 402) {
|
|
149
|
+
if (!res.ok) {
|
|
91
150
|
emit({
|
|
92
151
|
type: "error",
|
|
93
|
-
message: `\u2190 ${
|
|
94
|
-
data: { status:
|
|
152
|
+
message: `\u2190 ${res.status} ${res.statusText}`,
|
|
153
|
+
data: { status: res.status }
|
|
95
154
|
});
|
|
96
|
-
throw new MppPaymentError(url,
|
|
155
|
+
throw new MppPaymentError(url, res.status);
|
|
97
156
|
}
|
|
98
157
|
emit({
|
|
99
|
-
type:
|
|
100
|
-
message: `\u2190 ${
|
|
101
|
-
data: { status:
|
|
158
|
+
type: "success",
|
|
159
|
+
message: `\u2190 ${res.status} OK`,
|
|
160
|
+
data: { status: res.status }
|
|
102
161
|
});
|
|
103
|
-
return
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
162
|
+
return res;
|
|
163
|
+
}
|
|
164
|
+
const paymentRequestHeader = res.headers.get("payment-request");
|
|
165
|
+
if (!paymentRequestHeader) {
|
|
166
|
+
throw new MppPaymentError(url, 402, new Error("Server returned 402 without Payment-Request header"));
|
|
167
|
+
}
|
|
168
|
+
const params = parseHeaderParams(paymentRequestHeader);
|
|
169
|
+
if (!params.recipient) {
|
|
170
|
+
throw new MppPaymentError(url, 402, new Error("Payment-Request header missing recipient field"));
|
|
171
|
+
}
|
|
172
|
+
if (!params.amount) {
|
|
173
|
+
throw new MppPaymentError(url, 402, new Error("Payment-Request header missing amount field"));
|
|
174
|
+
}
|
|
175
|
+
const recipient = new PublicKey(params.recipient);
|
|
176
|
+
const amountSol = parseFloat(params.amount);
|
|
177
|
+
if (isNaN(amountSol) || amountSol <= 0) {
|
|
178
|
+
throw new MppPaymentError(url, 402, new Error(`Invalid payment amount: ${params.amount}`));
|
|
179
|
+
}
|
|
180
|
+
const lamports = Math.round(amountSol * LAMPORTS_PER_SOL);
|
|
181
|
+
emit({
|
|
182
|
+
type: "payment",
|
|
183
|
+
message: `Paying ${amountSol} SOL \u2192 ${params.recipient.slice(0, 8)}...`,
|
|
184
|
+
data: { amount: amountSol, recipient: params.recipient }
|
|
185
|
+
});
|
|
186
|
+
const { blockhash } = await connection.getLatestBlockhash("confirmed");
|
|
187
|
+
const tx = new Transaction({
|
|
188
|
+
recentBlockhash: blockhash,
|
|
189
|
+
feePayer: keypair.publicKey
|
|
190
|
+
}).add(
|
|
191
|
+
SystemProgram.transfer({
|
|
192
|
+
fromPubkey: keypair.publicKey,
|
|
193
|
+
toPubkey: recipient,
|
|
194
|
+
lamports
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
const signature = await sendAndConfirmTransaction(connection, tx, [keypair], {
|
|
198
|
+
commitment: "confirmed"
|
|
199
|
+
});
|
|
200
|
+
emit({
|
|
201
|
+
type: "payment",
|
|
202
|
+
message: `Confirmed: ${signature.slice(0, 16)}...`,
|
|
203
|
+
data: { signature, amount: amountSol }
|
|
204
|
+
});
|
|
205
|
+
emit({
|
|
206
|
+
type: "retry",
|
|
207
|
+
message: `\u2191 Retrying with payment proof`,
|
|
208
|
+
data: { signature }
|
|
209
|
+
});
|
|
210
|
+
const existingHeaders = init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init?.headers ?? {};
|
|
211
|
+
const retryRes = await fetch(url, {
|
|
212
|
+
...init,
|
|
213
|
+
signal,
|
|
214
|
+
headers: {
|
|
215
|
+
...existingHeaders,
|
|
216
|
+
"payment-receipt": `solana; signature="${signature}"; network="${network}"; amount="${amountSol}"`
|
|
107
217
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
218
|
+
});
|
|
219
|
+
emit({
|
|
220
|
+
type: retryRes.ok ? "success" : "error",
|
|
221
|
+
message: `\u2190 ${retryRes.status} ${retryRes.ok ? "OK" : retryRes.statusText}`,
|
|
222
|
+
data: { status: retryRes.status, signature }
|
|
223
|
+
});
|
|
224
|
+
return retryRes;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
227
|
+
throw new MppTimeoutError(url, timeout);
|
|
111
228
|
}
|
|
229
|
+
throw err;
|
|
230
|
+
} finally {
|
|
231
|
+
clearTimeout(timer);
|
|
112
232
|
}
|
|
113
233
|
};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return controller.signal;
|
|
121
|
-
}
|
|
122
|
-
signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
|
|
123
|
-
}
|
|
124
|
-
return controller.signal;
|
|
234
|
+
return {
|
|
235
|
+
address,
|
|
236
|
+
network,
|
|
237
|
+
method: "solana",
|
|
238
|
+
fetch: clientFetch
|
|
239
|
+
};
|
|
125
240
|
}
|
|
126
241
|
var _sharedClient = null;
|
|
127
242
|
async function mppFetch(url, init) {
|
|
@@ -135,31 +250,116 @@ mppFetch.reset = () => {
|
|
|
135
250
|
};
|
|
136
251
|
|
|
137
252
|
// src/server.ts
|
|
138
|
-
import {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
253
|
+
import {
|
|
254
|
+
Connection as Connection2,
|
|
255
|
+
Keypair as Keypair2,
|
|
256
|
+
PublicKey as PublicKey2,
|
|
257
|
+
LAMPORTS_PER_SOL as LAMPORTS_PER_SOL2
|
|
258
|
+
} from "@solana/web3.js";
|
|
259
|
+
var NETWORK_RPC2 = {
|
|
260
|
+
devnet: "https://api.devnet.solana.com",
|
|
261
|
+
testnet: "https://api.testnet.solana.com",
|
|
262
|
+
mainnet: "https://api.mainnet-beta.solana.com"
|
|
263
|
+
};
|
|
264
|
+
function parseHeaderParams2(header) {
|
|
265
|
+
const params = {};
|
|
266
|
+
const parts = header.split(";").map((s) => s.trim());
|
|
267
|
+
for (const part of parts.slice(1)) {
|
|
268
|
+
const eqIdx = part.indexOf("=");
|
|
269
|
+
if (eqIdx > 0) {
|
|
270
|
+
const key = part.slice(0, eqIdx).trim().toLowerCase();
|
|
271
|
+
const val = part.slice(eqIdx + 1).trim().replace(/^"|"$/g, "");
|
|
272
|
+
params[key] = val;
|
|
273
|
+
}
|
|
144
274
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
275
|
+
return params;
|
|
276
|
+
}
|
|
277
|
+
function createTestServer(config = {}) {
|
|
278
|
+
const network = config.network ?? "devnet";
|
|
279
|
+
const rpcUrl = config.rpcUrl ?? NETWORK_RPC2[network];
|
|
280
|
+
const serverKeypair = config.secretKey ? Keypair2.fromSecretKey(config.secretKey) : Keypair2.generate();
|
|
281
|
+
const recipientAddress = config.recipientAddress ?? serverKeypair.publicKey.toBase58();
|
|
282
|
+
const connection = new Connection2(rpcUrl, "confirmed");
|
|
283
|
+
const charge = ({ amount }) => async (req, res, next) => {
|
|
284
|
+
const receiptHeader = req.headers["payment-receipt"] ?? "";
|
|
285
|
+
if (!receiptHeader) {
|
|
286
|
+
res.status(402).set(
|
|
287
|
+
"Payment-Request",
|
|
288
|
+
`solana; amount="${amount}"; recipient="${recipientAddress}"; network="${network}"`
|
|
289
|
+
).json({
|
|
290
|
+
error: "Payment Required",
|
|
291
|
+
payment: {
|
|
292
|
+
amount,
|
|
293
|
+
currency: "SOL",
|
|
294
|
+
recipient: recipientAddress,
|
|
295
|
+
network
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const params = parseHeaderParams2(receiptHeader);
|
|
302
|
+
const { signature } = params;
|
|
303
|
+
if (!signature) {
|
|
304
|
+
res.status(403).json({ error: "Payment-Receipt missing signature field" });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const paidAmount = parseFloat(params.amount ?? "0");
|
|
308
|
+
const requiredAmount = parseFloat(amount);
|
|
309
|
+
if (isNaN(paidAmount) || paidAmount < requiredAmount) {
|
|
310
|
+
res.status(403).json({
|
|
311
|
+
error: `Insufficient payment: claimed ${params.amount ?? "0"} SOL, required ${amount} SOL`
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const tx = await connection.getParsedTransaction(signature, {
|
|
316
|
+
commitment: "confirmed",
|
|
317
|
+
maxSupportedTransactionVersion: 0
|
|
318
|
+
});
|
|
319
|
+
if (!tx) {
|
|
320
|
+
res.status(403).json({ error: "Transaction not found on chain" });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (tx.meta?.err) {
|
|
324
|
+
res.status(403).json({ error: "Transaction failed on chain" });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const accountKeys = tx.transaction.message.accountKeys;
|
|
328
|
+
const preBalances = tx.meta?.preBalances ?? [];
|
|
329
|
+
const postBalances = tx.meta?.postBalances ?? [];
|
|
330
|
+
const recipientPubkey = new PublicKey2(recipientAddress);
|
|
331
|
+
const recipientIdx = accountKeys.findIndex(
|
|
332
|
+
(k) => k.pubkey.toBase58() === recipientPubkey.toBase58()
|
|
333
|
+
);
|
|
334
|
+
if (recipientIdx < 0) {
|
|
335
|
+
res.status(403).json({
|
|
336
|
+
error: `Recipient ${recipientAddress.slice(0, 8)}... not found in transaction`
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const received = (postBalances[recipientIdx] - preBalances[recipientIdx]) / LAMPORTS_PER_SOL2;
|
|
341
|
+
if (received < requiredAmount) {
|
|
342
|
+
res.status(403).json({
|
|
343
|
+
error: `Payment too small: received ${received} SOL, required ${requiredAmount} SOL`
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
next();
|
|
348
|
+
} catch (err) {
|
|
349
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
350
|
+
res.status(403).json({ error: `Payment verification failed: ${message}` });
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
return {
|
|
354
|
+
charge,
|
|
355
|
+
recipientAddress,
|
|
356
|
+
network
|
|
357
|
+
};
|
|
159
358
|
}
|
|
160
359
|
export {
|
|
161
360
|
MppError,
|
|
162
361
|
MppFaucetError,
|
|
362
|
+
MppNetworkError,
|
|
163
363
|
MppPaymentError,
|
|
164
364
|
MppTimeoutError,
|
|
165
365
|
createTestClient,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mpp-test-sdk",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Test pay-per-request APIs on
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Test pay-per-request APIs on Solana devnet. Auto-creates wallets, airdrops SOL, handles 402 MPP payments — zero setup required.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -25,14 +25,21 @@
|
|
|
25
25
|
"prepublishOnly": "npm run build && npm run test"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"
|
|
29
|
-
"viem": "^2.48.4"
|
|
28
|
+
"@solana/web3.js": "^1.95.4"
|
|
30
29
|
},
|
|
31
30
|
"devDependencies": {
|
|
31
|
+
"@types/express": "^4.17.21",
|
|
32
|
+
"express": "^4.18.2",
|
|
32
33
|
"tsup": "^8.0.0",
|
|
33
34
|
"typescript": "^5.4.0",
|
|
34
35
|
"vitest": "^3.1.0"
|
|
35
36
|
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"express": ">=4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesOptional": {
|
|
41
|
+
"express": true
|
|
42
|
+
},
|
|
36
43
|
"engines": {
|
|
37
44
|
"node": ">=22"
|
|
38
45
|
},
|
|
@@ -43,9 +50,10 @@
|
|
|
43
50
|
"sdk",
|
|
44
51
|
"402",
|
|
45
52
|
"pay-per-request",
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"machine-to-machine"
|
|
53
|
+
"solana",
|
|
54
|
+
"devnet",
|
|
55
|
+
"machine-to-machine",
|
|
56
|
+
"mpp32"
|
|
49
57
|
],
|
|
50
58
|
"license": "MIT",
|
|
51
59
|
"repository": {
|