@elisym/merchant-node 0.1.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/LICENSE +21 -0
- package/README.md +178 -0
- package/dist/cli.js +1818 -0
- package/dist/cli.js.map +1 -0
- package/package.json +62 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1818 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, writeFileSync, readFileSync, statSync, renameSync, linkSync, rmSync, utimesSync, mkdirSync, chmodSync } from 'node:fs';
|
|
3
|
+
import { LIMITS, DELIVERY_METHODS, splitNip05, parseCaip19, canonicalPayoutAddress, priceInSubunits, isPublicHostname, KIND_PAYTO, KIND_PRODUCT, KIND_INBOX_RELAYS, buildProductEvent, buildStoreProfileEvent, buildPaytoEvent, buildStoreAuthEvent, encodeProductNaddr, MAX_FUTURE_SKEW_SECS, unwrapOrderMessage, productAddress, wrapOrderMessage, buildOrderMessage, KIND_GIFT_WRAP, deriveOrderPaymentReference, parsePayto, parseProduct } from '@elisym/commerce';
|
|
4
|
+
import { createSolanaRpc, address, isSignature } from '@solana/kit';
|
|
5
|
+
import { SimplePool } from 'nostr-tools/pool';
|
|
6
|
+
import { getPublicKey, generateSecretKey, finalizeEvent } from 'nostr-tools/pure';
|
|
7
|
+
import Decimal from 'decimal.js-light';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { resolve, join } from 'node:path';
|
|
12
|
+
import { hexToBytes, bytesToHex } from 'nostr-tools/utils';
|
|
13
|
+
import { composeSolanaPaymentRequest, verifyDirectSolanaPayment, listReferenceSignatures, directInstructionsFromRpcTransaction, ELISYM_PROTOCOL_TAG, boundTransferAmount } from '@elisym/pay-core';
|
|
14
|
+
import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';
|
|
15
|
+
|
|
16
|
+
// src/constants.ts
|
|
17
|
+
var TERMS_WINDOW_SECS = 45 * 60;
|
|
18
|
+
var ORDER_SCAN_MARGIN_SECS = 30 * 60;
|
|
19
|
+
var CATCH_UP_SECS = 3 * 24 * 60 * 60;
|
|
20
|
+
var WRAP_BACKDATE_SECS = 2 * 24 * 60 * 60;
|
|
21
|
+
var CATCH_UP_INTERVAL_MS = 6e4;
|
|
22
|
+
var SOLANA_MEDIUMS = { mainnet: "solana", devnet: "solana-devnet" };
|
|
23
|
+
var OFFER_RELAYS = [
|
|
24
|
+
"wss://relay.elisym.network",
|
|
25
|
+
"wss://relay.damus.io",
|
|
26
|
+
"wss://nos.lol",
|
|
27
|
+
"wss://relay.nostr.band"
|
|
28
|
+
];
|
|
29
|
+
var PUBLISH_DEADLINE_MS = 2e4;
|
|
30
|
+
var EARLIEST_ORDER_SECS = 16e8;
|
|
31
|
+
var MAX_RECEIPTS_PER_ORDER = 5;
|
|
32
|
+
var MAX_RECHECKS_PER_SWEEP = 20;
|
|
33
|
+
var RESUBSCRIBE_BACKOFF_MS = [1e3, 5e3, 15e3, 6e4];
|
|
34
|
+
var MAX_LIVE_CHECKS_PER_MINUTE = 20;
|
|
35
|
+
var ASK_AGAIN_DELAYS_MS = [3e3, 1e4, 3e4];
|
|
36
|
+
var RESEND_EVERY_SECS = 10 * 60;
|
|
37
|
+
var MAX_REPEATS_IN_FLIGHT = 4;
|
|
38
|
+
var DELIVERY_RELAYS_WANTED = 2;
|
|
39
|
+
var DELIVERY_SETTLE_SECS = 60 * 60;
|
|
40
|
+
var TERMS_CLOCK_MARGIN_SECS = 60;
|
|
41
|
+
|
|
42
|
+
// src/publish.ts
|
|
43
|
+
function errorText(error) {
|
|
44
|
+
return error instanceof Error ? error.message : String(error);
|
|
45
|
+
}
|
|
46
|
+
async function publishOne(pool, url, event, auth) {
|
|
47
|
+
const relay = await pool.ensureRelay(url);
|
|
48
|
+
try {
|
|
49
|
+
await relay.publish(event);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (!errorText(error).startsWith("auth-required")) {
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
await relay.auth(auth);
|
|
55
|
+
await relay.publish(event);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function publishToRelays(pool, relays, event, auth, log2, deadlineMs = PUBLISH_DEADLINE_MS) {
|
|
59
|
+
const accepted = [];
|
|
60
|
+
await Promise.all(
|
|
61
|
+
relays.map(async (url) => {
|
|
62
|
+
let timer;
|
|
63
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
64
|
+
timer = setTimeout(() => reject(new Error("timed out")), deadlineMs);
|
|
65
|
+
});
|
|
66
|
+
try {
|
|
67
|
+
await Promise.race([publishOne(pool, url, event, auth), deadline]);
|
|
68
|
+
accepted.push(url);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
log2(`publish to ${url} failed: ${errorText(error)}`);
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
return accepted;
|
|
77
|
+
}
|
|
78
|
+
var MAX_RELAY_URL_LENGTH = 256;
|
|
79
|
+
function checkoutRelaySpelling(value) {
|
|
80
|
+
if (value === void 0 || value.length > MAX_RELAY_URL_LENGTH || !URL.canParse(value)) {
|
|
81
|
+
return void 0;
|
|
82
|
+
}
|
|
83
|
+
const url = new URL(value);
|
|
84
|
+
if (url.protocol !== "wss:" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || !isPublicHostname(url.hostname)) {
|
|
85
|
+
return void 0;
|
|
86
|
+
}
|
|
87
|
+
return `wss://${url.host}${url.pathname.replace(/\/+/g, "/").replace(/\/$/, "")}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/checks.ts
|
|
91
|
+
var READ_WAIT_MS = 1e4;
|
|
92
|
+
async function checkInboxRelays(writer, reader, relays, storeSecretKey, storeAuth2, log2, now) {
|
|
93
|
+
const recipientSecretKey = generateSecretKey();
|
|
94
|
+
const recipient = getPublicKey(recipientSecretKey);
|
|
95
|
+
const probe = wrapOrderMessage(
|
|
96
|
+
buildOrderMessage(
|
|
97
|
+
{
|
|
98
|
+
type: "status",
|
|
99
|
+
buyerPubkey: recipient,
|
|
100
|
+
orderId: `probe-${now.toString(36)}`,
|
|
101
|
+
status: "pending"
|
|
102
|
+
},
|
|
103
|
+
now
|
|
104
|
+
),
|
|
105
|
+
storeSecretKey,
|
|
106
|
+
recipient
|
|
107
|
+
).recipientWrap;
|
|
108
|
+
const recipientAuth = async (template) => finalizeEvent(template, recipientSecretKey);
|
|
109
|
+
return await Promise.all(
|
|
110
|
+
relays.map(async (relay) => {
|
|
111
|
+
const accepted = await publishToRelays(writer, [relay], probe, storeAuth2, log2);
|
|
112
|
+
if (accepted.length === 0) {
|
|
113
|
+
return { relay, accepts: false, serves: false };
|
|
114
|
+
}
|
|
115
|
+
const found = await reader.querySync(
|
|
116
|
+
[relay],
|
|
117
|
+
{ kinds: [KIND_GIFT_WRAP], "#p": [recipient], ids: [probe.id] },
|
|
118
|
+
{ maxWait: READ_WAIT_MS, onauth: recipientAuth }
|
|
119
|
+
).catch(() => []);
|
|
120
|
+
return { relay, accepts: true, serves: found.some((event) => event.id === probe.id) };
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
async function checkDomain(domain, expected, fetcher = fetch) {
|
|
125
|
+
const url = `https://${domain}/.well-known/nostr.json?name=_`;
|
|
126
|
+
let response;
|
|
127
|
+
try {
|
|
128
|
+
response = await fetcher(url, { redirect: "error", signal: AbortSignal.timeout(READ_WAIT_MS) });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return { url, ok: false, problem: `not reachable (${errorText2(error)})` };
|
|
131
|
+
}
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
return { url, ok: false, problem: `answered ${response.status}` };
|
|
134
|
+
}
|
|
135
|
+
if (response.headers.get("access-control-allow-origin") !== "*") {
|
|
136
|
+
return { url, ok: false, problem: 'no "Access-Control-Allow-Origin: *" header' };
|
|
137
|
+
}
|
|
138
|
+
let served;
|
|
139
|
+
try {
|
|
140
|
+
const text = await response.text();
|
|
141
|
+
if (text.length > LIMITS.MAX_NIP05_DOCUMENT_BYTES) {
|
|
142
|
+
return { url, ok: false, problem: "the document is too large" };
|
|
143
|
+
}
|
|
144
|
+
served = JSON.parse(text);
|
|
145
|
+
} catch {
|
|
146
|
+
return { url, ok: false, problem: "not JSON" };
|
|
147
|
+
}
|
|
148
|
+
const names = typeof served === "object" && served !== null && "names" in served ? served.names : void 0;
|
|
149
|
+
for (const [name, pubkey] of Object.entries(expected.names)) {
|
|
150
|
+
if (names?.[name] !== pubkey) {
|
|
151
|
+
return { url, ok: false, problem: `"${name}" is not ${pubkey}` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { url, ok: true };
|
|
155
|
+
}
|
|
156
|
+
function offersNotHonoured(listing, payoutList, network, standing) {
|
|
157
|
+
if (listing === void 0 && payoutList === void 0) {
|
|
158
|
+
return void 0;
|
|
159
|
+
}
|
|
160
|
+
const targets = payoutList === void 0 ? [] : parsePayto(payoutList).targets;
|
|
161
|
+
const found = targets.filter(
|
|
162
|
+
(target) => target.caip19.chain.family !== "solana" || target.caip19.chain.network !== network || !standing.some(
|
|
163
|
+
(terms) => terms.caip19 === target.caip19.id && terms.payout === target.address
|
|
164
|
+
)
|
|
165
|
+
).map((target) => `${target.caip19.id} ${target.address}`);
|
|
166
|
+
const product = listing === void 0 ? void 0 : parseProduct(listing);
|
|
167
|
+
for (const id of product?.accept ?? []) {
|
|
168
|
+
const caip19 = parseCaip19(id);
|
|
169
|
+
const target = targets.find((candidate) => candidate.caip19.id === caip19?.id);
|
|
170
|
+
if (caip19 === void 0 || target === void 0 || product === void 0) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
let amount;
|
|
174
|
+
try {
|
|
175
|
+
amount = priceInSubunits(product.price, caip19.asset).toString();
|
|
176
|
+
} catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const honoured = standing.some(
|
|
180
|
+
(terms) => terms.caip19 === caip19.id && terms.payout === target.address && terms.amount === amount
|
|
181
|
+
);
|
|
182
|
+
if (!honoured) {
|
|
183
|
+
found.push(`${caip19.id} at ${amount}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return [...new Set(found)];
|
|
187
|
+
}
|
|
188
|
+
var CHECKOUT_INBOX_CAP = 8;
|
|
189
|
+
function inboxRelaysNotRead(inboxList, read) {
|
|
190
|
+
if (inboxList === void 0) {
|
|
191
|
+
return void 0;
|
|
192
|
+
}
|
|
193
|
+
const reading = read.map(checkoutRelaySpelling);
|
|
194
|
+
const listed = [];
|
|
195
|
+
for (const tag of inboxList.tags) {
|
|
196
|
+
const relay = tag[0] === "relay" ? checkoutRelaySpelling(tag[1]) : void 0;
|
|
197
|
+
if (relay !== void 0 && !listed.includes(relay)) {
|
|
198
|
+
listed.push(relay);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return listed.slice(0, CHECKOUT_INBOX_CAP).filter((relay) => !reading.includes(relay));
|
|
202
|
+
}
|
|
203
|
+
function offerProblems(views, inboxRelays, network, standing) {
|
|
204
|
+
const problems = /* @__PURE__ */ new Set();
|
|
205
|
+
let served = false;
|
|
206
|
+
for (const view of views) {
|
|
207
|
+
for (const relay of inboxRelaysNotRead(view.inboxList, inboxRelays) ?? []) {
|
|
208
|
+
problems.add(`orders sent to ${relay}, which this node does not read`);
|
|
209
|
+
}
|
|
210
|
+
const notHonoured = offersNotHonoured(view.listing, view.payoutList, network, standing);
|
|
211
|
+
served ||= notHonoured !== void 0;
|
|
212
|
+
for (const offer of notHonoured ?? []) {
|
|
213
|
+
problems.add(offer);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { problems: [...problems], served };
|
|
217
|
+
}
|
|
218
|
+
async function newestInboxList(pool, relays, storePubkey) {
|
|
219
|
+
const events = await pool.querySync(
|
|
220
|
+
[...relays],
|
|
221
|
+
{ kinds: [KIND_INBOX_RELAYS], authors: [storePubkey] },
|
|
222
|
+
{ maxWait: READ_WAIT_MS }
|
|
223
|
+
).catch(() => []);
|
|
224
|
+
return newest(
|
|
225
|
+
events.filter((event) => event.pubkey === storePubkey && event.kind === KIND_INBOX_RELAYS)
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
async function newestListing(pool, relays, storePubkey, d) {
|
|
229
|
+
const events = await pool.querySync(
|
|
230
|
+
[...relays],
|
|
231
|
+
{ kinds: [KIND_PRODUCT], authors: [storePubkey], "#d": [d] },
|
|
232
|
+
{ maxWait: READ_WAIT_MS }
|
|
233
|
+
).catch(() => []);
|
|
234
|
+
return newest(
|
|
235
|
+
events.filter(
|
|
236
|
+
(event) => event.pubkey === storePubkey && event.kind === KIND_PRODUCT && event.tags.some((tag) => tag[0] === "d" && tag[1] === d)
|
|
237
|
+
)
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
function newest(events) {
|
|
241
|
+
return events.reduce(
|
|
242
|
+
(best, event) => best === void 0 || event.created_at > best.created_at || event.created_at === best.created_at && event.id < best.id ? event : best,
|
|
243
|
+
void 0
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
async function newestPayoutList(pool, relays, ownerPubkey) {
|
|
247
|
+
const events = await pool.querySync(
|
|
248
|
+
[...relays],
|
|
249
|
+
{ kinds: [KIND_PAYTO], authors: [ownerPubkey] },
|
|
250
|
+
{ maxWait: READ_WAIT_MS }
|
|
251
|
+
).catch(() => []);
|
|
252
|
+
return newest(
|
|
253
|
+
events.filter((event) => event.pubkey === ownerPubkey && event.kind === KIND_PAYTO)
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
function errorText2(error) {
|
|
257
|
+
return error instanceof Error ? error.message : String(error);
|
|
258
|
+
}
|
|
259
|
+
var MAX_INBOX_RELAYS = 5;
|
|
260
|
+
function isLocalHost(host) {
|
|
261
|
+
return host === "localhost" || host === "127.0.0.1";
|
|
262
|
+
}
|
|
263
|
+
function urlWith(secure, insecure) {
|
|
264
|
+
return z.string().refine(
|
|
265
|
+
(value) => {
|
|
266
|
+
if (!URL.canParse(value)) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
const url = new URL(value);
|
|
270
|
+
return url.protocol === secure || url.protocol === insecure && isLocalHost(url.hostname);
|
|
271
|
+
},
|
|
272
|
+
{ message: `must be a ${secure}// URL` }
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
var PRICE_RE = /^\d{1,9}(\.\d{1,6})?$/;
|
|
276
|
+
var configSchema = z.object({
|
|
277
|
+
name: z.string().trim().min(1).max(100),
|
|
278
|
+
nip05: z.string().refine((value) => splitNip05(value) !== void 0, { message: "is not a nip05 address" }).refine((value) => splitNip05(value)?.local !== "owner", {
|
|
279
|
+
message: 'cannot use the name "owner" (it names the owner key)'
|
|
280
|
+
}).optional(),
|
|
281
|
+
network: z.enum(["mainnet", "devnet"]),
|
|
282
|
+
rpcUrl: urlWith("https:", "http:"),
|
|
283
|
+
inboxRelays: z.array(
|
|
284
|
+
urlWith("wss:", "ws:").refine(
|
|
285
|
+
(value) => value.startsWith("ws:") || checkoutRelaySpelling(value) !== void 0,
|
|
286
|
+
{ message: "must be a relay the checkout contacts: wss: on a public DNS name" }
|
|
287
|
+
)
|
|
288
|
+
).min(1).max(MAX_INBOX_RELAYS),
|
|
289
|
+
product: z.object({
|
|
290
|
+
d: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "use 1-64 letters, digits, dots, dashes or underscores"),
|
|
291
|
+
title: z.string().trim().min(1).max(200),
|
|
292
|
+
description: z.string().max(LIMITS.MAX_CONTENT_LENGTH),
|
|
293
|
+
summary: z.string().max(LIMITS.MAX_TAG_VALUE_LENGTH).optional(),
|
|
294
|
+
priceUsd: z.string().refine((value) => PRICE_RE.test(value) && new Decimal(value).gt(0), {
|
|
295
|
+
message: 'must be a USD amount above 0, such as "49" or "0.50"'
|
|
296
|
+
}),
|
|
297
|
+
delivery: z.object({
|
|
298
|
+
method: z.enum(DELIVERY_METHODS),
|
|
299
|
+
value: z.string().min(1).max(LIMITS.MAX_TAG_VALUE_LENGTH)
|
|
300
|
+
})
|
|
301
|
+
}),
|
|
302
|
+
payouts: z.array(
|
|
303
|
+
z.object({
|
|
304
|
+
caip19: z.string(),
|
|
305
|
+
address: z.string(),
|
|
306
|
+
signature: z.string().optional()
|
|
307
|
+
})
|
|
308
|
+
).min(1)
|
|
309
|
+
}).strict().superRefine((config, context) => {
|
|
310
|
+
const spellings = config.inboxRelays.map(
|
|
311
|
+
(relay) => checkoutRelaySpelling(relay) ?? relay.replace(/\/+$/, "")
|
|
312
|
+
);
|
|
313
|
+
spellings.forEach((spelling, index) => {
|
|
314
|
+
if (spellings.indexOf(spelling) !== index) {
|
|
315
|
+
context.addIssue({
|
|
316
|
+
code: "custom",
|
|
317
|
+
path: ["inboxRelays", index],
|
|
318
|
+
message: "is listed twice"
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
const seen = /* @__PURE__ */ new Set();
|
|
323
|
+
config.payouts.forEach((payout, index) => {
|
|
324
|
+
const path = ["payouts", index];
|
|
325
|
+
const caip19 = parseCaip19(payout.caip19);
|
|
326
|
+
if (caip19 === void 0 || caip19.chain.family !== "solana") {
|
|
327
|
+
context.addIssue({
|
|
328
|
+
code: "custom",
|
|
329
|
+
path: [...path, "caip19"],
|
|
330
|
+
message: "must be a Solana coin this node can verify"
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (caip19.chain.network !== config.network) {
|
|
335
|
+
context.addIssue({
|
|
336
|
+
code: "custom",
|
|
337
|
+
path: [...path, "caip19"],
|
|
338
|
+
message: `is on ${caip19.chain.network}, the node runs on ${config.network}`
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
if (canonicalPayoutAddress(caip19.chain, payout.address) !== payout.address) {
|
|
342
|
+
context.addIssue({
|
|
343
|
+
code: "custom",
|
|
344
|
+
path: [...path, "address"],
|
|
345
|
+
message: "is not a Solana address"
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
const price = config.product.priceUsd;
|
|
349
|
+
if (PRICE_RE.test(price) && new Decimal(price).gt(0)) {
|
|
350
|
+
try {
|
|
351
|
+
priceInSubunits({ amount: price, currency: "USD" }, caip19.asset);
|
|
352
|
+
} catch {
|
|
353
|
+
context.addIssue({
|
|
354
|
+
code: "custom",
|
|
355
|
+
path: [...path, "caip19"],
|
|
356
|
+
message: `${caip19.asset.symbol} cannot be paid a USD price`
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (seen.has(caip19.id)) {
|
|
361
|
+
context.addIssue({
|
|
362
|
+
code: "custom",
|
|
363
|
+
path: [...path, "caip19"],
|
|
364
|
+
message: "is listed twice: one payout per coin"
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
seen.add(caip19.id);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
function parseConfig(value) {
|
|
371
|
+
const parsed = configSchema.safeParse(value);
|
|
372
|
+
if (parsed.success) {
|
|
373
|
+
const config = parsed.data;
|
|
374
|
+
config.inboxRelays = config.inboxRelays.map((relay) => checkoutRelaySpelling(relay) ?? relay);
|
|
375
|
+
return { ok: true, config };
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
problems: parsed.error.issues.map(
|
|
380
|
+
(issue) => `${issue.path.length === 0 ? "config" : issue.path.join(".")}: ${issue.message}`
|
|
381
|
+
)
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function loadConfig(path) {
|
|
385
|
+
let value;
|
|
386
|
+
try {
|
|
387
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
388
|
+
} catch (error) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
const parsed = parseConfig(value);
|
|
394
|
+
if (!parsed.ok) {
|
|
395
|
+
throw new Error(`${path} is not usable:
|
|
396
|
+
- ${parsed.problems.join("\n- ")}`);
|
|
397
|
+
}
|
|
398
|
+
return parsed.config;
|
|
399
|
+
}
|
|
400
|
+
function configTemplate(network) {
|
|
401
|
+
const usdc = network === "mainnet" ? "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" : "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
|
|
402
|
+
return {
|
|
403
|
+
name: "My store",
|
|
404
|
+
network,
|
|
405
|
+
rpcUrl: network === "mainnet" ? "https://mainnet.helius-rpc.com/?api-key=<a server-side key>" : "https://api.devnet.solana.com",
|
|
406
|
+
inboxRelays: ["wss://relay.elisym.network", "wss://nos.lol"],
|
|
407
|
+
product: {
|
|
408
|
+
d: "my-product",
|
|
409
|
+
title: "My product",
|
|
410
|
+
description: "What the buyer gets.",
|
|
411
|
+
priceUsd: "10",
|
|
412
|
+
delivery: { method: "access", value: "https://example.com/<the link the buyer gets>" }
|
|
413
|
+
},
|
|
414
|
+
payouts: [{ caip19: usdc, address: "<your Solana wallet address>" }]
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
var HOME_ENV = "ELISYM_MERCHANT_HOME";
|
|
418
|
+
var DEFAULT_HOME_NAME = ".elisym-merchant";
|
|
419
|
+
function merchantHome(flag, env = process.env) {
|
|
420
|
+
const chosen = flag ?? env[HOME_ENV];
|
|
421
|
+
const dir = resolve(
|
|
422
|
+
chosen === void 0 || chosen === "" ? join(homedir(), DEFAULT_HOME_NAME) : chosen
|
|
423
|
+
);
|
|
424
|
+
return {
|
|
425
|
+
dir,
|
|
426
|
+
config: join(dir, "config.json"),
|
|
427
|
+
keys: join(dir, "keys.json"),
|
|
428
|
+
ledger: join(dir, "ledger.json"),
|
|
429
|
+
lock: join(dir, "run.lock"),
|
|
430
|
+
nostrJson: join(dir, "nostr.json")
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function ensureHome(home) {
|
|
434
|
+
mkdirSync(home.dir, { recursive: true, mode: 448 });
|
|
435
|
+
try {
|
|
436
|
+
chmodSync(home.dir, 448);
|
|
437
|
+
} catch {
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function isMissing(error) {
|
|
441
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
442
|
+
}
|
|
443
|
+
function loadOrCreateKeys(home, create) {
|
|
444
|
+
let text;
|
|
445
|
+
try {
|
|
446
|
+
text = readFileSync(home.keys, "utf8");
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (!isMissing(error)) {
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (text !== void 0) {
|
|
453
|
+
const stored = JSON.parse(text);
|
|
454
|
+
return { storeSecretKey: hexToBytes(stored.store), ownerSecretKey: hexToBytes(stored.owner) };
|
|
455
|
+
}
|
|
456
|
+
if (!create) {
|
|
457
|
+
throw new Error(`no store keys in ${home.dir}: run init first`);
|
|
458
|
+
}
|
|
459
|
+
const keys = { storeSecretKey: generateSecretKey(), ownerSecretKey: generateSecretKey() };
|
|
460
|
+
ensureHome(home);
|
|
461
|
+
writeFileSync(
|
|
462
|
+
home.keys,
|
|
463
|
+
`${JSON.stringify({ store: bytesToHex(keys.storeSecretKey), owner: bytesToHex(keys.ownerSecretKey) })}
|
|
464
|
+
`,
|
|
465
|
+
{ mode: 384, flag: "wx" }
|
|
466
|
+
);
|
|
467
|
+
return keys;
|
|
468
|
+
}
|
|
469
|
+
var LOCK_STALE_MS = 9e4;
|
|
470
|
+
var LOCK_HEARTBEAT_MS = 2e4;
|
|
471
|
+
function heldByRunningMerchant(home, now = Date.now()) {
|
|
472
|
+
try {
|
|
473
|
+
return now - statSync(home.lock).mtimeMs < LOCK_STALE_MS;
|
|
474
|
+
} catch {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function createLock(home, token) {
|
|
479
|
+
try {
|
|
480
|
+
writeFileSync(home.lock, token, { flag: "wx", mode: 384 });
|
|
481
|
+
return true;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function heldError(home) {
|
|
490
|
+
return new Error(
|
|
491
|
+
`another merchant holds ${home.lock}; a node that stopped without releasing it frees it ${LOCK_STALE_MS / 1e3} s after its last refresh`
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
function takeLock(home, options = {}) {
|
|
495
|
+
ensureHome(home);
|
|
496
|
+
const token = `${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
497
|
+
if (!createLock(home, token)) {
|
|
498
|
+
if (heldByRunningMerchant(home, options.judgedAt)) {
|
|
499
|
+
throw heldError(home);
|
|
500
|
+
}
|
|
501
|
+
const aside = `${home.lock}.${token}`;
|
|
502
|
+
try {
|
|
503
|
+
renameSync(home.lock, aside);
|
|
504
|
+
} catch {
|
|
505
|
+
throw heldError(home);
|
|
506
|
+
}
|
|
507
|
+
if (Date.now() - statSync(aside).mtimeMs < LOCK_STALE_MS) {
|
|
508
|
+
try {
|
|
509
|
+
linkSync(aside, home.lock);
|
|
510
|
+
} catch {
|
|
511
|
+
}
|
|
512
|
+
rmSync(aside, { force: true });
|
|
513
|
+
throw heldError(home);
|
|
514
|
+
}
|
|
515
|
+
rmSync(aside, { force: true });
|
|
516
|
+
if (!createLock(home, token)) {
|
|
517
|
+
throw heldError(home);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const ours = () => {
|
|
521
|
+
try {
|
|
522
|
+
return readFileSync(home.lock, "utf8") === token;
|
|
523
|
+
} catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
const onLost = options.onLost ?? (() => {
|
|
528
|
+
console.error(`error: ${home.lock} was taken by another process; stopping`);
|
|
529
|
+
process.exit(1);
|
|
530
|
+
});
|
|
531
|
+
const heartbeat = setInterval(() => {
|
|
532
|
+
try {
|
|
533
|
+
if (!ours()) {
|
|
534
|
+
clearInterval(heartbeat);
|
|
535
|
+
onLost();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const now = /* @__PURE__ */ new Date();
|
|
539
|
+
utimesSync(home.lock, now, now);
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
}, LOCK_HEARTBEAT_MS);
|
|
543
|
+
heartbeat.unref();
|
|
544
|
+
const release = () => {
|
|
545
|
+
clearInterval(heartbeat);
|
|
546
|
+
process.removeListener("exit", release);
|
|
547
|
+
if (ours()) {
|
|
548
|
+
rmSync(home.lock, { force: true });
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
process.once("exit", release);
|
|
552
|
+
return release;
|
|
553
|
+
}
|
|
554
|
+
function emptyLedger() {
|
|
555
|
+
return { version: 1, orders: {}, seenRumors: {}, claims: {}, terms: [], scans: {} };
|
|
556
|
+
}
|
|
557
|
+
function orderKey(buyerPubkey, orderId) {
|
|
558
|
+
return `${buyerPubkey}:${orderId}`;
|
|
559
|
+
}
|
|
560
|
+
function claimPayment(state, signature, key) {
|
|
561
|
+
const holder = state.claims[signature];
|
|
562
|
+
if (holder !== void 0 && holder !== key) {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
state.claims[signature] = key;
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
function openOrders(state, now, catchUpSecs) {
|
|
569
|
+
return Object.values(state.orders).filter(
|
|
570
|
+
(order) => order.paid === void 0 && now - order.createdAt <= catchUpSecs
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
function undeliveredOrders(state) {
|
|
574
|
+
return Object.values(state.orders).filter(
|
|
575
|
+
(order) => order.paid !== void 0 && order.deliveredAt === void 0
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
function pruneExpiredOrders(state, now) {
|
|
579
|
+
for (const [key, order] of Object.entries(state.orders)) {
|
|
580
|
+
if (order.paid === void 0 && now - order.createdAt > CATCH_UP_SECS + MAX_FUTURE_SKEW_SECS) {
|
|
581
|
+
delete state.orders[key];
|
|
582
|
+
(state.closedOrders ??= {})[key] = true;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function recordReport(order, tx, arrivedAt) {
|
|
587
|
+
order.reportedTxs.push(tx);
|
|
588
|
+
order.recheckedAt = { ...order.recheckedAt, [tx]: arrivedAt };
|
|
589
|
+
}
|
|
590
|
+
function loadLedger(path) {
|
|
591
|
+
let text;
|
|
592
|
+
try {
|
|
593
|
+
text = readFileSync(path, "utf8");
|
|
594
|
+
} catch {
|
|
595
|
+
return emptyLedger();
|
|
596
|
+
}
|
|
597
|
+
const state = JSON.parse(text);
|
|
598
|
+
if (state.version !== 1) {
|
|
599
|
+
throw new Error(`Unknown ledger version in ${path}`);
|
|
600
|
+
}
|
|
601
|
+
state.scans ??= {};
|
|
602
|
+
return state;
|
|
603
|
+
}
|
|
604
|
+
function saveLedger(path, state) {
|
|
605
|
+
const temporary = `${path}.tmp`;
|
|
606
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}
|
|
607
|
+
`, { mode: 384 });
|
|
608
|
+
renameSync(temporary, path);
|
|
609
|
+
}
|
|
610
|
+
function isSolanaSignature(value) {
|
|
611
|
+
try {
|
|
612
|
+
return isSignature(value);
|
|
613
|
+
} catch {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/intake.ts
|
|
619
|
+
function storeIdentity(storePubkey, d, mediums) {
|
|
620
|
+
return { storePubkey, productAddress: productAddress({ storePubkey, d }), mediums };
|
|
621
|
+
}
|
|
622
|
+
function intake(state, unwrapped, store, now = Math.floor(Date.now() / 1e3)) {
|
|
623
|
+
if (state.seenRumors[unwrapped.rumorId] === true) {
|
|
624
|
+
return { kind: "ignored", reason: "seen" };
|
|
625
|
+
}
|
|
626
|
+
const { message } = unwrapped;
|
|
627
|
+
if (unwrapped.recipientPubkey !== store.storePubkey) {
|
|
628
|
+
return { kind: "ignored", reason: "not_for_this_store" };
|
|
629
|
+
}
|
|
630
|
+
if (message.type === "order") {
|
|
631
|
+
if (message.storePubkey !== store.storePubkey) {
|
|
632
|
+
return { kind: "ignored", reason: "not_for_this_store" };
|
|
633
|
+
}
|
|
634
|
+
const [item, ...rest] = message.items;
|
|
635
|
+
if (unwrapped.createdAt < EARLIEST_ORDER_SECS || item === void 0 || rest.length > 0 || item.product !== store.productAddress || item.quantity !== 1) {
|
|
636
|
+
return { kind: "ignored", reason: "not_a_direct_order" };
|
|
637
|
+
}
|
|
638
|
+
const key = orderKey(unwrapped.senderPubkey, message.orderId);
|
|
639
|
+
state.seenRumors[unwrapped.rumorId] = true;
|
|
640
|
+
if (state.orders[key] !== void 0 || state.closedOrders?.[key] === true) {
|
|
641
|
+
return { kind: "ignored", reason: "order_id_reused" };
|
|
642
|
+
}
|
|
643
|
+
const reference = deriveOrderPaymentReference({
|
|
644
|
+
storePubkey: store.storePubkey,
|
|
645
|
+
buyerPubkey: unwrapped.senderPubkey,
|
|
646
|
+
orderId: message.orderId
|
|
647
|
+
});
|
|
648
|
+
const order = {
|
|
649
|
+
key,
|
|
650
|
+
buyerPubkey: unwrapped.senderPubkey,
|
|
651
|
+
orderId: message.orderId,
|
|
652
|
+
rumorId: unwrapped.rumorId,
|
|
653
|
+
createdAt: unwrapped.createdAt,
|
|
654
|
+
reference: reference.solana,
|
|
655
|
+
reportedTxs: [],
|
|
656
|
+
...message.email === void 0 ? {} : { email: message.email }
|
|
657
|
+
};
|
|
658
|
+
state.orders[key] = order;
|
|
659
|
+
return { kind: "order", order };
|
|
660
|
+
}
|
|
661
|
+
if (message.type === "receipt") {
|
|
662
|
+
if (message.storePubkey !== store.storePubkey) {
|
|
663
|
+
return { kind: "ignored", reason: "not_for_this_store" };
|
|
664
|
+
}
|
|
665
|
+
const order = state.orders[orderKey(unwrapped.senderPubkey, message.orderId)];
|
|
666
|
+
if (order === void 0) {
|
|
667
|
+
return { kind: "ignored", reason: "unknown_order" };
|
|
668
|
+
}
|
|
669
|
+
const { medium, reference, tx } = message.payment;
|
|
670
|
+
if (reference !== order.reference || !store.mediums.includes(medium) || !isSolanaSignature(tx)) {
|
|
671
|
+
return { kind: "ignored", reason: "foreign_payment" };
|
|
672
|
+
}
|
|
673
|
+
if (!order.reportedTxs.includes(tx) && order.reportedTxs.length >= MAX_RECEIPTS_PER_ORDER) {
|
|
674
|
+
return { kind: "ignored", reason: "too_many_receipts" };
|
|
675
|
+
}
|
|
676
|
+
state.seenRumors[unwrapped.rumorId] = true;
|
|
677
|
+
const isNew = !order.reportedTxs.includes(tx);
|
|
678
|
+
if (isNew) {
|
|
679
|
+
recordReport(order, tx, now);
|
|
680
|
+
}
|
|
681
|
+
return { kind: "receipt", order, tx, isNew };
|
|
682
|
+
}
|
|
683
|
+
return { kind: "ignored", reason: "not_handled" };
|
|
684
|
+
}
|
|
685
|
+
var EOSE_CLOSE_REASON = "closed automatically on eose";
|
|
686
|
+
var PAGE_WAIT_MS = 3e4;
|
|
687
|
+
var LIBRARY_WAIT_MS = PAGE_WAIT_MS + 15e3;
|
|
688
|
+
var BACKFILL_RETRY_MS = 3e4;
|
|
689
|
+
function resumePointAfterSweep(allLive, queuedAt, current) {
|
|
690
|
+
return allLive ? queuedAt : current;
|
|
691
|
+
}
|
|
692
|
+
var SeenWraps = class {
|
|
693
|
+
seen = /* @__PURE__ */ new Map();
|
|
694
|
+
/** True the first time a wrap id is offered, false after. */
|
|
695
|
+
admit(wrap) {
|
|
696
|
+
if (this.seen.has(wrap.id)) {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
this.seen.set(wrap.id, wrap.created_at);
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
/** Let a wrap be offered again (one that could not be acted on yet). */
|
|
703
|
+
forget(id) {
|
|
704
|
+
this.seen.delete(id);
|
|
705
|
+
}
|
|
706
|
+
/** Forget wraps dated before `oldestReadable`: no read reaches them any more. */
|
|
707
|
+
prune(oldestReadable) {
|
|
708
|
+
for (const [id, createdAt] of this.seen) {
|
|
709
|
+
if (createdAt < oldestReadable) {
|
|
710
|
+
this.seen.delete(id);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
get size() {
|
|
715
|
+
return this.seen.size;
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
function readSince(from) {
|
|
719
|
+
return from - WRAP_BACKDATE_SECS - MAX_FUTURE_SKEW_SECS;
|
|
720
|
+
}
|
|
721
|
+
function wrapFilter(storePubkey, since, until) {
|
|
722
|
+
return {
|
|
723
|
+
kinds: [KIND_GIFT_WRAP],
|
|
724
|
+
"#p": [storePubkey],
|
|
725
|
+
since,
|
|
726
|
+
...until === void 0 ? {} : { until }
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
function nextPageUntil(since, until, received, oldest) {
|
|
730
|
+
if (received === 0 || oldest <= since) {
|
|
731
|
+
return void 0;
|
|
732
|
+
}
|
|
733
|
+
const next = oldest < until ? oldest : until - 1;
|
|
734
|
+
return next > since ? next : void 0;
|
|
735
|
+
}
|
|
736
|
+
var InboxListener = class {
|
|
737
|
+
constructor(options) {
|
|
738
|
+
this.options = options;
|
|
739
|
+
this.setTimer = options.setTimer ?? ((task, ms) => {
|
|
740
|
+
const handle = setTimeout(task, ms);
|
|
741
|
+
return () => clearTimeout(handle);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
live = /* @__PURE__ */ new Set();
|
|
745
|
+
attempts = /* @__PURE__ */ new Map();
|
|
746
|
+
setTimer;
|
|
747
|
+
/** Whether every one of `relays` is currently read through and listening. */
|
|
748
|
+
allLive(relays) {
|
|
749
|
+
return relays.every((relay) => this.live.has(relay));
|
|
750
|
+
}
|
|
751
|
+
isLive(relay) {
|
|
752
|
+
return this.live.has(relay);
|
|
753
|
+
}
|
|
754
|
+
/** Listen on `relay` for every wrap sent since `from`. */
|
|
755
|
+
listen(relay, from) {
|
|
756
|
+
const { pool, storePubkey, auth, onWrap, log: log2, now } = this.options;
|
|
757
|
+
const since = readSince(from);
|
|
758
|
+
const openedAt = now();
|
|
759
|
+
let closed = false;
|
|
760
|
+
let readThrough = false;
|
|
761
|
+
const runBackfill = () => {
|
|
762
|
+
if (closed) {
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
void this.backfill(relay, since, openedAt).then((done) => {
|
|
766
|
+
if (closed) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (done) {
|
|
770
|
+
readThrough = true;
|
|
771
|
+
this.attempts.set(relay, 0);
|
|
772
|
+
this.live.add(relay);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
log2(`could not read the stored wraps of ${relay}; trying again`);
|
|
776
|
+
this.setTimer(runBackfill, BACKFILL_RETRY_MS);
|
|
777
|
+
});
|
|
778
|
+
};
|
|
779
|
+
pool.subscribe([relay], wrapFilter(storePubkey, since), {
|
|
780
|
+
onauth: auth,
|
|
781
|
+
onevent: onWrap,
|
|
782
|
+
// nostr-tools also calls this right before `onclose` when a subscription
|
|
783
|
+
// fails: the backfill starts on the next turn, and only if still open.
|
|
784
|
+
oneose: () => this.setTimer(runBackfill, 0),
|
|
785
|
+
onclose: (reasons) => {
|
|
786
|
+
closed = true;
|
|
787
|
+
this.live.delete(relay);
|
|
788
|
+
const attempt = this.attempts.get(relay) ?? 0;
|
|
789
|
+
this.attempts.set(relay, attempt + 1);
|
|
790
|
+
const pause = RESUBSCRIBE_BACKOFF_MS[Math.min(attempt, RESUBSCRIBE_BACKOFF_MS.length - 1)] ?? 6e4;
|
|
791
|
+
const resumeFrom = readThrough ? now() : from;
|
|
792
|
+
log2(`subscription on ${relay} closed (${reasons.join(", ")}); again in ${pause} ms`);
|
|
793
|
+
this.setTimer(() => this.listen(relay, resumeFrom), pause);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
/** Page backwards through `relay`'s stored wraps in `[since, until]`; true only when read to the end. */
|
|
798
|
+
backfill(relay, since, until) {
|
|
799
|
+
const { pool, storePubkey, auth, onWrap } = this.options;
|
|
800
|
+
return new Promise((resolve2) => {
|
|
801
|
+
let received = 0;
|
|
802
|
+
let oldest = until;
|
|
803
|
+
let timedOut = false;
|
|
804
|
+
let cancelTimer;
|
|
805
|
+
const subscription = pool.subscribeEose([relay], wrapFilter(storePubkey, since, until), {
|
|
806
|
+
maxWait: LIBRARY_WAIT_MS,
|
|
807
|
+
onauth: auth,
|
|
808
|
+
onevent: (wrap) => {
|
|
809
|
+
received += 1;
|
|
810
|
+
oldest = Math.min(oldest, wrap.created_at);
|
|
811
|
+
onWrap(wrap);
|
|
812
|
+
},
|
|
813
|
+
onclose: (reasons) => {
|
|
814
|
+
cancelTimer?.();
|
|
815
|
+
if (timedOut || !reasons.every((reason) => reason === EOSE_CLOSE_REASON)) {
|
|
816
|
+
resolve2(false);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const next = nextPageUntil(since, until, received, oldest);
|
|
820
|
+
if (next === void 0) {
|
|
821
|
+
resolve2(true);
|
|
822
|
+
} else {
|
|
823
|
+
void this.backfill(relay, since, next).then(resolve2);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
cancelTimer = this.setTimer(() => {
|
|
828
|
+
timedOut = true;
|
|
829
|
+
subscription.close();
|
|
830
|
+
}, PAGE_WAIT_MS);
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
function buildDeliveryReply(order, delivery, storeSecretKey, createdAt) {
|
|
835
|
+
if (order.paid === void 0) {
|
|
836
|
+
throw new Error("Only a paid order is delivered");
|
|
837
|
+
}
|
|
838
|
+
const rumor = buildOrderMessage(
|
|
839
|
+
{
|
|
840
|
+
type: "status",
|
|
841
|
+
buyerPubkey: order.buyerPubkey,
|
|
842
|
+
orderId: order.orderId,
|
|
843
|
+
status: "completed",
|
|
844
|
+
delivery,
|
|
845
|
+
receipt: {
|
|
846
|
+
medium: order.paid.medium,
|
|
847
|
+
tx: order.paid.signature,
|
|
848
|
+
amount: order.paid.amount,
|
|
849
|
+
fee: "0"
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
createdAt
|
|
853
|
+
);
|
|
854
|
+
return wrapOrderMessage(rumor, storeSecretKey, order.buyerPubkey);
|
|
855
|
+
}
|
|
856
|
+
function deliveryDone(accepted, relays, paidAgeSecs) {
|
|
857
|
+
if (accepted === 0) {
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
return accepted >= Math.min(DELIVERY_RELAYS_WANTED, relays) || paidAgeSecs >= DELIVERY_SETTLE_SECS;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/terms.ts
|
|
864
|
+
function sameTerms(left, right) {
|
|
865
|
+
return left.caip19 === right.caip19 && left.payout === right.payout && left.amount === right.amount;
|
|
866
|
+
}
|
|
867
|
+
function publishTerms(periods, terms, at) {
|
|
868
|
+
const standing = periods.find(
|
|
869
|
+
(period) => period.until === void 0 && period.terms.caip19 === terms.caip19
|
|
870
|
+
);
|
|
871
|
+
if (standing !== void 0 && sameTerms(standing.terms, terms)) {
|
|
872
|
+
return [...periods];
|
|
873
|
+
}
|
|
874
|
+
return [
|
|
875
|
+
...periods.map(
|
|
876
|
+
(period) => period === standing ? { ...period, until: Math.max(at, period.from) } : period
|
|
877
|
+
),
|
|
878
|
+
{ terms, from: at }
|
|
879
|
+
];
|
|
880
|
+
}
|
|
881
|
+
function retireTerms(periods, offered, at) {
|
|
882
|
+
return periods.map(
|
|
883
|
+
(period) => period.until === void 0 && !offered.includes(period.terms.caip19) ? { ...period, until: Math.max(at, period.from) } : period
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
function termsAt(periods, blockTime) {
|
|
887
|
+
const earliest = blockTime - TERMS_WINDOW_SECS;
|
|
888
|
+
const found = [];
|
|
889
|
+
for (const period of periods) {
|
|
890
|
+
const offered = period.from <= blockTime && (period.until === void 0 || period.until > earliest);
|
|
891
|
+
if (offered && !found.some((terms) => sameTerms(terms, period.terms))) {
|
|
892
|
+
found.push(period.terms);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return found;
|
|
896
|
+
}
|
|
897
|
+
function termsSince(periods, since) {
|
|
898
|
+
const earliest = since - TERMS_WINDOW_SECS;
|
|
899
|
+
const found = [];
|
|
900
|
+
for (const period of periods) {
|
|
901
|
+
if ((period.until === void 0 || period.until > earliest) && !found.some((terms) => sameTerms(terms, period.terms))) {
|
|
902
|
+
found.push(period.terms);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return found;
|
|
906
|
+
}
|
|
907
|
+
function standingTerms(periods) {
|
|
908
|
+
return periods.filter((period) => period.until === void 0).map((period) => period.terms);
|
|
909
|
+
}
|
|
910
|
+
function visibleTerms(standing, next, listingOut, payoutsOut) {
|
|
911
|
+
const listed = listingOut ? next : standing;
|
|
912
|
+
const payouts = payoutsOut ? next : standing;
|
|
913
|
+
const visible = [];
|
|
914
|
+
for (const { caip19, amount } of listed) {
|
|
915
|
+
const payout = payouts.find((terms) => terms.caip19 === caip19)?.payout;
|
|
916
|
+
if (payout !== void 0) {
|
|
917
|
+
visible.push({ caip19, payout, amount });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return visible;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/solana.ts
|
|
924
|
+
function solanaAsset(terms, network) {
|
|
925
|
+
const caip19 = parseCaip19(terms.caip19);
|
|
926
|
+
if (caip19 === void 0 || caip19.chain.family !== "solana" || caip19.chain.network !== network) {
|
|
927
|
+
return void 0;
|
|
928
|
+
}
|
|
929
|
+
return caip19.asset;
|
|
930
|
+
}
|
|
931
|
+
var ASK_AGAIN_REASONS = ["rpc_error", "unreadable", "not_found"];
|
|
932
|
+
async function checkPayment(state, order, signature, context) {
|
|
933
|
+
const holder = state.claims[signature];
|
|
934
|
+
if (holder !== void 0 && holder !== order.key) {
|
|
935
|
+
return { kind: "refused", reason: "claimed_by_another_order" };
|
|
936
|
+
}
|
|
937
|
+
if (order.paid !== void 0) {
|
|
938
|
+
return order.paid.signature === signature ? { kind: "paid", order } : { kind: "refused", reason: "order_already_paid" };
|
|
939
|
+
}
|
|
940
|
+
const candidates = termsSince(state.terms, order.createdAt - ORDER_SCAN_MARGIN_SECS);
|
|
941
|
+
let askAgain = false;
|
|
942
|
+
for (const terms of candidates) {
|
|
943
|
+
const asset = solanaAsset(terms, context.network);
|
|
944
|
+
if (asset === void 0) {
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
const request = composeSolanaPaymentRequest({
|
|
948
|
+
recipient: terms.payout,
|
|
949
|
+
amount: BigInt(terms.amount),
|
|
950
|
+
asset,
|
|
951
|
+
network: context.network,
|
|
952
|
+
reference: order.reference,
|
|
953
|
+
createdAt: order.createdAt
|
|
954
|
+
});
|
|
955
|
+
const verdict = await verifyDirectSolanaPayment(context.rpc, request, signature);
|
|
956
|
+
if (!verdict.verified) {
|
|
957
|
+
askAgain ||= ASK_AGAIN_REASONS.includes(verdict.reason);
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (verdict.blockTime === null) {
|
|
961
|
+
askAgain = true;
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
const offered = termsAt(state.terms, verdict.blockTime).some(
|
|
965
|
+
(standing) => standing.caip19 === terms.caip19 && standing.payout === terms.payout && standing.amount === terms.amount
|
|
966
|
+
);
|
|
967
|
+
if (!offered) {
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
if (!claimPayment(state, signature, order.key)) {
|
|
971
|
+
return { kind: "refused", reason: "claimed_by_another_order" };
|
|
972
|
+
}
|
|
973
|
+
order.paid = {
|
|
974
|
+
signature,
|
|
975
|
+
amount: verdict.amount.toString(),
|
|
976
|
+
blockTime: verdict.blockTime,
|
|
977
|
+
caip19: terms.caip19,
|
|
978
|
+
medium: SOLANA_MEDIUMS[context.network]
|
|
979
|
+
};
|
|
980
|
+
return { kind: "paid", order };
|
|
981
|
+
}
|
|
982
|
+
return askAgain ? { kind: "ask_again" } : { kind: "refused", reason: "not_a_payment_for_this_order" };
|
|
983
|
+
}
|
|
984
|
+
async function receivingAccount(terms, asset) {
|
|
985
|
+
if (asset.mint === void 0) {
|
|
986
|
+
return terms.payout;
|
|
987
|
+
}
|
|
988
|
+
const [account] = await findAssociatedTokenPda({
|
|
989
|
+
owner: address(terms.payout),
|
|
990
|
+
mint: address(asset.mint),
|
|
991
|
+
tokenProgram: asset.tokenProgram === void 0 ? TOKEN_PROGRAM_ADDRESS : address(asset.tokenProgram)
|
|
992
|
+
});
|
|
993
|
+
return account;
|
|
994
|
+
}
|
|
995
|
+
async function boundReferences(instructions, terms, asset) {
|
|
996
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
997
|
+
for (const instruction of instructions) {
|
|
998
|
+
const { accounts } = instruction;
|
|
999
|
+
const tag = accounts[accounts.length - 1];
|
|
1000
|
+
const reference = accounts[accounts.length - 2];
|
|
1001
|
+
if (tag?.address === ELISYM_PROTOCOL_TAG && reference !== void 0) {
|
|
1002
|
+
candidates.add(reference.address);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
const bound = [];
|
|
1006
|
+
for (const reference of candidates) {
|
|
1007
|
+
const amount = await boundTransferAmount(instructions, {
|
|
1008
|
+
reference,
|
|
1009
|
+
recipient: terms.payout,
|
|
1010
|
+
asset
|
|
1011
|
+
});
|
|
1012
|
+
if (amount > 0n) {
|
|
1013
|
+
bound.push(reference);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return bound;
|
|
1017
|
+
}
|
|
1018
|
+
async function scanTransaction(signature, terms, asset, context) {
|
|
1019
|
+
let transaction;
|
|
1020
|
+
try {
|
|
1021
|
+
transaction = await context.rpc.getTransaction(signature, {
|
|
1022
|
+
commitment: "confirmed",
|
|
1023
|
+
encoding: "json",
|
|
1024
|
+
maxSupportedTransactionVersion: 0
|
|
1025
|
+
}).send();
|
|
1026
|
+
} catch {
|
|
1027
|
+
return void 0;
|
|
1028
|
+
}
|
|
1029
|
+
if (transaction === null || typeof transaction !== "object") {
|
|
1030
|
+
return void 0;
|
|
1031
|
+
}
|
|
1032
|
+
const blockTime = Reflect.get(transaction, "blockTime");
|
|
1033
|
+
if (typeof blockTime !== "number" && typeof blockTime !== "bigint") {
|
|
1034
|
+
return void 0;
|
|
1035
|
+
}
|
|
1036
|
+
let instructions;
|
|
1037
|
+
try {
|
|
1038
|
+
instructions = directInstructionsFromRpcTransaction(transaction);
|
|
1039
|
+
} catch {
|
|
1040
|
+
return void 0;
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
blockTime: Number(blockTime),
|
|
1044
|
+
references: await boundReferences(instructions, terms, asset)
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
async function catchUp(state, context, now) {
|
|
1048
|
+
const result = { paid: [], incomplete: [] };
|
|
1049
|
+
const open = openOrders(state, now, CATCH_UP_SECS);
|
|
1050
|
+
forgetOldScans(state, now);
|
|
1051
|
+
if (open.length === 0) {
|
|
1052
|
+
return result;
|
|
1053
|
+
}
|
|
1054
|
+
const byReference = new Map(open.map((order) => [order.reference, order]));
|
|
1055
|
+
const credit = async (order, signature) => {
|
|
1056
|
+
if (order.paid !== void 0 || order.refusedTxs?.includes(signature) === true || state.claims[signature] !== void 0) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const check2 = await checkPayment(state, order, signature, context);
|
|
1060
|
+
if (check2.kind === "paid") {
|
|
1061
|
+
result.paid.push(order);
|
|
1062
|
+
} else if (check2.kind === "refused") {
|
|
1063
|
+
order.refusedTxs = [...order.refusedTxs ?? [], signature];
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
const pending = [];
|
|
1067
|
+
for (const order of open) {
|
|
1068
|
+
for (const signature of order.reportedTxs) {
|
|
1069
|
+
if (order.paid === void 0 && order.refusedTxs?.includes(signature) !== true && state.claims[signature] === void 0) {
|
|
1070
|
+
pending.push({ order, signature, checkedAt: order.recheckedAt?.[signature] ?? 0 });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
pending.sort((left, right) => left.checkedAt - right.checkedAt);
|
|
1075
|
+
for (const { order, signature } of pending.slice(0, MAX_RECHECKS_PER_SWEEP)) {
|
|
1076
|
+
order.recheckedAt = { ...order.recheckedAt, [signature]: now };
|
|
1077
|
+
await credit(order, signature);
|
|
1078
|
+
}
|
|
1079
|
+
let oldest = Number.POSITIVE_INFINITY;
|
|
1080
|
+
for (const order of open) {
|
|
1081
|
+
oldest = Math.min(oldest, order.createdAt);
|
|
1082
|
+
}
|
|
1083
|
+
oldest -= ORDER_SCAN_MARGIN_SECS;
|
|
1084
|
+
const scannedAccounts = /* @__PURE__ */ new Set();
|
|
1085
|
+
for (const terms of termsSince(state.terms, oldest)) {
|
|
1086
|
+
const asset = solanaAsset(terms, context.network);
|
|
1087
|
+
if (asset === void 0) {
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
const account = await receivingAccount(terms, asset);
|
|
1091
|
+
if (scannedAccounts.has(account)) {
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
scannedAccounts.add(account);
|
|
1095
|
+
const listing = await listReferenceSignatures(context.rpc, account, {
|
|
1096
|
+
notBefore: oldest,
|
|
1097
|
+
commitment: "confirmed"
|
|
1098
|
+
});
|
|
1099
|
+
if (!listing.complete) {
|
|
1100
|
+
result.incomplete.push(account);
|
|
1101
|
+
}
|
|
1102
|
+
for (const entry of listing.signatures) {
|
|
1103
|
+
if (entry.failed || !isSolanaSignature(entry.signature) || entry.blockTime !== null && entry.blockTime < oldest) {
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
const memo = `${account}:${entry.signature}`;
|
|
1107
|
+
let scan = state.scans[memo];
|
|
1108
|
+
if (scan === void 0) {
|
|
1109
|
+
scan = await scanTransaction(entry.signature, terms, asset, context);
|
|
1110
|
+
if (scan === void 0) {
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
state.scans[memo] = scan;
|
|
1114
|
+
}
|
|
1115
|
+
for (const reference of scan.references) {
|
|
1116
|
+
const order = byReference.get(reference);
|
|
1117
|
+
if (order !== void 0) {
|
|
1118
|
+
await credit(order, entry.signature);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return result;
|
|
1124
|
+
}
|
|
1125
|
+
function forgetOldScans(state, now) {
|
|
1126
|
+
const horizon = now - CATCH_UP_SECS - ORDER_SCAN_MARGIN_SECS - TERMS_WINDOW_SECS;
|
|
1127
|
+
for (const [memo, scan] of Object.entries(state.scans)) {
|
|
1128
|
+
if (scan.blockTime < horizon) {
|
|
1129
|
+
delete state.scans[memo];
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// src/runtime.ts
|
|
1135
|
+
var MerchantRuntime = class {
|
|
1136
|
+
constructor(deps) {
|
|
1137
|
+
this.deps = deps;
|
|
1138
|
+
this.check = deps.checkPayment ?? checkPayment;
|
|
1139
|
+
this.sweepCatchUp = deps.catchUp ?? catchUp;
|
|
1140
|
+
}
|
|
1141
|
+
seenWraps = new SeenWraps();
|
|
1142
|
+
check;
|
|
1143
|
+
/** Live checks in the current minute: `[minute, count]`. */
|
|
1144
|
+
liveChecks = [0, 0];
|
|
1145
|
+
sweepCatchUp;
|
|
1146
|
+
/** When each delivered order's status was last sent again (this run only). */
|
|
1147
|
+
resentAt = /* @__PURE__ */ new Map();
|
|
1148
|
+
repeatsInFlight = 0;
|
|
1149
|
+
/**
|
|
1150
|
+
* Whether a wrap goes on the queue: not one dated further ahead than any
|
|
1151
|
+
* genuine wrap can be (NIP-59 only back-dates; nothing would ever prune it),
|
|
1152
|
+
* and not one already handed on (the live burst overlapping the backfill, a
|
|
1153
|
+
* reconnect re-reading its window).
|
|
1154
|
+
*/
|
|
1155
|
+
admit(wrap) {
|
|
1156
|
+
if (wrap.created_at > this.deps.now() + MAX_FUTURE_SKEW_SECS) {
|
|
1157
|
+
return false;
|
|
1158
|
+
}
|
|
1159
|
+
return this.seenWraps.admit(wrap);
|
|
1160
|
+
}
|
|
1161
|
+
/** Take one wrap in: an order is recorded; a receipt's transaction is checked. */
|
|
1162
|
+
async handleWrap(wrap) {
|
|
1163
|
+
const { state, store, storeSecretKey, save, log: log2 } = this.deps;
|
|
1164
|
+
const unwrapped = unwrapOrderMessage(wrap, storeSecretKey);
|
|
1165
|
+
if (unwrapped === void 0) {
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const result = intake(state, unwrapped, store, this.deps.now());
|
|
1169
|
+
const { message } = unwrapped;
|
|
1170
|
+
const again = result.kind === "ignored" && result.reason === "seen" || result.kind === "receipt";
|
|
1171
|
+
if (again && (message.type === "order" || message.type === "receipt")) {
|
|
1172
|
+
const known = state.orders[orderKey(unwrapped.senderPubkey, message.orderId)];
|
|
1173
|
+
if (known?.deliveredAt !== void 0 && message.storePubkey === store.storePubkey) {
|
|
1174
|
+
if (result.kind === "receipt") {
|
|
1175
|
+
save();
|
|
1176
|
+
}
|
|
1177
|
+
this.resend(known);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
if (result.kind === "ignored") {
|
|
1182
|
+
if (result.reason === "unknown_order") {
|
|
1183
|
+
this.seenWraps.forget(wrap.id);
|
|
1184
|
+
}
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
save();
|
|
1188
|
+
if (result.kind === "order") {
|
|
1189
|
+
log2(`order ${result.order.key}`);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (!result.isNew || result.order.refusedTxs?.includes(result.tx) === true || !this.takeLiveCheck()) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
await this.checkReported(result.order, result.tx, 0);
|
|
1196
|
+
}
|
|
1197
|
+
/** Check one reported transaction now; one the RPC does not see yet is tried again soon. */
|
|
1198
|
+
async checkReported(order, tx, attempt) {
|
|
1199
|
+
const { state, context, save, log: log2 } = this.deps;
|
|
1200
|
+
const check2 = await this.check(state, order, tx, context);
|
|
1201
|
+
log2(`receipt ${order.key} ${tx}: ${check2.kind}`);
|
|
1202
|
+
if (check2.kind === "refused") {
|
|
1203
|
+
order.refusedTxs = [...order.refusedTxs ?? [], tx];
|
|
1204
|
+
}
|
|
1205
|
+
save();
|
|
1206
|
+
if (check2.kind === "paid") {
|
|
1207
|
+
await this.deliverPending();
|
|
1208
|
+
} else if (check2.kind === "ask_again") {
|
|
1209
|
+
this.askAgain(order.key, tx, attempt);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
askAgain(key, tx, attempt) {
|
|
1213
|
+
const delay = ASK_AGAIN_DELAYS_MS[attempt];
|
|
1214
|
+
if (delay === void 0 || this.deps.later === void 0) {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
this.deps.later(delay, async () => {
|
|
1218
|
+
const order = this.deps.state.orders[key];
|
|
1219
|
+
if (order === void 0 || order.paid !== void 0 || !order.reportedTxs.includes(tx) || order.refusedTxs?.includes(tx) === true || !this.takeLiveCheck()) {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
await this.checkReported(order, tx, attempt + 1);
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Send a delivered order's status again, at most every `RESEND_EVERY_SECS`,
|
|
1227
|
+
* off the queue (it changes no ledger state) and a few at a time: one skipped
|
|
1228
|
+
* now is sent when the order is read again.
|
|
1229
|
+
*/
|
|
1230
|
+
resend(order) {
|
|
1231
|
+
const now = this.deps.now();
|
|
1232
|
+
const last = this.resentAt.get(order.key);
|
|
1233
|
+
if (last !== void 0 && now - last < RESEND_EVERY_SECS || this.repeatsInFlight >= MAX_REPEATS_IN_FLIGHT) {
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
this.resentAt.set(order.key, now);
|
|
1237
|
+
this.repeatsInFlight += 1;
|
|
1238
|
+
const { deliver: deliver2, log: log2 } = this.deps;
|
|
1239
|
+
void deliver2(order, []).then((taken) => {
|
|
1240
|
+
log2(`delivery for ${order.key} sent again: taken by ${taken.length}`);
|
|
1241
|
+
}).catch((error) => {
|
|
1242
|
+
log2(`delivery for ${order.key} not sent again: ${String(error)}`);
|
|
1243
|
+
}).finally(() => {
|
|
1244
|
+
this.repeatsInFlight -= 1;
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
takeLiveCheck() {
|
|
1248
|
+
const minute = Math.floor(this.deps.now() / 60);
|
|
1249
|
+
const [current, count] = this.liveChecks;
|
|
1250
|
+
const used = current === minute ? count : 0;
|
|
1251
|
+
if (used >= MAX_LIVE_CHECKS_PER_MINUTE) {
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
this.liveChecks = [minute, used + 1];
|
|
1255
|
+
return true;
|
|
1256
|
+
}
|
|
1257
|
+
/** One catch-up sweep; `allLive` and `queuedAt` were taken when it was queued. */
|
|
1258
|
+
async sweep(allLive, queuedAt) {
|
|
1259
|
+
const { state, context, save, log: log2, now } = this.deps;
|
|
1260
|
+
state.resumeAt = resumePointAfterSweep(allLive, queuedAt, state.resumeAt);
|
|
1261
|
+
pruneExpiredOrders(state, now());
|
|
1262
|
+
this.seenWraps.prune(readSince(now()));
|
|
1263
|
+
for (const [key, at] of this.resentAt) {
|
|
1264
|
+
if (now() - at >= RESEND_EVERY_SECS) {
|
|
1265
|
+
this.resentAt.delete(key);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
const result = await this.sweepCatchUp(state, context, now());
|
|
1270
|
+
if (result.paid.length > 0) {
|
|
1271
|
+
log2(`catch-up found ${result.paid.length} payment(s)`);
|
|
1272
|
+
}
|
|
1273
|
+
for (const account of result.incomplete) {
|
|
1274
|
+
log2(`warning: the history of ${account} was cut short; older payments may be missed`);
|
|
1275
|
+
}
|
|
1276
|
+
} finally {
|
|
1277
|
+
save();
|
|
1278
|
+
await this.deliverPending();
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Deliver every paid order not yet delivered, all at once (a hung relay costs
|
|
1283
|
+
* one deadline, not one per order), each only to the relays that have not
|
|
1284
|
+
* taken it yet (the buyer gets no pile of copies while one relay is down).
|
|
1285
|
+
* An order counts as delivered per `deliveryDone`, and it is saved.
|
|
1286
|
+
*/
|
|
1287
|
+
async deliverPending() {
|
|
1288
|
+
const { state, save, log: log2, now, deliver: deliver2, inboxRelayCount } = this.deps;
|
|
1289
|
+
const pending = undeliveredOrders(state);
|
|
1290
|
+
if (pending.length === 0) {
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const taken = await Promise.all(
|
|
1294
|
+
pending.map((order) => deliver2(order, order.deliveredTo ?? []).catch(() => []))
|
|
1295
|
+
);
|
|
1296
|
+
const at = now();
|
|
1297
|
+
let changed = false;
|
|
1298
|
+
pending.forEach((order, index) => {
|
|
1299
|
+
const fresh = (taken[index] ?? []).filter(
|
|
1300
|
+
(relay) => !(order.deliveredTo ?? []).includes(relay)
|
|
1301
|
+
);
|
|
1302
|
+
if (fresh.length > 0) {
|
|
1303
|
+
changed = true;
|
|
1304
|
+
order.deliveredTo = [...order.deliveredTo ?? [], ...fresh];
|
|
1305
|
+
}
|
|
1306
|
+
const paidAge = at - (order.paid?.blockTime ?? at);
|
|
1307
|
+
if (deliveryDone(order.deliveredTo?.length ?? 0, inboxRelayCount, paidAge)) {
|
|
1308
|
+
changed = true;
|
|
1309
|
+
order.deliveredAt = at;
|
|
1310
|
+
log2(`delivered ${order.key} (${order.paid?.signature ?? ""})`);
|
|
1311
|
+
} else {
|
|
1312
|
+
log2(`delivery for ${order.key} taken by ${order.deliveredTo?.length ?? 0}; will retry`);
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
if (changed) {
|
|
1316
|
+
save();
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
// src/setup-ledger.ts
|
|
1322
|
+
function payoutListDate(state, payouts, newestOnRelays, now) {
|
|
1323
|
+
const signed = state.payto;
|
|
1324
|
+
return signed !== void 0 && signed.payouts === payouts && newestOnRelays === signed.createdAt ? signed.createdAt : now;
|
|
1325
|
+
}
|
|
1326
|
+
function setupRefusal(state, productD) {
|
|
1327
|
+
if (state.productD !== void 0 && state.productD !== productD) {
|
|
1328
|
+
return `the product id changed from "${state.productD}" to "${productD}": the old listing would stay payable with no one taking its orders. Keep the id, or give a new product its own home.`;
|
|
1329
|
+
}
|
|
1330
|
+
return void 0;
|
|
1331
|
+
}
|
|
1332
|
+
function recordPublished(state, next, outcome, at, payoutList, productD) {
|
|
1333
|
+
const visible = visibleTerms(standingTerms(state.terms), next, outcome.listing, outcome.payouts);
|
|
1334
|
+
state.terms = retireTerms(
|
|
1335
|
+
state.terms,
|
|
1336
|
+
visible.map((terms) => terms.caip19),
|
|
1337
|
+
at
|
|
1338
|
+
);
|
|
1339
|
+
for (const terms of visible) {
|
|
1340
|
+
state.terms = publishTerms(state.terms, terms, at);
|
|
1341
|
+
}
|
|
1342
|
+
if (outcome.payouts) {
|
|
1343
|
+
state.payto = payoutList;
|
|
1344
|
+
}
|
|
1345
|
+
if (outcome.listing) {
|
|
1346
|
+
state.productD = productD;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
var NOSTR_JSON_OWNER_NAME = "owner";
|
|
1350
|
+
function buildStoreEvents(config, keys, createdAt, options = {}) {
|
|
1351
|
+
const storePubkey = getPublicKey(keys.storeSecretKey);
|
|
1352
|
+
const ownerPubkey = getPublicKey(keys.ownerSecretKey);
|
|
1353
|
+
const price = { amount: config.product.priceUsd, currency: "USD" };
|
|
1354
|
+
const terms = config.payouts.map((payout) => {
|
|
1355
|
+
const caip19 = parseCaip19(payout.caip19);
|
|
1356
|
+
if (caip19 === void 0) {
|
|
1357
|
+
throw new Error(`Not a payable CAIP-19 asset: ${payout.caip19}`);
|
|
1358
|
+
}
|
|
1359
|
+
return {
|
|
1360
|
+
caip19: caip19.id,
|
|
1361
|
+
payout: payout.address,
|
|
1362
|
+
amount: priceInSubunits(price, caip19.asset).toString()
|
|
1363
|
+
};
|
|
1364
|
+
});
|
|
1365
|
+
const byStore = (template) => finalizeEvent(template, keys.storeSecretKey);
|
|
1366
|
+
const byOwner = (template) => finalizeEvent(template, keys.ownerSecretKey);
|
|
1367
|
+
const events = [
|
|
1368
|
+
byStore(
|
|
1369
|
+
buildProductEvent({
|
|
1370
|
+
d: config.product.d,
|
|
1371
|
+
title: config.product.title,
|
|
1372
|
+
description: config.product.description,
|
|
1373
|
+
...config.product.summary === void 0 ? {} : { summary: config.product.summary },
|
|
1374
|
+
price,
|
|
1375
|
+
accept: config.payouts.map((payout) => payout.caip19),
|
|
1376
|
+
createdAt
|
|
1377
|
+
})
|
|
1378
|
+
),
|
|
1379
|
+
byStore(
|
|
1380
|
+
buildStoreProfileEvent({
|
|
1381
|
+
name: config.name,
|
|
1382
|
+
ownerPubkey,
|
|
1383
|
+
createdAt,
|
|
1384
|
+
...config.nip05 === void 0 ? {} : { nip05: config.nip05 }
|
|
1385
|
+
})
|
|
1386
|
+
),
|
|
1387
|
+
byStore({
|
|
1388
|
+
kind: KIND_INBOX_RELAYS,
|
|
1389
|
+
created_at: createdAt,
|
|
1390
|
+
tags: config.inboxRelays.map((relay) => ["relay", relay]),
|
|
1391
|
+
content: ""
|
|
1392
|
+
}),
|
|
1393
|
+
byOwner(
|
|
1394
|
+
buildPaytoEvent({
|
|
1395
|
+
ownerPubkey,
|
|
1396
|
+
accept: config.payouts,
|
|
1397
|
+
createdAt: options.paytoCreatedAt ?? createdAt
|
|
1398
|
+
})
|
|
1399
|
+
),
|
|
1400
|
+
byOwner(buildStoreAuthEvent({ storePubkey, mode: "self-host", createdAt }))
|
|
1401
|
+
];
|
|
1402
|
+
const local = config.nip05 === void 0 ? "_" : splitNip05(config.nip05)?.local ?? "_";
|
|
1403
|
+
if (local === NOSTR_JSON_OWNER_NAME) {
|
|
1404
|
+
throw new Error(
|
|
1405
|
+
`The store's nip05 name cannot be "${NOSTR_JSON_OWNER_NAME}": it names the owner`
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
events,
|
|
1410
|
+
naddr: encodeProductNaddr({ storePubkey, d: config.product.d }, [...options.hints ?? []]),
|
|
1411
|
+
terms,
|
|
1412
|
+
nostrJson: { names: { [local]: storePubkey, [NOSTR_JSON_OWNER_NAME]: ownerPubkey } }
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// src/cli.ts
|
|
1417
|
+
var USAGE = `usage: elisym-merchant <command> [--home <dir>]
|
|
1418
|
+
|
|
1419
|
+
init [--network devnet|mainnet]
|
|
1420
|
+
make the home: a config.json to edit and the store's keys
|
|
1421
|
+
setup check the inbox relays and publish the store (after editing config.json)
|
|
1422
|
+
run take orders, verify payments, deliver
|
|
1423
|
+
orders list the orders: paid, delivered, the buyer's email
|
|
1424
|
+
check check the inbox relays, the owner's payout list and the domain
|
|
1425
|
+
|
|
1426
|
+
The home is --home, else $ELISYM_MERCHANT_HOME, else ~/.elisym-merchant.
|
|
1427
|
+
It holds the store's secret keys: keep it private and back it up.`;
|
|
1428
|
+
function storeAuth(keys) {
|
|
1429
|
+
return async (template) => finalizeEvent(template, keys.storeSecretKey);
|
|
1430
|
+
}
|
|
1431
|
+
function nowSecs() {
|
|
1432
|
+
return Math.floor(Date.now() / 1e3);
|
|
1433
|
+
}
|
|
1434
|
+
function log(message) {
|
|
1435
|
+
console.log(`${(/* @__PURE__ */ new Date()).toISOString()} ${message}`);
|
|
1436
|
+
}
|
|
1437
|
+
function errorText3(error) {
|
|
1438
|
+
return error instanceof Error ? error.message : String(error);
|
|
1439
|
+
}
|
|
1440
|
+
function parseArgs(argv) {
|
|
1441
|
+
const args = { command: void 0, home: void 0, network: void 0 };
|
|
1442
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1443
|
+
const arg = argv[index];
|
|
1444
|
+
if (arg === "--home" || arg === "--network") {
|
|
1445
|
+
const value = argv[index + 1];
|
|
1446
|
+
if (value === void 0 || value.startsWith("--")) {
|
|
1447
|
+
throw new Error(`${arg} needs a value`);
|
|
1448
|
+
}
|
|
1449
|
+
args[arg === "--home" ? "home" : "network"] = value;
|
|
1450
|
+
index += 1;
|
|
1451
|
+
} else if (args.command === void 0 && arg !== void 0 && !arg.startsWith("--")) {
|
|
1452
|
+
args.command = arg;
|
|
1453
|
+
} else {
|
|
1454
|
+
throw new Error(`unknown argument ${arg ?? ""}`);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return args;
|
|
1458
|
+
}
|
|
1459
|
+
function init(home, network) {
|
|
1460
|
+
if (network !== void 0 && network !== "devnet" && network !== "mainnet") {
|
|
1461
|
+
throw new Error("--network is devnet or mainnet");
|
|
1462
|
+
}
|
|
1463
|
+
ensureHome(home);
|
|
1464
|
+
if (existsSync(home.config)) {
|
|
1465
|
+
console.log(`config ${home.config} (kept)`);
|
|
1466
|
+
} else {
|
|
1467
|
+
writeFileSync(
|
|
1468
|
+
home.config,
|
|
1469
|
+
`${JSON.stringify(configTemplate(network === "mainnet" ? "mainnet" : "devnet"), null, 2)}
|
|
1470
|
+
`,
|
|
1471
|
+
{ mode: 384, flag: "wx" }
|
|
1472
|
+
);
|
|
1473
|
+
console.log(`config ${home.config} (edit it)`);
|
|
1474
|
+
}
|
|
1475
|
+
const keys = loadOrCreateKeys(home, true);
|
|
1476
|
+
console.log(`store ${getPublicKey(keys.storeSecretKey)}`);
|
|
1477
|
+
console.log(`owner ${getPublicKey(keys.ownerSecretKey)}`);
|
|
1478
|
+
console.log("next edit config.json, then run setup");
|
|
1479
|
+
}
|
|
1480
|
+
async function reportInboxRelays(pool, config, keys) {
|
|
1481
|
+
const reader = new SimplePool();
|
|
1482
|
+
let verdicts;
|
|
1483
|
+
try {
|
|
1484
|
+
verdicts = await checkInboxRelays(
|
|
1485
|
+
pool,
|
|
1486
|
+
reader,
|
|
1487
|
+
config.inboxRelays,
|
|
1488
|
+
keys.storeSecretKey,
|
|
1489
|
+
storeAuth(keys),
|
|
1490
|
+
log,
|
|
1491
|
+
nowSecs()
|
|
1492
|
+
);
|
|
1493
|
+
} finally {
|
|
1494
|
+
reader.destroy();
|
|
1495
|
+
}
|
|
1496
|
+
for (const verdict of verdicts) {
|
|
1497
|
+
let state = "refuses gift wraps for any key";
|
|
1498
|
+
if (verdict.serves) {
|
|
1499
|
+
state = "ok";
|
|
1500
|
+
} else if (verdict.accepts) {
|
|
1501
|
+
state = "takes gift wraps but does not serve them back";
|
|
1502
|
+
}
|
|
1503
|
+
console.log(`relay ${verdict.relay}: ${state}`);
|
|
1504
|
+
}
|
|
1505
|
+
console.log(
|
|
1506
|
+
"note each inbox relay must keep gift wraps (kind 1059) for at least two days past their date"
|
|
1507
|
+
);
|
|
1508
|
+
return verdicts.filter((verdict) => !verdict.serves).map((verdict) => verdict.relay);
|
|
1509
|
+
}
|
|
1510
|
+
async function reportDomain(home, config, nostrJson) {
|
|
1511
|
+
const split = config.nip05 === void 0 ? void 0 : splitNip05(config.nip05);
|
|
1512
|
+
if (split === void 0) {
|
|
1513
|
+
console.log("level C (no nip05)");
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
if (split.local !== "_") {
|
|
1517
|
+
console.log("level C: a named nip05 is not domain-wide; use _@domain or a bare domain for A");
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
writeFileSync(home.nostrJson, `${JSON.stringify(nostrJson)}
|
|
1521
|
+
`);
|
|
1522
|
+
console.log(
|
|
1523
|
+
`nostr serve ${home.nostrJson} at https://${split.domain}/.well-known/nostr.json with "Access-Control-Allow-Origin: *"`
|
|
1524
|
+
);
|
|
1525
|
+
const verdict = await checkDomain(split.domain, nostrJson);
|
|
1526
|
+
console.log(
|
|
1527
|
+
verdict.ok ? `level A: ${verdict.url} serves it` : `level C until ${verdict.url} serves it: ${verdict.problem ?? ""}`
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
async function setup(home) {
|
|
1531
|
+
takeLock(home);
|
|
1532
|
+
const config = loadConfig(home.config);
|
|
1533
|
+
const keys = loadOrCreateKeys(home, false);
|
|
1534
|
+
const state = loadLedger(home.ledger);
|
|
1535
|
+
const refusal = setupRefusal(state, config.product.d);
|
|
1536
|
+
if (refusal !== void 0) {
|
|
1537
|
+
throw new Error(refusal);
|
|
1538
|
+
}
|
|
1539
|
+
const pool = new SimplePool();
|
|
1540
|
+
try {
|
|
1541
|
+
const failing = await reportInboxRelays(pool, config, keys);
|
|
1542
|
+
if (failing.length > 0) {
|
|
1543
|
+
throw new Error(
|
|
1544
|
+
`these inbox relays do not take and serve gift wraps for any key: ${failing.join(", ")}. Replace them in config.json.`
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
const relays = [.../* @__PURE__ */ new Set([...OFFER_RELAYS, ...config.inboxRelays])];
|
|
1548
|
+
const now = nowSecs();
|
|
1549
|
+
const payouts = JSON.stringify(config.payouts);
|
|
1550
|
+
const newest2 = await newestPayoutList(pool, relays, getPublicKey(keys.ownerSecretKey));
|
|
1551
|
+
const paytoCreatedAt = payoutListDate(state, payouts, newest2?.created_at, now);
|
|
1552
|
+
const built = buildStoreEvents(config, keys, now, {
|
|
1553
|
+
hints: config.inboxRelays.slice(0, 2),
|
|
1554
|
+
paytoCreatedAt
|
|
1555
|
+
});
|
|
1556
|
+
const acceptedKinds = /* @__PURE__ */ new Set();
|
|
1557
|
+
const everywhereKinds = /* @__PURE__ */ new Set();
|
|
1558
|
+
for (const event of built.events) {
|
|
1559
|
+
const accepted = await publishToRelays(pool, relays, event, storeAuth(keys), log);
|
|
1560
|
+
log(`kind ${event.kind}: accepted by ${accepted.length}/${relays.length}`);
|
|
1561
|
+
if (accepted.length > 0) {
|
|
1562
|
+
acceptedKinds.add(event.kind);
|
|
1563
|
+
}
|
|
1564
|
+
if (accepted.some((relay) => OFFER_RELAYS.includes(relay))) {
|
|
1565
|
+
everywhereKinds.add(event.kind);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
const outcome = {
|
|
1569
|
+
listing: acceptedKinds.has(KIND_PRODUCT),
|
|
1570
|
+
payouts: acceptedKinds.has(KIND_PAYTO)
|
|
1571
|
+
};
|
|
1572
|
+
if (!outcome.listing && !outcome.payouts) {
|
|
1573
|
+
throw new Error(
|
|
1574
|
+
"neither the listing nor the payout list reached a relay: the ledger is left as it was"
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
recordPublished(
|
|
1578
|
+
state,
|
|
1579
|
+
built.terms,
|
|
1580
|
+
outcome,
|
|
1581
|
+
now - TERMS_CLOCK_MARGIN_SECS,
|
|
1582
|
+
{ createdAt: paytoCreatedAt, payouts },
|
|
1583
|
+
config.product.d
|
|
1584
|
+
);
|
|
1585
|
+
state.resumeAt ??= now;
|
|
1586
|
+
saveLedger(home.ledger, state);
|
|
1587
|
+
console.log(`store ${getPublicKey(keys.storeSecretKey)}`);
|
|
1588
|
+
console.log(`owner ${getPublicKey(keys.ownerSecretKey)}`);
|
|
1589
|
+
console.log(`naddr ${built.naddr}`);
|
|
1590
|
+
await reportDomain(home, config, built.nostrJson);
|
|
1591
|
+
const missing = [
|
|
1592
|
+
[KIND_PRODUCT, "the listing"],
|
|
1593
|
+
[KIND_PAYTO, "the payout list"],
|
|
1594
|
+
[KIND_INBOX_RELAYS, "the inbox list"]
|
|
1595
|
+
].filter(([kind]) => !everywhereKinds.has(kind)).map(([, name]) => name);
|
|
1596
|
+
if (missing.length > 0) {
|
|
1597
|
+
throw new Error(
|
|
1598
|
+
`${missing.join(", ")} reached none of the default relays: buyers may see part of the change (the ledger follows what they see). Run setup again.`
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
} finally {
|
|
1602
|
+
pool.destroy();
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
async function refuseOffersNotHonoured(pool, config, keys, state) {
|
|
1606
|
+
const storePubkey = getPublicKey(keys.storeSecretKey);
|
|
1607
|
+
const views = [OFFER_RELAYS, [.../* @__PURE__ */ new Set([...OFFER_RELAYS, ...config.inboxRelays])]];
|
|
1608
|
+
const read = await Promise.all(
|
|
1609
|
+
views.map(async (relays) => {
|
|
1610
|
+
const [listing, payoutList, inboxList] = await Promise.all([
|
|
1611
|
+
newestListing(pool, relays, storePubkey, config.product.d),
|
|
1612
|
+
newestPayoutList(pool, relays, getPublicKey(keys.ownerSecretKey)),
|
|
1613
|
+
newestInboxList(pool, relays, storePubkey)
|
|
1614
|
+
]);
|
|
1615
|
+
return { listing, payoutList, inboxList };
|
|
1616
|
+
})
|
|
1617
|
+
);
|
|
1618
|
+
const { problems, served } = offerProblems(
|
|
1619
|
+
read,
|
|
1620
|
+
config.inboxRelays,
|
|
1621
|
+
config.network,
|
|
1622
|
+
standingTerms(state.terms)
|
|
1623
|
+
);
|
|
1624
|
+
if (problems.length > 0) {
|
|
1625
|
+
throw new Error(
|
|
1626
|
+
`the relays offer buyers what this node does not honour: ${problems.join("; ")}. Run setup (it publishes what the config names and records it) before taking orders.`
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
if (!served) {
|
|
1630
|
+
log(
|
|
1631
|
+
"warning: no relay served the listing or the payout list; run setup if the store is not published"
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
var SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143 };
|
|
1636
|
+
function stopOnSignals() {
|
|
1637
|
+
for (const [signal, code] of Object.entries(SIGNAL_EXIT_CODES)) {
|
|
1638
|
+
process.once(signal, () => process.exit(code));
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
function deferSignals() {
|
|
1642
|
+
let requested;
|
|
1643
|
+
for (const [signal, code] of Object.entries(SIGNAL_EXIT_CODES)) {
|
|
1644
|
+
process.on(signal, () => {
|
|
1645
|
+
if (requested !== void 0) {
|
|
1646
|
+
process.exit(code);
|
|
1647
|
+
}
|
|
1648
|
+
requested = code;
|
|
1649
|
+
console.error("stopping once the ledger is written (again to stop now)");
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
return () => requested;
|
|
1653
|
+
}
|
|
1654
|
+
async function deliver(pool, order, skip, config, keys) {
|
|
1655
|
+
const reply = buildDeliveryReply(order, config.product.delivery, keys.storeSecretKey, nowSecs());
|
|
1656
|
+
return await publishToRelays(
|
|
1657
|
+
pool,
|
|
1658
|
+
config.inboxRelays.filter((relay) => !skip.includes(relay)),
|
|
1659
|
+
reply.recipientWrap,
|
|
1660
|
+
storeAuth(keys),
|
|
1661
|
+
log
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
async function run(home) {
|
|
1665
|
+
const config = loadConfig(home.config);
|
|
1666
|
+
const keys = loadOrCreateKeys(home, false);
|
|
1667
|
+
const storePubkey = getPublicKey(keys.storeSecretKey);
|
|
1668
|
+
takeLock(home);
|
|
1669
|
+
const pool = new SimplePool({ enablePing: true });
|
|
1670
|
+
const state = loadLedger(home.ledger);
|
|
1671
|
+
await refuseOffersNotHonoured(pool, config, keys, state);
|
|
1672
|
+
let queue = Promise.resolve();
|
|
1673
|
+
const enqueue = (task) => {
|
|
1674
|
+
queue = queue.then(task).catch((error) => {
|
|
1675
|
+
log(`error: ${errorText3(error)}`);
|
|
1676
|
+
});
|
|
1677
|
+
};
|
|
1678
|
+
const runtime = new MerchantRuntime({
|
|
1679
|
+
state,
|
|
1680
|
+
store: storeIdentity(storePubkey, config.product.d, [SOLANA_MEDIUMS[config.network]]),
|
|
1681
|
+
storeSecretKey: keys.storeSecretKey,
|
|
1682
|
+
context: { rpc: createSolanaRpc(config.rpcUrl), network: config.network },
|
|
1683
|
+
save: () => saveLedger(home.ledger, state),
|
|
1684
|
+
deliver: (order, skip) => deliver(pool, order, skip, config, keys),
|
|
1685
|
+
inboxRelayCount: config.inboxRelays.length,
|
|
1686
|
+
log,
|
|
1687
|
+
now: nowSecs,
|
|
1688
|
+
later: (ms, task) => {
|
|
1689
|
+
setTimeout(() => enqueue(task), ms);
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
const startedAt = nowSecs();
|
|
1693
|
+
const listener = new InboxListener({
|
|
1694
|
+
pool,
|
|
1695
|
+
storePubkey,
|
|
1696
|
+
auth: storeAuth(keys),
|
|
1697
|
+
onWrap: (wrap) => {
|
|
1698
|
+
if (runtime.admit(wrap)) {
|
|
1699
|
+
enqueue(() => runtime.handleWrap(wrap));
|
|
1700
|
+
}
|
|
1701
|
+
},
|
|
1702
|
+
log,
|
|
1703
|
+
now: nowSecs
|
|
1704
|
+
});
|
|
1705
|
+
state.resumeAt = Math.min(state.resumeAt ?? startedAt, startedAt);
|
|
1706
|
+
saveLedger(home.ledger, state);
|
|
1707
|
+
const resumeFrom = state.resumeAt;
|
|
1708
|
+
for (const relay of config.inboxRelays) {
|
|
1709
|
+
listener.listen(relay, resumeFrom);
|
|
1710
|
+
}
|
|
1711
|
+
log(`store ${storePubkey} listening on ${config.inboxRelays.join(", ")}`);
|
|
1712
|
+
let sweepQueued = false;
|
|
1713
|
+
const sweep = () => {
|
|
1714
|
+
if (sweepQueued) {
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
sweepQueued = true;
|
|
1718
|
+
const allLive = listener.allLive(config.inboxRelays);
|
|
1719
|
+
const queuedAt = nowSecs();
|
|
1720
|
+
enqueue(async () => {
|
|
1721
|
+
sweepQueued = false;
|
|
1722
|
+
await runtime.sweep(allLive, queuedAt);
|
|
1723
|
+
});
|
|
1724
|
+
};
|
|
1725
|
+
sweep();
|
|
1726
|
+
setInterval(sweep, CATCH_UP_INTERVAL_MS);
|
|
1727
|
+
}
|
|
1728
|
+
function orderStatus(order) {
|
|
1729
|
+
if (order.paid === void 0) {
|
|
1730
|
+
return "open";
|
|
1731
|
+
}
|
|
1732
|
+
return order.deliveredAt === void 0 ? "paid, delivering" : "delivered";
|
|
1733
|
+
}
|
|
1734
|
+
function listOrders(home) {
|
|
1735
|
+
const state = loadLedger(home.ledger);
|
|
1736
|
+
const all = Object.values(state.orders).sort((left, right) => left.createdAt - right.createdAt);
|
|
1737
|
+
for (const order of all) {
|
|
1738
|
+
const when = new Date(order.createdAt * 1e3).toISOString();
|
|
1739
|
+
const paid = order.paid === void 0 ? "" : ` ${order.paid.amount} ${order.paid.signature}`;
|
|
1740
|
+
const email = order.email === void 0 ? "" : ` email=${order.email}`;
|
|
1741
|
+
console.log(`${when} ${orderStatus(order)} ${order.key}${paid}${email}`);
|
|
1742
|
+
}
|
|
1743
|
+
console.log(`${all.length} order(s)`);
|
|
1744
|
+
}
|
|
1745
|
+
async function check(home) {
|
|
1746
|
+
const config = loadConfig(home.config);
|
|
1747
|
+
const keys = loadOrCreateKeys(home, false);
|
|
1748
|
+
if (heldByRunningMerchant(home)) {
|
|
1749
|
+
console.log("note a merchant is running on this home");
|
|
1750
|
+
}
|
|
1751
|
+
const pool = new SimplePool();
|
|
1752
|
+
try {
|
|
1753
|
+
const failing = await reportInboxRelays(pool, config, keys);
|
|
1754
|
+
let offers;
|
|
1755
|
+
try {
|
|
1756
|
+
await refuseOffersNotHonoured(pool, config, keys, loadLedger(home.ledger));
|
|
1757
|
+
} catch (error) {
|
|
1758
|
+
offers = error;
|
|
1759
|
+
}
|
|
1760
|
+
await reportDomain(home, config, buildStoreEvents(config, keys, nowSecs()).nostrJson);
|
|
1761
|
+
if (offers !== void 0) {
|
|
1762
|
+
throw offers;
|
|
1763
|
+
}
|
|
1764
|
+
if (failing.length > 0) {
|
|
1765
|
+
throw new Error(
|
|
1766
|
+
`these inbox relays do not take and serve gift wraps for any key: ${failing.join(", ")}`
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
} finally {
|
|
1770
|
+
pool.destroy();
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
async function main() {
|
|
1774
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1775
|
+
if (args.network !== void 0 && args.command !== "init") {
|
|
1776
|
+
throw new Error("--network is for init; the config names the network");
|
|
1777
|
+
}
|
|
1778
|
+
const home = merchantHome(args.home);
|
|
1779
|
+
switch (args.command) {
|
|
1780
|
+
case "init":
|
|
1781
|
+
init(home, args.network);
|
|
1782
|
+
return;
|
|
1783
|
+
case "setup": {
|
|
1784
|
+
const stopRequested = deferSignals();
|
|
1785
|
+
let failed = false;
|
|
1786
|
+
try {
|
|
1787
|
+
await setup(home);
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
console.error(`error: ${errorText3(error)}`);
|
|
1790
|
+
failed = true;
|
|
1791
|
+
}
|
|
1792
|
+
process.exit(stopRequested() ?? (failed ? 1 : 0));
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
case "run":
|
|
1796
|
+
stopOnSignals();
|
|
1797
|
+
await run(home);
|
|
1798
|
+
return;
|
|
1799
|
+
case "orders":
|
|
1800
|
+
listOrders(home);
|
|
1801
|
+
return;
|
|
1802
|
+
case "check":
|
|
1803
|
+
await check(home);
|
|
1804
|
+
process.exit(0);
|
|
1805
|
+
return;
|
|
1806
|
+
default:
|
|
1807
|
+
console.log(USAGE);
|
|
1808
|
+
process.exit(args.command === void 0 || args.command === "help" ? 0 : 1);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
try {
|
|
1812
|
+
await main();
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
console.error(`error: ${errorText3(error)}`);
|
|
1815
|
+
process.exit(1);
|
|
1816
|
+
}
|
|
1817
|
+
//# sourceMappingURL=cli.js.map
|
|
1818
|
+
//# sourceMappingURL=cli.js.map
|