@openreceive/http 0.4.10 → 0.4.12

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.
@@ -1,7 +1,8 @@
1
1
  // src/errors.ts
2
2
  import {
3
3
  isErrorCode,
4
- isRetryableErrorCode
4
+ isRetryableErrorCode,
5
+ publicErrorBody
5
6
  } from "@openreceive/core";
6
7
  var HttpError = class extends Error {
7
8
  status;
@@ -56,13 +57,13 @@ function hostError(message, status = 400, code = "INVALID_REQUEST") {
56
57
  }
57
58
  function mapHostRouteError(error) {
58
59
  if (error instanceof HostError || isServiceErrorShape(error)) {
59
- return { status: error.status, body: error.body };
60
+ return { status: error.status, body: publicErrorBody(error.body) };
60
61
  }
61
62
  if (isWalletErrorShape(error)) {
62
63
  const retryable = error.retryable ?? isRetryableErrorCode(error.code);
63
64
  return {
64
65
  status: walletErrorStatus(error.code, retryable),
65
- body: { code: error.code, message: error.message, retryable }
66
+ body: publicErrorBody({ code: error.code, message: error.message, retryable })
66
67
  };
67
68
  }
68
69
  return null;
@@ -98,13 +99,13 @@ function errorResponse(error, requestId) {
98
99
  if (error instanceof HttpError) {
99
100
  return jsonResponse(
100
101
  error.status,
101
- {
102
+ publicErrorBody({
102
103
  code: error.code,
103
104
  message: error.message,
104
105
  ...error.retryable === void 0 ? {} : { retryable: error.retryable },
105
106
  request_id: requestId,
106
107
  ...error.details === void 0 ? {} : { details: error.details }
107
- },
108
+ }),
108
109
  requestId,
109
110
  error.retryAfterSeconds === void 0 ? void 0 : [["retry-after", String(Math.max(1, Math.ceil(error.retryAfterSeconds)))]]
110
111
  );
@@ -112,7 +113,7 @@ function errorResponse(error, requestId) {
112
113
  if (isServiceErrorShape(error)) {
113
114
  return jsonResponse(
114
115
  error.status,
115
- { ...error.body, request_id: error.body.request_id ?? requestId },
116
+ publicErrorBody({ ...error.body, request_id: error.body.request_id ?? requestId }),
116
117
  requestId
117
118
  );
118
119
  }
@@ -120,7 +121,12 @@ function errorResponse(error, requestId) {
120
121
  const retryable = error.retryable ?? isRetryableErrorCode(error.code);
121
122
  return jsonResponse(
122
123
  walletErrorStatus(error.code, retryable),
123
- { code: error.code, message: error.message, retryable, request_id: requestId },
124
+ publicErrorBody({
125
+ code: error.code,
126
+ message: error.message,
127
+ retryable,
128
+ request_id: requestId
129
+ }),
124
130
  requestId
125
131
  );
126
132
  }
@@ -220,10 +226,10 @@ function isUnderPrefix(pathname, prefix) {
220
226
  }
221
227
 
222
228
  // src/handler.ts
223
- import { compact as compact2, unixSeconds as unixSeconds7 } from "@openreceive/core";
229
+ import { compact, unixSeconds as unixSeconds7 } from "@openreceive/core";
224
230
 
225
231
  // src/reconcile-gate.ts
226
- import { compact, unixSeconds as unixSeconds5 } from "@openreceive/core";
232
+ import { createPaymentScanWindow, unixSeconds as unixSeconds5 } from "@openreceive/core";
227
233
 
228
234
  // src/host-payments.ts
229
235
  import { unixSeconds as unixSeconds3 } from "@openreceive/core";
@@ -283,7 +289,9 @@ import { randomUUID } from "crypto";
283
289
  import {
284
290
  OPENRECEIVE_PAYMENTS_SCHEMA_VERSION,
285
291
  paymentsDdlStatements,
286
- unixSeconds as unixSeconds2
292
+ unixSeconds as unixSeconds2,
293
+ OPENRECEIVE_ATTEMPT_EXPIRY_GRACE_SECONDS as OPENRECEIVE_ATTEMPT_EXPIRY_GRACE_SECONDS2,
294
+ redactSecrets
287
295
  } from "@openreceive/core";
288
296
 
289
297
  // src/sql-adapters.ts
@@ -472,13 +480,18 @@ function isMissingTableError(error, dialect) {
472
480
  }
473
481
  return /no such table/i.test(message);
474
482
  }
475
- function parseClaimedAt(value) {
483
+ function parseGate(value) {
484
+ let gate;
476
485
  try {
477
- const parsed = JSON.parse(String(value));
478
- return typeof parsed.claimed_at === "number" && Number.isFinite(parsed.claimed_at) ? parsed.claimed_at : void 0;
486
+ gate = JSON.parse(String(value));
479
487
  } catch {
480
488
  return void 0;
481
489
  }
490
+ if (gate.version > 1)
491
+ throw new TypeError(
492
+ "A newer reconcile scheduler is installed; upgrade every application and worker together."
493
+ );
494
+ return gate.version === 1 ? gate : void 0;
482
495
  }
483
496
  function paymentsSchemaSql(dialect, tableName = "openreceive_payments", metaTableName = "openreceive_meta") {
484
497
  return paymentsDdlStatements({ dialect, tableName, metaTableName }).map((statement) => `${statement};`).join("\n");
@@ -539,19 +552,30 @@ function createSqlPayments(db, options = {}) {
539
552
  await assertSupportedSchema();
540
553
  return rowsForReference(adapter, reference);
541
554
  },
542
- async listReconcilableAttempts() {
555
+ async findByPaymentHash(paymentHash) {
556
+ await assertSupportedSchema();
557
+ const rows = await adapter.query(statement(`SELECT * FROM ${table} WHERE payment_hash = ?`), [
558
+ paymentHash.toLowerCase()
559
+ ]);
560
+ return rows[0] === void 0 ? void 0 : recordFromRow(rows[0]);
561
+ },
562
+ async listReconcilableAttempts(after) {
543
563
  await assertSupportedSchema();
544
564
  const rows = await adapter.query(
545
565
  statement(
546
- `SELECT payment_hash, created_at, expires_at FROM ${table} WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`
566
+ `SELECT payment_hash, created_at, checkout_data FROM ${table} WHERE status = 'pending' ${after == null ? "" : "AND (created_at > ? OR (created_at = ? AND payment_hash > ?))"} ORDER BY created_at ASC, payment_hash ASC LIMIT ?`
547
567
  ),
548
- [OPENRECEIVE_RECONCILE_BATCH_SIZE]
568
+ [
569
+ ...after == null ? [] : [after.created_at, after.created_at, after.payment_hash],
570
+ OPENRECEIVE_RECONCILE_BATCH_SIZE
571
+ ]
549
572
  );
550
573
  return rows.map(
551
574
  (row) => ({
552
575
  paymentHash: asString(row.payment_hash, "payment_hash"),
553
576
  createdAt: asInteger(row.created_at, "created_at"),
554
- expiresAt: asInteger(row.expires_at, "expires_at")
577
+ createdAtSource: savedCheckout(row).createdAtSource ?? "host",
578
+ expiresAt: settlementExpiresAt(row)
555
579
  })
556
580
  );
557
581
  },
@@ -559,7 +583,7 @@ function createSqlPayments(db, options = {}) {
559
583
  await assertSupportedSchema();
560
584
  const rows = await adapter.query(
561
585
  statement(
562
- `SELECT payment_hash, created_at, expires_at FROM ${table} WHERE payment_hash = ? AND status = 'pending'`
586
+ `SELECT payment_hash, created_at, checkout_data FROM ${table} WHERE payment_hash = ? AND status = 'pending'`
563
587
  ),
564
588
  [paymentHash.toLowerCase()]
565
589
  );
@@ -568,13 +592,13 @@ function createSqlPayments(db, options = {}) {
568
592
  return {
569
593
  paymentHash: asString(row.payment_hash, "payment_hash"),
570
594
  createdAt: asInteger(row.created_at, "created_at"),
571
- expiresAt: asInteger(row.expires_at, "expires_at")
595
+ createdAtSource: savedCheckout(row).createdAtSource ?? "host",
596
+ expiresAt: settlementExpiresAt(row)
572
597
  };
573
598
  },
574
- async claimReconcileGate({ now, intervalSeconds }) {
599
+ async claimReconcileGate({ now, intervalSeconds, leaseSeconds = 10 }) {
575
600
  await assertSupportedSchema();
576
601
  const token = randomUUID();
577
- const claimValue = JSON.stringify({ claimed_at: now, token });
578
602
  const insertIfAbsent = adapter.dialect === "postgres" ? `INSERT INTO ${metaTable} (key, value, rev) VALUES (?, ?, 0) ON CONFLICT (key) DO NOTHING` : `INSERT OR IGNORE INTO ${metaTable} (key, value, rev) VALUES (?, ?, 0)`;
579
603
  for (let attempt = 0; attempt < RECONCILE_GATE_CAS_RETRIES; attempt += 1) {
580
604
  const rows = await adapter.query(
@@ -582,13 +606,27 @@ function createSqlPayments(db, options = {}) {
582
606
  [RECONCILE_GATE_META_KEY]
583
607
  );
584
608
  const current = rows[0];
609
+ const gate = current === void 0 ? void 0 : parseGate(current.value);
610
+ if (gate !== void 0 && isFreshTimestamp(
611
+ now,
612
+ gate.claimed_at,
613
+ Math.max(intervalSeconds, gate.interval_seconds ?? 2)
614
+ ))
615
+ return null;
616
+ if (gate !== void 0 && gate.claimed_at <= now + META_CLOCK_SKEW_SECONDS && gate.lease_until > now)
617
+ return null;
618
+ const scheduler = gate?.scheduler ?? { cursor: null, windows: [] };
619
+ const claimValue = JSON.stringify({
620
+ version: 1,
621
+ claimed_at: now,
622
+ token,
623
+ lease_until: now + leaseSeconds,
624
+ interval_seconds: intervalSeconds,
625
+ scheduler
626
+ });
585
627
  if (current === void 0) {
586
628
  await adapter.query(statement(insertIfAbsent), [RECONCILE_GATE_META_KEY, claimValue]);
587
629
  } else {
588
- const claimedAt = parseClaimedAt(current.value);
589
- if (claimedAt !== void 0 && isFreshTimestamp(now, claimedAt, intervalSeconds)) {
590
- return false;
591
- }
592
630
  await adapter.query(
593
631
  statement(`UPDATE ${metaTable} SET value = ?, rev = rev + 1 WHERE key = ? AND rev = ?`),
594
632
  [claimValue, RECONCILE_GATE_META_KEY, asInteger(current.rev, "rev")]
@@ -598,9 +636,99 @@ function createSqlPayments(db, options = {}) {
598
636
  statement(`SELECT value FROM ${metaTable} WHERE key = ? LIMIT 1`),
599
637
  [RECONCILE_GATE_META_KEY]
600
638
  );
601
- if (readback[0] !== void 0 && String(readback[0].value) === claimValue) return true;
639
+ if (readback[0] !== void 0 && String(readback[0].value) === claimValue)
640
+ return { token, scheduler };
602
641
  }
603
- return false;
642
+ return null;
643
+ },
644
+ async checkpointReconcileGate({ claim, scheduler, now, release = false, intervalSeconds }) {
645
+ await assertSupportedSchema();
646
+ if (scheduler.windows.length > 2 || scheduler.windows.some(
647
+ (window) => window.attempts.length > OPENRECEIVE_RECONCILE_BATCH_SIZE
648
+ ))
649
+ throw new RangeError("Reconcile progress exceeds its cohort bound.");
650
+ const rows = await adapter.query(
651
+ statement(`SELECT value, rev FROM ${metaTable} WHERE key = ? LIMIT 1`),
652
+ [RECONCILE_GATE_META_KEY]
653
+ );
654
+ const current = rows[0];
655
+ if (current === void 0) return false;
656
+ const gate = parseGate(current.value);
657
+ if (gate?.token !== claim.token || gate.lease_until <= now) return false;
658
+ const value = JSON.stringify({
659
+ ...gate,
660
+ scheduler,
661
+ lease_until: release ? 0 : gate.lease_until,
662
+ interval_seconds: intervalSeconds ?? gate.interval_seconds
663
+ });
664
+ if (new TextEncoder().encode(value).length > 131072)
665
+ throw new RangeError("Reconcile progress exceeds 128 KiB.");
666
+ await adapter.query(
667
+ statement(`UPDATE ${metaTable} SET value = ?, rev = rev + 1 WHERE key = ? AND rev = ?`),
668
+ [value, RECONCILE_GATE_META_KEY, asInteger(current.rev, "rev")]
669
+ );
670
+ const readback = await adapter.query(
671
+ statement(`SELECT value FROM ${metaTable} WHERE key = ? LIMIT 1`),
672
+ [RECONCILE_GATE_META_KEY]
673
+ );
674
+ return readback[0]?.value === value;
675
+ },
676
+ async listRepairCandidates({ after = "", limit = 100 } = {}) {
677
+ await assertSupportedSchema();
678
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200)
679
+ throw new RangeError("Repair report limit must be between 1 and 200.");
680
+ const rows = await adapter.query(
681
+ statement(
682
+ `SELECT payment_hash, reference, status, status_reason, updated_at, expires_at, checkout_data, swap_data IS NOT NULL AS has_swap FROM ${table} WHERE status IN ('expired', 'attention') AND paid_at IS NULL AND payment_hash > ? ORDER BY payment_hash LIMIT ?`
683
+ ),
684
+ [after, limit]
685
+ );
686
+ return {
687
+ candidates: rows.map(repairCandidate).filter((row) => row !== null),
688
+ nextCursor: rows.length === limit ? asString(rows.at(-1).payment_hash, "payment_hash") : null
689
+ };
690
+ },
691
+ async requeueAttempt(input) {
692
+ await assertSupportedSchema();
693
+ if (!input.reason.trim() || input.reason.length > 500)
694
+ throw new TypeError("A reviewed repair reason of 1\u2013500 characters is required.");
695
+ return adapter.transaction(async (tx) => {
696
+ const hash = input.paymentHash.toLowerCase();
697
+ const preliminary = await tx.query(
698
+ statement(`SELECT reference FROM ${table} WHERE payment_hash = ?`),
699
+ [hash]
700
+ );
701
+ if (preliminary[0] === void 0) return false;
702
+ await lockReference(tx, asString(preliminary[0].reference, "reference"));
703
+ const rows = await tx.query(
704
+ statement(
705
+ `SELECT payment_hash, reference, status, status_reason, updated_at, expires_at, checkout_data, swap_data IS NOT NULL AS has_swap FROM ${table} WHERE payment_hash = ? AND paid_at IS NULL`
706
+ ),
707
+ [hash]
708
+ );
709
+ const row = rows[0];
710
+ if (row === void 0 || row.status !== input.expectedStatus || asInteger(row.updated_at, "updated_at") !== input.expectedUpdatedAt)
711
+ return false;
712
+ const candidate = repairCandidate(row);
713
+ if (candidate === null) return false;
714
+ const auditKey = `payment_repair:${hash}:${input.expectedStatus}:${input.expectedUpdatedAt}`;
715
+ const prior = await tx.query(statement(`SELECT key FROM ${metaTable} WHERE key = ?`), [
716
+ auditKey
717
+ ]);
718
+ if (prior.length > 0) return false;
719
+ const now = clock();
720
+ await tx.query(
721
+ statement(
722
+ `UPDATE ${table} SET status = 'pending', status_reason = 'operator_requeued', updated_at = ? WHERE payment_hash = ? AND status = ? AND updated_at = ?`
723
+ ),
724
+ [now, hash, input.expectedStatus, input.expectedUpdatedAt]
725
+ );
726
+ await tx.query(statement(`INSERT INTO ${metaTable} (key, value, rev) VALUES (?, ?, 0)`), [
727
+ auditKey,
728
+ JSON.stringify({ ...candidate, reviewedAt: now, reason: redactSecrets(input.reason) })
729
+ ]);
730
+ return true;
731
+ });
604
732
  },
605
733
  async commitAttempt(input) {
606
734
  await assertSupportedSchema();
@@ -654,6 +782,7 @@ function createSqlPayments(db, options = {}) {
654
782
  });
655
783
  },
656
784
  async countAttemptsFromIp(clientIp, sinceUnixSeconds) {
785
+ await assertSupportedSchema();
657
786
  const rows = await adapter.query(
658
787
  statement(`SELECT COUNT(*) AS n FROM ${table} WHERE client_ip = ? AND inserted_at >= ?`),
659
788
  [clientIp, sinceUnixSeconds]
@@ -663,6 +792,12 @@ function createSqlPayments(db, options = {}) {
663
792
  async recordReconciliation(transition) {
664
793
  await assertSupportedSchema();
665
794
  await adapter.transaction(async (tx) => {
795
+ const rows = await tx.query(
796
+ statement(`SELECT reference FROM ${table} WHERE payment_hash = ?`),
797
+ [transition.paymentHash.toLowerCase()]
798
+ );
799
+ if (rows[0] === void 0) return;
800
+ await lockReference(tx, asString(rows[0].reference, "reference"));
666
801
  await tx.query(
667
802
  statement(
668
803
  `UPDATE ${table} SET status = ?, status_reason = ?, updated_at = ? WHERE payment_hash = ? AND status = 'pending'`
@@ -676,10 +811,10 @@ function createSqlPayments(db, options = {}) {
676
811
  );
677
812
  });
678
813
  },
679
- // Same write-once claim as markPaidOnce, without a fulfillment hook: in
680
- // custom-repository mode the host's own handler runs outside this
681
- // transaction, so it is handed no transactional query.
682
- recordSettlement: (settlement) => markPaidOnce(settlement, () => void 0),
814
+ recordSettlementWithFulfillment: (settlement, fulfill) => markPaidOnce(
815
+ settlement,
816
+ ({ query, ...context }) => fulfill({ ...context, transaction: { query } })
817
+ ),
683
818
  markPaidOnce
684
819
  };
685
820
  async function markPaidOnce(input, fulfill) {
@@ -695,7 +830,7 @@ function createSqlPayments(db, options = {}) {
695
830
  await lockReference(tx, asString(reference, "reference"));
696
831
  const rows = await rowsForReference(tx, asString(reference, "reference"));
697
832
  const row = rows.find((candidate) => candidate.paymentHash === paymentHash);
698
- if (row === void 0 || row.status === "settled") return false;
833
+ if (row === void 0 || row.status !== "pending") return false;
699
834
  const firstForReference = !rows.some((candidate) => candidate.status === "settled");
700
835
  const now = clock();
701
836
  await tx.query(
@@ -741,6 +876,50 @@ function recordFromRow(row) {
741
876
  swapData: swapData === null || swapData === void 0 ? null : parseRowJson(asString(swapData, "swap_data"), "swap_data", paymentHash)
742
877
  };
743
878
  }
879
+ function savedCheckout(row) {
880
+ const hash = asString(row.payment_hash, "payment_hash");
881
+ const checkout = parseRowJson(
882
+ asString(row.checkout_data, "checkout_data"),
883
+ "checkout_data",
884
+ hash
885
+ );
886
+ if (typeof checkout === "object" && checkout !== null) {
887
+ const saved = checkout;
888
+ const value = saved.expiresAt ?? saved.expires_at;
889
+ if (typeof value === "number" && Number.isSafeInteger(value) && value > 0)
890
+ return {
891
+ expiresAt: value,
892
+ createdAtSource: (saved.createdAtSource ?? saved.created_at_source) === "wallet" ? "wallet" : "host"
893
+ };
894
+ }
895
+ throw new TypeError(
896
+ `Invalid checkout_data invoice expiry on openreceive payment attempt ${hash}.`
897
+ );
898
+ }
899
+ function settlementExpiresAt(row) {
900
+ return savedCheckout(row).expiresAt;
901
+ }
902
+ function repairCandidate(row) {
903
+ if (row.status !== "expired" && row.status !== "attention") return null;
904
+ const expires = settlementExpiresAt(row);
905
+ const instruction = asInteger(row.expires_at, "expires_at");
906
+ const updated = asInteger(row.updated_at, "updated_at");
907
+ const reason = row.status_reason == null ? null : asString(row.status_reason, "status_reason");
908
+ const early = Boolean(row.has_swap) && expires > instruction && updated >= instruction + OPENRECEIVE_ATTEMPT_EXPIRY_GRACE_SECONDS2 && updated < expires + OPENRECEIVE_ATTEMPT_EXPIRY_GRACE_SECONDS2 && ["not_found_after_expiry", "no_finality_after_expiry", "unsettled_after_expiry"].includes(
909
+ reason ?? ""
910
+ );
911
+ if (!early && row.status !== "attention") return null;
912
+ return {
913
+ paymentHash: asString(row.payment_hash, "payment_hash"),
914
+ reference: asString(row.reference, "reference"),
915
+ status: row.status,
916
+ statusReason: reason,
917
+ updatedAt: updated,
918
+ instructionExpiresAt: instruction,
919
+ settlementExpiresAt: expires,
920
+ category: early ? "early_swap_closure" : "attention"
921
+ };
922
+ }
744
923
  function parseRowJson(value, column, paymentHash) {
745
924
  try {
746
925
  return JSON.parse(value);
@@ -802,7 +981,7 @@ function createHost(options) {
802
981
  }
803
982
  if (options.onPaid === void 0) {
804
983
  throw new TypeError(
805
- "OpenReceive host requires onPaid (per-reference settlement context in db mode; the raw settlement event in custom repository mode). Pass onPaid to fulfill the order \u2014 it runs exactly once per reference. https://openreceive.org/guides/api-reference.md#onpaid"
984
+ "OpenReceive host requires onPaid (per-reference settlement context in db mode; the raw transaction context in custom repository mode). Pass onPaid to write fulfillment atomically for the first settled attempt per reference. https://openreceive.org/guides/api-reference.md#onpaid"
806
985
  );
807
986
  }
808
987
  let payments;
@@ -816,6 +995,7 @@ function createHost(options) {
816
995
  const fulfill = options.onPaid;
817
996
  onPaid = async (input) => {
818
997
  await repository.markPaidOnce(input, fulfill);
998
+ await assertSettlementCommitted(repository, input.paymentHash);
819
999
  };
820
1000
  } else {
821
1001
  if (options.payments?.listForReference === void 0) {
@@ -838,21 +1018,20 @@ function createHost(options) {
838
1018
  "OpenReceive host requires payments.recordReconciliation \u2014 how a scan outcome (settled/expired/attention) is written back. Implement it, or pass db to use the built-in SQL repository. https://openreceive.org/guides/storage.md"
839
1019
  );
840
1020
  }
841
- if (typeof options.payments.recordSettlement !== "function") {
1021
+ if (typeof options.payments.recordSettlementWithFulfillment !== "function") {
842
1022
  throw new TypeError(
843
- "OpenReceive host requires payments.recordSettlement (the write-once settlement claim). Implement it, or pass db to use the built-in SQL repository. https://openreceive.org/guides/storage.md"
1023
+ "OpenReceive host requires payments.recordSettlementWithFulfillment(input, fulfill). Await fulfillment inside the reference settlement transaction; boolean-only repositories are unsupported. Implement it, or pass db to use the built-in SQL repository. https://openreceive.org/guides/storage.md"
844
1024
  );
845
1025
  }
1026
+ if (typeof options.payments.findByPaymentHash !== "function")
1027
+ throw new TypeError(
1028
+ "OpenReceive host requires payments.findByPaymentHash to verify durable settlement acknowledgment."
1029
+ );
846
1030
  payments = options.payments;
847
1031
  const custom = options.payments;
848
- const notify = options.onPaid;
849
1032
  onPaid = async (settlement) => {
850
- const claimed = await custom.recordSettlement({
851
- paymentHash: settlement.paymentHash,
852
- paidAt: settlement.paidAt,
853
- ...settlement.details === void 0 ? {} : { details: settlement.details }
854
- });
855
- if (claimed) await notify(settlement);
1033
+ await custom.recordSettlementWithFulfillment(settlement, options.onPaid);
1034
+ await assertSettlementCommitted(custom, settlement.paymentHash);
856
1035
  };
857
1036
  }
858
1037
  const clock = options.clock ?? unixSeconds3;
@@ -913,7 +1092,19 @@ function createHost(options) {
913
1092
  };
914
1093
  return {
915
1094
  resolveCheckout,
916
- onCheckoutCreated: (input) => payments.commitAttempt(input),
1095
+ onCheckoutCreated: async (input) => {
1096
+ try {
1097
+ await payments.commitAttempt(input);
1098
+ } catch (error) {
1099
+ if (error instanceof HttpError || isServiceErrorShape(error)) throw error;
1100
+ throw new HttpError(
1101
+ 503,
1102
+ "INTERNAL",
1103
+ "Payment storage is unavailable; payer instructions were withheld. Please retry.",
1104
+ { retryable: true }
1105
+ );
1106
+ }
1107
+ },
917
1108
  onPaid,
918
1109
  payments
919
1110
  };
@@ -985,6 +1176,16 @@ function storedPaymentHash(value) {
985
1176
  }
986
1177
  return normalized;
987
1178
  }
1179
+ async function assertSettlementCommitted(repository, paymentHash) {
1180
+ const committed = await repository.findByPaymentHash(paymentHash);
1181
+ if (committed?.status !== "settled")
1182
+ throw new HttpError(
1183
+ 503,
1184
+ "INTERNAL",
1185
+ "The payment settlement is not committed; retry reconciliation.",
1186
+ { retryable: true }
1187
+ );
1188
+ }
988
1189
 
989
1190
  // src/reconcile-loop.ts
990
1191
  import { unixSeconds as unixSeconds4 } from "@openreceive/core";
@@ -996,7 +1197,7 @@ async function reconcileHostPayments(input) {
996
1197
  attempts.map((attempt) => [attempt.paymentHash.toLowerCase(), attempt])
997
1198
  );
998
1199
  const scannedAt = clock();
999
- const checks = await input.service.reconcilePayments({
1200
+ const checks = input.checks ?? await input.service.reconcilePayments({
1000
1201
  attempts,
1001
1202
  overlapSeconds: input.overlapSeconds,
1002
1203
  ...input.maxPages === void 0 ? {} : { maxPages: input.maxPages }
@@ -1026,7 +1227,7 @@ async function reconcileHostPayments(input) {
1026
1227
  const transition = reconciliationTransition(
1027
1228
  attempt,
1028
1229
  checked.status,
1029
- scannedAt,
1230
+ checked.coverageStartedAt ?? scannedAt,
1030
1231
  walletTransaction?.state ?? walletTransaction?.transaction_state
1031
1232
  );
1032
1233
  if (transition !== null) {
@@ -1050,9 +1251,9 @@ async function startReconciler(input) {
1050
1251
  if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 250) {
1051
1252
  throw new RangeError("pollIntervalMs must be a safe integer of at least 250");
1052
1253
  }
1053
- if (typeof input.host.payments.claimReconcileGate !== "function") {
1254
+ if (typeof input.host.payments.claimReconcileGate !== "function" || typeof input.host.payments.checkpointReconcileGate !== "function") {
1054
1255
  throw new TypeError(
1055
- "The reconciler requires payments.claimReconcileGate (a durable CAS gate shared by every worker); implement it on the repository so all scan entry points stay within one budget."
1256
+ "The reconciler requires payments.claimReconcileGate and checkpointReconcileGate (a durable CAS gate shared by every worker); implement it on the repository so all scan entry points stay within one budget."
1056
1257
  );
1057
1258
  }
1058
1259
  const overlapSeconds = input.overlapSeconds ?? 60;
@@ -1129,9 +1330,9 @@ function intervalForInvoiceAge(elapsedSeconds) {
1129
1330
  }
1130
1331
  async function maybeReconcilePayments(input) {
1131
1332
  const claimReconcileGate = input.host.payments.claimReconcileGate;
1132
- if (typeof claimReconcileGate !== "function") {
1333
+ if (typeof claimReconcileGate !== "function" || typeof input.host.payments.checkpointReconcileGate !== "function") {
1133
1334
  throw new TypeError(
1134
- "Opportunistic reconcile requires payments.claimReconcileGate (a durable CAS gate); implement it on the repository or disable with opportunisticReconcile: false."
1335
+ "Opportunistic reconcile requires payments.claimReconcileGate and checkpointReconcileGate (durable lease and progress CAS); implement it on the repository or disable with opportunisticReconcile: false."
1135
1336
  );
1136
1337
  }
1137
1338
  const report = input.onError ?? ((error) => {
@@ -1142,36 +1343,153 @@ async function maybeReconcilePayments(input) {
1142
1343
  );
1143
1344
  });
1144
1345
  try {
1145
- const attempts = await input.host.payments.listReconcilableAttempts();
1146
- if (attempts.length === 0) return { reason: "no_pending" };
1147
1346
  const clock = input.clock ?? unixSeconds5;
1148
1347
  const now = clock();
1149
- const intervalSeconds = reconcileIntervalSeconds(attempts, now, input.minIntervalSeconds);
1150
- const claimed = await claimReconcileGate.call(input.host.payments, { now, intervalSeconds });
1151
- if (!claimed) return { reason: "gate_busy" };
1152
- const checks = await withScanTimeout(
1153
- reconcileHostPayments({
1154
- service: input.service,
1155
- host: input.host,
1156
- // Already read above to size the gate interval; the pass scans that
1157
- // exact batch rather than repeating the query on the request path.
1158
- attempts,
1159
- overlapSeconds: input.overlapSeconds,
1160
- maxPages: input.maxPages ?? OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES,
1161
- ...compact({ clock: input.clock })
1162
- }),
1348
+ const minInterval = Math.max(2, input.minIntervalSeconds ?? 2);
1349
+ const timeout = Math.min(
1350
+ OPENRECEIVE_RECONCILE_SCAN_TIMEOUT_MS,
1163
1351
  input.scanTimeoutMs ?? OPENRECEIVE_RECONCILE_SCAN_TIMEOUT_MS
1164
1352
  );
1165
- return { reason: "ran", checks };
1353
+ const claim = await claimReconcileGate.call(input.host.payments, {
1354
+ now,
1355
+ intervalSeconds: minInterval,
1356
+ leaseSeconds: Math.ceil(timeout / 1e3) + 1
1357
+ });
1358
+ if (claim === null) return { reason: "gate_busy" };
1359
+ if (typeof claim !== "object" || typeof claim.token !== "string")
1360
+ throw new TypeError(
1361
+ "claimReconcileGate must return a lease claim or null; boolean gate repositories must upgrade."
1362
+ );
1363
+ const scheduler = structuredClone(claim.scheduler);
1364
+ const queued = new Set(
1365
+ scheduler.windows.flatMap((window2) => window2.attempts.map((attempt) => attempt.payment_hash))
1366
+ );
1367
+ if (scheduler.windows.length < 2) {
1368
+ let candidates = await input.host.payments.listReconcilableAttempts(scheduler.cursor);
1369
+ if (candidates.length === 0 && scheduler.cursor !== null)
1370
+ candidates = await input.host.payments.listReconcilableAttempts(null);
1371
+ const last = candidates.at(-1);
1372
+ scheduler.cursor = last === void 0 || candidates.length < OPENRECEIVE_RECONCILE_BATCH_SIZE ? null : { created_at: last.createdAt, payment_hash: last.paymentHash };
1373
+ const fresh = candidates.filter((attempt) => !queued.has(attempt.paymentHash));
1374
+ if (fresh.length > 0)
1375
+ scheduler.windows.push(
1376
+ createPaymentScanWindow(
1377
+ fresh.map((attempt) => ({
1378
+ payment_hash: attempt.paymentHash,
1379
+ created_at: attempt.createdAt,
1380
+ expires_at: attempt.expiresAt,
1381
+ created_at_source: attempt.createdAtSource ?? "host"
1382
+ })),
1383
+ now,
1384
+ input.overlapSeconds
1385
+ )
1386
+ );
1387
+ }
1388
+ const window = scheduler.windows.shift();
1389
+ if (window === void 0) {
1390
+ await input.host.payments.checkpointReconcileGate({
1391
+ claim,
1392
+ scheduler,
1393
+ now: clock(),
1394
+ release: true
1395
+ });
1396
+ return { reason: "no_pending" };
1397
+ }
1398
+ const attempts = window.attempts.map((attempt) => ({
1399
+ paymentHash: attempt.payment_hash,
1400
+ createdAt: attempt.created_at,
1401
+ expiresAt: attempt.expires_at,
1402
+ createdAtSource: attempt.created_at_source
1403
+ }));
1404
+ const intervalSeconds = reconcileIntervalSeconds(attempts, now, minInterval);
1405
+ if (!await input.host.payments.checkpointReconcileGate({
1406
+ claim,
1407
+ scheduler,
1408
+ now: clock(),
1409
+ intervalSeconds
1410
+ }))
1411
+ return { reason: "gate_busy" };
1412
+ const streamed = /* @__PURE__ */ new Set();
1413
+ const deliveryFailures = [];
1414
+ const deadline = Date.now() + timeout;
1415
+ const controller = new AbortController();
1416
+ const slice = await withScanTimeout(
1417
+ input.service.scanPaymentSlice({
1418
+ window,
1419
+ maxPages: Math.min(
1420
+ OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES,
1421
+ input.maxPages ?? OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES
1422
+ ),
1423
+ deadline,
1424
+ signal: controller.signal,
1425
+ onFinality: async (check) => {
1426
+ if (streamed.has(check.paymentHash) || Date.now() >= deadline) return;
1427
+ if (!await input.host.payments.checkpointReconcileGate({
1428
+ claim,
1429
+ scheduler,
1430
+ now: clock(),
1431
+ intervalSeconds
1432
+ }))
1433
+ throw new Error("Reconciliation lease expired before finality delivery.");
1434
+ streamed.add(check.paymentHash);
1435
+ try {
1436
+ await reconcileHostPayments({
1437
+ service: input.service,
1438
+ host: input.host,
1439
+ attempts,
1440
+ checks: [check],
1441
+ clock
1442
+ });
1443
+ } catch (error) {
1444
+ deliveryFailures.push(error);
1445
+ }
1446
+ }
1447
+ }),
1448
+ timeout,
1449
+ controller
1450
+ );
1451
+ if (slice.outcome === "continued") {
1452
+ const halves = scheduler.windows.length === 0 ? splitWindow(slice.window, clock(), input.overlapSeconds) : void 0;
1453
+ scheduler.windows.push(...halves ?? [slice.window]);
1454
+ }
1455
+ if (slice.outcome === "stalled")
1456
+ report(
1457
+ new Error(
1458
+ "Wallet history made no pagination progress; attempts remain pending and other cohorts will be served."
1459
+ )
1460
+ );
1461
+ if (!await input.host.payments.checkpointReconcileGate({
1462
+ claim,
1463
+ scheduler,
1464
+ now: clock(),
1465
+ release: true,
1466
+ intervalSeconds
1467
+ }))
1468
+ return { reason: "gate_busy" };
1469
+ await reconcileHostPayments({
1470
+ service: input.service,
1471
+ host: input.host,
1472
+ attempts,
1473
+ checks: slice.checks.filter((check) => !streamed.has(check.paymentHash)),
1474
+ clock
1475
+ });
1476
+ if (deliveryFailures.length > 0)
1477
+ throw new AggregateError(
1478
+ deliveryFailures,
1479
+ "One or more settlement transactions failed; pending attempts will be retried."
1480
+ );
1481
+ return { reason: "ran", checks: slice.checks };
1166
1482
  } catch (error) {
1167
1483
  report(error);
1168
1484
  return { reason: "scan_failed" };
1169
1485
  }
1170
1486
  }
1171
- function withScanTimeout(work, timeoutMs) {
1487
+ function withScanTimeout(work, timeoutMs, controller) {
1172
1488
  return new Promise((resolve, reject) => {
1173
1489
  const timer = setTimeout(() => {
1174
- reject(new Error(`reconcile scan exceeded ${timeoutMs}ms`));
1490
+ const error = new Error(`reconcile scan exceeded ${timeoutMs}ms`);
1491
+ controller.abort(error);
1492
+ reject(error);
1175
1493
  }, timeoutMs);
1176
1494
  work.then(
1177
1495
  (value) => {
@@ -1185,6 +1503,18 @@ function withScanTimeout(work, timeoutMs) {
1185
1503
  );
1186
1504
  });
1187
1505
  }
1506
+ function splitWindow(window, now, overlap) {
1507
+ if (!window.attempts.every((attempt) => attempt.created_at_source === "wallet")) return void 0;
1508
+ const timestamps = [...new Set(window.attempts.map((attempt) => attempt.created_at))].sort(
1509
+ (a, b) => a - b
1510
+ );
1511
+ if (timestamps.length < 2) return void 0;
1512
+ const boundary = timestamps[Math.floor(timestamps.length / 2)];
1513
+ return [
1514
+ window.attempts.filter((attempt) => attempt.created_at < boundary),
1515
+ window.attempts.filter((attempt) => attempt.created_at >= boundary)
1516
+ ].map((attempts) => createPaymentScanWindow(attempts, now, overlap));
1517
+ }
1188
1518
 
1189
1519
  // src/http-request.ts
1190
1520
  import { isRecord, nonEmptyString } from "@openreceive/core";
@@ -1290,15 +1620,16 @@ function ratesCurrencies(raw) {
1290
1620
  }
1291
1621
  return currencies;
1292
1622
  }
1293
- function optionalCheckoutFields(body) {
1294
- const memo = trimmedField(body.memo);
1295
- if (memo !== void 0 && memo.length > MAX_MEMO_LENGTH) {
1623
+ function optionalCheckoutFields(body, hostDescription) {
1624
+ const bodyMemo = trimmedField(body.memo);
1625
+ if (bodyMemo !== void 0 && bodyMemo.length > MAX_MEMO_LENGTH) {
1296
1626
  throw new HttpError(
1297
1627
  400,
1298
1628
  "INVALID_REQUEST",
1299
1629
  `memo must be ${MAX_MEMO_LENGTH} characters or fewer.`
1300
1630
  );
1301
1631
  }
1632
+ const memo = bodyMemo ?? hostDescription;
1302
1633
  const metadata = readRecord(body.metadata);
1303
1634
  return {
1304
1635
  ...memo === void 0 ? {} : { memo },
@@ -1564,9 +1895,9 @@ function createHttpHandler(options) {
1564
1895
  }
1565
1896
  const rateLimit = options.rateLimitHook ?? resolveRateLimiting(options);
1566
1897
  const reconcile = options.opportunisticReconcile === false ? void 0 : typeof options.opportunisticReconcile === "object" ? options.opportunisticReconcile : {};
1567
- if (reconcile !== void 0 && typeof options.host.payments.claimReconcileGate !== "function") {
1898
+ if (reconcile !== void 0 && (typeof options.host.payments.claimReconcileGate !== "function" || typeof options.host.payments.checkpointReconcileGate !== "function")) {
1568
1899
  throw new TypeError(
1569
- "Opportunistic reconcile (on by default) requires payments.claimReconcileGate \u2014 a durable compare-and-set gate shared by every worker (the built-in SQL repository implements it over openreceive_meta). Implement it on the custom repository, or pass opportunisticReconcile: false and run your own settlement worker. https://openreceive.org/guides/storage.md"
1900
+ "Opportunistic reconcile (on by default) requires payments.claimReconcileGate and checkpointReconcileGate \u2014 a durable compare-and-set gate shared by every worker (the built-in SQL repository implements it over openreceive_meta). Implement it on the custom repository, or pass opportunisticReconcile: false and run your own settlement worker. https://openreceive.org/guides/storage.md"
1570
1901
  );
1571
1902
  }
1572
1903
  const rawExtractClientIp = (typeof options.rateLimiting === "object" ? options.rateLimiting.ip : void 0) ?? resolveClientIp;
@@ -1619,7 +1950,7 @@ async function dispatch(runtime, request, requestId, native) {
1619
1950
  host: runtime.host,
1620
1951
  // `service`/`host` stay outside compact: it is recursive, and they
1621
1952
  // are handles whose identity the reconcile pass relies on.
1622
- ...compact2({
1953
+ ...compact({
1623
1954
  minIntervalSeconds: runtime.reconcile.minIntervalSeconds,
1624
1955
  clock: runtime.clock
1625
1956
  })
@@ -1671,7 +2002,7 @@ async function dispatch(runtime, request, requestId, native) {
1671
2002
  mint: () => runtime.service.createCheckout({
1672
2003
  reference,
1673
2004
  amount: requireResolvedAmount(resolved2),
1674
- ...optionalCheckoutFields(body)
2005
+ ...optionalCheckoutFields(body, resolved2.description)
1675
2006
  }),
1676
2007
  recover: () => committedCheckout(reference, resolved2),
1677
2008
  attempt: (minted) => ({ paymentHash: minted.paymentHash, checkout: minted })
@@ -1764,7 +2095,7 @@ async function dispatch(runtime, request, requestId, native) {
1764
2095
  reference,
1765
2096
  amount: requireResolvedAmount(resolved2),
1766
2097
  payInAsset,
1767
- ...optionalCheckoutFields(body)
2098
+ ...optionalCheckoutFields(body, resolved2.description)
1768
2099
  }),
1769
2100
  recover: () => recoverCommittedSwap(runtime, reference, resolved2),
1770
2101
  attempt: (minted) => ({
@@ -1876,10 +2207,9 @@ async function persistCheckoutAttempt(runtime, input) {
1876
2207
  } catch (error) {
1877
2208
  if (error instanceof HttpError || isServiceErrorShape(error)) throw error;
1878
2209
  throw new HttpError(
1879
- 503,
1880
- "INTERNAL",
1881
- "The host could not persist this payment attempt; payer instructions were withheld. Please retry.",
1882
- { retryable: true }
2210
+ 409,
2211
+ "CONFLICT",
2212
+ "The host did not accept this payment attempt; payer instructions were withheld."
1883
2213
  );
1884
2214
  }
1885
2215
  }
@@ -1974,9 +2304,9 @@ async function startNotificationListener(input) {
1974
2304
  retryable: false
1975
2305
  });
1976
2306
  }
1977
- if (typeof input.host.payments.claimReconcileGate !== "function") {
2307
+ if (typeof input.host.payments.claimReconcileGate !== "function" || typeof input.host.payments.checkpointReconcileGate !== "function") {
1978
2308
  throw new TypeError(
1979
- "The notification listener requires payments.claimReconcileGate (a durable CAS gate shared by every worker); implement it on the repository so all scan entry points stay within one budget."
2309
+ "The notification listener requires payments.claimReconcileGate and checkpointReconcileGate (a durable CAS gate shared by every worker); implement it on the repository so all scan entry points stay within one budget."
1980
2310
  );
1981
2311
  }
1982
2312
  const reportError = (error) => {
@@ -2125,9 +2455,29 @@ async function startNotificationWorker(input) {
2125
2455
 
2126
2456
  // src/orm-adapters.ts
2127
2457
  function knexDb(knex, dialect) {
2458
+ const connectionOwner = (executor) => {
2459
+ const owner = executor.client;
2460
+ if (typeof owner?.acquireConnection !== "function" || typeof owner.releaseConnection !== "function") {
2461
+ throw new TypeError(
2462
+ "PostgreSQL knexDb requires Knex client.acquireConnection/releaseConnection on both the instance and transaction."
2463
+ );
2464
+ }
2465
+ return owner;
2466
+ };
2467
+ if (dialect === "postgres") connectionOwner(knex);
2128
2468
  const queryOn = (executor) => async (sql, params = []) => {
2129
- const result = await executor.raw(sql, [...params]);
2130
- return dialect === "sqlite" ? result : result.rows ?? [];
2469
+ if (dialect === "sqlite")
2470
+ return await executor.raw(sql, [...params]);
2471
+ const owner = connectionOwner(executor);
2472
+ const connection = await owner.acquireConnection();
2473
+ try {
2474
+ const driver = connection;
2475
+ if (typeof driver?.query !== "function")
2476
+ throw new TypeError("PostgreSQL knexDb requires a native pg connection.");
2477
+ return (await driver.query(sql, [...params])).rows ?? [];
2478
+ } finally {
2479
+ await owner.releaseConnection(connection);
2480
+ }
2131
2481
  };
2132
2482
  return {
2133
2483
  dialect,
@@ -2177,14 +2527,14 @@ function sequelizeDb(sequelize, dialect) {
2177
2527
  }
2178
2528
 
2179
2529
  // src/stack.ts
2180
- import { compact as compact3 } from "@openreceive/core";
2530
+ import { compact as compact2, redactSecrets as redactSecrets2 } from "@openreceive/core";
2181
2531
  import { createOpenReceive } from "@openreceive/node";
2182
2532
  function createStack(options) {
2183
2533
  const { wallet, storage, amountFor, clock, onBootFailure, ...handlerOptions } = options;
2184
2534
  const host = createHost({
2185
2535
  ...storage,
2186
2536
  amountFor,
2187
- ...compact3({ clock })
2537
+ ...compact2({ clock })
2188
2538
  });
2189
2539
  const prefix = normalizePrefix(options.prefix ?? "/openreceive");
2190
2540
  let ownedService;
@@ -2193,13 +2543,15 @@ function createStack(options) {
2193
2543
  if (wallet.service === void 0) ownedService = resolved;
2194
2544
  return createHttpHandler({
2195
2545
  ...handlerOptions,
2196
- ...compact3({ clock }),
2546
+ ...compact2({ clock }),
2197
2547
  service: resolved,
2198
2548
  host
2199
2549
  });
2200
2550
  })();
2201
2551
  boot.catch((error) => {
2202
- const message = `OpenReceive stack failed to start: ${error instanceof Error ? error.message : String(error)}`;
2552
+ const message = `OpenReceive stack failed to start: ${redactSecrets2(
2553
+ error instanceof Error ? error.message : String(error)
2554
+ )}`;
2203
2555
  if (onBootFailure === void 0) console.error(message);
2204
2556
  else onBootFailure(message);
2205
2557
  });