@profullstack/coinpay 0.6.11 → 0.9.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/CHANGELOG.md +39 -0
- package/README.md +189 -0
- package/bin/coinpay.js +1869 -93
- package/package.json +41 -9
- package/src/auth.js +2 -1
- package/src/card-payments.js +23 -14
- package/src/client.d.ts +52 -0
- package/src/client.js +93 -9
- package/src/finances-tui.js +1005 -0
- package/src/finances.d.ts +333 -0
- package/src/finances.js +445 -0
- package/src/index.d.ts +37 -1
- package/src/index.js +67 -0
- package/src/invoices.d.ts +78 -0
- package/src/invoices.js +250 -0
- package/src/lightning.js +8 -3
- package/src/swap.js +36 -5
- package/src/taskmarket.js +329 -0
- package/src/tokens.d.ts +14 -0
- package/src/tokens.js +34 -0
- package/src/wallet.d.ts +11 -0
- package/src/wallet.js +283 -25
- package/src/webhooks.js +37 -3
- package/src/x402-browser.js +318 -0
- package/src/x402-pay-server.js +272 -0
- package/src/x402-v2.js +416 -0
- package/src/x402.js +164 -19
package/src/invoices.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invoice SDK Module
|
|
3
|
+
*
|
|
4
|
+
* Create, manage, and send invoices with crypto + card payment support.
|
|
5
|
+
* Invoices can be sent to clients who pay via generated crypto addresses
|
|
6
|
+
* or optional Stripe checkout.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { CoinPayClient } from '@profullstack/coinpay';
|
|
10
|
+
* import { createInvoice, publishInvoice, sendInvoice, getInvoicePaymentData } from '@profullstack/coinpay';
|
|
11
|
+
*
|
|
12
|
+
* const client = new CoinPayClient({ apiKey: 'your-key' });
|
|
13
|
+
*
|
|
14
|
+
* // Create an invoice
|
|
15
|
+
* const invoice = await createInvoice(client, {
|
|
16
|
+
* businessId: 'biz_123',
|
|
17
|
+
* currency: 'USD',
|
|
18
|
+
* amount: 250.00,
|
|
19
|
+
* cryptoCurrency: 'ETH',
|
|
20
|
+
* dueDate: '2026-04-01',
|
|
21
|
+
* notes: 'Web development - March 2026',
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // Send it to the client
|
|
25
|
+
* const sent = await sendInvoice(client, invoice.id);
|
|
26
|
+
*
|
|
27
|
+
* // Public: get payment data (no auth)
|
|
28
|
+
* const paymentData = await getInvoicePaymentData(client, invoice.id);
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a new invoice
|
|
33
|
+
* @param {CoinPayClient} client - API client instance
|
|
34
|
+
* @param {Object} options - Invoice parameters
|
|
35
|
+
* @param {string} [options.businessId] - Business ID (optional for business-scoped API keys)
|
|
36
|
+
* @param {string} [options.clientId] - Client ID (optional)
|
|
37
|
+
* @param {string} options.currency - Fiat currency code (USD, EUR, etc.)
|
|
38
|
+
* @param {number} options.amount - Invoice amount
|
|
39
|
+
* @param {string} [options.cryptoCurrency] - Preferred crypto for payment
|
|
40
|
+
* @param {string} [options.dueDate] - Due date (ISO string or YYYY-MM-DD)
|
|
41
|
+
* @param {string} [options.notes] - Notes / description
|
|
42
|
+
* @param {string} [options.walletId] - Wallet ID for receiving payment
|
|
43
|
+
* @param {string} [options.merchantWalletAddress] - Direct wallet address for receiving payment
|
|
44
|
+
* @returns {Promise<Object>} Created invoice
|
|
45
|
+
*/
|
|
46
|
+
export async function createInvoice(client, {
|
|
47
|
+
businessId,
|
|
48
|
+
clientId,
|
|
49
|
+
currency,
|
|
50
|
+
amount,
|
|
51
|
+
cryptoCurrency,
|
|
52
|
+
dueDate,
|
|
53
|
+
notes,
|
|
54
|
+
walletId,
|
|
55
|
+
merchantWalletAddress,
|
|
56
|
+
}) {
|
|
57
|
+
const body = {
|
|
58
|
+
business_id: businessId,
|
|
59
|
+
currency,
|
|
60
|
+
amount,
|
|
61
|
+
};
|
|
62
|
+
if (clientId) body.client_id = clientId;
|
|
63
|
+
if (cryptoCurrency) body.crypto_currency = cryptoCurrency;
|
|
64
|
+
if (dueDate) body.due_date = dueDate;
|
|
65
|
+
if (notes) body.notes = notes;
|
|
66
|
+
if (walletId) body.wallet_id = walletId;
|
|
67
|
+
if (merchantWalletAddress) body.merchant_wallet_address = merchantWalletAddress;
|
|
68
|
+
|
|
69
|
+
const data = await client.request('/invoices', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return normalizeInvoice(data);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* List invoices with optional filters
|
|
79
|
+
* @param {CoinPayClient} client
|
|
80
|
+
* @param {Object} [filters]
|
|
81
|
+
* @param {string} [filters.businessId] - Filter by business ID
|
|
82
|
+
* @param {string} [filters.status] - Filter by status (draft, sent, paid, overdue, cancelled)
|
|
83
|
+
* @param {string} [filters.clientId] - Filter by client ID
|
|
84
|
+
* @param {string} [filters.dateFrom] - Filter from date (ISO string)
|
|
85
|
+
* @param {string} [filters.dateTo] - Filter to date (ISO string)
|
|
86
|
+
* @returns {Promise<Object>} { invoices, total?, ... }
|
|
87
|
+
*/
|
|
88
|
+
export async function listInvoices(client, filters = {}) {
|
|
89
|
+
const params = new URLSearchParams();
|
|
90
|
+
if (filters.businessId) params.set('business_id', filters.businessId);
|
|
91
|
+
if (filters.status) params.set('status', filters.status);
|
|
92
|
+
if (filters.clientId) params.set('client_id', filters.clientId);
|
|
93
|
+
if (filters.dateFrom) params.set('date_from', filters.dateFrom);
|
|
94
|
+
if (filters.dateTo) params.set('date_to', filters.dateTo);
|
|
95
|
+
|
|
96
|
+
const query = params.toString();
|
|
97
|
+
const endpoint = query ? `/invoices?${query}` : '/invoices';
|
|
98
|
+
const data = await client.request(endpoint);
|
|
99
|
+
|
|
100
|
+
// Handle both array and paginated responses
|
|
101
|
+
if (Array.isArray(data)) {
|
|
102
|
+
return { invoices: data.map(normalizeInvoice) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
invoices: (data.invoices || []).map(normalizeInvoice),
|
|
107
|
+
total: data.total,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get a single invoice by ID
|
|
113
|
+
* @param {CoinPayClient} client
|
|
114
|
+
* @param {string} id - Invoice ID
|
|
115
|
+
* @returns {Promise<Object>} Invoice details
|
|
116
|
+
*/
|
|
117
|
+
export async function getInvoice(client, id) {
|
|
118
|
+
const data = await client.request(`/invoices/${encodeURIComponent(id)}`);
|
|
119
|
+
return normalizeInvoice(data);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Update an invoice
|
|
124
|
+
* @param {CoinPayClient} client
|
|
125
|
+
* @param {string} id - Invoice ID
|
|
126
|
+
* @param {Object} updates - Partial invoice fields to update
|
|
127
|
+
* @param {number} [updates.amount] - Updated amount
|
|
128
|
+
* @param {string} [updates.currency] - Updated currency
|
|
129
|
+
* @param {string} [updates.cryptoCurrency] - Updated crypto currency
|
|
130
|
+
* @param {string} [updates.dueDate] - Updated due date
|
|
131
|
+
* @param {string} [updates.notes] - Updated notes
|
|
132
|
+
* @param {string} [updates.clientId] - Updated client ID
|
|
133
|
+
* @param {string} [updates.walletId] - Updated wallet ID
|
|
134
|
+
* @param {string} [updates.merchantWalletAddress] - Updated wallet address
|
|
135
|
+
* @returns {Promise<Object>} Updated invoice
|
|
136
|
+
*/
|
|
137
|
+
export async function updateInvoice(client, id, updates) {
|
|
138
|
+
const body = {};
|
|
139
|
+
if (updates.amount !== undefined) body.amount = updates.amount;
|
|
140
|
+
if (updates.currency !== undefined) body.currency = updates.currency;
|
|
141
|
+
if (updates.cryptoCurrency !== undefined) body.crypto_currency = updates.cryptoCurrency;
|
|
142
|
+
if (updates.dueDate !== undefined) body.due_date = updates.dueDate;
|
|
143
|
+
if (updates.notes !== undefined) body.notes = updates.notes;
|
|
144
|
+
if (updates.clientId !== undefined) body.client_id = updates.clientId;
|
|
145
|
+
if (updates.walletId !== undefined) body.wallet_id = updates.walletId;
|
|
146
|
+
if (updates.merchantWalletAddress !== undefined) body.merchant_wallet_address = updates.merchantWalletAddress;
|
|
147
|
+
|
|
148
|
+
const data = await client.request(`/invoices/${encodeURIComponent(id)}`, {
|
|
149
|
+
method: 'PUT',
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return normalizeInvoice(data);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Delete a draft invoice
|
|
158
|
+
* @param {CoinPayClient} client
|
|
159
|
+
* @param {string} id - Invoice ID (must be in draft status)
|
|
160
|
+
* @returns {Promise<Object>} Deletion confirmation
|
|
161
|
+
*/
|
|
162
|
+
export async function deleteInvoice(client, id) {
|
|
163
|
+
return client.request(`/invoices/${encodeURIComponent(id)}`, {
|
|
164
|
+
method: 'DELETE',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Send an invoice to the client
|
|
170
|
+
* Generates a payment address and optional Stripe checkout link.
|
|
171
|
+
* @param {CoinPayClient} client
|
|
172
|
+
* @param {string} id - Invoice ID
|
|
173
|
+
* @returns {Promise<Object>} Send result with payment details
|
|
174
|
+
*/
|
|
175
|
+
export async function sendInvoice(client, id) {
|
|
176
|
+
const data = await client.request(`/invoices/${encodeURIComponent(id)}/send`, {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
});
|
|
179
|
+
return data;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Publish an invoice without emailing the client.
|
|
184
|
+
* Generates live payment details that can be shared manually.
|
|
185
|
+
* @param {CoinPayClient} client
|
|
186
|
+
* @param {string} id - Draft invoice ID
|
|
187
|
+
* @returns {Promise<Object>} Publish result with invoice and payment link
|
|
188
|
+
*/
|
|
189
|
+
export async function publishInvoice(client, id) {
|
|
190
|
+
return client.request(`/invoices/${encodeURIComponent(id)}/publish`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get invoice payment data (public, no auth required)
|
|
197
|
+
* Used by clients/customers to view payment instructions.
|
|
198
|
+
* @param {CoinPayClient} client
|
|
199
|
+
* @param {string} id - Invoice ID
|
|
200
|
+
* @returns {Promise<Object>} Payment data (address, amount, status, etc.)
|
|
201
|
+
*/
|
|
202
|
+
export async function getInvoicePaymentData(client, id) {
|
|
203
|
+
const data = await client.requestUnauthenticated(`/invoices/${encodeURIComponent(id)}/pay`);
|
|
204
|
+
return data;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Constants ──
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Invoice status constants
|
|
211
|
+
*/
|
|
212
|
+
export const InvoiceStatus = {
|
|
213
|
+
DRAFT: 'draft',
|
|
214
|
+
SENT: 'sent',
|
|
215
|
+
PAID: 'paid',
|
|
216
|
+
OVERDUE: 'overdue',
|
|
217
|
+
CANCELLED: 'cancelled',
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// ── Helpers ──
|
|
221
|
+
|
|
222
|
+
function normalizeInvoice(data) {
|
|
223
|
+
if (!data) return data;
|
|
224
|
+
|
|
225
|
+
// Single-invoice API responses are wrapped, while older SDK callers and
|
|
226
|
+
// tests may still supply the invoice object directly.
|
|
227
|
+
const invoice = data.invoice ?? data;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
id: invoice.id,
|
|
231
|
+
invoiceNumber: invoice.invoice_number,
|
|
232
|
+
businessId: invoice.business_id,
|
|
233
|
+
clientId: invoice.client_id,
|
|
234
|
+
currency: invoice.currency,
|
|
235
|
+
amount: invoice.amount,
|
|
236
|
+
cryptoCurrency: invoice.crypto_currency,
|
|
237
|
+
cryptoAmount: invoice.crypto_amount,
|
|
238
|
+
status: invoice.status,
|
|
239
|
+
dueDate: invoice.due_date,
|
|
240
|
+
notes: invoice.notes,
|
|
241
|
+
walletId: invoice.wallet_id,
|
|
242
|
+
merchantWalletAddress: invoice.merchant_wallet_address,
|
|
243
|
+
paymentAddress: invoice.payment_address,
|
|
244
|
+
stripeCheckoutUrl: invoice.stripe_checkout_url,
|
|
245
|
+
paidAt: invoice.paid_at,
|
|
246
|
+
sentAt: invoice.sent_at,
|
|
247
|
+
createdAt: invoice.created_at,
|
|
248
|
+
updatedAt: invoice.updated_at,
|
|
249
|
+
};
|
|
250
|
+
}
|
package/src/lightning.js
CHANGED
|
@@ -26,16 +26,21 @@ export class LightningClient {
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Enable Lightning for a wallet (provisions an LNbits custodial wallet).
|
|
29
|
+
*
|
|
29
30
|
* @param {Object} params
|
|
30
31
|
* @param {string} params.wallet_id - Wallet UUID
|
|
31
|
-
* @param {string} params.mnemonic -
|
|
32
|
+
* @param {string} [params.mnemonic] - **Deprecated and ignored.** The server
|
|
33
|
+
* required a BIP39 mnemonic here, validated it, and never used it: the
|
|
34
|
+
* provisioned wallet is custodial, so there is no signer and nothing to
|
|
35
|
+
* derive (NEW-20). The seed reconstructs every key on every chain, and it
|
|
36
|
+
* is no longer sent. Stop passing it.
|
|
32
37
|
* @param {string} [params.business_id] - Optional business UUID
|
|
33
38
|
* @returns {Promise<Object>} The provisioned node record
|
|
34
39
|
*/
|
|
35
|
-
async enableWallet({ wallet_id,
|
|
40
|
+
async enableWallet({ wallet_id, business_id }) {
|
|
36
41
|
return this.#client.request('/lightning/nodes', {
|
|
37
42
|
method: 'POST',
|
|
38
|
-
body: JSON.stringify({ wallet_id,
|
|
43
|
+
body: JSON.stringify({ wallet_id, business_id }),
|
|
39
44
|
});
|
|
40
45
|
}
|
|
41
46
|
|
package/src/swap.js
CHANGED
|
@@ -238,11 +238,42 @@ export class SwapClient {
|
|
|
238
238
|
|
|
239
239
|
const startTime = Date.now();
|
|
240
240
|
let lastStatus = null;
|
|
241
|
-
|
|
241
|
+
|
|
242
|
+
// F3-L5-02: a failed poll must not end the watch.
|
|
243
|
+
//
|
|
244
|
+
// `getSwapStatus` was awaited bare inside this loop, so one 502, 504 or
|
|
245
|
+
// socket timeout from the provider threw straight out of `waitForSwap`.
|
|
246
|
+
// The swap itself is unaffected — it is still in flight at the provider —
|
|
247
|
+
// but the caller has lost all visibility of it, and the natural reaction to
|
|
248
|
+
// "the call threw" is to assume the swap failed. A transient blip on a
|
|
249
|
+
// poll that runs for up to an hour is expected, not exceptional.
|
|
250
|
+
//
|
|
251
|
+
// Consecutive failures are what matter: an isolated blip is retried on the
|
|
252
|
+
// next tick, while a provider that is genuinely gone stops the loop rather
|
|
253
|
+
// than spinning silently until the timeout.
|
|
254
|
+
const maxConsecutiveErrors = options.maxConsecutiveErrors ?? 5;
|
|
255
|
+
let consecutiveErrors = 0;
|
|
256
|
+
|
|
242
257
|
while (Date.now() - startTime < timeout) {
|
|
243
|
-
|
|
258
|
+
let result;
|
|
259
|
+
try {
|
|
260
|
+
result = await this.getSwapStatus(swapId);
|
|
261
|
+
consecutiveErrors = 0;
|
|
262
|
+
} catch (err) {
|
|
263
|
+
consecutiveErrors++;
|
|
264
|
+
if (consecutiveErrors >= maxConsecutiveErrors) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`Lost contact with the swap provider after ${consecutiveErrors} consecutive failures ` +
|
|
267
|
+
`while tracking ${swapId}. The swap may still be in progress — check its status directly. ` +
|
|
268
|
+
`Last error: ${err.message}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
244
275
|
const currentStatus = result.swap?.status;
|
|
245
|
-
|
|
276
|
+
|
|
246
277
|
// Notify on status change
|
|
247
278
|
if (currentStatus !== lastStatus) {
|
|
248
279
|
if (onStatusChange && lastStatus !== null) {
|
|
@@ -250,12 +281,12 @@ export class SwapClient {
|
|
|
250
281
|
}
|
|
251
282
|
lastStatus = currentStatus;
|
|
252
283
|
}
|
|
253
|
-
|
|
284
|
+
|
|
254
285
|
// Check if we've reached a target status
|
|
255
286
|
if (targetStatuses.includes(currentStatus)) {
|
|
256
287
|
return result;
|
|
257
288
|
}
|
|
258
|
-
|
|
289
|
+
|
|
259
290
|
// Wait before next poll
|
|
260
291
|
await new Promise(resolve => setTimeout(resolve, interval));
|
|
261
292
|
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskMarket delegation for CoinPay agent wallets.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the CoinPayPortal x402 v2 rails to TaskMarket (https://taskmarket.dev):
|
|
5
|
+
* an agent or user can discover open work, fetch live task status, list
|
|
6
|
+
* submissions, and create a fully-funded task through the TaskMarket HTTP API
|
|
7
|
+
* using the same EIP-712 transfer-authorization mechanics as the rest of this
|
|
8
|
+
* SDK.
|
|
9
|
+
*
|
|
10
|
+
* Safety properties (mirroring the TaskMarket integration bounty rules):
|
|
11
|
+
*
|
|
12
|
+
* - No private key, seed phrase, token or cookie is ever requested, stored,
|
|
13
|
+
* logged or committed. All signing is delegated to a caller-supplied
|
|
14
|
+
* `signer` object (e.g. an EIP-1193 wallet or a CoinPay web-wallet signer).
|
|
15
|
+
* - Every spend requires fresh, explicit user authorization: the `authorize`
|
|
16
|
+
* callback is invoked with the exact amount, asset, payee and deadline, and
|
|
17
|
+
* must return `true` before any payment header is produced.
|
|
18
|
+
* - `spendingLimitUsd` caps the amount that may be authorized in one call.
|
|
19
|
+
* The guard is only enforced for assets with known decimals; for any other
|
|
20
|
+
* asset the call refuses to proceed rather than pay blind.
|
|
21
|
+
* - The created task's live status and its submissions are readable through
|
|
22
|
+
* `getTask` / `listSubmissions` and are meant to be presented to a human for
|
|
23
|
+
* review; this module never silently accepts or rejects work.
|
|
24
|
+
* - No blind payment retries: a second POST happens exactly once; if its
|
|
25
|
+
* settlement status is unknown the error tells the caller to verify with
|
|
26
|
+
* `getTask` instead of re-paying.
|
|
27
|
+
*
|
|
28
|
+
* @module taskmarket
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { randomUUID } from 'node:crypto';
|
|
32
|
+
import {
|
|
33
|
+
buildAuthorization,
|
|
34
|
+
encodePaymentHeader,
|
|
35
|
+
requiredAmount,
|
|
36
|
+
selectAcceptEntry,
|
|
37
|
+
X402_VERSION,
|
|
38
|
+
} from './x402-v2.js';
|
|
39
|
+
|
|
40
|
+
/** TaskMarket API base URL (overridable for tests / self-hosted). */
|
|
41
|
+
export const TASKMARKET_API_URL =
|
|
42
|
+
process.env.TASKMARKET_API_URL ?? 'https://api.taskmarket.dev';
|
|
43
|
+
|
|
44
|
+
/** Amounts are quoted in base units; USDC settles at 1e6 per dollar. */
|
|
45
|
+
const DECIMALS = {
|
|
46
|
+
USDC: 6,
|
|
47
|
+
USDC_E: 6,
|
|
48
|
+
USDT: 6,
|
|
49
|
+
ETH: 18,
|
|
50
|
+
POL: 18,
|
|
51
|
+
SOL: 9,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Error raised for non-2xx responses from the TaskMarket API. */
|
|
55
|
+
export class TaskMarketError extends Error {
|
|
56
|
+
constructor(status, message, body) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'TaskMarketError';
|
|
59
|
+
this.status = status;
|
|
60
|
+
this.body = body;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Error raised when the caller declines the payment authorization. */
|
|
65
|
+
export class PaymentNotAuthorizedError extends Error {
|
|
66
|
+
constructor(details) {
|
|
67
|
+
super('Payment not authorized by caller');
|
|
68
|
+
this.name = 'PaymentNotAuthorizedError';
|
|
69
|
+
this.details = details;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True when a single 402 `accepts` entry stays within the caller's spending
|
|
75
|
+
* cap. Amount comparison happens in base units (`amount * 10^decimals`).
|
|
76
|
+
*
|
|
77
|
+
* @param {object} accept one entry of the 402 `accepts` array
|
|
78
|
+
* @param {number} spendingLimitUsd maximum USD the caller allows
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function withinSpendingLimit(accept, spendingLimitUsd) {
|
|
82
|
+
if (!spendingLimitUsd) return true;
|
|
83
|
+
const asset = String(accept?.asset ?? '').toUpperCase();
|
|
84
|
+
const decimals = DECIMALS[asset];
|
|
85
|
+
if (decimals === undefined) {
|
|
86
|
+
throw new TaskMarketError(
|
|
87
|
+
0,
|
|
88
|
+
`Cannot enforce a USD spending limit for asset "${asset}": ` +
|
|
89
|
+
'its decimals are unknown. Pass an amount with known decimals or ' +
|
|
90
|
+
'leave spendingLimitUsd unset only if you fully trust the flow.'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const amount = Number(requiredAmount(accept));
|
|
94
|
+
return amount <= spendingLimitUsd * 10 ** decimals;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse a TaskMarket JSON response, tolerating both `{data: {...}}` wrappers
|
|
99
|
+
* (task list) and bare payloads (task detail, submissions list).
|
|
100
|
+
*/
|
|
101
|
+
function unwrap(result, key) {
|
|
102
|
+
if (result && typeof result === 'object') {
|
|
103
|
+
if (key && result[key] !== undefined) return result[key];
|
|
104
|
+
if (result.data !== undefined) {
|
|
105
|
+
if (key && result.data[key] !== undefined) return result.data[key];
|
|
106
|
+
return result.data;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Discover open TaskMarket tasks.
|
|
114
|
+
*
|
|
115
|
+
* Listing is public on TaskMarket; a `signer` is therefore optional and only
|
|
116
|
+
* used to attach the EIP-191 read-auth headers when provided.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} [options]
|
|
119
|
+
* @param {string} [options.status='open']
|
|
120
|
+
* @param {number} [options.limit=50]
|
|
121
|
+
* @param {string} [options.phase] lifecycle filter (active, in_review, ...)
|
|
122
|
+
* @param {string} [options.mode] bounty | claim | pitch | benchmark | auction
|
|
123
|
+
* @param {string} [options.tags] comma-separated tags
|
|
124
|
+
* @param {object} [options.signer] optional `{address, signMessage}` wallet
|
|
125
|
+
* @param {Function} [options.fetchImpl] fetch substitute for tests
|
|
126
|
+
* @returns {Promise<object[]>}
|
|
127
|
+
*/
|
|
128
|
+
export async function discoverTasks(options = {}) {
|
|
129
|
+
const {
|
|
130
|
+
status = 'open',
|
|
131
|
+
limit = 50,
|
|
132
|
+
phase,
|
|
133
|
+
mode,
|
|
134
|
+
tags,
|
|
135
|
+
signer,
|
|
136
|
+
fetchImpl = fetch,
|
|
137
|
+
} = options;
|
|
138
|
+
const params = new URLSearchParams({ status: String(status), limit: String(limit) });
|
|
139
|
+
if (phase) params.set('phase', phase);
|
|
140
|
+
if (mode) params.set('mode', mode);
|
|
141
|
+
if (tags) params.set('tags', tags);
|
|
142
|
+
|
|
143
|
+
let headers = { 'User-Agent': 'coinpay-sdk-taskmarket/0.8' };
|
|
144
|
+
if (signer && signer.address && signer.signMessage) {
|
|
145
|
+
const message = `TaskMarket read auth for ${signer.address}`;
|
|
146
|
+
const signature = await signer.signMessage({ message });
|
|
147
|
+
headers['X-Taskmarket-Address'] = signer.address;
|
|
148
|
+
headers['X-Taskmarket-Signature'] = signature;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const res = await fetchImpl(`${TASKMARKET_API_URL}/api/tasks?${params}`, { headers });
|
|
152
|
+
const result = await res.json().catch(() => ({}));
|
|
153
|
+
if (!res.ok) throw new TaskMarketError(res.status, `GET /api/tasks failed (${res.status})`, result);
|
|
154
|
+
return unwrap(result, 'tasks') ?? [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fetch the live status of one TaskMarket task.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} taskId
|
|
161
|
+
* @param {object} [options]
|
|
162
|
+
* @param {Function} [options.fetchImpl]
|
|
163
|
+
* @returns {Promise<object>}
|
|
164
|
+
*/
|
|
165
|
+
export async function getTask(taskId, options = {}) {
|
|
166
|
+
const { fetchImpl = fetch } = options;
|
|
167
|
+
const res = await fetchImpl(`${TASKMARKET_API_URL}/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
168
|
+
headers: { 'User-Agent': 'coinpay-sdk-taskmarket/0.8' },
|
|
169
|
+
});
|
|
170
|
+
const result = await res.json().catch(() => ({}));
|
|
171
|
+
if (!res.ok) throw new TaskMarketError(res.status, `GET /api/tasks/${taskId} failed (${res.status})`, result);
|
|
172
|
+
return unwrap(result);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* List the submissions of a TaskMarket task for human review.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} taskId
|
|
179
|
+
* @param {object} [options]
|
|
180
|
+
* @param {Function} [options.fetchImpl]
|
|
181
|
+
* @returns {Promise<object[]>}
|
|
182
|
+
*/
|
|
183
|
+
export async function listSubmissions(taskId, options = {}) {
|
|
184
|
+
const { fetchImpl = fetch } = options;
|
|
185
|
+
const res = await fetchImpl(
|
|
186
|
+
`${TASKMARKET_API_URL}/api/tasks/${encodeURIComponent(taskId)}/submissions`,
|
|
187
|
+
{ headers: { 'User-Agent': 'coinpay-sdk-taskmarket/0.8' } }
|
|
188
|
+
);
|
|
189
|
+
const result = await res.json().catch(() => []);
|
|
190
|
+
if (!res.ok) throw new TaskMarketError(res.status, `GET /api/tasks/${taskId}/submissions failed (${res.status})`, result);
|
|
191
|
+
return Array.isArray(result) ? result : (unwrap(result, 'submissions') ?? []);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Create a TaskMarket task and pay for it over x402 v2.
|
|
196
|
+
*
|
|
197
|
+
* Flow:
|
|
198
|
+
* 1. POST the task body with an idempotency key.
|
|
199
|
+
* 2. TaskMarket answers `402 Payment Required` with the accepted rails.
|
|
200
|
+
* 3. `spendingLimitUsd` is enforced against the quoted amount.
|
|
201
|
+
* 4. `authorize` is invoked with the exact resource, payee, amount, asset
|
|
202
|
+
* and timeout; it must return `true` (fresh, explicit user consent).
|
|
203
|
+
* 5. The EIP-712 transfer authorization is built, signed by `signer`, and
|
|
204
|
+
* sent once as the `PAYMENT-SIGNATURE` header.
|
|
205
|
+
* 6. The created task id is returned; callers can then poll `getTask`.
|
|
206
|
+
*
|
|
207
|
+
* @param {object} task `{title, description, reward, ...}` — fields are passed
|
|
208
|
+
* through verbatim (reward is in base units).
|
|
209
|
+
* @param {object} options
|
|
210
|
+
* @param {object} options.signer `{address, signTypedData({domain, types, primaryType, message})}`
|
|
211
|
+
* @param {string[]} [options.capabilities] CAIP-2 ids the signer can pay
|
|
212
|
+
* @param {number} [options.spendingLimitUsd] hard cap in USD for this call
|
|
213
|
+
* @param {Function} [options.authorize] `({resource, scheme, network, amount, asset, payTo, maxTimeoutSeconds}) => boolean`
|
|
214
|
+
* @param {string} [options.idempotencyKey] defaults to a fresh UUID
|
|
215
|
+
* @param {Function} [options.fetchImpl] fetch substitute for tests
|
|
216
|
+
* @returns {Promise<{taskId: string}>}
|
|
217
|
+
*/
|
|
218
|
+
export async function createTask(task, options) {
|
|
219
|
+
if (!options?.signer) throw new TypeError('createTask requires options.signer');
|
|
220
|
+
if (!task?.title || !task?.description) throw new TypeError('task requires title and description');
|
|
221
|
+
if (!(Number(task.reward) > 0)) throw new TypeError('task requires reward > 0 (base units)');
|
|
222
|
+
|
|
223
|
+
const {
|
|
224
|
+
signer,
|
|
225
|
+
capabilities = ['eip155:8453', 'eip155:1'],
|
|
226
|
+
spendingLimitUsd,
|
|
227
|
+
authorize,
|
|
228
|
+
idempotencyKey = randomUUID(),
|
|
229
|
+
fetchImpl = fetch,
|
|
230
|
+
} = options;
|
|
231
|
+
|
|
232
|
+
const url = `${TASKMARKET_API_URL}/api/tasks`;
|
|
233
|
+
const body = JSON.stringify(task);
|
|
234
|
+
|
|
235
|
+
const res = await fetchImpl(url, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: {
|
|
238
|
+
'Content-Type': 'application/json',
|
|
239
|
+
'User-Agent': 'coinpay-sdk-taskmarket/0.8',
|
|
240
|
+
'X-Taskmarket-Idempotency-Key': idempotencyKey,
|
|
241
|
+
},
|
|
242
|
+
body,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (res.ok) return res.json().catch(() => ({}));
|
|
246
|
+
if (res.status !== 402) {
|
|
247
|
+
const result = await res.json().catch(() => ({}));
|
|
248
|
+
throw new TaskMarketError(res.status, `POST /api/tasks failed (${res.status})`, result);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Payment required: pick a rail we can sign, then ask the user. ──
|
|
252
|
+
const requirements = await res.json().catch(() => ({}));
|
|
253
|
+
const accepts = Array.isArray(requirements.accepts) ? requirements.accepts : [];
|
|
254
|
+
if (accepts.length === 0) {
|
|
255
|
+
throw new TaskMarketError(402, 'TaskMarket sent no accepted payment methods', requirements);
|
|
256
|
+
}
|
|
257
|
+
const accept = selectAcceptEntry(accepts, capabilities) ?? accepts[0];
|
|
258
|
+
if (!accept) throw new TaskMarketError(402, 'No acceptable payment method for this wallet', requirements);
|
|
259
|
+
|
|
260
|
+
if (!withinSpendingLimit(accept, spendingLimitUsd)) {
|
|
261
|
+
throw new TaskMarketError(
|
|
262
|
+
402,
|
|
263
|
+
`Payment of ${requiredAmount(accept)} ${accept.asset} exceeds spendingLimitUsd=${spendingLimitUsd}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const details = {
|
|
268
|
+
resource: requirements.resource,
|
|
269
|
+
scheme: accept.scheme,
|
|
270
|
+
network: accept.network,
|
|
271
|
+
amount: requiredAmount(accept),
|
|
272
|
+
asset: accept.asset,
|
|
273
|
+
payTo: accept.payTo,
|
|
274
|
+
maxTimeoutSeconds: accept.maxTimeoutSeconds,
|
|
275
|
+
};
|
|
276
|
+
if (authorize && !(await authorize(details))) {
|
|
277
|
+
throw new PaymentNotAuthorizedError(details);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const authorization = buildAuthorization({
|
|
281
|
+
from: signer.address,
|
|
282
|
+
to: accept.payTo,
|
|
283
|
+
value: requiredAmount(accept),
|
|
284
|
+
validForSeconds: Number(accept.maxTimeoutSeconds ?? 300),
|
|
285
|
+
});
|
|
286
|
+
const signature = await signer.signTypedData({
|
|
287
|
+
domain: accept.extra?.eip712?.domain,
|
|
288
|
+
types: accept.extra?.eip712?.types,
|
|
289
|
+
primaryType: accept.extra?.eip712?.primaryType,
|
|
290
|
+
message: authorization,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const paymentPayload = {
|
|
294
|
+
x402Version: X402_VERSION,
|
|
295
|
+
resource: requirements.resource,
|
|
296
|
+
accepted: {
|
|
297
|
+
scheme: accept.scheme,
|
|
298
|
+
network: accept.network,
|
|
299
|
+
amount: requiredAmount(accept),
|
|
300
|
+
asset: accept.asset,
|
|
301
|
+
payTo: accept.payTo,
|
|
302
|
+
maxTimeoutSeconds: accept.maxTimeoutSeconds,
|
|
303
|
+
extra: accept.extra,
|
|
304
|
+
},
|
|
305
|
+
payload: { authorization, signature },
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const paid = await fetchImpl(url, {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
headers: {
|
|
311
|
+
'Content-Type': 'application/json',
|
|
312
|
+
'User-Agent': 'coinpay-sdk-taskmarket/0.8',
|
|
313
|
+
'X-Taskmarket-Idempotency-Key': idempotencyKey,
|
|
314
|
+
'PAYMENT-SIGNATURE': encodePaymentHeader(paymentPayload),
|
|
315
|
+
},
|
|
316
|
+
body,
|
|
317
|
+
});
|
|
318
|
+
const result = await paid.json().catch(() => ({}));
|
|
319
|
+
if (!paid.ok) {
|
|
320
|
+
// Settlement state is unknown — never auto-retry the payment.
|
|
321
|
+
throw new TaskMarketError(
|
|
322
|
+
paid.status,
|
|
323
|
+
`POST /api/tasks failed after payment (${paid.status}). ` +
|
|
324
|
+
'Verify settlement with getTask(taskId) before retrying.',
|
|
325
|
+
result
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
package/src/tokens.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { CoinPayClient, SupportedCoinsParams, SupportedCoinsResponse, TokensResponse } from './client.js';
|
|
2
|
+
|
|
3
|
+
export interface TokenDiscoveryParams extends SupportedCoinsParams {
|
|
4
|
+
/** API key — required if `client` is not provided */
|
|
5
|
+
apiKey?: string;
|
|
6
|
+
/** Existing CoinPayClient instance */
|
|
7
|
+
client?: CoinPayClient;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** List checkout-friendly tokens for a business. */
|
|
11
|
+
export function getTokens(params?: TokenDiscoveryParams): Promise<TokensResponse>;
|
|
12
|
+
|
|
13
|
+
/** List supported payment coins for a business. */
|
|
14
|
+
export function getSupportedCoins(params?: TokenDiscoveryParams): Promise<SupportedCoinsResponse>;
|