@elisym/merchant-node 0.1.0 → 0.2.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/dist/cli.js +793 -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,23 @@ var configSchema = z.object({
|
|
|
366
406
|
}
|
|
367
407
|
seen.add(caip19.id);
|
|
368
408
|
});
|
|
409
|
+
if (solanaPayouts > 0 && config.rpcUrl === void 0) {
|
|
410
|
+
context.addIssue({
|
|
411
|
+
code: "custom",
|
|
412
|
+
path: ["rpcUrl"],
|
|
413
|
+
message: "is required with a Solana payout"
|
|
414
|
+
});
|
|
415
|
+
}
|
|
369
416
|
});
|
|
417
|
+
function tempoRegistryNetwork(network) {
|
|
418
|
+
return network === "mainnet" ? "mainnet" : "devnet";
|
|
419
|
+
}
|
|
420
|
+
function isPayableTempoAddress(address2) {
|
|
421
|
+
const coins = EVM_ASSETS.flatMap(
|
|
422
|
+
(coin) => coin.mint === void 0 ? [] : [coin.mint.toLowerCase()]
|
|
423
|
+
);
|
|
424
|
+
return !TEMPO_UNPAYABLE_ADDRESSES.includes(address2) && !coins.includes(address2);
|
|
425
|
+
}
|
|
370
426
|
function parseConfig(value) {
|
|
371
427
|
const parsed = configSchema.safeParse(value);
|
|
372
428
|
if (parsed.success) {
|
|
@@ -414,6 +470,155 @@ function configTemplate(network) {
|
|
|
414
470
|
payouts: [{ caip19: usdc, address: "<your Solana wallet address>" }]
|
|
415
471
|
};
|
|
416
472
|
}
|
|
473
|
+
function emptyLedger() {
|
|
474
|
+
return { version: 1, orders: {}, seenRumors: {}, claims: {}, terms: [], scans: {} };
|
|
475
|
+
}
|
|
476
|
+
function orderKey(buyerPubkey, orderId) {
|
|
477
|
+
return `${buyerPubkey}:${orderId}`;
|
|
478
|
+
}
|
|
479
|
+
function claimPayment(state, signature, key) {
|
|
480
|
+
const holder = state.claims[signature];
|
|
481
|
+
if (holder !== void 0 && holder !== key) {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
state.claims[signature] = key;
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
function openOrders(state, now, catchUpSecs) {
|
|
488
|
+
return Object.values(state.orders).filter(
|
|
489
|
+
(order) => order.paid === void 0 && now - order.createdAt <= catchUpSecs
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
function undeliveredOrders(state) {
|
|
493
|
+
return Object.values(state.orders).filter(
|
|
494
|
+
(order) => order.paid !== void 0 && order.deliveredAt === void 0
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
function pruneExpiredOrders(state, now) {
|
|
498
|
+
for (const [key, order] of Object.entries(state.orders)) {
|
|
499
|
+
if (order.paid === void 0 && now - order.createdAt > CATCH_UP_SECS + MAX_FUTURE_SKEW_SECS) {
|
|
500
|
+
delete state.orders[key];
|
|
501
|
+
(state.closedOrders ??= {})[key] = true;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function recordReport(order, tx, arrivedAt) {
|
|
506
|
+
order.reportedTxs.push(tx);
|
|
507
|
+
order.recheckedAt = { ...order.recheckedAt, [tx]: arrivedAt };
|
|
508
|
+
}
|
|
509
|
+
function loadLedger(path) {
|
|
510
|
+
let text;
|
|
511
|
+
try {
|
|
512
|
+
text = readFileSync(path, "utf8");
|
|
513
|
+
} catch {
|
|
514
|
+
return emptyLedger();
|
|
515
|
+
}
|
|
516
|
+
const state = JSON.parse(text);
|
|
517
|
+
if (state.version !== 1 && state.version !== 2) {
|
|
518
|
+
throw new Error(`Unknown ledger version in ${path}`);
|
|
519
|
+
}
|
|
520
|
+
state.scans ??= {};
|
|
521
|
+
return state;
|
|
522
|
+
}
|
|
523
|
+
function markTempo(state) {
|
|
524
|
+
state.version = 2;
|
|
525
|
+
}
|
|
526
|
+
function saveLedger(path, state) {
|
|
527
|
+
const temporary = `${path}.tmp`;
|
|
528
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}
|
|
529
|
+
`, { mode: 384 });
|
|
530
|
+
renameSync(temporary, path);
|
|
531
|
+
}
|
|
532
|
+
function isSolanaSignature(value) {
|
|
533
|
+
try {
|
|
534
|
+
return isSignature(value);
|
|
535
|
+
} catch {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/hand.ts
|
|
541
|
+
var TEMPO_HASH_RE = /^0x[0-9a-f]{64}$/;
|
|
542
|
+
var POSITIVE_SUBUNITS_RE = /^[1-9]\d{0,38}$/;
|
|
543
|
+
function isRefundTx(tx) {
|
|
544
|
+
return TEMPO_HASH_RE.test(tx) || isSolanaSignature(tx);
|
|
545
|
+
}
|
|
546
|
+
function planHandAnswer(state, key, request) {
|
|
547
|
+
const [buyerPubkey, orderId] = key.split(":");
|
|
548
|
+
if (buyerPubkey === void 0 || orderId === void 0 || key.split(":").length !== 2) {
|
|
549
|
+
return { ok: false, problem: `${key} is not <buyer>:<orderId>` };
|
|
550
|
+
}
|
|
551
|
+
if (request.kind === "refunded") {
|
|
552
|
+
if (!isRefundTx(request.tx)) {
|
|
553
|
+
return { ok: false, problem: "the refund tx is neither a Solana signature nor a Tempo hash" };
|
|
554
|
+
}
|
|
555
|
+
if (!POSITIVE_SUBUNITS_RE.test(request.amount)) {
|
|
556
|
+
return {
|
|
557
|
+
ok: false,
|
|
558
|
+
problem: "the refund amount must be subunits above 0 (an unpaid order is released with deliver, never a zero refund)"
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const stored = state.answeredByHand?.[key];
|
|
563
|
+
if (stored !== void 0) {
|
|
564
|
+
if (stored.kind !== request.kind) {
|
|
565
|
+
return {
|
|
566
|
+
ok: false,
|
|
567
|
+
problem: `${key} was already answered as ${stored.kind}: the other answer is refused`
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
return { ok: true, answer: stored, buyerPubkey, orderId, rerun: true };
|
|
571
|
+
}
|
|
572
|
+
const order = state.orders[key];
|
|
573
|
+
if (order === void 0 && state.closedOrders?.[key] !== true) {
|
|
574
|
+
return { ok: false, problem: `no order ${key} in the ledger` };
|
|
575
|
+
}
|
|
576
|
+
if (order?.paid !== void 0) {
|
|
577
|
+
return {
|
|
578
|
+
ok: false,
|
|
579
|
+
problem: `${key} is paid: the node delivers it itself (a refund of a delivered order is not handled here)`
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const answer = {
|
|
583
|
+
kind: request.kind,
|
|
584
|
+
...request.kind === "delivered" ? { delivery: { ...request.delivery } } : { tx: request.tx, amount: request.amount },
|
|
585
|
+
reportedTxs: [...order?.reportedTxs ?? []],
|
|
586
|
+
refusedTxs: [...order?.refusedTxs ?? []],
|
|
587
|
+
noLegTxs: [...order?.noLegTxs ?? []]
|
|
588
|
+
};
|
|
589
|
+
return { ok: true, answer, buyerPubkey, orderId, rerun: false };
|
|
590
|
+
}
|
|
591
|
+
function applyHandAnswer(state, key, answer) {
|
|
592
|
+
delete state.orders[key];
|
|
593
|
+
(state.closedOrders ??= {})[key] = true;
|
|
594
|
+
(state.answeredByHand ??= {})[key] = answer;
|
|
595
|
+
if (answer.tx !== void 0 && TEMPO_HASH_RE.test(answer.tx)) {
|
|
596
|
+
markTempo(state);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function buildHandAnswer(plan, storeSecretKey, createdAt) {
|
|
600
|
+
const { answer } = plan;
|
|
601
|
+
if (answer.kind === "delivered" && answer.delivery === void 0) {
|
|
602
|
+
throw new Error("A hand delivery carries what was delivered");
|
|
603
|
+
}
|
|
604
|
+
const rumor = buildOrderMessage(
|
|
605
|
+
answer.kind === "delivered" ? {
|
|
606
|
+
type: "status",
|
|
607
|
+
buyerPubkey: plan.buyerPubkey,
|
|
608
|
+
orderId: plan.orderId,
|
|
609
|
+
status: "completed",
|
|
610
|
+
...answer.delivery === void 0 ? {} : { delivery: answer.delivery }
|
|
611
|
+
} : {
|
|
612
|
+
type: "status",
|
|
613
|
+
buyerPubkey: plan.buyerPubkey,
|
|
614
|
+
orderId: plan.orderId,
|
|
615
|
+
status: "cancelled",
|
|
616
|
+
refund: { tx: answer.tx ?? "", amount: answer.amount ?? "" }
|
|
617
|
+
},
|
|
618
|
+
createdAt
|
|
619
|
+
);
|
|
620
|
+
return wrapOrderMessage(rumor, storeSecretKey, plan.buyerPubkey);
|
|
621
|
+
}
|
|
417
622
|
var HOME_ENV = "ELISYM_MERCHANT_HOME";
|
|
418
623
|
var DEFAULT_HOME_NAME = ".elisym-merchant";
|
|
419
624
|
function merchantHome(flag, env = process.env) {
|
|
@@ -551,68 +756,401 @@ function takeLock(home, options = {}) {
|
|
|
551
756
|
process.once("exit", release);
|
|
552
757
|
return release;
|
|
553
758
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
return `${buyerPubkey}:${orderId}`;
|
|
759
|
+
|
|
760
|
+
// src/terms.ts
|
|
761
|
+
function sameTerms(left, right) {
|
|
762
|
+
return left.caip19 === right.caip19 && left.payout === right.payout && left.amount === right.amount;
|
|
559
763
|
}
|
|
560
|
-
function
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
764
|
+
function publishTerms(periods, terms, at) {
|
|
765
|
+
const standing = periods.find(
|
|
766
|
+
(period) => period.until === void 0 && period.terms.caip19 === terms.caip19
|
|
767
|
+
);
|
|
768
|
+
if (standing !== void 0 && sameTerms(standing.terms, terms)) {
|
|
769
|
+
return [...periods];
|
|
564
770
|
}
|
|
565
|
-
|
|
566
|
-
|
|
771
|
+
return [
|
|
772
|
+
...periods.map(
|
|
773
|
+
(period) => period === standing ? { ...period, until: Math.max(at, period.from) } : period
|
|
774
|
+
),
|
|
775
|
+
{ terms, from: at }
|
|
776
|
+
];
|
|
567
777
|
}
|
|
568
|
-
function
|
|
569
|
-
return
|
|
570
|
-
(
|
|
778
|
+
function retireTerms(periods, offered, at) {
|
|
779
|
+
return periods.map(
|
|
780
|
+
(period) => period.until === void 0 && !offered.includes(period.terms.caip19) ? { ...period, until: Math.max(at, period.from) } : period
|
|
571
781
|
);
|
|
572
782
|
}
|
|
573
|
-
function
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
)
|
|
783
|
+
function termsAt(periods, blockTime2) {
|
|
784
|
+
const earliest = blockTime2 - TERMS_WINDOW_SECS;
|
|
785
|
+
const found = [];
|
|
786
|
+
for (const period of periods) {
|
|
787
|
+
const offered = period.from <= blockTime2 && (period.until === void 0 || period.until > earliest);
|
|
788
|
+
if (offered && !found.some((terms) => sameTerms(terms, period.terms))) {
|
|
789
|
+
found.push(period.terms);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return found;
|
|
577
793
|
}
|
|
578
|
-
function
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
794
|
+
function termsSince(periods, since) {
|
|
795
|
+
const earliest = since - TERMS_WINDOW_SECS;
|
|
796
|
+
const found = [];
|
|
797
|
+
for (const period of periods) {
|
|
798
|
+
if ((period.until === void 0 || period.until > earliest) && !found.some((terms) => sameTerms(terms, period.terms))) {
|
|
799
|
+
found.push(period.terms);
|
|
583
800
|
}
|
|
584
801
|
}
|
|
802
|
+
return found;
|
|
585
803
|
}
|
|
586
|
-
function
|
|
587
|
-
|
|
588
|
-
order.recheckedAt = { ...order.recheckedAt, [tx]: arrivedAt };
|
|
804
|
+
function standingTerms(periods) {
|
|
805
|
+
return periods.filter((period) => period.until === void 0).map((period) => period.terms);
|
|
589
806
|
}
|
|
590
|
-
function
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
|
|
807
|
+
function visibleTerms(standing, next, listingOut, payoutsOut) {
|
|
808
|
+
const listed = listingOut ? next : standing;
|
|
809
|
+
const payouts = payoutsOut ? next : standing;
|
|
810
|
+
const visible = [];
|
|
811
|
+
for (const { caip19, amount } of listed) {
|
|
812
|
+
const payout = payouts.find((terms) => terms.caip19 === caip19)?.payout;
|
|
813
|
+
if (payout !== void 0) {
|
|
814
|
+
visible.push({ caip19, payout, amount });
|
|
815
|
+
}
|
|
596
816
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
817
|
+
return visible;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/tempo.ts
|
|
821
|
+
function tempoContextFor(config, storePubkey) {
|
|
822
|
+
if (config.tempo === void 0) {
|
|
823
|
+
return void 0;
|
|
600
824
|
}
|
|
601
|
-
|
|
602
|
-
|
|
825
|
+
const chain = chainByCaip2(config.tempo.network === "mainnet" ? "eip155:4217" : "eip155:42431");
|
|
826
|
+
const url = config.tempo.rpcUrl ?? chain?.rpcUrls[0];
|
|
827
|
+
if (chain === void 0 || url === void 0) {
|
|
828
|
+
return void 0;
|
|
829
|
+
}
|
|
830
|
+
return {
|
|
831
|
+
client: createJsonRpcClient(url),
|
|
832
|
+
chain,
|
|
833
|
+
medium: config.tempo.network === "mainnet" ? "tempo" : "tempo-moderato",
|
|
834
|
+
storePubkey
|
|
835
|
+
};
|
|
603
836
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
837
|
+
var MAX_BLOCK_SAMPLES = 1e4;
|
|
838
|
+
var TEMPO_HASH_RE2 = /^0x[0-9a-f]{64}$/;
|
|
839
|
+
var FLOOR_MARGIN_SECS = Math.max(ORDER_SCAN_MARGIN_SECS, 900);
|
|
840
|
+
function tempoMemo(order, storePubkey) {
|
|
841
|
+
return deriveOrderPaymentReference({
|
|
842
|
+
storePubkey,
|
|
843
|
+
buyerPubkey: order.buyerPubkey,
|
|
844
|
+
orderId: order.orderId
|
|
845
|
+
}).tempo;
|
|
846
|
+
}
|
|
847
|
+
function tempoTerms(terms, context) {
|
|
848
|
+
return terms.flatMap((each) => {
|
|
849
|
+
const caip19 = parseCaip19(each.caip19);
|
|
850
|
+
const mint = caip19?.asset.mint;
|
|
851
|
+
if (caip19?.chain.caip2 !== context.chain.caip2 || mint === void 0) {
|
|
852
|
+
return [];
|
|
853
|
+
}
|
|
854
|
+
return [{ terms: each, token: mint.toLowerCase() }];
|
|
855
|
+
});
|
|
609
856
|
}
|
|
610
|
-
function
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
857
|
+
async function blockTime(context, number) {
|
|
858
|
+
const cached = context.samples?.get(number);
|
|
859
|
+
if (cached !== void 0) {
|
|
860
|
+
return cached;
|
|
861
|
+
}
|
|
862
|
+
const block = await readBlockByNumber(context.client, number);
|
|
863
|
+
if (block === null) {
|
|
864
|
+
return void 0;
|
|
865
|
+
}
|
|
866
|
+
const samples = context.samples ??= /* @__PURE__ */ new Map();
|
|
867
|
+
if (samples.size >= MAX_BLOCK_SAMPLES) {
|
|
868
|
+
samples.clear();
|
|
869
|
+
}
|
|
870
|
+
samples.set(number, block.timestamp);
|
|
871
|
+
return block.timestamp;
|
|
872
|
+
}
|
|
873
|
+
async function blockAtTime(context, time) {
|
|
874
|
+
const head = await readFinalizedBlock(context.client);
|
|
875
|
+
if (head === null) {
|
|
876
|
+
return void 0;
|
|
877
|
+
}
|
|
878
|
+
const target = Math.max(time, head.timestamp - CATCH_UP_SECS - FLOOR_MARGIN_SECS);
|
|
879
|
+
if (head.timestamp <= target) {
|
|
880
|
+
return head;
|
|
881
|
+
}
|
|
882
|
+
let low = 0;
|
|
883
|
+
let high = head.number;
|
|
884
|
+
let found = { number: 0, timestamp: 0 };
|
|
885
|
+
for (const [number, at] of context.samples ?? []) {
|
|
886
|
+
if (number > high || number < low) {
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (at <= target && number >= low) {
|
|
890
|
+
low = number;
|
|
891
|
+
found = { number, timestamp: at };
|
|
892
|
+
} else if (at > target && number <= high) {
|
|
893
|
+
high = number;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
while (low <= high) {
|
|
897
|
+
const middle = Math.floor((low + high) / 2);
|
|
898
|
+
const at = await blockTime(context, middle);
|
|
899
|
+
if (at === void 0) {
|
|
900
|
+
return void 0;
|
|
901
|
+
}
|
|
902
|
+
if (at <= target) {
|
|
903
|
+
found = { number: middle, timestamp: at };
|
|
904
|
+
low = middle + 1;
|
|
905
|
+
} else {
|
|
906
|
+
high = middle - 1;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return found;
|
|
910
|
+
}
|
|
911
|
+
function orderFloor(context, order) {
|
|
912
|
+
return blockAtTime(context, order.createdAt - FLOOR_MARGIN_SECS);
|
|
913
|
+
}
|
|
914
|
+
async function verifyTerms(state, order, hash, context, candidates, floor, satisfies) {
|
|
915
|
+
const memo = tempoMemo(order, context.storePubkey);
|
|
916
|
+
const verdicts = [];
|
|
917
|
+
for (const candidate of candidates) {
|
|
918
|
+
const caip19 = parseCaip19(candidate.terms.caip19);
|
|
919
|
+
if (caip19 === void 0) {
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
let request;
|
|
923
|
+
try {
|
|
924
|
+
request = composeTempoPaymentRequest({
|
|
925
|
+
chain: context.chain,
|
|
926
|
+
asset: caip19.asset,
|
|
927
|
+
recipient: candidate.terms.payout,
|
|
928
|
+
amount: BigInt(candidate.terms.amount),
|
|
929
|
+
feeBps: 0,
|
|
930
|
+
treasury: candidate.terms.payout,
|
|
931
|
+
memo,
|
|
932
|
+
createdAt: floor.timestamp
|
|
933
|
+
});
|
|
934
|
+
} catch {
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const result = await verifyTempoPayment(context.client, request, {
|
|
938
|
+
txSignature: hash,
|
|
939
|
+
fromBlock: floor.number,
|
|
940
|
+
pollBudgetMs: 0
|
|
941
|
+
});
|
|
942
|
+
if (result.outcome === "verified") {
|
|
943
|
+
const leg = result.providerLeg;
|
|
944
|
+
const at = await blockTime(context, leg.blockNumber);
|
|
945
|
+
if (at === void 0) {
|
|
946
|
+
verdicts.push("ask");
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
const offered = termsAt(state.terms, at).some(
|
|
950
|
+
(standing) => standing.caip19 === candidate.terms.caip19 && standing.payout === candidate.terms.payout && standing.amount === candidate.terms.amount
|
|
951
|
+
);
|
|
952
|
+
if (leg.blockNumber < floor.number || !offered) {
|
|
953
|
+
verdicts.push("refused");
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
if (!claimPayment(state, result.settlementId, order.key)) {
|
|
957
|
+
return "claimed";
|
|
958
|
+
}
|
|
959
|
+
markTempo(state);
|
|
960
|
+
order.paid = {
|
|
961
|
+
signature: leg.transactionHash,
|
|
962
|
+
amount: leg.amount.toString(),
|
|
963
|
+
blockTime: at,
|
|
964
|
+
caip19: candidate.terms.caip19,
|
|
965
|
+
medium: context.medium
|
|
966
|
+
};
|
|
967
|
+
return "paid";
|
|
968
|
+
}
|
|
969
|
+
if (result.outcome === "refused" && (result.code === "reverted" || result.code === "no_provider_leg")) {
|
|
970
|
+
verdicts.push(satisfies(candidate) ? "ask" : "no_leg");
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
verdicts.push("ask");
|
|
974
|
+
}
|
|
975
|
+
if (verdicts.includes("ask")) {
|
|
976
|
+
return "ask";
|
|
977
|
+
}
|
|
978
|
+
if (verdicts.length > 0 && verdicts.every((verdict) => verdict === "refused")) {
|
|
979
|
+
return "refused";
|
|
980
|
+
}
|
|
981
|
+
return "no_leg";
|
|
982
|
+
}
|
|
983
|
+
function asCheck(verdict, order) {
|
|
984
|
+
switch (verdict) {
|
|
985
|
+
case "paid":
|
|
986
|
+
return { kind: "paid", order };
|
|
987
|
+
case "claimed":
|
|
988
|
+
return { kind: "refused", reason: "claimed_by_another_order" };
|
|
989
|
+
case "refused":
|
|
990
|
+
return { kind: "refused", reason: "not_a_payment_for_this_order" };
|
|
991
|
+
case "no_leg":
|
|
992
|
+
return { kind: "no_leg" };
|
|
993
|
+
case "ask":
|
|
994
|
+
return { kind: "ask_again" };
|
|
995
|
+
}
|
|
996
|
+
return { kind: "ask_again" };
|
|
997
|
+
}
|
|
998
|
+
async function checkTempoPayment(state, order, hash, context) {
|
|
999
|
+
if (order.paid !== void 0) {
|
|
1000
|
+
return order.paid.signature === hash ? { kind: "paid", order } : { kind: "refused", reason: "order_already_paid" };
|
|
1001
|
+
}
|
|
1002
|
+
const candidates = tempoTerms(
|
|
1003
|
+
termsSince(state.terms, order.createdAt - ORDER_SCAN_MARGIN_SECS),
|
|
1004
|
+
context
|
|
1005
|
+
);
|
|
1006
|
+
if (candidates.length === 0) {
|
|
1007
|
+
return { kind: "refused", reason: "not_a_payment_for_this_order" };
|
|
1008
|
+
}
|
|
1009
|
+
const minAmount = candidates.reduce(
|
|
1010
|
+
(lowest, candidate) => BigInt(candidate.terms.amount) < lowest ? BigInt(candidate.terms.amount) : lowest,
|
|
1011
|
+
BigInt(candidates[0]?.terms.amount ?? "0")
|
|
1012
|
+
);
|
|
1013
|
+
const pre = await readTempoReceiptLegs(context.client, hash, {
|
|
1014
|
+
chain: context.chain,
|
|
1015
|
+
tokens: [...new Set(candidates.map((candidate) => candidate.token))],
|
|
1016
|
+
recipients: [...new Set(candidates.map((candidate) => candidate.terms.payout))],
|
|
1017
|
+
memo: tempoMemo(order, context.storePubkey),
|
|
1018
|
+
minAmount
|
|
1019
|
+
});
|
|
1020
|
+
if (pre.kind === "unreadable" || pre.kind === "absent") {
|
|
1021
|
+
return { kind: "ask_again" };
|
|
1022
|
+
}
|
|
1023
|
+
if (pre.kind === "none") {
|
|
1024
|
+
return { kind: "no_leg" };
|
|
1025
|
+
}
|
|
1026
|
+
const named = candidates.filter(
|
|
1027
|
+
(candidate) => pre.transfers.some((leg) => leg.token === candidate.token && leg.to === candidate.terms.payout)
|
|
1028
|
+
);
|
|
1029
|
+
if (named.length === 0) {
|
|
1030
|
+
if (pre.blocked.length > 0) {
|
|
1031
|
+
order.blockedTx = hash;
|
|
1032
|
+
markTempo(state);
|
|
1033
|
+
return { kind: "blocked" };
|
|
1034
|
+
}
|
|
1035
|
+
return { kind: "no_leg" };
|
|
1036
|
+
}
|
|
1037
|
+
const floor = await orderFloor(context, order);
|
|
1038
|
+
if (floor === void 0) {
|
|
1039
|
+
return { kind: "ask_again" };
|
|
1040
|
+
}
|
|
1041
|
+
return asCheck(await verifyTerms(state, order, hash, context, named, floor, () => false), order);
|
|
1042
|
+
}
|
|
1043
|
+
function recordTempoCheck(state, order, hash, check2) {
|
|
1044
|
+
if (check2.kind === "refused") {
|
|
1045
|
+
order.refusedTxs = [...order.refusedTxs ?? [], hash];
|
|
1046
|
+
} else if (check2.kind === "no_leg" && order.noLegTxs?.includes(hash) !== true) {
|
|
1047
|
+
order.noLegTxs = [...order.noLegTxs ?? [], hash];
|
|
615
1048
|
}
|
|
1049
|
+
markTempo(state);
|
|
1050
|
+
}
|
|
1051
|
+
async function catchUpTempo(state, context, now) {
|
|
1052
|
+
const result = { paid: [], incomplete: [] };
|
|
1053
|
+
const open = openOrders(state, now, CATCH_UP_SECS);
|
|
1054
|
+
if (open.length === 0) {
|
|
1055
|
+
return result;
|
|
1056
|
+
}
|
|
1057
|
+
const reported = [];
|
|
1058
|
+
for (const order of open) {
|
|
1059
|
+
for (const hash of order.reportedTxs) {
|
|
1060
|
+
if (TEMPO_HASH_RE2.test(hash) && order.refusedTxs?.includes(hash) !== true && order.noLegTxs?.includes(hash) !== true && order.blockedTx !== hash) {
|
|
1061
|
+
reported.push({ order, hash, checkedAt: order.recheckedAt?.[hash] ?? 0 });
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
reported.sort((left, right) => left.checkedAt - right.checkedAt);
|
|
1066
|
+
for (const { order, hash } of reported.slice(0, MAX_RECHECKS_PER_SWEEP)) {
|
|
1067
|
+
if (order.paid !== void 0) {
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
order.recheckedAt = { ...order.recheckedAt, [hash]: now };
|
|
1071
|
+
const check2 = await checkTempoPayment(state, order, hash, context);
|
|
1072
|
+
recordTempoCheck(state, order, hash, check2);
|
|
1073
|
+
if (check2.kind === "paid") {
|
|
1074
|
+
result.paid.push(order);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
const oldest = Math.min(...open.map((order) => order.createdAt));
|
|
1078
|
+
const candidates = tempoTerms(termsSince(state.terms, oldest - ORDER_SCAN_MARGIN_SECS), context);
|
|
1079
|
+
if (candidates.length === 0) {
|
|
1080
|
+
return result;
|
|
1081
|
+
}
|
|
1082
|
+
const floor = await blockAtTime(context, oldest - FLOOR_MARGIN_SECS);
|
|
1083
|
+
if (floor === void 0) {
|
|
1084
|
+
return result;
|
|
1085
|
+
}
|
|
1086
|
+
const byMemo = new Map(open.map((order) => [tempoMemo(order, context.storePubkey), order]));
|
|
1087
|
+
const matches = [];
|
|
1088
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
1089
|
+
for (const candidate of candidates) {
|
|
1090
|
+
const key = `${candidate.token}:${candidate.terms.payout}`;
|
|
1091
|
+
pairs.set(key, [...pairs.get(key) ?? [], candidate]);
|
|
1092
|
+
}
|
|
1093
|
+
for (const [key, terms] of pairs) {
|
|
1094
|
+
const [token, payout] = key.split(":");
|
|
1095
|
+
const minAmount = terms.reduce(
|
|
1096
|
+
(lowest, each) => BigInt(each.terms.amount) < lowest ? BigInt(each.terms.amount) : lowest,
|
|
1097
|
+
BigInt(terms[0]?.terms.amount ?? "0")
|
|
1098
|
+
);
|
|
1099
|
+
const scan = await listTempoLogs(context.client, {
|
|
1100
|
+
token,
|
|
1101
|
+
event: "TransferWithMemo",
|
|
1102
|
+
to: payout,
|
|
1103
|
+
fromBlock: floor.number,
|
|
1104
|
+
minAmount
|
|
1105
|
+
});
|
|
1106
|
+
if (!scan.complete) {
|
|
1107
|
+
result.incomplete.push(`${context.chain.caip2} ${token} ${payout}`);
|
|
1108
|
+
}
|
|
1109
|
+
for (const leg of scan.candidates) {
|
|
1110
|
+
const order = leg.memo === void 0 ? void 0 : byMemo.get(leg.memo);
|
|
1111
|
+
if (order === void 0 || order.paid !== void 0 || order.refusedTxs?.includes(leg.transactionHash) === true || order.tempoNoLeg?.includes(leg.transactionHash) === true) {
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
matches.push({ order, leg, checkedAt: order.recheckedAt?.[leg.transactionHash] ?? 0 });
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
matches.sort((left, right) => left.checkedAt - right.checkedAt);
|
|
1118
|
+
for (const { order, leg } of matches.slice(0, MAX_RECHECKS_PER_SWEEP)) {
|
|
1119
|
+
if (order.paid !== void 0) {
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
const hash = leg.transactionHash;
|
|
1123
|
+
order.recheckedAt = { ...order.recheckedAt, [hash]: now };
|
|
1124
|
+
const orderFloorBlock = await orderFloor(context, order);
|
|
1125
|
+
if (orderFloorBlock === void 0) {
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
const orderTerms = tempoTerms(
|
|
1129
|
+
termsSince(state.terms, order.createdAt - ORDER_SCAN_MARGIN_SECS),
|
|
1130
|
+
context
|
|
1131
|
+
);
|
|
1132
|
+
const verdict = await verifyTerms(
|
|
1133
|
+
state,
|
|
1134
|
+
order,
|
|
1135
|
+
hash,
|
|
1136
|
+
context,
|
|
1137
|
+
orderTerms,
|
|
1138
|
+
orderFloorBlock,
|
|
1139
|
+
(candidate) => candidate.token === leg.token && candidate.terms.payout === leg.to && leg.amount >= BigInt(candidate.terms.amount)
|
|
1140
|
+
);
|
|
1141
|
+
markTempo(state);
|
|
1142
|
+
if (order.noLegTxs?.includes(hash) === true) {
|
|
1143
|
+
order.noLegTxs = order.noLegTxs.filter((each) => each !== hash);
|
|
1144
|
+
}
|
|
1145
|
+
if (verdict === "paid") {
|
|
1146
|
+
result.paid.push(order);
|
|
1147
|
+
} else if (verdict === "refused") {
|
|
1148
|
+
order.refusedTxs = [...order.refusedTxs ?? [], hash];
|
|
1149
|
+
} else if (verdict === "no_leg") {
|
|
1150
|
+
order.tempoNoLeg = [...order.tempoNoLeg ?? [], hash];
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return result;
|
|
616
1154
|
}
|
|
617
1155
|
|
|
618
1156
|
// src/intake.ts
|
|
@@ -667,7 +1205,13 @@ function intake(state, unwrapped, store, now = Math.floor(Date.now() / 1e3)) {
|
|
|
667
1205
|
return { kind: "ignored", reason: "unknown_order" };
|
|
668
1206
|
}
|
|
669
1207
|
const { medium, reference, tx } = message.payment;
|
|
670
|
-
|
|
1208
|
+
const tempo = medium.startsWith("tempo");
|
|
1209
|
+
const expected = tempo ? deriveOrderPaymentReference({
|
|
1210
|
+
storePubkey: store.storePubkey,
|
|
1211
|
+
buyerPubkey: order.buyerPubkey,
|
|
1212
|
+
orderId: order.orderId
|
|
1213
|
+
}).tempo : order.reference;
|
|
1214
|
+
if (reference !== expected || !store.mediums.includes(medium) || !(tempo ? TEMPO_HASH_RE2.test(tx) : isSolanaSignature(tx))) {
|
|
671
1215
|
return { kind: "ignored", reason: "foreign_payment" };
|
|
672
1216
|
}
|
|
673
1217
|
if (!order.reportedTxs.includes(tx) && order.reportedTxs.length >= MAX_RECEIPTS_PER_ORDER) {
|
|
@@ -677,6 +1221,9 @@ function intake(state, unwrapped, store, now = Math.floor(Date.now() / 1e3)) {
|
|
|
677
1221
|
const isNew = !order.reportedTxs.includes(tx);
|
|
678
1222
|
if (isNew) {
|
|
679
1223
|
recordReport(order, tx, now);
|
|
1224
|
+
if (tempo) {
|
|
1225
|
+
markTempo(state);
|
|
1226
|
+
}
|
|
680
1227
|
}
|
|
681
1228
|
return { kind: "receipt", order, tx, isNew };
|
|
682
1229
|
}
|
|
@@ -859,68 +1406,6 @@ function deliveryDone(accepted, relays, paidAgeSecs) {
|
|
|
859
1406
|
}
|
|
860
1407
|
return accepted >= Math.min(DELIVERY_RELAYS_WANTED, relays) || paidAgeSecs >= DELIVERY_SETTLE_SECS;
|
|
861
1408
|
}
|
|
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
1409
|
function solanaAsset(terms, network) {
|
|
925
1410
|
const caip19 = parseCaip19(terms.caip19);
|
|
926
1411
|
if (caip19 === void 0 || caip19.chain.family !== "solana" || caip19.chain.network !== network) {
|
|
@@ -1029,8 +1514,8 @@ async function scanTransaction(signature, terms, asset, context) {
|
|
|
1029
1514
|
if (transaction === null || typeof transaction !== "object") {
|
|
1030
1515
|
return void 0;
|
|
1031
1516
|
}
|
|
1032
|
-
const
|
|
1033
|
-
if (typeof
|
|
1517
|
+
const blockTime2 = Reflect.get(transaction, "blockTime");
|
|
1518
|
+
if (typeof blockTime2 !== "number" && typeof blockTime2 !== "bigint") {
|
|
1034
1519
|
return void 0;
|
|
1035
1520
|
}
|
|
1036
1521
|
let instructions;
|
|
@@ -1040,7 +1525,7 @@ async function scanTransaction(signature, terms, asset, context) {
|
|
|
1040
1525
|
return void 0;
|
|
1041
1526
|
}
|
|
1042
1527
|
return {
|
|
1043
|
-
blockTime: Number(
|
|
1528
|
+
blockTime: Number(blockTime2),
|
|
1044
1529
|
references: await boundReferences(instructions, terms, asset)
|
|
1045
1530
|
};
|
|
1046
1531
|
}
|
|
@@ -1066,7 +1551,7 @@ async function catchUp(state, context, now) {
|
|
|
1066
1551
|
const pending = [];
|
|
1067
1552
|
for (const order of open) {
|
|
1068
1553
|
for (const signature of order.reportedTxs) {
|
|
1069
|
-
if (order.paid === void 0 && order.refusedTxs?.includes(signature) !== true && state.claims[signature] === void 0) {
|
|
1554
|
+
if (order.paid === void 0 && isSolanaSignature(signature) && order.refusedTxs?.includes(signature) !== true && state.claims[signature] === void 0) {
|
|
1070
1555
|
pending.push({ order, signature, checkedAt: order.recheckedAt?.[signature] ?? 0 });
|
|
1071
1556
|
}
|
|
1072
1557
|
}
|
|
@@ -1132,6 +1617,9 @@ function forgetOldScans(state, now) {
|
|
|
1132
1617
|
}
|
|
1133
1618
|
|
|
1134
1619
|
// src/runtime.ts
|
|
1620
|
+
function settled(order, tx) {
|
|
1621
|
+
return order.refusedTxs?.includes(tx) === true || order.noLegTxs?.includes(tx) === true || order.blockedTx === tx;
|
|
1622
|
+
}
|
|
1135
1623
|
var MerchantRuntime = class {
|
|
1136
1624
|
constructor(deps) {
|
|
1137
1625
|
this.deps = deps;
|
|
@@ -1189,7 +1677,7 @@ var MerchantRuntime = class {
|
|
|
1189
1677
|
log2(`order ${result.order.key}`);
|
|
1190
1678
|
return;
|
|
1191
1679
|
}
|
|
1192
|
-
if (!result.isNew || result.order
|
|
1680
|
+
if (!result.isNew || settled(result.order, result.tx) || !this.takeLiveCheck()) {
|
|
1193
1681
|
return;
|
|
1194
1682
|
}
|
|
1195
1683
|
await this.checkReported(result.order, result.tx, 0);
|
|
@@ -1197,11 +1685,26 @@ var MerchantRuntime = class {
|
|
|
1197
1685
|
/** Check one reported transaction now; one the RPC does not see yet is tried again soon. */
|
|
1198
1686
|
async checkReported(order, tx, attempt) {
|
|
1199
1687
|
const { state, context, save, log: log2 } = this.deps;
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1688
|
+
let check2;
|
|
1689
|
+
if (TEMPO_HASH_RE2.test(tx)) {
|
|
1690
|
+
if (this.deps.tempo === void 0) {
|
|
1691
|
+
log2(`receipt ${order.key} ${tx}: a Tempo payment, and this node has no tempo block`);
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
check2 = await (this.deps.checkTempoPayment ?? checkTempoPayment)(
|
|
1695
|
+
state,
|
|
1696
|
+
order,
|
|
1697
|
+
tx,
|
|
1698
|
+
this.deps.tempo
|
|
1699
|
+
);
|
|
1700
|
+
recordTempoCheck(state, order, tx, check2);
|
|
1701
|
+
} else {
|
|
1702
|
+
check2 = await this.check(state, order, tx, context);
|
|
1703
|
+
if (check2.kind === "refused") {
|
|
1704
|
+
order.refusedTxs = [...order.refusedTxs ?? [], tx];
|
|
1705
|
+
}
|
|
1204
1706
|
}
|
|
1707
|
+
log2(`receipt ${order.key} ${tx}: ${check2.kind}`);
|
|
1205
1708
|
save();
|
|
1206
1709
|
if (check2.kind === "paid") {
|
|
1207
1710
|
await this.deliverPending();
|
|
@@ -1216,7 +1719,7 @@ var MerchantRuntime = class {
|
|
|
1216
1719
|
}
|
|
1217
1720
|
this.deps.later(delay, async () => {
|
|
1218
1721
|
const order = this.deps.state.orders[key];
|
|
1219
|
-
if (order === void 0 || order.paid !== void 0 || !order.reportedTxs.includes(tx) || order
|
|
1722
|
+
if (order === void 0 || order.paid !== void 0 || !order.reportedTxs.includes(tx) || settled(order, tx) || !this.takeLiveCheck()) {
|
|
1220
1723
|
return;
|
|
1221
1724
|
}
|
|
1222
1725
|
await this.checkReported(order, tx, attempt + 1);
|
|
@@ -1266,12 +1769,33 @@ var MerchantRuntime = class {
|
|
|
1266
1769
|
}
|
|
1267
1770
|
}
|
|
1268
1771
|
try {
|
|
1269
|
-
const
|
|
1270
|
-
|
|
1271
|
-
|
|
1772
|
+
const tempo = this.deps.tempo;
|
|
1773
|
+
const rails = [
|
|
1774
|
+
{ rail: "solana", run: () => this.sweepCatchUp(state, context, now()) }
|
|
1775
|
+
];
|
|
1776
|
+
if (tempo !== void 0) {
|
|
1777
|
+
rails.push({
|
|
1778
|
+
rail: "tempo",
|
|
1779
|
+
run: () => (this.deps.catchUpTempo ?? catchUpTempo)(state, tempo, now())
|
|
1780
|
+
});
|
|
1272
1781
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1782
|
+
const results = [];
|
|
1783
|
+
for (const { rail, run: run2 } of rails) {
|
|
1784
|
+
try {
|
|
1785
|
+
results.push(await run2());
|
|
1786
|
+
} catch (error) {
|
|
1787
|
+
log2(
|
|
1788
|
+
`${rail} catch-up failed this time: ${error instanceof Error ? error.message : String(error)}`
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
for (const result of results) {
|
|
1793
|
+
if (result.paid.length > 0) {
|
|
1794
|
+
log2(`catch-up found ${result.paid.length} payment(s)`);
|
|
1795
|
+
}
|
|
1796
|
+
for (const account of result.incomplete) {
|
|
1797
|
+
log2(`warning: the history of ${account} was cut short; older payments may be missed`);
|
|
1798
|
+
}
|
|
1275
1799
|
}
|
|
1276
1800
|
} finally {
|
|
1277
1801
|
save();
|
|
@@ -1422,6 +1946,11 @@ var USAGE = `usage: elisym-merchant <command> [--home <dir>]
|
|
|
1422
1946
|
run take orders, verify payments, deliver
|
|
1423
1947
|
orders list the orders: paid, delivered, the buyer's email
|
|
1424
1948
|
check check the inbox relays, the owner's payout list and the domain
|
|
1949
|
+
deliver <buyer>:<orderId> [--yes]
|
|
1950
|
+
answer an unpaid order by hand with the configured delivery
|
|
1951
|
+
refund <buyer>:<orderId> --tx <refund tx> --amount <subunits> [--yes]
|
|
1952
|
+
answer an unpaid order by hand with a refund you already sent
|
|
1953
|
+
(both need the node stopped; --yes skips the confirmation)
|
|
1425
1954
|
|
|
1426
1955
|
The home is --home, else $ELISYM_MERCHANT_HOME, else ~/.elisym-merchant.
|
|
1427
1956
|
It holds the store's secret keys: keep it private and back it up.`;
|
|
@@ -1437,19 +1966,40 @@ function log(message) {
|
|
|
1437
1966
|
function errorText3(error) {
|
|
1438
1967
|
return error instanceof Error ? error.message : String(error);
|
|
1439
1968
|
}
|
|
1969
|
+
var VALUE_FLAGS = {
|
|
1970
|
+
"--home": "home",
|
|
1971
|
+
"--network": "network",
|
|
1972
|
+
"--tx": "tx",
|
|
1973
|
+
"--amount": "amount"
|
|
1974
|
+
};
|
|
1975
|
+
function isValueFlag(arg) {
|
|
1976
|
+
return arg in VALUE_FLAGS;
|
|
1977
|
+
}
|
|
1440
1978
|
function parseArgs(argv) {
|
|
1441
|
-
const args = {
|
|
1979
|
+
const args = {
|
|
1980
|
+
command: void 0,
|
|
1981
|
+
target: void 0,
|
|
1982
|
+
home: void 0,
|
|
1983
|
+
network: void 0,
|
|
1984
|
+
tx: void 0,
|
|
1985
|
+
amount: void 0,
|
|
1986
|
+
yes: false
|
|
1987
|
+
};
|
|
1442
1988
|
for (let index = 0; index < argv.length; index += 1) {
|
|
1443
1989
|
const arg = argv[index];
|
|
1444
|
-
if (arg
|
|
1990
|
+
if (arg !== void 0 && isValueFlag(arg)) {
|
|
1445
1991
|
const value = argv[index + 1];
|
|
1446
1992
|
if (value === void 0 || value.startsWith("--")) {
|
|
1447
1993
|
throw new Error(`${arg} needs a value`);
|
|
1448
1994
|
}
|
|
1449
|
-
args[arg
|
|
1995
|
+
args[VALUE_FLAGS[arg]] = value;
|
|
1450
1996
|
index += 1;
|
|
1997
|
+
} else if (arg === "--yes") {
|
|
1998
|
+
args.yes = true;
|
|
1451
1999
|
} else if (args.command === void 0 && arg !== void 0 && !arg.startsWith("--")) {
|
|
1452
2000
|
args.command = arg;
|
|
2001
|
+
} else if ((args.command === "deliver" || args.command === "refund") && args.target === void 0 && arg !== void 0 && !arg.startsWith("--")) {
|
|
2002
|
+
args.target = arg;
|
|
1453
2003
|
} else {
|
|
1454
2004
|
throw new Error(`unknown argument ${arg ?? ""}`);
|
|
1455
2005
|
}
|
|
@@ -1619,7 +2169,8 @@ async function refuseOffersNotHonoured(pool, config, keys, state) {
|
|
|
1619
2169
|
read,
|
|
1620
2170
|
config.inboxRelays,
|
|
1621
2171
|
config.network,
|
|
1622
|
-
standingTerms(state.terms)
|
|
2172
|
+
standingTerms(state.terms),
|
|
2173
|
+
config.tempo === void 0 ? void 0 : tempoRegistryNetwork(config.tempo.network)
|
|
1623
2174
|
);
|
|
1624
2175
|
if (problems.length > 0) {
|
|
1625
2176
|
throw new Error(
|
|
@@ -1675,11 +2226,23 @@ async function run(home) {
|
|
|
1675
2226
|
log(`error: ${errorText3(error)}`);
|
|
1676
2227
|
});
|
|
1677
2228
|
};
|
|
2229
|
+
const tempo = tempoContextFor(config, storePubkey);
|
|
2230
|
+
const mediums = [
|
|
2231
|
+
...config.rpcUrl === void 0 ? [] : [SOLANA_MEDIUMS[config.network]],
|
|
2232
|
+
...tempo === void 0 ? [] : [tempo.medium]
|
|
2233
|
+
];
|
|
1678
2234
|
const runtime = new MerchantRuntime({
|
|
1679
2235
|
state,
|
|
1680
|
-
store: storeIdentity(storePubkey, config.product.d,
|
|
2236
|
+
store: storeIdentity(storePubkey, config.product.d, mediums),
|
|
1681
2237
|
storeSecretKey: keys.storeSecretKey,
|
|
1682
|
-
|
|
2238
|
+
// With no Solana payout the Solana catch-up has no terms to scan and reads nothing.
|
|
2239
|
+
context: {
|
|
2240
|
+
rpc: createSolanaRpc(config.rpcUrl ?? "https://api.devnet.solana.com"),
|
|
2241
|
+
network: config.network
|
|
2242
|
+
},
|
|
2243
|
+
...tempo === void 0 ? {} : { tempo },
|
|
2244
|
+
// No Solana payout configured: the Solana sweep reads nothing (no cluster to guess).
|
|
2245
|
+
...config.rpcUrl === void 0 ? { catchUp: async () => ({ paid: [], incomplete: [] }) } : {},
|
|
1683
2246
|
save: () => saveLedger(home.ledger, state),
|
|
1684
2247
|
deliver: (order, skip) => deliver(pool, order, skip, config, keys),
|
|
1685
2248
|
inboxRelayCount: config.inboxRelays.length,
|
|
@@ -1741,6 +2304,10 @@ function listOrders(home) {
|
|
|
1741
2304
|
console.log(`${when} ${orderStatus(order)} ${order.key}${paid}${email}`);
|
|
1742
2305
|
}
|
|
1743
2306
|
console.log(`${all.length} order(s)`);
|
|
2307
|
+
for (const [key, answer] of Object.entries(state.answeredByHand ?? {})) {
|
|
2308
|
+
const detail = answer.kind === "delivered" ? answer.delivery?.value ?? "" : `${answer.amount ?? ""} in ${answer.tx ?? ""}`;
|
|
2309
|
+
console.log(`answered by hand: ${key} ${answer.kind} ${detail}`);
|
|
2310
|
+
}
|
|
1744
2311
|
}
|
|
1745
2312
|
async function check(home) {
|
|
1746
2313
|
const config = loadConfig(home.config);
|
|
@@ -1770,6 +2337,72 @@ async function check(home) {
|
|
|
1770
2337
|
pool.destroy();
|
|
1771
2338
|
}
|
|
1772
2339
|
}
|
|
2340
|
+
async function confirmed(question, yes) {
|
|
2341
|
+
if (yes) {
|
|
2342
|
+
return true;
|
|
2343
|
+
}
|
|
2344
|
+
if (!process.stdin.isTTY) {
|
|
2345
|
+
throw new Error("no terminal to confirm on: pass --yes to answer without asking");
|
|
2346
|
+
}
|
|
2347
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
2348
|
+
try {
|
|
2349
|
+
const answer = await prompt.question(`${question} [y/N] `);
|
|
2350
|
+
return answer.trim().toLowerCase() === "y";
|
|
2351
|
+
} finally {
|
|
2352
|
+
prompt.close();
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
async function answerByHand(home, args) {
|
|
2356
|
+
const key = args.target;
|
|
2357
|
+
if (key === void 0) {
|
|
2358
|
+
throw new Error(`${args.command ?? ""} needs the order: <buyer>:<orderId> (see orders)`);
|
|
2359
|
+
}
|
|
2360
|
+
const config = loadConfig(home.config);
|
|
2361
|
+
const keys = loadOrCreateKeys(home, false);
|
|
2362
|
+
takeLock(home);
|
|
2363
|
+
const state = loadLedger(home.ledger);
|
|
2364
|
+
const request = args.command === "deliver" ? { kind: "delivered", delivery: config.product.delivery } : { kind: "refunded", tx: args.tx ?? "", amount: args.amount ?? "" };
|
|
2365
|
+
const plan = planHandAnswer(state, key, request);
|
|
2366
|
+
if (!plan.ok) {
|
|
2367
|
+
throw new Error(plan.problem);
|
|
2368
|
+
}
|
|
2369
|
+
const order = state.orders[key];
|
|
2370
|
+
const held = order ?? plan.answer;
|
|
2371
|
+
console.log(`order ${key}${order === void 0 ? " (closed)" : ""}`);
|
|
2372
|
+
console.log(`reported ${held.reportedTxs.join(", ") || "-"}`);
|
|
2373
|
+
console.log(`refused ${(held.refusedTxs ?? []).join(", ") || "-"}`);
|
|
2374
|
+
console.log(`no leg ${(held.noLegTxs ?? []).join(", ") || "-"}`);
|
|
2375
|
+
if (order?.blockedTx !== void 0) {
|
|
2376
|
+
console.log(`blocked ${order.blockedTx}`);
|
|
2377
|
+
}
|
|
2378
|
+
const what = plan.answer.kind === "delivered" ? `deliver "${plan.answer.delivery?.value ?? ""}"` : `report a refund of ${plan.answer.amount ?? ""} in ${plan.answer.tx ?? ""}`;
|
|
2379
|
+
if (!await confirmed(`${plan.rerun ? "Send again" : "Close the order and"} ${what}?`, args.yes)) {
|
|
2380
|
+
throw new Error("not confirmed: nothing was sent");
|
|
2381
|
+
}
|
|
2382
|
+
if (!plan.rerun) {
|
|
2383
|
+
applyHandAnswer(state, key, plan.answer);
|
|
2384
|
+
saveLedger(home.ledger, state);
|
|
2385
|
+
}
|
|
2386
|
+
const pool = new SimplePool();
|
|
2387
|
+
try {
|
|
2388
|
+
const wrap = buildHandAnswer(plan, keys.storeSecretKey, nowSecs());
|
|
2389
|
+
const taken = await publishToRelays(
|
|
2390
|
+
pool,
|
|
2391
|
+
config.inboxRelays,
|
|
2392
|
+
wrap.recipientWrap,
|
|
2393
|
+
storeAuth(keys),
|
|
2394
|
+
log
|
|
2395
|
+
);
|
|
2396
|
+
console.log(
|
|
2397
|
+
`taken by ${taken.length} of ${config.inboxRelays.length}: ${taken.join(", ") || "-"}`
|
|
2398
|
+
);
|
|
2399
|
+
if (!deliveryDone(taken.length, config.inboxRelays.length, 0)) {
|
|
2400
|
+
throw new Error("too few inbox relays took it: run the same command again to send it again");
|
|
2401
|
+
}
|
|
2402
|
+
} finally {
|
|
2403
|
+
pool.destroy();
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
1773
2406
|
async function main() {
|
|
1774
2407
|
const args = parseArgs(process.argv.slice(2));
|
|
1775
2408
|
if (args.network !== void 0 && args.command !== "init") {
|
|
@@ -1803,6 +2436,11 @@ async function main() {
|
|
|
1803
2436
|
await check(home);
|
|
1804
2437
|
process.exit(0);
|
|
1805
2438
|
return;
|
|
2439
|
+
case "deliver":
|
|
2440
|
+
case "refund":
|
|
2441
|
+
await answerByHand(home, args);
|
|
2442
|
+
process.exit(0);
|
|
2443
|
+
return;
|
|
1806
2444
|
default:
|
|
1807
2445
|
console.log(USAGE);
|
|
1808
2446
|
process.exit(args.command === void 0 || args.command === "help" ? 0 : 1);
|