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/dist/index.mjs CHANGED
@@ -1,6 +1,13 @@
1
1
  // src/client.ts
2
- import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
3
- import { Mppx, tempo } from "mppx/client";
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(`Failed to fund wallet ${address} from testnet faucet`);
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 FAUCET_RPC = "https://rpc.testnet.tempo.xyz";
45
- async function fundWallet(address) {
46
- const res = await fetch(FAUCET_RPC, {
47
- method: "POST",
48
- headers: { "Content-Type": "application/json" },
49
- body: JSON.stringify({
50
- jsonrpc: "2.0",
51
- id: 1,
52
- method: "tempo_fundAddress",
53
- params: [address]
54
- })
55
- });
56
- if (!res.ok) {
57
- throw new MppFaucetError(address, new Error(`HTTP ${res.status}`));
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
- const json = await res.json();
60
- if (json.error) {
61
- throw new MppFaucetError(address, new Error(json.error.message ?? "RPC error"));
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 key = config?.privateKey ?? generatePrivateKey();
69
- const account = privateKeyToAccount(key);
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 ${account.address}`,
73
- data: { address: account.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
- return {
81
- address: account.address,
82
- method: "tempo",
83
- fetch: async (url, init) => {
84
- emit({ type: "request", message: `\u2192 ${url}` });
85
- const controller = new AbortController();
86
- const timer = setTimeout(() => controller.abort(), timeout);
87
- const signal = init?.signal ? anySignal([init.signal, controller.signal]) : controller.signal;
88
- try {
89
- const response = await mppxClient.fetch(url, { ...init, signal });
90
- if (!response.ok && response.status !== 402) {
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 ${response.status}`,
94
- data: { status: response.status }
152
+ message: `\u2190 ${res.status} ${res.statusText}`,
153
+ data: { status: res.status }
95
154
  });
96
- throw new MppPaymentError(url, response.status);
155
+ throw new MppPaymentError(url, res.status);
97
156
  }
98
157
  emit({
99
- type: response.ok ? "success" : "payment",
100
- message: `\u2190 ${response.status}`,
101
- data: { status: response.status }
158
+ type: "success",
159
+ message: `\u2190 ${res.status} OK`,
160
+ data: { status: res.status }
102
161
  });
103
- return response;
104
- } catch (err) {
105
- if (err instanceof Error && err.name === "AbortError") {
106
- throw new MppTimeoutError(url, timeout);
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
- throw err;
109
- } finally {
110
- clearTimeout(timer);
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
- function anySignal(signals) {
116
- const controller = new AbortController();
117
- for (const signal of signals) {
118
- if (signal.aborted) {
119
- controller.abort(signal.reason);
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 { Mppx as Mppx2, tempo as tempo2 } from "mppx/express";
139
- import { privateKeyToAccount as privateKeyToAccount2, generatePrivateKey as generatePrivateKey2 } from "viem/accounts";
140
- var PATHUSD = "0x20c0000000000000000000000000000000000000";
141
- function createTestServer(config) {
142
- if (!config.secretKey) {
143
- throw new Error("createTestServer: secretKey is required");
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
- const serverKey = config.privateKey ?? generatePrivateKey2();
146
- const serverAccount = privateKeyToAccount2(serverKey);
147
- const mppx = Mppx2.create({
148
- secretKey: config.secretKey,
149
- methods: [
150
- tempo2({
151
- testnet: true,
152
- currency: config.currency ?? PATHUSD,
153
- recipient: serverAccount.address,
154
- account: serverAccount
155
- })
156
- ]
157
- });
158
- return mppx;
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.0.0",
4
- "description": "Test pay-per-request APIs on Tempo testnet. Zero setup - auto-creates wallets and handles 402 payments.",
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
- "mppx": "^0.6.3",
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
- "tempo",
47
- "testnet",
48
- "machine-to-machine"
53
+ "solana",
54
+ "devnet",
55
+ "machine-to-machine",
56
+ "mpp32"
49
57
  ],
50
58
  "license": "MIT",
51
59
  "repository": {