@nullpay/mcp 1.0.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 +49 -0
- package/dist/aleo.d.ts +67 -0
- package/dist/aleo.js +515 -0
- package/dist/backend-client.d.ts +45 -0
- package/dist/backend-client.js +93 -0
- package/dist/crypto.d.ts +3 -0
- package/dist/crypto.js +66 -0
- package/dist/esm.d.ts +1 -0
- package/dist/esm.js +7 -0
- package/dist/protocol.d.ts +19 -0
- package/dist/protocol.js +120 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +111 -0
- package/dist/service.d.ts +179 -0
- package/dist/service.js +620 -0
- package/dist/session-store.d.ts +9 -0
- package/dist/session-store.js +32 -0
- package/dist/types.d.ts +110 -0
- package/dist/types.js +2 -0
- package/dist/utils/crypto.d.ts +3 -0
- package/dist/utils/crypto.js +66 -0
- package/dist/utils/env.d.ts +6 -0
- package/dist/utils/env.js +15 -0
- package/dist/utils/esm.d.ts +1 -0
- package/dist/utils/esm.js +7 -0
- package/dist/utils/formatters.d.ts +14 -0
- package/dist/utils/formatters.js +132 -0
- package/package.json +39 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NullPayMcpService = void 0;
|
|
4
|
+
const aleo_1 = require("./aleo");
|
|
5
|
+
const crypto_1 = require("./crypto");
|
|
6
|
+
const esm_1 = require("./esm");
|
|
7
|
+
function normalizeCurrency(value) {
|
|
8
|
+
const normalized = (value || 'CREDITS').toUpperCase();
|
|
9
|
+
if (normalized === 'USDCX' || normalized === 'USAD' || normalized === 'ANY') {
|
|
10
|
+
return normalized;
|
|
11
|
+
}
|
|
12
|
+
return 'CREDITS';
|
|
13
|
+
}
|
|
14
|
+
function normalizePaymentCurrency(value) {
|
|
15
|
+
if (!value) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const normalized = value.toUpperCase();
|
|
19
|
+
if (normalized === 'USDCX' || normalized === 'USAD') {
|
|
20
|
+
return normalized;
|
|
21
|
+
}
|
|
22
|
+
return 'CREDITS';
|
|
23
|
+
}
|
|
24
|
+
function normalizeInvoiceType(value) {
|
|
25
|
+
if (value === 'multipay' || value === 'donation')
|
|
26
|
+
return value;
|
|
27
|
+
return 'standard';
|
|
28
|
+
}
|
|
29
|
+
function tokenTypeLabel(tokenType) {
|
|
30
|
+
if (tokenType === 1)
|
|
31
|
+
return 'USDCX';
|
|
32
|
+
if (tokenType === 2)
|
|
33
|
+
return 'USAD';
|
|
34
|
+
if (tokenType === 3)
|
|
35
|
+
return 'ANY';
|
|
36
|
+
return 'CREDITS';
|
|
37
|
+
}
|
|
38
|
+
function invoiceTypeLabel(invoiceType) {
|
|
39
|
+
if (invoiceType === 1)
|
|
40
|
+
return 'multipay';
|
|
41
|
+
if (invoiceType === 2)
|
|
42
|
+
return 'donation';
|
|
43
|
+
return 'standard';
|
|
44
|
+
}
|
|
45
|
+
function currencyToTokenType(currency) {
|
|
46
|
+
if (currency === 'USDCX')
|
|
47
|
+
return 1;
|
|
48
|
+
if (currency === 'USAD')
|
|
49
|
+
return 2;
|
|
50
|
+
if (currency === 'ANY')
|
|
51
|
+
return 3;
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
function linkTokenToCurrency(token) {
|
|
55
|
+
if (!token) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const normalized = token.trim().toLowerCase();
|
|
59
|
+
if (normalized === 'usdcx')
|
|
60
|
+
return 'USDCX';
|
|
61
|
+
if (normalized === 'usad')
|
|
62
|
+
return 'USAD';
|
|
63
|
+
if (normalized === 'any')
|
|
64
|
+
return 'ANY';
|
|
65
|
+
return 'CREDITS';
|
|
66
|
+
}
|
|
67
|
+
function linkTypeToInvoiceType(type) {
|
|
68
|
+
if (type === 'multipay' || type === 'donation') {
|
|
69
|
+
return type;
|
|
70
|
+
}
|
|
71
|
+
return 'standard';
|
|
72
|
+
}
|
|
73
|
+
function parseAmount(value) {
|
|
74
|
+
if (typeof value === 'number') {
|
|
75
|
+
return Number.isFinite(value) ? value : undefined;
|
|
76
|
+
}
|
|
77
|
+
if (!value) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const parsed = Number(value);
|
|
81
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
82
|
+
}
|
|
83
|
+
function shouldMarkInvoiceSettled(invoiceType) {
|
|
84
|
+
return invoiceType !== 1 && invoiceType !== 2;
|
|
85
|
+
}
|
|
86
|
+
function formatInvoiceSummary(invoice) {
|
|
87
|
+
const paymentIds = Array.isArray(invoice.payment_tx_ids) && invoice.payment_tx_ids.length > 0
|
|
88
|
+
? invoice.payment_tx_ids.join(', ')
|
|
89
|
+
: 'none';
|
|
90
|
+
const amount = invoice.amount ?? 0;
|
|
91
|
+
return [
|
|
92
|
+
`invoice=${invoice.invoice_hash}`,
|
|
93
|
+
`status=${invoice.status}`,
|
|
94
|
+
`amount=${amount}`,
|
|
95
|
+
`token=${tokenTypeLabel(invoice.token_type)}`,
|
|
96
|
+
`type=${invoiceTypeLabel(invoice.invoice_type)}`,
|
|
97
|
+
`created=${invoice.created_at || 'unknown'}`,
|
|
98
|
+
`invoice_tx=${invoice.invoice_transaction_id || 'none'}`,
|
|
99
|
+
`payment_txs=${paymentIds}`
|
|
100
|
+
].join(' | ');
|
|
101
|
+
}
|
|
102
|
+
function getAmountSource(invoice) {
|
|
103
|
+
if (typeof invoice.amount_micro === 'number') {
|
|
104
|
+
return 'record';
|
|
105
|
+
}
|
|
106
|
+
if (typeof invoice.amount === 'number' && invoice.amount > 0) {
|
|
107
|
+
return 'database';
|
|
108
|
+
}
|
|
109
|
+
return 'missing';
|
|
110
|
+
}
|
|
111
|
+
function buildAmountLookupHint(invoice, hasInvoiceLookupKey) {
|
|
112
|
+
const amountSource = getAmountSource(invoice);
|
|
113
|
+
if (amountSource === 'record') {
|
|
114
|
+
return ' | amount_source=record';
|
|
115
|
+
}
|
|
116
|
+
if (amountSource === 'database') {
|
|
117
|
+
return ' | amount_source=database';
|
|
118
|
+
}
|
|
119
|
+
if (hasInvoiceLookupKey) {
|
|
120
|
+
return ' | amount_source=missing | record_lookup=not_found_or_unreadable_for_selected_wallet';
|
|
121
|
+
}
|
|
122
|
+
return ' | amount_source=db_only (private key missing to fetch record-backed amount)';
|
|
123
|
+
}
|
|
124
|
+
function readEnvTrimmed(name) {
|
|
125
|
+
const value = process.env[name]?.trim();
|
|
126
|
+
return value ? value : undefined;
|
|
127
|
+
}
|
|
128
|
+
function getMainWalletEnv() {
|
|
129
|
+
return {
|
|
130
|
+
address: readEnvTrimmed('NULLPAY_MAIN_ADDRESS'),
|
|
131
|
+
password: readEnvTrimmed('NULLPAY_MAIN_PASSWORD'),
|
|
132
|
+
privateKey: readEnvTrimmed('NULLPAY_MAIN_PRIVATE_KEY') || readEnvTrimmed('NULLPAY_MAIN_PVT_KEY')
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
class NullPayMcpService {
|
|
136
|
+
constructor(backend, sessions, publicBaseUrl) {
|
|
137
|
+
this.backend = backend;
|
|
138
|
+
this.sessions = sessions;
|
|
139
|
+
this.publicBaseUrl = publicBaseUrl;
|
|
140
|
+
}
|
|
141
|
+
listTools() {
|
|
142
|
+
return [
|
|
143
|
+
{
|
|
144
|
+
name: 'login',
|
|
145
|
+
description: 'Login to NullPay, validate password, create burner wallet, or switch active wallet. If NULLPAY_MAIN_ADDRESS and NULLPAY_MAIN_PASSWORD are configured, call this tool with empty arguments and do not ask the user to share secrets in chat. The MCP server can also read NULLPAY_MAIN_PRIVATE_KEY from env without exposing it to the model.',
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
address: { type: 'string', description: 'User main Aleo address. Optional. If NULLPAY_MAIN_ADDRESS is set in env, use that and do not ask the user for it.' },
|
|
150
|
+
password: { type: 'string', description: 'User NullPay password. Optional. If NULLPAY_MAIN_PASSWORD is set in env, use that and do not ask the user for it.' },
|
|
151
|
+
main_private_key: { type: 'string', description: 'Optional direct main wallet private key. Prefer NULLPAY_MAIN_PRIVATE_KEY in env so the model never sees it.' },
|
|
152
|
+
create_burner_wallet: { type: 'boolean', description: 'Generate and store a burner wallet when missing.' },
|
|
153
|
+
wallet_preference: { type: 'string', enum: ['main', 'burner'], description: 'Select active wallet for later tool calls.' }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'create_invoice',
|
|
159
|
+
description: 'Create a NullPay invoice using the active main or burner wallet address.',
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
amount: { type: 'number' },
|
|
164
|
+
currency: { type: 'string', enum: ['CREDITS', 'USDCX', 'USAD', 'ANY'] },
|
|
165
|
+
memo: { type: 'string' },
|
|
166
|
+
invoice_type: { type: 'string', enum: ['standard', 'multipay', 'donation'] },
|
|
167
|
+
wallet: { type: 'string', enum: ['main', 'burner'] },
|
|
168
|
+
line_items: { type: 'array' }
|
|
169
|
+
},
|
|
170
|
+
required: ['amount']
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'pay_invoice',
|
|
175
|
+
description: 'Pay a NullPay payment link or invoice from the selected wallet. Prefer providing the full payment link so the MCP can use the merchant address, amount, salt, token, and session id exactly like the original checkout flow.',
|
|
176
|
+
inputSchema: {
|
|
177
|
+
type: 'object',
|
|
178
|
+
properties: {
|
|
179
|
+
payment_link: { type: 'string', description: 'Full NullPay payment link, for example https://nullpay.app/pay?...' },
|
|
180
|
+
invoice_hash: { type: 'string', description: 'Optional invoice hash if you do not provide the full payment link.' },
|
|
181
|
+
wallet: { type: 'string', enum: ['main', 'burner'] },
|
|
182
|
+
amount: { type: 'number', description: 'Optional override. If payment_link is present, its amount is used by default.' },
|
|
183
|
+
currency: { type: 'string', enum: ['CREDITS', 'USDCX', 'USAD'], description: 'Optional override. Required only when the link/invoice allows ANY token.' },
|
|
184
|
+
session_id: { type: 'string', description: 'Optional hosted-checkout session id if it is not already present in the link.' }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'get_transaction_info',
|
|
190
|
+
description: 'Get one invoice by hash or list recent transactions for the active wallet. When available, use the main-wallet private key to enrich invoice amounts from private records.',
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
properties: {
|
|
194
|
+
invoice_hash: { type: 'string' },
|
|
195
|
+
wallet: { type: 'string', enum: ['main', 'burner'] },
|
|
196
|
+
limit: { type: 'number' }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
async callTool(name, args) {
|
|
203
|
+
try {
|
|
204
|
+
if (name === 'login')
|
|
205
|
+
return await this.login(args);
|
|
206
|
+
if (name === 'create_invoice')
|
|
207
|
+
return await this.createInvoice(args);
|
|
208
|
+
if (name === 'pay_invoice')
|
|
209
|
+
return await this.payInvoice(args);
|
|
210
|
+
if (name === 'get_transaction_info')
|
|
211
|
+
return await this.getTransactionInfo(args);
|
|
212
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: 'text', text: message }],
|
|
218
|
+
isError: true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async login(args) {
|
|
223
|
+
const envMain = getMainWalletEnv();
|
|
224
|
+
const address = (args.address || envMain.address || '').trim();
|
|
225
|
+
const password = args.password || envMain.password || '';
|
|
226
|
+
const mainPrivateKey = args.main_private_key || envMain.privateKey || null;
|
|
227
|
+
if (!address || !password) {
|
|
228
|
+
throw new Error('Address and password are required. You can pass them directly or set NULLPAY_MAIN_ADDRESS and NULLPAY_MAIN_PASSWORD in env.');
|
|
229
|
+
}
|
|
230
|
+
const addressHash = (0, crypto_1.hashAddress)(address);
|
|
231
|
+
const existingProfile = await this.backend.getUserProfile(addressHash);
|
|
232
|
+
let encryptedMainAddress = existingProfile?.main_address || null;
|
|
233
|
+
if (encryptedMainAddress) {
|
|
234
|
+
const decrypted = await (0, crypto_1.decryptWithPassword)(encryptedMainAddress, password);
|
|
235
|
+
if (decrypted !== address) {
|
|
236
|
+
throw new Error('Password is incorrect for this NullPay account.');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
encryptedMainAddress = await (0, crypto_1.encryptWithPassword)(address, password);
|
|
241
|
+
}
|
|
242
|
+
if (!encryptedMainAddress) {
|
|
243
|
+
throw new Error('Main wallet address encryption failed.');
|
|
244
|
+
}
|
|
245
|
+
let encryptedBurnerAddress = existingProfile?.burner_address || null;
|
|
246
|
+
let encryptedBurnerKey = existingProfile?.encrypted_burner_key || null;
|
|
247
|
+
let burnerAddress = null;
|
|
248
|
+
if (encryptedBurnerAddress) {
|
|
249
|
+
burnerAddress = await (0, crypto_1.decryptWithPassword)(encryptedBurnerAddress, password);
|
|
250
|
+
}
|
|
251
|
+
if (args.create_burner_wallet && !encryptedBurnerKey) {
|
|
252
|
+
const { PrivateKey } = await (0, esm_1.dynamicImport)('@provablehq/sdk');
|
|
253
|
+
const burnerPrivateKey = new PrivateKey();
|
|
254
|
+
const nextBurnerAddress = burnerPrivateKey.to_address().to_string();
|
|
255
|
+
burnerAddress = nextBurnerAddress;
|
|
256
|
+
encryptedBurnerAddress = await (0, crypto_1.encryptWithPassword)(nextBurnerAddress, password);
|
|
257
|
+
encryptedBurnerKey = await (0, crypto_1.encryptWithPassword)(burnerPrivateKey.to_string(), password);
|
|
258
|
+
}
|
|
259
|
+
await this.backend.upsertUserProfile({
|
|
260
|
+
address_hash: addressHash,
|
|
261
|
+
main_address: encryptedMainAddress,
|
|
262
|
+
burner_address: encryptedBurnerAddress,
|
|
263
|
+
encrypted_burner_key: encryptedBurnerKey,
|
|
264
|
+
});
|
|
265
|
+
const preferredWallet = (args.wallet_preference || (mainPrivateKey ? 'main' : (encryptedBurnerKey ? 'burner' : 'main')));
|
|
266
|
+
if (preferredWallet === 'burner' && !encryptedBurnerKey) {
|
|
267
|
+
throw new Error('Burner wallet is not available yet. Create one first or switch to main.');
|
|
268
|
+
}
|
|
269
|
+
const now = new Date().toISOString();
|
|
270
|
+
this.sessions.set({
|
|
271
|
+
address,
|
|
272
|
+
addressHash,
|
|
273
|
+
password,
|
|
274
|
+
activeWallet: preferredWallet,
|
|
275
|
+
encryptedMainAddress,
|
|
276
|
+
encryptedBurnerAddress,
|
|
277
|
+
encryptedBurnerKey,
|
|
278
|
+
mainPrivateKey,
|
|
279
|
+
hasMainPrivateKeyInEnv: Boolean(envMain.privateKey),
|
|
280
|
+
createdAt: now,
|
|
281
|
+
updatedAt: now,
|
|
282
|
+
});
|
|
283
|
+
const usedEnvCredentials = Boolean(envMain.address && envMain.password && !args.address && !args.password);
|
|
284
|
+
const lines = [
|
|
285
|
+
usedEnvCredentials ? 'Used main-wallet address and password from MCP env.' : `Logged in as ${address}.`,
|
|
286
|
+
encryptedBurnerKey
|
|
287
|
+
? `Active wallet: ${preferredWallet}. Burner wallet is available${burnerAddress ? ` at ${burnerAddress}` : ''}.`
|
|
288
|
+
: 'Active wallet: main. No burner wallet is stored yet.',
|
|
289
|
+
];
|
|
290
|
+
if (mainPrivateKey) {
|
|
291
|
+
lines.push('Main wallet private key is available for record-backed amount lookup and main-wallet payments. Active wallet is set to main by default, and you can switch to burner anytime by logging in again with wallet_preference set to burner. Invoice lookup will prefer the main wallet records even when you pay from burner.');
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
lines.push('Set NULLPAY_MAIN_PRIVATE_KEY in the MCP server env to let NullPay fetch invoice amounts from your main-wallet records and pay from your main wallet without exposing the key to the model.');
|
|
295
|
+
}
|
|
296
|
+
if (!encryptedBurnerKey) {
|
|
297
|
+
lines.push('Recommended next step: create a burner wallet for private automated payments.');
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
content: [{ type: 'text', text: lines.join(' ') }],
|
|
301
|
+
structuredContent: {
|
|
302
|
+
address,
|
|
303
|
+
active_wallet: preferredWallet,
|
|
304
|
+
has_burner_wallet: Boolean(encryptedBurnerKey),
|
|
305
|
+
burner_address: burnerAddress,
|
|
306
|
+
has_main_private_key: Boolean(mainPrivateKey),
|
|
307
|
+
main_private_key_from_env: Boolean(envMain.privateKey),
|
|
308
|
+
used_env_credentials: usedEnvCredentials,
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
async createInvoice(args) {
|
|
313
|
+
const wallet = this.resolveWallet(args.wallet);
|
|
314
|
+
const currency = normalizeCurrency(args.currency);
|
|
315
|
+
const invoiceType = normalizeInvoiceType(args.invoice_type);
|
|
316
|
+
if (invoiceType !== 'donation' && (!args.amount || args.amount <= 0)) {
|
|
317
|
+
throw new Error('Amount must be greater than zero for standard or multipay invoices.');
|
|
318
|
+
}
|
|
319
|
+
const merchantAddress = await this.resolveWalletAddress(wallet);
|
|
320
|
+
const salt = (0, aleo_1.generateSalt)();
|
|
321
|
+
const relayResponse = await this.backend.relayCreateInvoice({
|
|
322
|
+
merchant_address: merchantAddress,
|
|
323
|
+
amount: invoiceType === 'donation' ? 0 : Number(args.amount),
|
|
324
|
+
currency,
|
|
325
|
+
salt,
|
|
326
|
+
memo: args.memo,
|
|
327
|
+
invoice_type: (0, aleo_1.invoiceTypeToNumber)(invoiceType),
|
|
328
|
+
});
|
|
329
|
+
const invoiceHash = await (0, aleo_1.waitForInvoiceHash)(salt);
|
|
330
|
+
await this.backend.createInvoiceRow((0, aleo_1.createInvoiceDbRecord)({
|
|
331
|
+
invoiceHash,
|
|
332
|
+
merchantAddress,
|
|
333
|
+
amount: invoiceType === 'donation' ? 0 : Number(args.amount),
|
|
334
|
+
memo: args.memo,
|
|
335
|
+
invoiceType,
|
|
336
|
+
currency,
|
|
337
|
+
salt,
|
|
338
|
+
invoiceTxId: relayResponse.tx_id,
|
|
339
|
+
wallet,
|
|
340
|
+
lineItems: args.line_items,
|
|
341
|
+
merchantAddressHash: (0, crypto_1.hashAddress)(merchantAddress),
|
|
342
|
+
}));
|
|
343
|
+
const paymentLink = (0, aleo_1.buildPaymentLink)(this.publicBaseUrl, {
|
|
344
|
+
merchant: merchantAddress,
|
|
345
|
+
amount: invoiceType === 'donation' ? 0 : Number(args.amount),
|
|
346
|
+
salt,
|
|
347
|
+
memo: args.memo,
|
|
348
|
+
invoiceType,
|
|
349
|
+
currency,
|
|
350
|
+
invoiceHash,
|
|
351
|
+
});
|
|
352
|
+
return {
|
|
353
|
+
content: [{
|
|
354
|
+
type: 'text',
|
|
355
|
+
text: `Invoice created with hash ${invoiceHash}. Active wallet ${wallet} was used. Payment link: ${paymentLink}`
|
|
356
|
+
}],
|
|
357
|
+
structuredContent: {
|
|
358
|
+
invoice_hash: invoiceHash,
|
|
359
|
+
invoice_transaction_id: relayResponse.tx_id,
|
|
360
|
+
wallet,
|
|
361
|
+
merchant_address: merchantAddress,
|
|
362
|
+
payment_link: paymentLink,
|
|
363
|
+
currency,
|
|
364
|
+
invoice_type: invoiceType,
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
async payInvoice(args) {
|
|
369
|
+
const wallet = this.resolveWallet(args.wallet);
|
|
370
|
+
const walletPrivateKey = await this.resolveWalletPrivateKey(wallet);
|
|
371
|
+
const resolved = await this.resolvePayInvoiceContext(args, wallet);
|
|
372
|
+
const { invoice, sessionId, source } = resolved;
|
|
373
|
+
const { authorization } = await (0, aleo_1.createSponsoredPaymentAuthorization)({
|
|
374
|
+
walletPrivateKey,
|
|
375
|
+
invoice,
|
|
376
|
+
amount: args.amount,
|
|
377
|
+
currency: normalizePaymentCurrency(args.currency),
|
|
378
|
+
});
|
|
379
|
+
const sponsored = await this.backend.sponsorExecution({
|
|
380
|
+
execution_authorization_string: authorization,
|
|
381
|
+
programName: 'zk_pay_proofs_privacy_v22.aleo',
|
|
382
|
+
});
|
|
383
|
+
const txId = sponsored.transaction?.id;
|
|
384
|
+
if (!txId) {
|
|
385
|
+
throw new Error('Sponsored payment did not return a transaction id.');
|
|
386
|
+
}
|
|
387
|
+
const invoiceUpdate = {
|
|
388
|
+
payment_tx_ids: [txId],
|
|
389
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
390
|
+
};
|
|
391
|
+
if (shouldMarkInvoiceSettled(invoice.invoice_type)) {
|
|
392
|
+
invoiceUpdate.status = 'SETTLED';
|
|
393
|
+
}
|
|
394
|
+
await this.backend.updateInvoice(invoice.invoice_hash, invoiceUpdate);
|
|
395
|
+
if (sessionId) {
|
|
396
|
+
await this.backend.updateCheckoutSession(sessionId, {
|
|
397
|
+
status: 'SETTLED',
|
|
398
|
+
tx_id: txId,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
const invoiceStatusNote = shouldMarkInvoiceSettled(invoice.invoice_type)
|
|
402
|
+
? 'Invoice status updated to SETTLED.'
|
|
403
|
+
: 'Payment recorded on the invoice while keeping the invoice open for additional multipay/donation activity.';
|
|
404
|
+
return {
|
|
405
|
+
content: [{
|
|
406
|
+
type: 'text',
|
|
407
|
+
text: `Invoice ${invoice.invoice_hash} was paid from ${wallet} wallet using ${invoice.amount ?? args.amount ?? 0} ${tokenTypeLabel(invoice.token_type)} to ${invoice.designated_address || invoice.merchant_address}. ${invoiceStatusNote}`
|
|
408
|
+
}],
|
|
409
|
+
structuredContent: {
|
|
410
|
+
invoice_hash: invoice.invoice_hash,
|
|
411
|
+
payment_tx_id: txId,
|
|
412
|
+
wallet,
|
|
413
|
+
amount: invoice.amount ?? args.amount ?? null,
|
|
414
|
+
token: tokenTypeLabel(invoice.token_type),
|
|
415
|
+
amount_source: getAmountSource(invoice),
|
|
416
|
+
payment_source: source,
|
|
417
|
+
session_id: sessionId || null,
|
|
418
|
+
invoice_status_updated_to: shouldMarkInvoiceSettled(invoice.invoice_type) ? 'SETTLED' : 'PENDING',
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
async getTransactionInfo(args) {
|
|
423
|
+
const wallet = this.resolveWallet(args.wallet);
|
|
424
|
+
const walletAddress = await this.resolveWalletAddress(wallet);
|
|
425
|
+
const walletPrivateKey = await this.resolveWalletPrivateKeyOptional(wallet);
|
|
426
|
+
const invoiceLookupPrivateKey = await this.resolveInvoiceLookupPrivateKey(wallet);
|
|
427
|
+
if (args.invoice_hash) {
|
|
428
|
+
const invoice = await this.getEnrichedInvoice(args.invoice_hash, invoiceLookupPrivateKey);
|
|
429
|
+
const onChain = await (0, aleo_1.getInvoiceStatusData)(args.invoice_hash);
|
|
430
|
+
const onChainStatus = onChain ? ` | on_chain_status=${onChain.status} | on_chain_token=${tokenTypeLabel(onChain.tokenType)} | on_chain_type=${invoiceTypeLabel(onChain.invoiceType)}` : '';
|
|
431
|
+
const amountHint = buildAmountLookupHint(invoice, Boolean(invoiceLookupPrivateKey));
|
|
432
|
+
const missingAmountNote = getAmountSource(invoice) === 'missing'
|
|
433
|
+
? ' | note=invoice_amount_not_recovered_automatically; multipay invoices may still have a fixed amount; missing amount here indicates lookup failure, not a donation-style free amount'
|
|
434
|
+
: '';
|
|
435
|
+
return {
|
|
436
|
+
content: [{
|
|
437
|
+
type: 'text',
|
|
438
|
+
text: `Transaction details: ${formatInvoiceSummary(invoice)}${onChainStatus}${amountHint}${missingAmountNote}`
|
|
439
|
+
}],
|
|
440
|
+
structuredContent: {
|
|
441
|
+
invoice,
|
|
442
|
+
on_chain: onChain,
|
|
443
|
+
amount_source: getAmountSource(invoice),
|
|
444
|
+
has_wallet_private_key: Boolean(walletPrivateKey),
|
|
445
|
+
has_invoice_lookup_key: Boolean(invoiceLookupPrivateKey),
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
const invoices = await this.backend.getMerchantInvoices((0, crypto_1.hashAddress)(walletAddress));
|
|
450
|
+
const limit = Math.max(1, Math.min(Number(args.limit || 10), 50));
|
|
451
|
+
const recentBase = invoices.slice(0, limit);
|
|
452
|
+
const recent = await Promise.all(recentBase.map((invoice) => this.enrichInvoiceIfPossible(invoice, invoiceLookupPrivateKey)));
|
|
453
|
+
const summaries = recent.map((invoice, index) => `${index + 1}. ${formatInvoiceSummary(invoice)}${buildAmountLookupHint(invoice, Boolean(invoiceLookupPrivateKey))}`);
|
|
454
|
+
const summaryText = recent.length > 0
|
|
455
|
+
? `Last ${recent.length} transactions for ${wallet} wallet ${walletAddress}:\n${summaries.join('\n')}`
|
|
456
|
+
: `No transactions found for ${wallet} wallet ${walletAddress}.`;
|
|
457
|
+
return {
|
|
458
|
+
content: [{
|
|
459
|
+
type: 'text',
|
|
460
|
+
text: summaryText
|
|
461
|
+
}],
|
|
462
|
+
structuredContent: {
|
|
463
|
+
wallet,
|
|
464
|
+
wallet_address: walletAddress,
|
|
465
|
+
invoices: recent,
|
|
466
|
+
has_wallet_private_key: Boolean(walletPrivateKey),
|
|
467
|
+
has_invoice_lookup_key: Boolean(invoiceLookupPrivateKey),
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
resolveWallet(wallet) {
|
|
472
|
+
const session = this.sessions.require();
|
|
473
|
+
const target = wallet || session.activeWallet;
|
|
474
|
+
if (target === 'burner' && !session.encryptedBurnerKey) {
|
|
475
|
+
throw new Error('Burner wallet is not available. Create one first or use main wallet.');
|
|
476
|
+
}
|
|
477
|
+
if (target !== session.activeWallet) {
|
|
478
|
+
this.sessions.updateWallet(target);
|
|
479
|
+
}
|
|
480
|
+
return target;
|
|
481
|
+
}
|
|
482
|
+
async resolveWalletAddress(wallet) {
|
|
483
|
+
const session = this.sessions.require();
|
|
484
|
+
if (wallet === 'burner') {
|
|
485
|
+
if (!session.encryptedBurnerAddress) {
|
|
486
|
+
throw new Error('Burner wallet address is not stored.');
|
|
487
|
+
}
|
|
488
|
+
return await (0, crypto_1.decryptWithPassword)(session.encryptedBurnerAddress, session.password);
|
|
489
|
+
}
|
|
490
|
+
return session.address;
|
|
491
|
+
}
|
|
492
|
+
async resolveWalletPrivateKey(wallet) {
|
|
493
|
+
const key = await this.resolveWalletPrivateKeyOptional(wallet);
|
|
494
|
+
if (!key) {
|
|
495
|
+
if (wallet === 'main') {
|
|
496
|
+
throw new Error('Main wallet private key is not available. Set NULLPAY_MAIN_PRIVATE_KEY in env or log in with main_private_key so NullPay can fetch amounts from records and pay from your main wallet.');
|
|
497
|
+
}
|
|
498
|
+
throw new Error('Burner wallet private key is not available. Create a burner wallet first.');
|
|
499
|
+
}
|
|
500
|
+
return key;
|
|
501
|
+
}
|
|
502
|
+
async resolveWalletPrivateKeyOptional(wallet) {
|
|
503
|
+
const session = this.sessions.require();
|
|
504
|
+
if (wallet === 'main') {
|
|
505
|
+
return session.mainPrivateKey || null;
|
|
506
|
+
}
|
|
507
|
+
if (!session.encryptedBurnerKey) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
return await (0, crypto_1.decryptWithPassword)(session.encryptedBurnerKey, session.password);
|
|
511
|
+
}
|
|
512
|
+
async resolveInvoiceLookupPrivateKey(wallet) {
|
|
513
|
+
const session = this.sessions.require();
|
|
514
|
+
if (session.mainPrivateKey) {
|
|
515
|
+
return session.mainPrivateKey;
|
|
516
|
+
}
|
|
517
|
+
const targetWallet = wallet || session.activeWallet;
|
|
518
|
+
return await this.resolveWalletPrivateKeyOptional(targetWallet);
|
|
519
|
+
}
|
|
520
|
+
async resolvePayInvoiceContext(args, wallet) {
|
|
521
|
+
const paymentLink = args.payment_link?.trim();
|
|
522
|
+
const explicitCurrency = normalizePaymentCurrency(args.currency);
|
|
523
|
+
if (paymentLink) {
|
|
524
|
+
const parsedUrl = new URL(paymentLink);
|
|
525
|
+
const saltFromLink = parsedUrl.searchParams.get('salt') || undefined;
|
|
526
|
+
let invoiceHash = (args.invoice_hash || parsedUrl.searchParams.get('hash') || '').trim();
|
|
527
|
+
if (!invoiceHash && saltFromLink) {
|
|
528
|
+
try {
|
|
529
|
+
invoiceHash = await (0, aleo_1.waitForInvoiceHash)(saltFromLink, 5, 1000);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
invoiceHash = '';
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (!invoiceHash) {
|
|
536
|
+
throw new Error('Payment link is missing invoice hash and the MCP could not resolve it from salt.');
|
|
537
|
+
}
|
|
538
|
+
let dbInvoice = null;
|
|
539
|
+
try {
|
|
540
|
+
dbInvoice = await this.backend.getInvoice(invoiceHash);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
dbInvoice = null;
|
|
544
|
+
}
|
|
545
|
+
const linkAmount = parseAmount(parsedUrl.searchParams.get('amount'));
|
|
546
|
+
const linkCurrency = linkTokenToCurrency(parsedUrl.searchParams.get('token'));
|
|
547
|
+
const linkInvoiceType = linkTypeToInvoiceType(parsedUrl.searchParams.get('type'));
|
|
548
|
+
const effectiveCurrency = explicitCurrency || (linkCurrency && linkCurrency !== 'ANY' ? linkCurrency : undefined);
|
|
549
|
+
const effectiveInvoiceType = dbInvoice?.invoice_type ?? (0, aleo_1.invoiceTypeToNumber)(linkInvoiceType);
|
|
550
|
+
const amount = args.amount ?? linkAmount ?? dbInvoice?.amount ?? undefined;
|
|
551
|
+
const merchantAddress = parsedUrl.searchParams.get('merchant') || dbInvoice?.designated_address || dbInvoice?.merchant_address || undefined;
|
|
552
|
+
const salt = saltFromLink || dbInvoice?.salt || undefined;
|
|
553
|
+
const sessionId = args.session_id || parsedUrl.searchParams.get('session_id') || undefined;
|
|
554
|
+
const tokenType = dbInvoice?.token_type ?? (effectiveCurrency ? currencyToTokenType(effectiveCurrency) : currencyToTokenType(linkCurrency || 'CREDITS'));
|
|
555
|
+
if (!merchantAddress || !merchantAddress.startsWith('aleo')) {
|
|
556
|
+
throw new Error('Payment link is missing a valid merchant address.');
|
|
557
|
+
}
|
|
558
|
+
if (!salt) {
|
|
559
|
+
throw new Error('Payment link is missing the invoice salt.');
|
|
560
|
+
}
|
|
561
|
+
if ((linkCurrency === 'ANY' || dbInvoice?.token_type === 3) && !explicitCurrency) {
|
|
562
|
+
throw new Error('This payment link accepts ANY token. Specify currency as CREDITS, USDCX, or USAD when calling pay_invoice.');
|
|
563
|
+
}
|
|
564
|
+
if (typeof amount !== 'number' || amount <= 0) {
|
|
565
|
+
throw new Error('Payment link does not contain a usable amount. Pass amount explicitly if needed.');
|
|
566
|
+
}
|
|
567
|
+
const invoice = {
|
|
568
|
+
invoice_hash: invoiceHash,
|
|
569
|
+
merchant_address: merchantAddress,
|
|
570
|
+
designated_address: merchantAddress,
|
|
571
|
+
merchant_address_hash: dbInvoice?.merchant_address_hash || null,
|
|
572
|
+
is_burner: dbInvoice?.is_burner,
|
|
573
|
+
amount,
|
|
574
|
+
amount_micro: Math.round(amount * 1000000),
|
|
575
|
+
memo: dbInvoice?.memo || parsedUrl.searchParams.get('memo') || null,
|
|
576
|
+
status: dbInvoice?.status || 'PENDING',
|
|
577
|
+
invoice_transaction_id: dbInvoice?.invoice_transaction_id || null,
|
|
578
|
+
payment_tx_ids: dbInvoice?.payment_tx_ids || null,
|
|
579
|
+
created_at: dbInvoice?.created_at,
|
|
580
|
+
updated_at: dbInvoice?.updated_at,
|
|
581
|
+
salt,
|
|
582
|
+
invoice_type: effectiveInvoiceType,
|
|
583
|
+
token_type: tokenType,
|
|
584
|
+
invoice_items: dbInvoice?.invoice_items || null,
|
|
585
|
+
for_sdk: dbInvoice?.for_sdk,
|
|
586
|
+
};
|
|
587
|
+
return { invoice, sessionId, source: 'payment_link' };
|
|
588
|
+
}
|
|
589
|
+
if (!args.invoice_hash) {
|
|
590
|
+
throw new Error('Provide either payment_link or invoice_hash to pay an invoice.');
|
|
591
|
+
}
|
|
592
|
+
const invoiceLookupPrivateKey = await this.resolveInvoiceLookupPrivateKey(wallet);
|
|
593
|
+
const invoice = await this.getEnrichedInvoice(args.invoice_hash, invoiceLookupPrivateKey);
|
|
594
|
+
if (getAmountSource(invoice) === 'missing' && args.amount === undefined) {
|
|
595
|
+
throw new Error('Invoice amount could not be recovered automatically. Paste the full payment link instead so NullPay can use the merchant address, amount, and salt directly, or provide amount explicitly.');
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
invoice: {
|
|
599
|
+
...invoice,
|
|
600
|
+
amount: args.amount ?? invoice.amount,
|
|
601
|
+
token_type: invoice.token_type ?? (explicitCurrency ? currencyToTokenType(explicitCurrency) : invoice.token_type),
|
|
602
|
+
},
|
|
603
|
+
sessionId: args.session_id,
|
|
604
|
+
source: 'invoice_hash',
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
async enrichInvoiceIfPossible(invoice, walletPrivateKey) {
|
|
608
|
+
try {
|
|
609
|
+
return await (0, aleo_1.enrichInvoiceWithRecordAmount)(invoice, walletPrivateKey);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return invoice;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async getEnrichedInvoice(invoiceHash, walletPrivateKey) {
|
|
616
|
+
const invoice = await this.backend.getInvoice(invoiceHash);
|
|
617
|
+
return await this.enrichInvoiceIfPossible(invoice, walletPrivateKey);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
exports.NullPayMcpService = NullPayMcpService;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SessionState, WalletPreference } from './types';
|
|
2
|
+
export declare class SessionStore {
|
|
3
|
+
private session;
|
|
4
|
+
get(): SessionState | null;
|
|
5
|
+
require(): SessionState;
|
|
6
|
+
set(session: SessionState): SessionState;
|
|
7
|
+
updateWallet(activeWallet: WalletPreference): SessionState;
|
|
8
|
+
clear(): void;
|
|
9
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SessionStore = void 0;
|
|
4
|
+
class SessionStore {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.session = null;
|
|
7
|
+
}
|
|
8
|
+
get() {
|
|
9
|
+
return this.session;
|
|
10
|
+
}
|
|
11
|
+
require() {
|
|
12
|
+
if (!this.session) {
|
|
13
|
+
throw new Error('No active NullPay session. Call login first.');
|
|
14
|
+
}
|
|
15
|
+
return this.session;
|
|
16
|
+
}
|
|
17
|
+
set(session) {
|
|
18
|
+
this.session = session;
|
|
19
|
+
return session;
|
|
20
|
+
}
|
|
21
|
+
updateWallet(activeWallet) {
|
|
22
|
+
const current = this.require();
|
|
23
|
+
current.activeWallet = activeWallet;
|
|
24
|
+
current.updatedAt = new Date().toISOString();
|
|
25
|
+
this.session = current;
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
clear() {
|
|
29
|
+
this.session = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.SessionStore = SessionStore;
|