@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 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,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
- function emptyLedger() {
555
- return { version: 1, orders: {}, seenRumors: {}, claims: {}, terms: [], scans: {} };
556
- }
557
- function orderKey(buyerPubkey, orderId) {
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 claimPayment(state, signature, key) {
561
- const holder = state.claims[signature];
562
- if (holder !== void 0 && holder !== key) {
563
- return false;
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
- state.claims[signature] = key;
566
- return true;
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 openOrders(state, now, catchUpSecs) {
569
- return Object.values(state.orders).filter(
570
- (order) => order.paid === void 0 && now - order.createdAt <= catchUpSecs
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 undeliveredOrders(state) {
574
- return Object.values(state.orders).filter(
575
- (order) => order.paid !== void 0 && order.deliveredAt === void 0
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 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;
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 recordReport(order, tx, arrivedAt) {
587
- order.reportedTxs.push(tx);
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 loadLedger(path) {
591
- let text;
592
- try {
593
- text = readFileSync(path, "utf8");
594
- } catch {
595
- return emptyLedger();
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
- const state = JSON.parse(text);
598
- if (state.version !== 1) {
599
- throw new Error(`Unknown ledger version in ${path}`);
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
- state.scans ??= {};
602
- return state;
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
- function saveLedger(path, state) {
605
- const temporary = `${path}.tmp`;
606
- writeFileSync(temporary, `${JSON.stringify(state, null, 2)}
607
- `, { mode: 384 });
608
- renameSync(temporary, path);
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 isSolanaSignature(value) {
611
- try {
612
- return isSignature(value);
613
- } catch {
614
- return false;
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
- if (reference !== order.reference || !store.mediums.includes(medium) || !isSolanaSignature(tx)) {
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 blockTime = Reflect.get(transaction, "blockTime");
1033
- if (typeof blockTime !== "number" && typeof blockTime !== "bigint") {
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(blockTime),
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.refusedTxs?.includes(result.tx) === true || !this.takeLiveCheck()) {
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
- 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];
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.refusedTxs?.includes(tx) === true || !this.takeLiveCheck()) {
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 result = await this.sweepCatchUp(state, context, now());
1270
- if (result.paid.length > 0) {
1271
- log2(`catch-up found ${result.paid.length} payment(s)`);
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
- for (const account of result.incomplete) {
1274
- log2(`warning: the history of ${account} was cut short; older payments may be missed`);
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 = { command: void 0, home: void 0, network: void 0 };
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 === "--home" || arg === "--network") {
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 === "--home" ? "home" : "network"] = value;
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, [SOLANA_MEDIUMS[config.network]]),
2236
+ store: storeIdentity(storePubkey, config.product.d, mediums),
1681
2237
  storeSecretKey: keys.storeSecretKey,
1682
- context: { rpc: createSolanaRpc(config.rpcUrl), network: config.network },
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);