@elisym/merchant-node 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -33
- package/dist/cli.js +800 -155
- package/dist/cli.js.map +1 -1
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, writeFileSync, readFileSync,
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { existsSync, writeFileSync, readFileSync, renameSync, statSync, linkSync, rmSync, utimesSync, mkdirSync, chmodSync } from 'node:fs';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { LIMITS, DELIVERY_METHODS, splitNip05, parseCaip19, canonicalPayoutAddress, priceInSubunits, isPublicHostname, KIND_PAYTO, KIND_PRODUCT, KIND_INBOX_RELAYS, buildOrderMessage, wrapOrderMessage, buildProductEvent, buildStoreProfileEvent, buildPaytoEvent, buildStoreAuthEvent, encodeProductNaddr, MAX_FUTURE_SKEW_SECS, unwrapOrderMessage, productAddress, KIND_GIFT_WRAP, deriveOrderPaymentReference, parsePayto, parseProduct } from '@elisym/commerce';
|
|
5
|
+
import { createSolanaRpc, isSignature, address } from '@solana/kit';
|
|
5
6
|
import { SimplePool } from 'nostr-tools/pool';
|
|
6
7
|
import { getPublicKey, generateSecretKey, finalizeEvent } from 'nostr-tools/pure';
|
|
8
|
+
import { EVM_ASSETS, chainByCaip2, composeSolanaPaymentRequest, verifyDirectSolanaPayment, listReferenceSignatures, directInstructionsFromRpcTransaction, ELISYM_PROTOCOL_TAG, boundTransferAmount } from '@elisym/pay-core';
|
|
9
|
+
import { TEMPO_UNPAYABLE_ADDRESSES, createJsonRpcClient, readTempoReceiptLegs, listTempoLogs, composeTempoPaymentRequest, verifyTempoPayment, readFinalizedBlock, readBlockByNumber } from '@elisym/pay-core/evm';
|
|
7
10
|
import Decimal from 'decimal.js-light';
|
|
8
11
|
import { z } from 'zod';
|
|
9
12
|
import { randomBytes } from 'node:crypto';
|
|
10
13
|
import { homedir } from 'node:os';
|
|
11
14
|
import { resolve, join } from 'node:path';
|
|
12
15
|
import { hexToBytes, bytesToHex } from 'nostr-tools/utils';
|
|
13
|
-
import { composeSolanaPaymentRequest, verifyDirectSolanaPayment, listReferenceSignatures, directInstructionsFromRpcTransaction, ELISYM_PROTOCOL_TAG, boundTransferAmount } from '@elisym/pay-core';
|
|
14
16
|
import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';
|
|
15
17
|
|
|
16
18
|
// src/constants.ts
|
|
@@ -153,13 +155,13 @@ async function checkDomain(domain, expected, fetcher = fetch) {
|
|
|
153
155
|
}
|
|
154
156
|
return { url, ok: true };
|
|
155
157
|
}
|
|
156
|
-
function offersNotHonoured(listing, payoutList, network, standing) {
|
|
158
|
+
function offersNotHonoured(listing, payoutList, network, standing, tempoNetwork) {
|
|
157
159
|
if (listing === void 0 && payoutList === void 0) {
|
|
158
160
|
return void 0;
|
|
159
161
|
}
|
|
160
162
|
const targets = payoutList === void 0 ? [] : parsePayto(payoutList).targets;
|
|
161
163
|
const found = targets.filter(
|
|
162
|
-
(target) => target.caip19.chain.family
|
|
164
|
+
(target) => (target.caip19.chain.family === "solana" ? target.caip19.chain.network !== network : target.caip19.chain.family !== "evm" || tempoNetwork === void 0 || target.caip19.chain.network !== tempoNetwork) || !standing.some(
|
|
163
165
|
(terms) => terms.caip19 === target.caip19.id && terms.payout === target.address
|
|
164
166
|
)
|
|
165
167
|
).map((target) => `${target.caip19.id} ${target.address}`);
|
|
@@ -200,14 +202,20 @@ function inboxRelaysNotRead(inboxList, read) {
|
|
|
200
202
|
}
|
|
201
203
|
return listed.slice(0, CHECKOUT_INBOX_CAP).filter((relay) => !reading.includes(relay));
|
|
202
204
|
}
|
|
203
|
-
function offerProblems(views, inboxRelays, network, standing) {
|
|
205
|
+
function offerProblems(views, inboxRelays, network, standing, tempoNetwork) {
|
|
204
206
|
const problems = /* @__PURE__ */ new Set();
|
|
205
207
|
let served = false;
|
|
206
208
|
for (const view of views) {
|
|
207
209
|
for (const relay of inboxRelaysNotRead(view.inboxList, inboxRelays) ?? []) {
|
|
208
210
|
problems.add(`orders sent to ${relay}, which this node does not read`);
|
|
209
211
|
}
|
|
210
|
-
const notHonoured = offersNotHonoured(
|
|
212
|
+
const notHonoured = offersNotHonoured(
|
|
213
|
+
view.listing,
|
|
214
|
+
view.payoutList,
|
|
215
|
+
network,
|
|
216
|
+
standing,
|
|
217
|
+
tempoNetwork
|
|
218
|
+
);
|
|
211
219
|
served ||= notHonoured !== void 0;
|
|
212
220
|
for (const offer of notHonoured ?? []) {
|
|
213
221
|
problems.add(offer);
|
|
@@ -279,7 +287,11 @@ var configSchema = z.object({
|
|
|
279
287
|
message: 'cannot use the name "owner" (it names the owner key)'
|
|
280
288
|
}).optional(),
|
|
281
289
|
network: z.enum(["mainnet", "devnet"]),
|
|
282
|
-
rpcUrl: urlWith("https:", "http:"),
|
|
290
|
+
rpcUrl: urlWith("https:", "http:").optional(),
|
|
291
|
+
tempo: z.object({
|
|
292
|
+
network: z.enum(["mainnet", "moderato"]),
|
|
293
|
+
rpcUrl: urlWith("https:", "http:").optional()
|
|
294
|
+
}).strict().optional(),
|
|
283
295
|
inboxRelays: z.array(
|
|
284
296
|
urlWith("wss:", "ws:").refine(
|
|
285
297
|
(value) => value.startsWith("ws:") || checkoutRelaySpelling(value) !== void 0,
|
|
@@ -320,30 +332,58 @@ var configSchema = z.object({
|
|
|
320
332
|
}
|
|
321
333
|
});
|
|
322
334
|
const seen = /* @__PURE__ */ new Set();
|
|
335
|
+
let solanaPayouts = 0;
|
|
323
336
|
config.payouts.forEach((payout, index) => {
|
|
324
337
|
const path = ["payouts", index];
|
|
325
338
|
const caip19 = parseCaip19(payout.caip19);
|
|
326
|
-
if (caip19 === void 0
|
|
339
|
+
if (caip19 === void 0) {
|
|
327
340
|
context.addIssue({
|
|
328
341
|
code: "custom",
|
|
329
342
|
path: [...path, "caip19"],
|
|
330
|
-
message: "must be a
|
|
343
|
+
message: "must be a coin this node can verify"
|
|
331
344
|
});
|
|
332
345
|
return;
|
|
333
346
|
}
|
|
334
|
-
if (caip19.chain.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
+
if (caip19.chain.family === "evm") {
|
|
348
|
+
if (config.tempo === void 0) {
|
|
349
|
+
context.addIssue({
|
|
350
|
+
code: "custom",
|
|
351
|
+
path: [...path, "caip19"],
|
|
352
|
+
message: 'is a Tempo coin: add a "tempo" block for the node to verify it'
|
|
353
|
+
});
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const wanted = tempoRegistryNetwork(config.tempo.network);
|
|
357
|
+
if (caip19.chain.network !== wanted) {
|
|
358
|
+
context.addIssue({
|
|
359
|
+
code: "custom",
|
|
360
|
+
path: [...path, "caip19"],
|
|
361
|
+
message: `is not on Tempo ${config.tempo.network}`
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (canonicalPayoutAddress(caip19.chain, payout.address) !== payout.address || !isPayableTempoAddress(payout.address)) {
|
|
365
|
+
context.addIssue({
|
|
366
|
+
code: "custom",
|
|
367
|
+
path: [...path, "address"],
|
|
368
|
+
message: "is not a Tempo address a payment can go to (lowercase 0x, not a system or coin address)"
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
solanaPayouts += 1;
|
|
373
|
+
if (caip19.chain.network !== config.network) {
|
|
374
|
+
context.addIssue({
|
|
375
|
+
code: "custom",
|
|
376
|
+
path: [...path, "caip19"],
|
|
377
|
+
message: `is on ${caip19.chain.network}, the node runs on ${config.network}`
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (canonicalPayoutAddress(caip19.chain, payout.address) !== payout.address) {
|
|
381
|
+
context.addIssue({
|
|
382
|
+
code: "custom",
|
|
383
|
+
path: [...path, "address"],
|
|
384
|
+
message: "is not a Solana address"
|
|
385
|
+
});
|
|
386
|
+
}
|
|
347
387
|
}
|
|
348
388
|
const price = config.product.priceUsd;
|
|
349
389
|
if (PRICE_RE.test(price) && new Decimal(price).gt(0)) {
|
|
@@ -366,7 +406,30 @@ var configSchema = z.object({
|
|
|
366
406
|
}
|
|
367
407
|
seen.add(caip19.id);
|
|
368
408
|
});
|
|
409
|
+
if (config.tempo !== void 0 && tempoRegistryNetwork(config.tempo.network) !== config.network) {
|
|
410
|
+
context.addIssue({
|
|
411
|
+
code: "custom",
|
|
412
|
+
path: ["tempo", "network"],
|
|
413
|
+
message: `is ${config.tempo.network}, the node runs on ${config.network}: use network "mainnet" with Tempo "mainnet", or network "devnet" with Tempo "moderato"`
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (solanaPayouts > 0 && config.rpcUrl === void 0) {
|
|
417
|
+
context.addIssue({
|
|
418
|
+
code: "custom",
|
|
419
|
+
path: ["rpcUrl"],
|
|
420
|
+
message: "is required with a Solana payout"
|
|
421
|
+
});
|
|
422
|
+
}
|
|
369
423
|
});
|
|
424
|
+
function tempoRegistryNetwork(network) {
|
|
425
|
+
return network === "mainnet" ? "mainnet" : "devnet";
|
|
426
|
+
}
|
|
427
|
+
function isPayableTempoAddress(address2) {
|
|
428
|
+
const coins = EVM_ASSETS.flatMap(
|
|
429
|
+
(coin) => coin.mint === void 0 ? [] : [coin.mint.toLowerCase()]
|
|
430
|
+
);
|
|
431
|
+
return !TEMPO_UNPAYABLE_ADDRESSES.includes(address2) && !coins.includes(address2);
|
|
432
|
+
}
|
|
370
433
|
function parseConfig(value) {
|
|
371
434
|
const parsed = configSchema.safeParse(value);
|
|
372
435
|
if (parsed.success) {
|
|
@@ -414,6 +477,155 @@ function configTemplate(network) {
|
|
|
414
477
|
payouts: [{ caip19: usdc, address: "<your Solana wallet address>" }]
|
|
415
478
|
};
|
|
416
479
|
}
|
|
480
|
+
function emptyLedger() {
|
|
481
|
+
return { version: 1, orders: {}, seenRumors: {}, claims: {}, terms: [], scans: {} };
|
|
482
|
+
}
|
|
483
|
+
function orderKey(buyerPubkey, orderId) {
|
|
484
|
+
return `${buyerPubkey}:${orderId}`;
|
|
485
|
+
}
|
|
486
|
+
function claimPayment(state, signature, key) {
|
|
487
|
+
const holder = state.claims[signature];
|
|
488
|
+
if (holder !== void 0 && holder !== key) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
state.claims[signature] = key;
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
function openOrders(state, now, catchUpSecs) {
|
|
495
|
+
return Object.values(state.orders).filter(
|
|
496
|
+
(order) => order.paid === void 0 && now - order.createdAt <= catchUpSecs
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
function undeliveredOrders(state) {
|
|
500
|
+
return Object.values(state.orders).filter(
|
|
501
|
+
(order) => order.paid !== void 0 && order.deliveredAt === void 0
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
function pruneExpiredOrders(state, now) {
|
|
505
|
+
for (const [key, order] of Object.entries(state.orders)) {
|
|
506
|
+
if (order.paid === void 0 && now - order.createdAt > CATCH_UP_SECS + MAX_FUTURE_SKEW_SECS) {
|
|
507
|
+
delete state.orders[key];
|
|
508
|
+
(state.closedOrders ??= {})[key] = true;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function recordReport(order, tx, arrivedAt) {
|
|
513
|
+
order.reportedTxs.push(tx);
|
|
514
|
+
order.recheckedAt = { ...order.recheckedAt, [tx]: arrivedAt };
|
|
515
|
+
}
|
|
516
|
+
function loadLedger(path) {
|
|
517
|
+
let text;
|
|
518
|
+
try {
|
|
519
|
+
text = readFileSync(path, "utf8");
|
|
520
|
+
} catch {
|
|
521
|
+
return emptyLedger();
|
|
522
|
+
}
|
|
523
|
+
const state = JSON.parse(text);
|
|
524
|
+
if (state.version !== 1 && state.version !== 2) {
|
|
525
|
+
throw new Error(`Unknown ledger version in ${path}`);
|
|
526
|
+
}
|
|
527
|
+
state.scans ??= {};
|
|
528
|
+
return state;
|
|
529
|
+
}
|
|
530
|
+
function markTempo(state) {
|
|
531
|
+
state.version = 2;
|
|
532
|
+
}
|
|
533
|
+
function saveLedger(path, state) {
|
|
534
|
+
const temporary = `${path}.tmp`;
|
|
535
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}
|
|
536
|
+
`, { mode: 384 });
|
|
537
|
+
renameSync(temporary, path);
|
|
538
|
+
}
|
|
539
|
+
function isSolanaSignature(value) {
|
|
540
|
+
try {
|
|
541
|
+
return isSignature(value);
|
|
542
|
+
} catch {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// src/hand.ts
|
|
548
|
+
var TEMPO_HASH_RE = /^0x[0-9a-f]{64}$/;
|
|
549
|
+
var POSITIVE_SUBUNITS_RE = /^[1-9]\d{0,38}$/;
|
|
550
|
+
function isRefundTx(tx) {
|
|
551
|
+
return TEMPO_HASH_RE.test(tx) || isSolanaSignature(tx);
|
|
552
|
+
}
|
|
553
|
+
function planHandAnswer(state, key, request) {
|
|
554
|
+
const [buyerPubkey, orderId] = key.split(":");
|
|
555
|
+
if (buyerPubkey === void 0 || orderId === void 0 || key.split(":").length !== 2) {
|
|
556
|
+
return { ok: false, problem: `${key} is not <buyer>:<orderId>` };
|
|
557
|
+
}
|
|
558
|
+
if (request.kind === "refunded") {
|
|
559
|
+
if (!isRefundTx(request.tx)) {
|
|
560
|
+
return { ok: false, problem: "the refund tx is neither a Solana signature nor a Tempo hash" };
|
|
561
|
+
}
|
|
562
|
+
if (!POSITIVE_SUBUNITS_RE.test(request.amount)) {
|
|
563
|
+
return {
|
|
564
|
+
ok: false,
|
|
565
|
+
problem: "the refund amount must be subunits above 0 (an unpaid order is released with deliver, never a zero refund)"
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const stored = state.answeredByHand?.[key];
|
|
570
|
+
if (stored !== void 0) {
|
|
571
|
+
if (stored.kind !== request.kind) {
|
|
572
|
+
return {
|
|
573
|
+
ok: false,
|
|
574
|
+
problem: `${key} was already answered as ${stored.kind}: the other answer is refused`
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
return { ok: true, answer: stored, buyerPubkey, orderId, rerun: true };
|
|
578
|
+
}
|
|
579
|
+
const order = state.orders[key];
|
|
580
|
+
if (order === void 0 && state.closedOrders?.[key] !== true) {
|
|
581
|
+
return { ok: false, problem: `no order ${key} in the ledger` };
|
|
582
|
+
}
|
|
583
|
+
if (order?.paid !== void 0) {
|
|
584
|
+
return {
|
|
585
|
+
ok: false,
|
|
586
|
+
problem: `${key} is paid: the node delivers it itself (a refund of a delivered order is not handled here)`
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const answer = {
|
|
590
|
+
kind: request.kind,
|
|
591
|
+
...request.kind === "delivered" ? { delivery: { ...request.delivery } } : { tx: request.tx, amount: request.amount },
|
|
592
|
+
reportedTxs: [...order?.reportedTxs ?? []],
|
|
593
|
+
refusedTxs: [...order?.refusedTxs ?? []],
|
|
594
|
+
noLegTxs: [...order?.noLegTxs ?? []]
|
|
595
|
+
};
|
|
596
|
+
return { ok: true, answer, buyerPubkey, orderId, rerun: false };
|
|
597
|
+
}
|
|
598
|
+
function applyHandAnswer(state, key, answer) {
|
|
599
|
+
delete state.orders[key];
|
|
600
|
+
(state.closedOrders ??= {})[key] = true;
|
|
601
|
+
(state.answeredByHand ??= {})[key] = answer;
|
|
602
|
+
if (answer.tx !== void 0 && TEMPO_HASH_RE.test(answer.tx)) {
|
|
603
|
+
markTempo(state);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function buildHandAnswer(plan, storeSecretKey, createdAt) {
|
|
607
|
+
const { answer } = plan;
|
|
608
|
+
if (answer.kind === "delivered" && answer.delivery === void 0) {
|
|
609
|
+
throw new Error("A hand delivery carries what was delivered");
|
|
610
|
+
}
|
|
611
|
+
const rumor = buildOrderMessage(
|
|
612
|
+
answer.kind === "delivered" ? {
|
|
613
|
+
type: "status",
|
|
614
|
+
buyerPubkey: plan.buyerPubkey,
|
|
615
|
+
orderId: plan.orderId,
|
|
616
|
+
status: "completed",
|
|
617
|
+
...answer.delivery === void 0 ? {} : { delivery: answer.delivery }
|
|
618
|
+
} : {
|
|
619
|
+
type: "status",
|
|
620
|
+
buyerPubkey: plan.buyerPubkey,
|
|
621
|
+
orderId: plan.orderId,
|
|
622
|
+
status: "cancelled",
|
|
623
|
+
refund: { tx: answer.tx ?? "", amount: answer.amount ?? "" }
|
|
624
|
+
},
|
|
625
|
+
createdAt
|
|
626
|
+
);
|
|
627
|
+
return wrapOrderMessage(rumor, storeSecretKey, plan.buyerPubkey);
|
|
628
|
+
}
|
|
417
629
|
var HOME_ENV = "ELISYM_MERCHANT_HOME";
|
|
418
630
|
var DEFAULT_HOME_NAME = ".elisym-merchant";
|
|
419
631
|
function merchantHome(flag, env = process.env) {
|
|
@@ -551,68 +763,401 @@ function takeLock(home, options = {}) {
|
|
|
551
763
|
process.once("exit", release);
|
|
552
764
|
return release;
|
|
553
765
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
return `${buyerPubkey}:${orderId}`;
|
|
766
|
+
|
|
767
|
+
// src/terms.ts
|
|
768
|
+
function sameTerms(left, right) {
|
|
769
|
+
return left.caip19 === right.caip19 && left.payout === right.payout && left.amount === right.amount;
|
|
559
770
|
}
|
|
560
|
-
function
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
771
|
+
function publishTerms(periods, terms, at) {
|
|
772
|
+
const standing = periods.find(
|
|
773
|
+
(period) => period.until === void 0 && period.terms.caip19 === terms.caip19
|
|
774
|
+
);
|
|
775
|
+
if (standing !== void 0 && sameTerms(standing.terms, terms)) {
|
|
776
|
+
return [...periods];
|
|
564
777
|
}
|
|
565
|
-
|
|
566
|
-
|
|
778
|
+
return [
|
|
779
|
+
...periods.map(
|
|
780
|
+
(period) => period === standing ? { ...period, until: Math.max(at, period.from) } : period
|
|
781
|
+
),
|
|
782
|
+
{ terms, from: at }
|
|
783
|
+
];
|
|
567
784
|
}
|
|
568
|
-
function
|
|
569
|
-
return
|
|
570
|
-
(
|
|
785
|
+
function retireTerms(periods, offered, at) {
|
|
786
|
+
return periods.map(
|
|
787
|
+
(period) => period.until === void 0 && !offered.includes(period.terms.caip19) ? { ...period, until: Math.max(at, period.from) } : period
|
|
571
788
|
);
|
|
572
789
|
}
|
|
573
|
-
function
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
)
|
|
790
|
+
function termsAt(periods, blockTime2) {
|
|
791
|
+
const earliest = blockTime2 - TERMS_WINDOW_SECS;
|
|
792
|
+
const found = [];
|
|
793
|
+
for (const period of periods) {
|
|
794
|
+
const offered = period.from <= blockTime2 && (period.until === void 0 || period.until > earliest);
|
|
795
|
+
if (offered && !found.some((terms) => sameTerms(terms, period.terms))) {
|
|
796
|
+
found.push(period.terms);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return found;
|
|
577
800
|
}
|
|
578
|
-
function
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
801
|
+
function termsSince(periods, since) {
|
|
802
|
+
const earliest = since - TERMS_WINDOW_SECS;
|
|
803
|
+
const found = [];
|
|
804
|
+
for (const period of periods) {
|
|
805
|
+
if ((period.until === void 0 || period.until > earliest) && !found.some((terms) => sameTerms(terms, period.terms))) {
|
|
806
|
+
found.push(period.terms);
|
|
583
807
|
}
|
|
584
808
|
}
|
|
809
|
+
return found;
|
|
585
810
|
}
|
|
586
|
-
function
|
|
587
|
-
|
|
588
|
-
order.recheckedAt = { ...order.recheckedAt, [tx]: arrivedAt };
|
|
811
|
+
function standingTerms(periods) {
|
|
812
|
+
return periods.filter((period) => period.until === void 0).map((period) => period.terms);
|
|
589
813
|
}
|
|
590
|
-
function
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
|
|
814
|
+
function visibleTerms(standing, next, listingOut, payoutsOut) {
|
|
815
|
+
const listed = listingOut ? next : standing;
|
|
816
|
+
const payouts = payoutsOut ? next : standing;
|
|
817
|
+
const visible = [];
|
|
818
|
+
for (const { caip19, amount } of listed) {
|
|
819
|
+
const payout = payouts.find((terms) => terms.caip19 === caip19)?.payout;
|
|
820
|
+
if (payout !== void 0) {
|
|
821
|
+
visible.push({ caip19, payout, amount });
|
|
822
|
+
}
|
|
596
823
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
824
|
+
return visible;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// src/tempo.ts
|
|
828
|
+
function tempoContextFor(config, storePubkey) {
|
|
829
|
+
if (config.tempo === void 0) {
|
|
830
|
+
return void 0;
|
|
600
831
|
}
|
|
601
|
-
|
|
602
|
-
|
|
832
|
+
const chain = chainByCaip2(config.tempo.network === "mainnet" ? "eip155:4217" : "eip155:42431");
|
|
833
|
+
const url = config.tempo.rpcUrl ?? chain?.rpcUrls[0];
|
|
834
|
+
if (chain === void 0 || url === void 0) {
|
|
835
|
+
return void 0;
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
client: createJsonRpcClient(url),
|
|
839
|
+
chain,
|
|
840
|
+
medium: config.tempo.network === "mainnet" ? "tempo" : "tempo-moderato",
|
|
841
|
+
storePubkey
|
|
842
|
+
};
|
|
603
843
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
844
|
+
var MAX_BLOCK_SAMPLES = 1e4;
|
|
845
|
+
var TEMPO_HASH_RE2 = /^0x[0-9a-f]{64}$/;
|
|
846
|
+
var FLOOR_MARGIN_SECS = Math.max(ORDER_SCAN_MARGIN_SECS, 900);
|
|
847
|
+
function tempoMemo(order, storePubkey) {
|
|
848
|
+
return deriveOrderPaymentReference({
|
|
849
|
+
storePubkey,
|
|
850
|
+
buyerPubkey: order.buyerPubkey,
|
|
851
|
+
orderId: order.orderId
|
|
852
|
+
}).tempo;
|
|
853
|
+
}
|
|
854
|
+
function tempoTerms(terms, context) {
|
|
855
|
+
return terms.flatMap((each) => {
|
|
856
|
+
const caip19 = parseCaip19(each.caip19);
|
|
857
|
+
const mint = caip19?.asset.mint;
|
|
858
|
+
if (caip19?.chain.caip2 !== context.chain.caip2 || mint === void 0) {
|
|
859
|
+
return [];
|
|
860
|
+
}
|
|
861
|
+
return [{ terms: each, token: mint.toLowerCase() }];
|
|
862
|
+
});
|
|
609
863
|
}
|
|
610
|
-
function
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
864
|
+
async function blockTime(context, number) {
|
|
865
|
+
const cached = context.samples?.get(number);
|
|
866
|
+
if (cached !== void 0) {
|
|
867
|
+
return cached;
|
|
868
|
+
}
|
|
869
|
+
const block = await readBlockByNumber(context.client, number);
|
|
870
|
+
if (block === null) {
|
|
871
|
+
return void 0;
|
|
872
|
+
}
|
|
873
|
+
const samples = context.samples ??= /* @__PURE__ */ new Map();
|
|
874
|
+
if (samples.size >= MAX_BLOCK_SAMPLES) {
|
|
875
|
+
samples.clear();
|
|
876
|
+
}
|
|
877
|
+
samples.set(number, block.timestamp);
|
|
878
|
+
return block.timestamp;
|
|
879
|
+
}
|
|
880
|
+
async function blockAtTime(context, time) {
|
|
881
|
+
const head = await readFinalizedBlock(context.client);
|
|
882
|
+
if (head === null) {
|
|
883
|
+
return void 0;
|
|
884
|
+
}
|
|
885
|
+
const target = Math.max(time, head.timestamp - CATCH_UP_SECS - FLOOR_MARGIN_SECS);
|
|
886
|
+
if (head.timestamp <= target) {
|
|
887
|
+
return head;
|
|
888
|
+
}
|
|
889
|
+
let low = 0;
|
|
890
|
+
let high = head.number;
|
|
891
|
+
let found = { number: 0, timestamp: 0 };
|
|
892
|
+
for (const [number, at] of context.samples ?? []) {
|
|
893
|
+
if (number > high || number < low) {
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
if (at <= target && number >= low) {
|
|
897
|
+
low = number;
|
|
898
|
+
found = { number, timestamp: at };
|
|
899
|
+
} else if (at > target && number <= high) {
|
|
900
|
+
high = number;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
while (low <= high) {
|
|
904
|
+
const middle = Math.floor((low + high) / 2);
|
|
905
|
+
const at = await blockTime(context, middle);
|
|
906
|
+
if (at === void 0) {
|
|
907
|
+
return void 0;
|
|
908
|
+
}
|
|
909
|
+
if (at <= target) {
|
|
910
|
+
found = { number: middle, timestamp: at };
|
|
911
|
+
low = middle + 1;
|
|
912
|
+
} else {
|
|
913
|
+
high = middle - 1;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return found;
|
|
917
|
+
}
|
|
918
|
+
function orderFloor(context, order) {
|
|
919
|
+
return blockAtTime(context, order.createdAt - FLOOR_MARGIN_SECS);
|
|
920
|
+
}
|
|
921
|
+
async function verifyTerms(state, order, hash, context, candidates, floor, satisfies) {
|
|
922
|
+
const memo = tempoMemo(order, context.storePubkey);
|
|
923
|
+
const verdicts = [];
|
|
924
|
+
for (const candidate of candidates) {
|
|
925
|
+
const caip19 = parseCaip19(candidate.terms.caip19);
|
|
926
|
+
if (caip19 === void 0) {
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
let request;
|
|
930
|
+
try {
|
|
931
|
+
request = composeTempoPaymentRequest({
|
|
932
|
+
chain: context.chain,
|
|
933
|
+
asset: caip19.asset,
|
|
934
|
+
recipient: candidate.terms.payout,
|
|
935
|
+
amount: BigInt(candidate.terms.amount),
|
|
936
|
+
feeBps: 0,
|
|
937
|
+
treasury: candidate.terms.payout,
|
|
938
|
+
memo,
|
|
939
|
+
createdAt: floor.timestamp
|
|
940
|
+
});
|
|
941
|
+
} catch {
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
const result = await verifyTempoPayment(context.client, request, {
|
|
945
|
+
txSignature: hash,
|
|
946
|
+
fromBlock: floor.number,
|
|
947
|
+
pollBudgetMs: 0
|
|
948
|
+
});
|
|
949
|
+
if (result.outcome === "verified") {
|
|
950
|
+
const leg = result.providerLeg;
|
|
951
|
+
const at = await blockTime(context, leg.blockNumber);
|
|
952
|
+
if (at === void 0) {
|
|
953
|
+
verdicts.push("ask");
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
const offered = termsAt(state.terms, at).some(
|
|
957
|
+
(standing) => standing.caip19 === candidate.terms.caip19 && standing.payout === candidate.terms.payout && standing.amount === candidate.terms.amount
|
|
958
|
+
);
|
|
959
|
+
if (leg.blockNumber < floor.number || !offered) {
|
|
960
|
+
verdicts.push("refused");
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (!claimPayment(state, result.settlementId, order.key)) {
|
|
964
|
+
return "claimed";
|
|
965
|
+
}
|
|
966
|
+
markTempo(state);
|
|
967
|
+
order.paid = {
|
|
968
|
+
signature: leg.transactionHash,
|
|
969
|
+
amount: leg.amount.toString(),
|
|
970
|
+
blockTime: at,
|
|
971
|
+
caip19: candidate.terms.caip19,
|
|
972
|
+
medium: context.medium
|
|
973
|
+
};
|
|
974
|
+
return "paid";
|
|
975
|
+
}
|
|
976
|
+
if (result.outcome === "refused" && (result.code === "reverted" || result.code === "no_provider_leg")) {
|
|
977
|
+
verdicts.push(satisfies(candidate) ? "ask" : "no_leg");
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
verdicts.push("ask");
|
|
981
|
+
}
|
|
982
|
+
if (verdicts.includes("ask")) {
|
|
983
|
+
return "ask";
|
|
984
|
+
}
|
|
985
|
+
if (verdicts.length > 0 && verdicts.every((verdict) => verdict === "refused")) {
|
|
986
|
+
return "refused";
|
|
987
|
+
}
|
|
988
|
+
return "no_leg";
|
|
989
|
+
}
|
|
990
|
+
function asCheck(verdict, order) {
|
|
991
|
+
switch (verdict) {
|
|
992
|
+
case "paid":
|
|
993
|
+
return { kind: "paid", order };
|
|
994
|
+
case "claimed":
|
|
995
|
+
return { kind: "refused", reason: "claimed_by_another_order" };
|
|
996
|
+
case "refused":
|
|
997
|
+
return { kind: "refused", reason: "not_a_payment_for_this_order" };
|
|
998
|
+
case "no_leg":
|
|
999
|
+
return { kind: "no_leg" };
|
|
1000
|
+
case "ask":
|
|
1001
|
+
return { kind: "ask_again" };
|
|
1002
|
+
}
|
|
1003
|
+
return { kind: "ask_again" };
|
|
1004
|
+
}
|
|
1005
|
+
async function checkTempoPayment(state, order, hash, context) {
|
|
1006
|
+
if (order.paid !== void 0) {
|
|
1007
|
+
return order.paid.signature === hash ? { kind: "paid", order } : { kind: "refused", reason: "order_already_paid" };
|
|
615
1008
|
}
|
|
1009
|
+
const candidates = tempoTerms(
|
|
1010
|
+
termsSince(state.terms, order.createdAt - ORDER_SCAN_MARGIN_SECS),
|
|
1011
|
+
context
|
|
1012
|
+
);
|
|
1013
|
+
if (candidates.length === 0) {
|
|
1014
|
+
return { kind: "refused", reason: "not_a_payment_for_this_order" };
|
|
1015
|
+
}
|
|
1016
|
+
const minAmount = candidates.reduce(
|
|
1017
|
+
(lowest, candidate) => BigInt(candidate.terms.amount) < lowest ? BigInt(candidate.terms.amount) : lowest,
|
|
1018
|
+
BigInt(candidates[0]?.terms.amount ?? "0")
|
|
1019
|
+
);
|
|
1020
|
+
const pre = await readTempoReceiptLegs(context.client, hash, {
|
|
1021
|
+
chain: context.chain,
|
|
1022
|
+
tokens: [...new Set(candidates.map((candidate) => candidate.token))],
|
|
1023
|
+
recipients: [...new Set(candidates.map((candidate) => candidate.terms.payout))],
|
|
1024
|
+
memo: tempoMemo(order, context.storePubkey),
|
|
1025
|
+
minAmount
|
|
1026
|
+
});
|
|
1027
|
+
if (pre.kind === "unreadable" || pre.kind === "absent") {
|
|
1028
|
+
return { kind: "ask_again" };
|
|
1029
|
+
}
|
|
1030
|
+
if (pre.kind === "none") {
|
|
1031
|
+
return { kind: "no_leg" };
|
|
1032
|
+
}
|
|
1033
|
+
const named = candidates.filter(
|
|
1034
|
+
(candidate) => pre.transfers.some((leg) => leg.token === candidate.token && leg.to === candidate.terms.payout)
|
|
1035
|
+
);
|
|
1036
|
+
if (named.length === 0) {
|
|
1037
|
+
if (pre.blocked.length > 0) {
|
|
1038
|
+
order.blockedTx = hash;
|
|
1039
|
+
markTempo(state);
|
|
1040
|
+
return { kind: "blocked" };
|
|
1041
|
+
}
|
|
1042
|
+
return { kind: "no_leg" };
|
|
1043
|
+
}
|
|
1044
|
+
const floor = await orderFloor(context, order);
|
|
1045
|
+
if (floor === void 0) {
|
|
1046
|
+
return { kind: "ask_again" };
|
|
1047
|
+
}
|
|
1048
|
+
return asCheck(await verifyTerms(state, order, hash, context, named, floor, () => false), order);
|
|
1049
|
+
}
|
|
1050
|
+
function recordTempoCheck(state, order, hash, check2) {
|
|
1051
|
+
if (check2.kind === "refused") {
|
|
1052
|
+
order.refusedTxs = [...order.refusedTxs ?? [], hash];
|
|
1053
|
+
} else if (check2.kind === "no_leg" && order.noLegTxs?.includes(hash) !== true) {
|
|
1054
|
+
order.noLegTxs = [...order.noLegTxs ?? [], hash];
|
|
1055
|
+
}
|
|
1056
|
+
markTempo(state);
|
|
1057
|
+
}
|
|
1058
|
+
async function catchUpTempo(state, context, now) {
|
|
1059
|
+
const result = { paid: [], incomplete: [] };
|
|
1060
|
+
const open = openOrders(state, now, CATCH_UP_SECS);
|
|
1061
|
+
if (open.length === 0) {
|
|
1062
|
+
return result;
|
|
1063
|
+
}
|
|
1064
|
+
const reported = [];
|
|
1065
|
+
for (const order of open) {
|
|
1066
|
+
for (const hash of order.reportedTxs) {
|
|
1067
|
+
if (TEMPO_HASH_RE2.test(hash) && order.refusedTxs?.includes(hash) !== true && order.noLegTxs?.includes(hash) !== true && order.blockedTx !== hash) {
|
|
1068
|
+
reported.push({ order, hash, checkedAt: order.recheckedAt?.[hash] ?? 0 });
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
reported.sort((left, right) => left.checkedAt - right.checkedAt);
|
|
1073
|
+
for (const { order, hash } of reported.slice(0, MAX_RECHECKS_PER_SWEEP)) {
|
|
1074
|
+
if (order.paid !== void 0) {
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
order.recheckedAt = { ...order.recheckedAt, [hash]: now };
|
|
1078
|
+
const check2 = await checkTempoPayment(state, order, hash, context);
|
|
1079
|
+
recordTempoCheck(state, order, hash, check2);
|
|
1080
|
+
if (check2.kind === "paid") {
|
|
1081
|
+
result.paid.push(order);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const oldest = Math.min(...open.map((order) => order.createdAt));
|
|
1085
|
+
const candidates = tempoTerms(termsSince(state.terms, oldest - ORDER_SCAN_MARGIN_SECS), context);
|
|
1086
|
+
if (candidates.length === 0) {
|
|
1087
|
+
return result;
|
|
1088
|
+
}
|
|
1089
|
+
const floor = await blockAtTime(context, oldest - FLOOR_MARGIN_SECS);
|
|
1090
|
+
if (floor === void 0) {
|
|
1091
|
+
return result;
|
|
1092
|
+
}
|
|
1093
|
+
const byMemo = new Map(open.map((order) => [tempoMemo(order, context.storePubkey), order]));
|
|
1094
|
+
const matches = [];
|
|
1095
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
1096
|
+
for (const candidate of candidates) {
|
|
1097
|
+
const key = `${candidate.token}:${candidate.terms.payout}`;
|
|
1098
|
+
pairs.set(key, [...pairs.get(key) ?? [], candidate]);
|
|
1099
|
+
}
|
|
1100
|
+
for (const [key, terms] of pairs) {
|
|
1101
|
+
const [token, payout] = key.split(":");
|
|
1102
|
+
const minAmount = terms.reduce(
|
|
1103
|
+
(lowest, each) => BigInt(each.terms.amount) < lowest ? BigInt(each.terms.amount) : lowest,
|
|
1104
|
+
BigInt(terms[0]?.terms.amount ?? "0")
|
|
1105
|
+
);
|
|
1106
|
+
const scan = await listTempoLogs(context.client, {
|
|
1107
|
+
token,
|
|
1108
|
+
event: "TransferWithMemo",
|
|
1109
|
+
to: payout,
|
|
1110
|
+
fromBlock: floor.number,
|
|
1111
|
+
minAmount
|
|
1112
|
+
});
|
|
1113
|
+
if (!scan.complete) {
|
|
1114
|
+
result.incomplete.push(`${context.chain.caip2} ${token} ${payout}`);
|
|
1115
|
+
}
|
|
1116
|
+
for (const leg of scan.candidates) {
|
|
1117
|
+
const order = leg.memo === void 0 ? void 0 : byMemo.get(leg.memo);
|
|
1118
|
+
if (order === void 0 || order.paid !== void 0 || order.refusedTxs?.includes(leg.transactionHash) === true || order.tempoNoLeg?.includes(leg.transactionHash) === true) {
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
matches.push({ order, leg, checkedAt: order.recheckedAt?.[leg.transactionHash] ?? 0 });
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
matches.sort((left, right) => left.checkedAt - right.checkedAt);
|
|
1125
|
+
for (const { order, leg } of matches.slice(0, MAX_RECHECKS_PER_SWEEP)) {
|
|
1126
|
+
if (order.paid !== void 0) {
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
const hash = leg.transactionHash;
|
|
1130
|
+
order.recheckedAt = { ...order.recheckedAt, [hash]: now };
|
|
1131
|
+
const orderFloorBlock = await orderFloor(context, order);
|
|
1132
|
+
if (orderFloorBlock === void 0) {
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
const orderTerms = tempoTerms(
|
|
1136
|
+
termsSince(state.terms, order.createdAt - ORDER_SCAN_MARGIN_SECS),
|
|
1137
|
+
context
|
|
1138
|
+
);
|
|
1139
|
+
const verdict = await verifyTerms(
|
|
1140
|
+
state,
|
|
1141
|
+
order,
|
|
1142
|
+
hash,
|
|
1143
|
+
context,
|
|
1144
|
+
orderTerms,
|
|
1145
|
+
orderFloorBlock,
|
|
1146
|
+
(candidate) => candidate.token === leg.token && candidate.terms.payout === leg.to && leg.amount >= BigInt(candidate.terms.amount)
|
|
1147
|
+
);
|
|
1148
|
+
markTempo(state);
|
|
1149
|
+
if (order.noLegTxs?.includes(hash) === true) {
|
|
1150
|
+
order.noLegTxs = order.noLegTxs.filter((each) => each !== hash);
|
|
1151
|
+
}
|
|
1152
|
+
if (verdict === "paid") {
|
|
1153
|
+
result.paid.push(order);
|
|
1154
|
+
} else if (verdict === "refused") {
|
|
1155
|
+
order.refusedTxs = [...order.refusedTxs ?? [], hash];
|
|
1156
|
+
} else if (verdict === "no_leg") {
|
|
1157
|
+
order.tempoNoLeg = [...order.tempoNoLeg ?? [], hash];
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return result;
|
|
616
1161
|
}
|
|
617
1162
|
|
|
618
1163
|
// src/intake.ts
|
|
@@ -667,7 +1212,13 @@ function intake(state, unwrapped, store, now = Math.floor(Date.now() / 1e3)) {
|
|
|
667
1212
|
return { kind: "ignored", reason: "unknown_order" };
|
|
668
1213
|
}
|
|
669
1214
|
const { medium, reference, tx } = message.payment;
|
|
670
|
-
|
|
1215
|
+
const tempo = medium.startsWith("tempo");
|
|
1216
|
+
const expected = tempo ? deriveOrderPaymentReference({
|
|
1217
|
+
storePubkey: store.storePubkey,
|
|
1218
|
+
buyerPubkey: order.buyerPubkey,
|
|
1219
|
+
orderId: order.orderId
|
|
1220
|
+
}).tempo : order.reference;
|
|
1221
|
+
if (reference !== expected || !store.mediums.includes(medium) || !(tempo ? TEMPO_HASH_RE2.test(tx) : isSolanaSignature(tx))) {
|
|
671
1222
|
return { kind: "ignored", reason: "foreign_payment" };
|
|
672
1223
|
}
|
|
673
1224
|
if (!order.reportedTxs.includes(tx) && order.reportedTxs.length >= MAX_RECEIPTS_PER_ORDER) {
|
|
@@ -677,6 +1228,9 @@ function intake(state, unwrapped, store, now = Math.floor(Date.now() / 1e3)) {
|
|
|
677
1228
|
const isNew = !order.reportedTxs.includes(tx);
|
|
678
1229
|
if (isNew) {
|
|
679
1230
|
recordReport(order, tx, now);
|
|
1231
|
+
if (tempo) {
|
|
1232
|
+
markTempo(state);
|
|
1233
|
+
}
|
|
680
1234
|
}
|
|
681
1235
|
return { kind: "receipt", order, tx, isNew };
|
|
682
1236
|
}
|
|
@@ -859,68 +1413,6 @@ function deliveryDone(accepted, relays, paidAgeSecs) {
|
|
|
859
1413
|
}
|
|
860
1414
|
return accepted >= Math.min(DELIVERY_RELAYS_WANTED, relays) || paidAgeSecs >= DELIVERY_SETTLE_SECS;
|
|
861
1415
|
}
|
|
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
1416
|
function solanaAsset(terms, network) {
|
|
925
1417
|
const caip19 = parseCaip19(terms.caip19);
|
|
926
1418
|
if (caip19 === void 0 || caip19.chain.family !== "solana" || caip19.chain.network !== network) {
|
|
@@ -1029,8 +1521,8 @@ async function scanTransaction(signature, terms, asset, context) {
|
|
|
1029
1521
|
if (transaction === null || typeof transaction !== "object") {
|
|
1030
1522
|
return void 0;
|
|
1031
1523
|
}
|
|
1032
|
-
const
|
|
1033
|
-
if (typeof
|
|
1524
|
+
const blockTime2 = Reflect.get(transaction, "blockTime");
|
|
1525
|
+
if (typeof blockTime2 !== "number" && typeof blockTime2 !== "bigint") {
|
|
1034
1526
|
return void 0;
|
|
1035
1527
|
}
|
|
1036
1528
|
let instructions;
|
|
@@ -1040,7 +1532,7 @@ async function scanTransaction(signature, terms, asset, context) {
|
|
|
1040
1532
|
return void 0;
|
|
1041
1533
|
}
|
|
1042
1534
|
return {
|
|
1043
|
-
blockTime: Number(
|
|
1535
|
+
blockTime: Number(blockTime2),
|
|
1044
1536
|
references: await boundReferences(instructions, terms, asset)
|
|
1045
1537
|
};
|
|
1046
1538
|
}
|
|
@@ -1066,7 +1558,7 @@ async function catchUp(state, context, now) {
|
|
|
1066
1558
|
const pending = [];
|
|
1067
1559
|
for (const order of open) {
|
|
1068
1560
|
for (const signature of order.reportedTxs) {
|
|
1069
|
-
if (order.paid === void 0 && order.refusedTxs?.includes(signature) !== true && state.claims[signature] === void 0) {
|
|
1561
|
+
if (order.paid === void 0 && isSolanaSignature(signature) && order.refusedTxs?.includes(signature) !== true && state.claims[signature] === void 0) {
|
|
1070
1562
|
pending.push({ order, signature, checkedAt: order.recheckedAt?.[signature] ?? 0 });
|
|
1071
1563
|
}
|
|
1072
1564
|
}
|
|
@@ -1132,6 +1624,9 @@ function forgetOldScans(state, now) {
|
|
|
1132
1624
|
}
|
|
1133
1625
|
|
|
1134
1626
|
// src/runtime.ts
|
|
1627
|
+
function settled(order, tx) {
|
|
1628
|
+
return order.refusedTxs?.includes(tx) === true || order.noLegTxs?.includes(tx) === true || order.blockedTx === tx;
|
|
1629
|
+
}
|
|
1135
1630
|
var MerchantRuntime = class {
|
|
1136
1631
|
constructor(deps) {
|
|
1137
1632
|
this.deps = deps;
|
|
@@ -1189,7 +1684,7 @@ var MerchantRuntime = class {
|
|
|
1189
1684
|
log2(`order ${result.order.key}`);
|
|
1190
1685
|
return;
|
|
1191
1686
|
}
|
|
1192
|
-
if (!result.isNew || result.order
|
|
1687
|
+
if (!result.isNew || settled(result.order, result.tx) || !this.takeLiveCheck()) {
|
|
1193
1688
|
return;
|
|
1194
1689
|
}
|
|
1195
1690
|
await this.checkReported(result.order, result.tx, 0);
|
|
@@ -1197,11 +1692,26 @@ var MerchantRuntime = class {
|
|
|
1197
1692
|
/** Check one reported transaction now; one the RPC does not see yet is tried again soon. */
|
|
1198
1693
|
async checkReported(order, tx, attempt) {
|
|
1199
1694
|
const { state, context, save, log: log2 } = this.deps;
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1695
|
+
let check2;
|
|
1696
|
+
if (TEMPO_HASH_RE2.test(tx)) {
|
|
1697
|
+
if (this.deps.tempo === void 0) {
|
|
1698
|
+
log2(`receipt ${order.key} ${tx}: a Tempo payment, and this node has no tempo block`);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
check2 = await (this.deps.checkTempoPayment ?? checkTempoPayment)(
|
|
1702
|
+
state,
|
|
1703
|
+
order,
|
|
1704
|
+
tx,
|
|
1705
|
+
this.deps.tempo
|
|
1706
|
+
);
|
|
1707
|
+
recordTempoCheck(state, order, tx, check2);
|
|
1708
|
+
} else {
|
|
1709
|
+
check2 = await this.check(state, order, tx, context);
|
|
1710
|
+
if (check2.kind === "refused") {
|
|
1711
|
+
order.refusedTxs = [...order.refusedTxs ?? [], tx];
|
|
1712
|
+
}
|
|
1204
1713
|
}
|
|
1714
|
+
log2(`receipt ${order.key} ${tx}: ${check2.kind}`);
|
|
1205
1715
|
save();
|
|
1206
1716
|
if (check2.kind === "paid") {
|
|
1207
1717
|
await this.deliverPending();
|
|
@@ -1216,7 +1726,7 @@ var MerchantRuntime = class {
|
|
|
1216
1726
|
}
|
|
1217
1727
|
this.deps.later(delay, async () => {
|
|
1218
1728
|
const order = this.deps.state.orders[key];
|
|
1219
|
-
if (order === void 0 || order.paid !== void 0 || !order.reportedTxs.includes(tx) || order
|
|
1729
|
+
if (order === void 0 || order.paid !== void 0 || !order.reportedTxs.includes(tx) || settled(order, tx) || !this.takeLiveCheck()) {
|
|
1220
1730
|
return;
|
|
1221
1731
|
}
|
|
1222
1732
|
await this.checkReported(order, tx, attempt + 1);
|
|
@@ -1266,12 +1776,33 @@ var MerchantRuntime = class {
|
|
|
1266
1776
|
}
|
|
1267
1777
|
}
|
|
1268
1778
|
try {
|
|
1269
|
-
const
|
|
1270
|
-
|
|
1271
|
-
|
|
1779
|
+
const tempo = this.deps.tempo;
|
|
1780
|
+
const rails = [
|
|
1781
|
+
{ rail: "solana", run: () => this.sweepCatchUp(state, context, now()) }
|
|
1782
|
+
];
|
|
1783
|
+
if (tempo !== void 0) {
|
|
1784
|
+
rails.push({
|
|
1785
|
+
rail: "tempo",
|
|
1786
|
+
run: () => (this.deps.catchUpTempo ?? catchUpTempo)(state, tempo, now())
|
|
1787
|
+
});
|
|
1272
1788
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1789
|
+
const results = [];
|
|
1790
|
+
for (const { rail, run: run2 } of rails) {
|
|
1791
|
+
try {
|
|
1792
|
+
results.push(await run2());
|
|
1793
|
+
} catch (error) {
|
|
1794
|
+
log2(
|
|
1795
|
+
`${rail} catch-up failed this time: ${error instanceof Error ? error.message : String(error)}`
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
for (const result of results) {
|
|
1800
|
+
if (result.paid.length > 0) {
|
|
1801
|
+
log2(`catch-up found ${result.paid.length} payment(s)`);
|
|
1802
|
+
}
|
|
1803
|
+
for (const account of result.incomplete) {
|
|
1804
|
+
log2(`warning: the history of ${account} was cut short; older payments may be missed`);
|
|
1805
|
+
}
|
|
1275
1806
|
}
|
|
1276
1807
|
} finally {
|
|
1277
1808
|
save();
|
|
@@ -1422,6 +1953,11 @@ var USAGE = `usage: elisym-merchant <command> [--home <dir>]
|
|
|
1422
1953
|
run take orders, verify payments, deliver
|
|
1423
1954
|
orders list the orders: paid, delivered, the buyer's email
|
|
1424
1955
|
check check the inbox relays, the owner's payout list and the domain
|
|
1956
|
+
deliver <buyer>:<orderId> [--yes]
|
|
1957
|
+
answer an unpaid order by hand with the configured delivery
|
|
1958
|
+
refund <buyer>:<orderId> --tx <refund tx> --amount <subunits> [--yes]
|
|
1959
|
+
answer an unpaid order by hand with a refund you already sent
|
|
1960
|
+
(both need the node stopped; --yes skips the confirmation)
|
|
1425
1961
|
|
|
1426
1962
|
The home is --home, else $ELISYM_MERCHANT_HOME, else ~/.elisym-merchant.
|
|
1427
1963
|
It holds the store's secret keys: keep it private and back it up.`;
|
|
@@ -1437,19 +1973,40 @@ function log(message) {
|
|
|
1437
1973
|
function errorText3(error) {
|
|
1438
1974
|
return error instanceof Error ? error.message : String(error);
|
|
1439
1975
|
}
|
|
1976
|
+
var VALUE_FLAGS = {
|
|
1977
|
+
"--home": "home",
|
|
1978
|
+
"--network": "network",
|
|
1979
|
+
"--tx": "tx",
|
|
1980
|
+
"--amount": "amount"
|
|
1981
|
+
};
|
|
1982
|
+
function isValueFlag(arg) {
|
|
1983
|
+
return arg in VALUE_FLAGS;
|
|
1984
|
+
}
|
|
1440
1985
|
function parseArgs(argv) {
|
|
1441
|
-
const args = {
|
|
1986
|
+
const args = {
|
|
1987
|
+
command: void 0,
|
|
1988
|
+
target: void 0,
|
|
1989
|
+
home: void 0,
|
|
1990
|
+
network: void 0,
|
|
1991
|
+
tx: void 0,
|
|
1992
|
+
amount: void 0,
|
|
1993
|
+
yes: false
|
|
1994
|
+
};
|
|
1442
1995
|
for (let index = 0; index < argv.length; index += 1) {
|
|
1443
1996
|
const arg = argv[index];
|
|
1444
|
-
if (arg
|
|
1997
|
+
if (arg !== void 0 && isValueFlag(arg)) {
|
|
1445
1998
|
const value = argv[index + 1];
|
|
1446
1999
|
if (value === void 0 || value.startsWith("--")) {
|
|
1447
2000
|
throw new Error(`${arg} needs a value`);
|
|
1448
2001
|
}
|
|
1449
|
-
args[arg
|
|
2002
|
+
args[VALUE_FLAGS[arg]] = value;
|
|
1450
2003
|
index += 1;
|
|
2004
|
+
} else if (arg === "--yes") {
|
|
2005
|
+
args.yes = true;
|
|
1451
2006
|
} else if (args.command === void 0 && arg !== void 0 && !arg.startsWith("--")) {
|
|
1452
2007
|
args.command = arg;
|
|
2008
|
+
} else if ((args.command === "deliver" || args.command === "refund") && args.target === void 0 && arg !== void 0 && !arg.startsWith("--")) {
|
|
2009
|
+
args.target = arg;
|
|
1453
2010
|
} else {
|
|
1454
2011
|
throw new Error(`unknown argument ${arg ?? ""}`);
|
|
1455
2012
|
}
|
|
@@ -1619,7 +2176,8 @@ async function refuseOffersNotHonoured(pool, config, keys, state) {
|
|
|
1619
2176
|
read,
|
|
1620
2177
|
config.inboxRelays,
|
|
1621
2178
|
config.network,
|
|
1622
|
-
standingTerms(state.terms)
|
|
2179
|
+
standingTerms(state.terms),
|
|
2180
|
+
config.tempo === void 0 ? void 0 : tempoRegistryNetwork(config.tempo.network)
|
|
1623
2181
|
);
|
|
1624
2182
|
if (problems.length > 0) {
|
|
1625
2183
|
throw new Error(
|
|
@@ -1675,11 +2233,23 @@ async function run(home) {
|
|
|
1675
2233
|
log(`error: ${errorText3(error)}`);
|
|
1676
2234
|
});
|
|
1677
2235
|
};
|
|
2236
|
+
const tempo = tempoContextFor(config, storePubkey);
|
|
2237
|
+
const mediums = [
|
|
2238
|
+
...config.rpcUrl === void 0 ? [] : [SOLANA_MEDIUMS[config.network]],
|
|
2239
|
+
...tempo === void 0 ? [] : [tempo.medium]
|
|
2240
|
+
];
|
|
1678
2241
|
const runtime = new MerchantRuntime({
|
|
1679
2242
|
state,
|
|
1680
|
-
store: storeIdentity(storePubkey, config.product.d,
|
|
2243
|
+
store: storeIdentity(storePubkey, config.product.d, mediums),
|
|
1681
2244
|
storeSecretKey: keys.storeSecretKey,
|
|
1682
|
-
|
|
2245
|
+
// With no Solana payout the Solana catch-up has no terms to scan and reads nothing.
|
|
2246
|
+
context: {
|
|
2247
|
+
rpc: createSolanaRpc(config.rpcUrl ?? "https://api.devnet.solana.com"),
|
|
2248
|
+
network: config.network
|
|
2249
|
+
},
|
|
2250
|
+
...tempo === void 0 ? {} : { tempo },
|
|
2251
|
+
// No Solana payout configured: the Solana sweep reads nothing (no cluster to guess).
|
|
2252
|
+
...config.rpcUrl === void 0 ? { catchUp: async () => ({ paid: [], incomplete: [] }) } : {},
|
|
1683
2253
|
save: () => saveLedger(home.ledger, state),
|
|
1684
2254
|
deliver: (order, skip) => deliver(pool, order, skip, config, keys),
|
|
1685
2255
|
inboxRelayCount: config.inboxRelays.length,
|
|
@@ -1741,6 +2311,10 @@ function listOrders(home) {
|
|
|
1741
2311
|
console.log(`${when} ${orderStatus(order)} ${order.key}${paid}${email}`);
|
|
1742
2312
|
}
|
|
1743
2313
|
console.log(`${all.length} order(s)`);
|
|
2314
|
+
for (const [key, answer] of Object.entries(state.answeredByHand ?? {})) {
|
|
2315
|
+
const detail = answer.kind === "delivered" ? answer.delivery?.value ?? "" : `${answer.amount ?? ""} in ${answer.tx ?? ""}`;
|
|
2316
|
+
console.log(`answered by hand: ${key} ${answer.kind} ${detail}`);
|
|
2317
|
+
}
|
|
1744
2318
|
}
|
|
1745
2319
|
async function check(home) {
|
|
1746
2320
|
const config = loadConfig(home.config);
|
|
@@ -1770,6 +2344,72 @@ async function check(home) {
|
|
|
1770
2344
|
pool.destroy();
|
|
1771
2345
|
}
|
|
1772
2346
|
}
|
|
2347
|
+
async function confirmed(question, yes) {
|
|
2348
|
+
if (yes) {
|
|
2349
|
+
return true;
|
|
2350
|
+
}
|
|
2351
|
+
if (!process.stdin.isTTY) {
|
|
2352
|
+
throw new Error("no terminal to confirm on: pass --yes to answer without asking");
|
|
2353
|
+
}
|
|
2354
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
2355
|
+
try {
|
|
2356
|
+
const answer = await prompt.question(`${question} [y/N] `);
|
|
2357
|
+
return answer.trim().toLowerCase() === "y";
|
|
2358
|
+
} finally {
|
|
2359
|
+
prompt.close();
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
async function answerByHand(home, args) {
|
|
2363
|
+
const key = args.target;
|
|
2364
|
+
if (key === void 0) {
|
|
2365
|
+
throw new Error(`${args.command ?? ""} needs the order: <buyer>:<orderId> (see orders)`);
|
|
2366
|
+
}
|
|
2367
|
+
const config = loadConfig(home.config);
|
|
2368
|
+
const keys = loadOrCreateKeys(home, false);
|
|
2369
|
+
takeLock(home);
|
|
2370
|
+
const state = loadLedger(home.ledger);
|
|
2371
|
+
const request = args.command === "deliver" ? { kind: "delivered", delivery: config.product.delivery } : { kind: "refunded", tx: args.tx ?? "", amount: args.amount ?? "" };
|
|
2372
|
+
const plan = planHandAnswer(state, key, request);
|
|
2373
|
+
if (!plan.ok) {
|
|
2374
|
+
throw new Error(plan.problem);
|
|
2375
|
+
}
|
|
2376
|
+
const order = state.orders[key];
|
|
2377
|
+
const held = order ?? plan.answer;
|
|
2378
|
+
console.log(`order ${key}${order === void 0 ? " (closed)" : ""}`);
|
|
2379
|
+
console.log(`reported ${held.reportedTxs.join(", ") || "-"}`);
|
|
2380
|
+
console.log(`refused ${(held.refusedTxs ?? []).join(", ") || "-"}`);
|
|
2381
|
+
console.log(`no leg ${(held.noLegTxs ?? []).join(", ") || "-"}`);
|
|
2382
|
+
if (order?.blockedTx !== void 0) {
|
|
2383
|
+
console.log(`blocked ${order.blockedTx}`);
|
|
2384
|
+
}
|
|
2385
|
+
const what = plan.answer.kind === "delivered" ? `deliver "${plan.answer.delivery?.value ?? ""}"` : `report a refund of ${plan.answer.amount ?? ""} in ${plan.answer.tx ?? ""}`;
|
|
2386
|
+
if (!await confirmed(`${plan.rerun ? "Send again" : "Close the order and"} ${what}?`, args.yes)) {
|
|
2387
|
+
throw new Error("not confirmed: nothing was sent");
|
|
2388
|
+
}
|
|
2389
|
+
if (!plan.rerun) {
|
|
2390
|
+
applyHandAnswer(state, key, plan.answer);
|
|
2391
|
+
saveLedger(home.ledger, state);
|
|
2392
|
+
}
|
|
2393
|
+
const pool = new SimplePool();
|
|
2394
|
+
try {
|
|
2395
|
+
const wrap = buildHandAnswer(plan, keys.storeSecretKey, nowSecs());
|
|
2396
|
+
const taken = await publishToRelays(
|
|
2397
|
+
pool,
|
|
2398
|
+
config.inboxRelays,
|
|
2399
|
+
wrap.recipientWrap,
|
|
2400
|
+
storeAuth(keys),
|
|
2401
|
+
log
|
|
2402
|
+
);
|
|
2403
|
+
console.log(
|
|
2404
|
+
`taken by ${taken.length} of ${config.inboxRelays.length}: ${taken.join(", ") || "-"}`
|
|
2405
|
+
);
|
|
2406
|
+
if (!deliveryDone(taken.length, config.inboxRelays.length, 0)) {
|
|
2407
|
+
throw new Error("too few inbox relays took it: run the same command again to send it again");
|
|
2408
|
+
}
|
|
2409
|
+
} finally {
|
|
2410
|
+
pool.destroy();
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
1773
2413
|
async function main() {
|
|
1774
2414
|
const args = parseArgs(process.argv.slice(2));
|
|
1775
2415
|
if (args.network !== void 0 && args.command !== "init") {
|
|
@@ -1803,6 +2443,11 @@ async function main() {
|
|
|
1803
2443
|
await check(home);
|
|
1804
2444
|
process.exit(0);
|
|
1805
2445
|
return;
|
|
2446
|
+
case "deliver":
|
|
2447
|
+
case "refund":
|
|
2448
|
+
await answerByHand(home, args);
|
|
2449
|
+
process.exit(0);
|
|
2450
|
+
return;
|
|
1806
2451
|
default:
|
|
1807
2452
|
console.log(USAGE);
|
|
1808
2453
|
process.exit(args.command === void 0 || args.command === "help" ? 0 : 1);
|