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.js CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  MppError: () => MppError,
24
24
  MppFaucetError: () => MppFaucetError,
25
+ MppNetworkError: () => MppNetworkError,
25
26
  MppPaymentError: () => MppPaymentError,
26
27
  MppTimeoutError: () => MppTimeoutError,
27
28
  createTestClient: () => createTestClient,
@@ -31,8 +32,7 @@ __export(index_exports, {
31
32
  module.exports = __toCommonJS(index_exports);
32
33
 
33
34
  // src/client.ts
34
- var import_accounts = require("viem/accounts");
35
- var import_client = require("mppx/client");
35
+ var import_web3 = require("@solana/web3.js");
36
36
 
37
37
  // src/errors.ts
38
38
  var MppError = class extends Error {
@@ -44,7 +44,9 @@ var MppError = class extends Error {
44
44
  var MppFaucetError = class extends MppError {
45
45
  address;
46
46
  constructor(address, cause) {
47
- super(`Failed to fund wallet ${address} from testnet faucet`);
47
+ super(
48
+ `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.`
49
+ );
48
50
  this.name = "MppFaucetError";
49
51
  this.address = address;
50
52
  this.cause = cause;
@@ -65,95 +67,201 @@ var MppTimeoutError = class extends MppError {
65
67
  url;
66
68
  timeoutMs;
67
69
  constructor(url, timeoutMs) {
68
- super(`Request to ${url} timed out after ${timeoutMs}ms`);
70
+ super(`Request to ${url} timed out after ${timeoutMs}ms. Increase the timeout option or check your Solana RPC connection.`);
69
71
  this.name = "MppTimeoutError";
70
72
  this.url = url;
71
73
  this.timeoutMs = timeoutMs;
72
74
  }
73
75
  };
76
+ var MppNetworkError = class extends MppError {
77
+ network;
78
+ constructor(network, message) {
79
+ super(
80
+ message ?? `Network configuration error for "${network}". Mainnet requires a pre-funded secretKey (no airdrop available).`
81
+ );
82
+ this.name = "MppNetworkError";
83
+ this.network = network;
84
+ }
85
+ };
74
86
 
75
87
  // src/client.ts
76
- var FAUCET_RPC = "https://rpc.testnet.tempo.xyz";
77
- async function fundWallet(address) {
78
- const res = await fetch(FAUCET_RPC, {
79
- method: "POST",
80
- headers: { "Content-Type": "application/json" },
81
- body: JSON.stringify({
82
- jsonrpc: "2.0",
83
- id: 1,
84
- method: "tempo_fundAddress",
85
- params: [address]
86
- })
87
- });
88
- if (!res.ok) {
89
- throw new MppFaucetError(address, new Error(`HTTP ${res.status}`));
88
+ var NETWORK_RPC = {
89
+ devnet: "https://api.devnet.solana.com",
90
+ testnet: "https://api.testnet.solana.com",
91
+ mainnet: "https://api.mainnet-beta.solana.com"
92
+ };
93
+ var AIRDROP_NETWORKS = ["devnet", "testnet"];
94
+ async function airdropWithRetry(connection, publicKey, retries = 3) {
95
+ for (let attempt = 0; attempt < retries; attempt++) {
96
+ try {
97
+ const sig = await connection.requestAirdrop(publicKey, 2 * import_web3.LAMPORTS_PER_SOL);
98
+ await connection.confirmTransaction(sig, "confirmed");
99
+ return;
100
+ } catch (err) {
101
+ if (attempt === retries - 1) {
102
+ throw new MppFaucetError(publicKey.toBase58(), err);
103
+ }
104
+ await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt)));
105
+ }
90
106
  }
91
- const json = await res.json();
92
- if (json.error) {
93
- throw new MppFaucetError(address, new Error(json.error.message ?? "RPC error"));
107
+ }
108
+ function parseHeaderParams(header) {
109
+ const params = {};
110
+ const parts = header.split(";").map((s) => s.trim());
111
+ for (const part of parts.slice(1)) {
112
+ const eqIdx = part.indexOf("=");
113
+ if (eqIdx > 0) {
114
+ const key = part.slice(0, eqIdx).trim().toLowerCase();
115
+ const val = part.slice(eqIdx + 1).trim().replace(/^"|"$/g, "");
116
+ params[key] = val;
117
+ }
118
+ }
119
+ return params;
120
+ }
121
+ function anySignal(signals) {
122
+ const controller = new AbortController();
123
+ for (const signal of signals) {
124
+ if (signal.aborted) {
125
+ controller.abort(signal.reason);
126
+ return controller.signal;
127
+ }
128
+ signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
94
129
  }
130
+ return controller.signal;
95
131
  }
96
132
  async function createTestClient(config) {
97
133
  const emit = config?.onStep ?? (() => {
98
134
  });
99
135
  const timeout = config?.timeout ?? 3e4;
100
- const key = config?.privateKey ?? (0, import_accounts.generatePrivateKey)();
101
- const account = (0, import_accounts.privateKeyToAccount)(key);
136
+ const network = config?.network ?? "devnet";
137
+ const rpcUrl = config?.rpcUrl ?? NETWORK_RPC[network];
138
+ if (network === "mainnet" && !config?.secretKey) {
139
+ throw new MppNetworkError(
140
+ "mainnet",
141
+ "createTestClient: mainnet requires a pre-funded secretKey. Airdrop is not available on mainnet. Pass your keypair's secretKey in the config."
142
+ );
143
+ }
144
+ const keypair = config?.secretKey ? import_web3.Keypair.fromSecretKey(config.secretKey) : import_web3.Keypair.generate();
145
+ const connection = new import_web3.Connection(rpcUrl, "confirmed");
146
+ const address = keypair.publicKey.toBase58();
102
147
  emit({
103
148
  type: "wallet-created",
104
- message: `Wallet ${account.address}`,
105
- data: { address: account.address }
106
- });
107
- await fundWallet(account.address);
108
- emit({ type: "funded", message: "Wallet funded on testnet" });
109
- const mppxClient = import_client.Mppx.create({
110
- methods: [(0, import_client.tempo)({ account, testnet: true })]
149
+ message: `Wallet ${address}`,
150
+ data: { address, network }
111
151
  });
112
- return {
113
- address: account.address,
114
- method: "tempo",
115
- fetch: async (url, init) => {
116
- emit({ type: "request", message: `\u2192 ${url}` });
117
- const controller = new AbortController();
118
- const timer = setTimeout(() => controller.abort(), timeout);
119
- const signal = init?.signal ? anySignal([init.signal, controller.signal]) : controller.signal;
120
- try {
121
- const response = await mppxClient.fetch(url, { ...init, signal });
122
- if (!response.ok && response.status !== 402) {
152
+ if (AIRDROP_NETWORKS.includes(network)) {
153
+ await airdropWithRetry(connection, keypair.publicKey);
154
+ emit({
155
+ type: "funded",
156
+ message: `Wallet funded via ${network} airdrop (2 SOL)`,
157
+ data: { network, amount: 2 }
158
+ });
159
+ } else {
160
+ emit({
161
+ type: "funded",
162
+ message: "Using pre-funded mainnet wallet",
163
+ data: { network }
164
+ });
165
+ }
166
+ const clientFetch = async (url, init) => {
167
+ emit({ type: "request", message: `\u2192 ${url}`, data: { url } });
168
+ const controller = new AbortController();
169
+ const timer = setTimeout(() => controller.abort(), timeout);
170
+ const signal = init?.signal ? anySignal([init.signal, controller.signal]) : controller.signal;
171
+ try {
172
+ const res = await fetch(url, { ...init, signal });
173
+ if (res.status !== 402) {
174
+ if (!res.ok) {
123
175
  emit({
124
176
  type: "error",
125
- message: `\u2190 ${response.status}`,
126
- data: { status: response.status }
177
+ message: `\u2190 ${res.status} ${res.statusText}`,
178
+ data: { status: res.status }
127
179
  });
128
- throw new MppPaymentError(url, response.status);
180
+ throw new MppPaymentError(url, res.status);
129
181
  }
130
182
  emit({
131
- type: response.ok ? "success" : "payment",
132
- message: `\u2190 ${response.status}`,
133
- data: { status: response.status }
183
+ type: "success",
184
+ message: `\u2190 ${res.status} OK`,
185
+ data: { status: res.status }
134
186
  });
135
- return response;
136
- } catch (err) {
137
- if (err instanceof Error && err.name === "AbortError") {
138
- throw new MppTimeoutError(url, timeout);
187
+ return res;
188
+ }
189
+ const paymentRequestHeader = res.headers.get("payment-request");
190
+ if (!paymentRequestHeader) {
191
+ throw new MppPaymentError(url, 402, new Error("Server returned 402 without Payment-Request header"));
192
+ }
193
+ const params = parseHeaderParams(paymentRequestHeader);
194
+ if (!params.recipient) {
195
+ throw new MppPaymentError(url, 402, new Error("Payment-Request header missing recipient field"));
196
+ }
197
+ if (!params.amount) {
198
+ throw new MppPaymentError(url, 402, new Error("Payment-Request header missing amount field"));
199
+ }
200
+ const recipient = new import_web3.PublicKey(params.recipient);
201
+ const amountSol = parseFloat(params.amount);
202
+ if (isNaN(amountSol) || amountSol <= 0) {
203
+ throw new MppPaymentError(url, 402, new Error(`Invalid payment amount: ${params.amount}`));
204
+ }
205
+ const lamports = Math.round(amountSol * import_web3.LAMPORTS_PER_SOL);
206
+ emit({
207
+ type: "payment",
208
+ message: `Paying ${amountSol} SOL \u2192 ${params.recipient.slice(0, 8)}...`,
209
+ data: { amount: amountSol, recipient: params.recipient }
210
+ });
211
+ const { blockhash } = await connection.getLatestBlockhash("confirmed");
212
+ const tx = new import_web3.Transaction({
213
+ recentBlockhash: blockhash,
214
+ feePayer: keypair.publicKey
215
+ }).add(
216
+ import_web3.SystemProgram.transfer({
217
+ fromPubkey: keypair.publicKey,
218
+ toPubkey: recipient,
219
+ lamports
220
+ })
221
+ );
222
+ const signature = await (0, import_web3.sendAndConfirmTransaction)(connection, tx, [keypair], {
223
+ commitment: "confirmed"
224
+ });
225
+ emit({
226
+ type: "payment",
227
+ message: `Confirmed: ${signature.slice(0, 16)}...`,
228
+ data: { signature, amount: amountSol }
229
+ });
230
+ emit({
231
+ type: "retry",
232
+ message: `\u2191 Retrying with payment proof`,
233
+ data: { signature }
234
+ });
235
+ const existingHeaders = init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init?.headers ?? {};
236
+ const retryRes = await fetch(url, {
237
+ ...init,
238
+ signal,
239
+ headers: {
240
+ ...existingHeaders,
241
+ "payment-receipt": `solana; signature="${signature}"; network="${network}"; amount="${amountSol}"`
139
242
  }
140
- throw err;
141
- } finally {
142
- clearTimeout(timer);
243
+ });
244
+ emit({
245
+ type: retryRes.ok ? "success" : "error",
246
+ message: `\u2190 ${retryRes.status} ${retryRes.ok ? "OK" : retryRes.statusText}`,
247
+ data: { status: retryRes.status, signature }
248
+ });
249
+ return retryRes;
250
+ } catch (err) {
251
+ if (err instanceof Error && err.name === "AbortError") {
252
+ throw new MppTimeoutError(url, timeout);
143
253
  }
254
+ throw err;
255
+ } finally {
256
+ clearTimeout(timer);
144
257
  }
145
258
  };
146
- }
147
- function anySignal(signals) {
148
- const controller = new AbortController();
149
- for (const signal of signals) {
150
- if (signal.aborted) {
151
- controller.abort(signal.reason);
152
- return controller.signal;
153
- }
154
- signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
155
- }
156
- return controller.signal;
259
+ return {
260
+ address,
261
+ network,
262
+ method: "solana",
263
+ fetch: clientFetch
264
+ };
157
265
  }
158
266
  var _sharedClient = null;
159
267
  async function mppFetch(url, init) {
@@ -167,32 +275,112 @@ mppFetch.reset = () => {
167
275
  };
168
276
 
169
277
  // src/server.ts
170
- var import_express = require("mppx/express");
171
- var import_accounts2 = require("viem/accounts");
172
- var PATHUSD = "0x20c0000000000000000000000000000000000000";
173
- function createTestServer(config) {
174
- if (!config.secretKey) {
175
- throw new Error("createTestServer: secretKey is required");
278
+ var import_web32 = require("@solana/web3.js");
279
+ var NETWORK_RPC2 = {
280
+ devnet: "https://api.devnet.solana.com",
281
+ testnet: "https://api.testnet.solana.com",
282
+ mainnet: "https://api.mainnet-beta.solana.com"
283
+ };
284
+ function parseHeaderParams2(header) {
285
+ const params = {};
286
+ const parts = header.split(";").map((s) => s.trim());
287
+ for (const part of parts.slice(1)) {
288
+ const eqIdx = part.indexOf("=");
289
+ if (eqIdx > 0) {
290
+ const key = part.slice(0, eqIdx).trim().toLowerCase();
291
+ const val = part.slice(eqIdx + 1).trim().replace(/^"|"$/g, "");
292
+ params[key] = val;
293
+ }
176
294
  }
177
- const serverKey = config.privateKey ?? (0, import_accounts2.generatePrivateKey)();
178
- const serverAccount = (0, import_accounts2.privateKeyToAccount)(serverKey);
179
- const mppx = import_express.Mppx.create({
180
- secretKey: config.secretKey,
181
- methods: [
182
- (0, import_express.tempo)({
183
- testnet: true,
184
- currency: config.currency ?? PATHUSD,
185
- recipient: serverAccount.address,
186
- account: serverAccount
187
- })
188
- ]
189
- });
190
- return mppx;
295
+ return params;
296
+ }
297
+ function createTestServer(config = {}) {
298
+ const network = config.network ?? "devnet";
299
+ const rpcUrl = config.rpcUrl ?? NETWORK_RPC2[network];
300
+ const serverKeypair = config.secretKey ? import_web32.Keypair.fromSecretKey(config.secretKey) : import_web32.Keypair.generate();
301
+ const recipientAddress = config.recipientAddress ?? serverKeypair.publicKey.toBase58();
302
+ const connection = new import_web32.Connection(rpcUrl, "confirmed");
303
+ const charge = ({ amount }) => async (req, res, next) => {
304
+ const receiptHeader = req.headers["payment-receipt"] ?? "";
305
+ if (!receiptHeader) {
306
+ res.status(402).set(
307
+ "Payment-Request",
308
+ `solana; amount="${amount}"; recipient="${recipientAddress}"; network="${network}"`
309
+ ).json({
310
+ error: "Payment Required",
311
+ payment: {
312
+ amount,
313
+ currency: "SOL",
314
+ recipient: recipientAddress,
315
+ network
316
+ }
317
+ });
318
+ return;
319
+ }
320
+ try {
321
+ const params = parseHeaderParams2(receiptHeader);
322
+ const { signature } = params;
323
+ if (!signature) {
324
+ res.status(403).json({ error: "Payment-Receipt missing signature field" });
325
+ return;
326
+ }
327
+ const paidAmount = parseFloat(params.amount ?? "0");
328
+ const requiredAmount = parseFloat(amount);
329
+ if (isNaN(paidAmount) || paidAmount < requiredAmount) {
330
+ res.status(403).json({
331
+ error: `Insufficient payment: claimed ${params.amount ?? "0"} SOL, required ${amount} SOL`
332
+ });
333
+ return;
334
+ }
335
+ const tx = await connection.getParsedTransaction(signature, {
336
+ commitment: "confirmed",
337
+ maxSupportedTransactionVersion: 0
338
+ });
339
+ if (!tx) {
340
+ res.status(403).json({ error: "Transaction not found on chain" });
341
+ return;
342
+ }
343
+ if (tx.meta?.err) {
344
+ res.status(403).json({ error: "Transaction failed on chain" });
345
+ return;
346
+ }
347
+ const accountKeys = tx.transaction.message.accountKeys;
348
+ const preBalances = tx.meta?.preBalances ?? [];
349
+ const postBalances = tx.meta?.postBalances ?? [];
350
+ const recipientPubkey = new import_web32.PublicKey(recipientAddress);
351
+ const recipientIdx = accountKeys.findIndex(
352
+ (k) => k.pubkey.toBase58() === recipientPubkey.toBase58()
353
+ );
354
+ if (recipientIdx < 0) {
355
+ res.status(403).json({
356
+ error: `Recipient ${recipientAddress.slice(0, 8)}... not found in transaction`
357
+ });
358
+ return;
359
+ }
360
+ const received = (postBalances[recipientIdx] - preBalances[recipientIdx]) / import_web32.LAMPORTS_PER_SOL;
361
+ if (received < requiredAmount) {
362
+ res.status(403).json({
363
+ error: `Payment too small: received ${received} SOL, required ${requiredAmount} SOL`
364
+ });
365
+ return;
366
+ }
367
+ next();
368
+ } catch (err) {
369
+ const message = err instanceof Error ? err.message : "Unknown error";
370
+ res.status(403).json({ error: `Payment verification failed: ${message}` });
371
+ }
372
+ };
373
+ return {
374
+ charge,
375
+ recipientAddress,
376
+ network
377
+ };
191
378
  }
192
379
  // Annotate the CommonJS export names for ESM import in node:
193
380
  0 && (module.exports = {
194
381
  MppError,
195
382
  MppFaucetError,
383
+ MppNetworkError,
196
384
  MppPaymentError,
197
385
  MppTimeoutError,
198
386
  createTestClient,