@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/dist/cli.js CHANGED
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, writeFileSync, readFileSync, statSync, renameSync, linkSync, rmSync, utimesSync, mkdirSync, chmodSync } from 'node:fs';
3
- import { LIMITS, DELIVERY_METHODS, splitNip05, parseCaip19, canonicalPayoutAddress, priceInSubunits, isPublicHostname, KIND_PAYTO, KIND_PRODUCT, KIND_INBOX_RELAYS, buildProductEvent, buildStoreProfileEvent, buildPaytoEvent, buildStoreAuthEvent, encodeProductNaddr, MAX_FUTURE_SKEW_SECS, unwrapOrderMessage, productAddress, wrapOrderMessage, buildOrderMessage, KIND_GIFT_WRAP, deriveOrderPaymentReference, parsePayto, parseProduct } from '@elisym/commerce';
4
- import { createSolanaRpc, address, isSignature } from '@solana/kit';
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 !== "solana" || target.caip19.chain.network !== network || !standing.some(
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(view.listing, view.payoutList, network, standing);
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 || caip19.chain.family !== "solana") {
339
+ if (caip19 === void 0) {
327
340
  context.addIssue({
328
341
  code: "custom",
329
342
  path: [...path, "caip19"],
330
- message: "must be a Solana coin this node can verify"
343
+ message: "must be a coin this node can verify"
331
344
  });
332
345
  return;
333
346
  }
334
- if (caip19.chain.network !== config.network) {
335
- context.addIssue({
336
- code: "custom",
337
- path: [...path, "caip19"],
338
- message: `is on ${caip19.chain.network}, the node runs on ${config.network}`
339
- });
340
- }
341
- if (canonicalPayoutAddress(caip19.chain, payout.address) !== payout.address) {
342
- context.addIssue({
343
- code: "custom",
344
- path: [...path, "address"],
345
- message: "is not a Solana address"
346
- });
347
+ 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
- function emptyLedger() {
555
- return { version: 1, orders: {}, seenRumors: {}, claims: {}, terms: [], scans: {} };
556
- }
557
- function orderKey(buyerPubkey, orderId) {
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 claimPayment(state, signature, key) {
561
- const holder = state.claims[signature];
562
- if (holder !== void 0 && holder !== key) {
563
- return false;
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
- state.claims[signature] = key;
566
- return true;
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 openOrders(state, now, catchUpSecs) {
569
- return Object.values(state.orders).filter(
570
- (order) => order.paid === void 0 && now - order.createdAt <= catchUpSecs
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 undeliveredOrders(state) {
574
- return Object.values(state.orders).filter(
575
- (order) => order.paid !== void 0 && order.deliveredAt === void 0
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 pruneExpiredOrders(state, now) {
579
- for (const [key, order] of Object.entries(state.orders)) {
580
- if (order.paid === void 0 && now - order.createdAt > CATCH_UP_SECS + MAX_FUTURE_SKEW_SECS) {
581
- delete state.orders[key];
582
- (state.closedOrders ??= {})[key] = true;
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 recordReport(order, tx, arrivedAt) {
587
- order.reportedTxs.push(tx);
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 loadLedger(path) {
591
- let text;
592
- try {
593
- text = readFileSync(path, "utf8");
594
- } catch {
595
- return emptyLedger();
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
- const state = JSON.parse(text);
598
- if (state.version !== 1) {
599
- throw new Error(`Unknown ledger version in ${path}`);
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
- state.scans ??= {};
602
- return state;
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
- function saveLedger(path, state) {
605
- const temporary = `${path}.tmp`;
606
- writeFileSync(temporary, `${JSON.stringify(state, null, 2)}
607
- `, { mode: 384 });
608
- renameSync(temporary, path);
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 isSolanaSignature(value) {
611
- try {
612
- return isSignature(value);
613
- } catch {
614
- return false;
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
- if (reference !== order.reference || !store.mediums.includes(medium) || !isSolanaSignature(tx)) {
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 blockTime = Reflect.get(transaction, "blockTime");
1033
- if (typeof blockTime !== "number" && typeof blockTime !== "bigint") {
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(blockTime),
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.refusedTxs?.includes(result.tx) === true || !this.takeLiveCheck()) {
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
- const check2 = await this.check(state, order, tx, context);
1201
- log2(`receipt ${order.key} ${tx}: ${check2.kind}`);
1202
- if (check2.kind === "refused") {
1203
- order.refusedTxs = [...order.refusedTxs ?? [], tx];
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.refusedTxs?.includes(tx) === true || !this.takeLiveCheck()) {
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 result = await this.sweepCatchUp(state, context, now());
1270
- if (result.paid.length > 0) {
1271
- log2(`catch-up found ${result.paid.length} payment(s)`);
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
- for (const account of result.incomplete) {
1274
- log2(`warning: the history of ${account} was cut short; older payments may be missed`);
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 = { command: void 0, home: void 0, network: void 0 };
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 === "--home" || arg === "--network") {
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 === "--home" ? "home" : "network"] = value;
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, [SOLANA_MEDIUMS[config.network]]),
2243
+ store: storeIdentity(storePubkey, config.product.d, mediums),
1681
2244
  storeSecretKey: keys.storeSecretKey,
1682
- context: { rpc: createSolanaRpc(config.rpcUrl), network: config.network },
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);