@toon-protocol/sdk 2.1.0 → 3.0.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.
@@ -520,6 +520,313 @@ function decryptFulfillClaim(params) {
520
520
  }
521
521
  }
522
522
 
523
+ // src/stream-receipts.ts
524
+ import { schnorr } from "@noble/curves/secp256k1.js";
525
+ import { sha256 } from "@noble/hashes/sha2.js";
526
+ import { bytesToHex as bytesToHex2, hexToBytes } from "@noble/hashes/utils.js";
527
+ var STREAM_RECEIPT_VERSION = 1;
528
+ var STREAM_RECEIPT_SIGNING_TAG = "toon.stream-receipt.v1";
529
+ var STREAM_NONCE_REGEX = /^[0-9a-f]{32}$/;
530
+ var SIG_REGEX = /^[0-9a-f]{128}$/;
531
+ var HEX64_REGEX = /^[0-9a-f]{64}$/;
532
+ var DECIMAL_UINT_REGEX = /^(0|[1-9]\d*)$/;
533
+ var RATE_REGEX = /^(0|[1-9]\d*)(\.\d+)?$/;
534
+ function isValidStreamNonce(value) {
535
+ return typeof value === "string" && STREAM_NONCE_REGEX.test(value);
536
+ }
537
+ function parseStreamReceipt(value) {
538
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
539
+ throw new Error("receipt must be an object");
540
+ }
541
+ const obj = value;
542
+ if (obj["v"] !== STREAM_RECEIPT_VERSION) {
543
+ throw new Error(
544
+ `receipt.v must be ${STREAM_RECEIPT_VERSION}, got ${String(obj["v"])}`
545
+ );
546
+ }
547
+ if (!isValidStreamNonce(obj["streamNonce"])) {
548
+ throw new Error("receipt.streamNonce must be 32-char lowercase hex");
549
+ }
550
+ const seq = obj["seq"];
551
+ if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 1) {
552
+ throw new Error("receipt.seq must be a positive safe integer");
553
+ }
554
+ const cumulativeDelivered = obj["cumulativeDelivered"];
555
+ if (typeof cumulativeDelivered !== "string" || !DECIMAL_UINT_REGEX.test(cumulativeDelivered)) {
556
+ throw new Error(
557
+ "receipt.cumulativeDelivered must be a non-negative integer decimal string"
558
+ );
559
+ }
560
+ const rate = obj["rate"];
561
+ if (typeof rate !== "string" || !RATE_REGEX.test(rate) || /^0(\.0+)?$/.test(rate)) {
562
+ throw new Error("receipt.rate must be a positive decimal string");
563
+ }
564
+ const rateTimestamp = obj["rateTimestamp"];
565
+ if (typeof rateTimestamp !== "number" || !Number.isInteger(rateTimestamp) || rateTimestamp <= 0) {
566
+ throw new Error(
567
+ "receipt.rateTimestamp must be a positive integer (unix ms)"
568
+ );
569
+ }
570
+ const sig = obj["sig"];
571
+ if (typeof sig !== "string" || !SIG_REGEX.test(sig)) {
572
+ throw new Error("receipt.sig must be 128-char lowercase hex");
573
+ }
574
+ return {
575
+ v: STREAM_RECEIPT_VERSION,
576
+ streamNonce: obj["streamNonce"],
577
+ seq,
578
+ cumulativeDelivered,
579
+ rate,
580
+ rateTimestamp,
581
+ sig
582
+ };
583
+ }
584
+ function encodeReceiptSigningPayload(fields) {
585
+ if (!isValidStreamNonce(fields.streamNonce)) {
586
+ throw new Error("streamNonce must be 32-char lowercase hex");
587
+ }
588
+ const tag = Buffer.from(STREAM_RECEIPT_SIGNING_TAG, "utf8");
589
+ const cumulative = Buffer.from(fields.cumulativeDelivered, "utf8");
590
+ const rate = Buffer.from(fields.rate, "utf8");
591
+ const buf = Buffer.alloc(
592
+ 4 + tag.length + 1 + 16 + 8 + 4 + cumulative.length + 4 + rate.length + 8
593
+ );
594
+ let off = 0;
595
+ buf.writeUInt32BE(tag.length, off);
596
+ off += 4;
597
+ tag.copy(buf, off);
598
+ off += tag.length;
599
+ buf.writeUInt8(fields.v, off);
600
+ off += 1;
601
+ Buffer.from(hexToBytes(fields.streamNonce)).copy(buf, off);
602
+ off += 16;
603
+ buf.writeBigUInt64BE(BigInt(fields.seq), off);
604
+ off += 8;
605
+ buf.writeUInt32BE(cumulative.length, off);
606
+ off += 4;
607
+ cumulative.copy(buf, off);
608
+ off += cumulative.length;
609
+ buf.writeUInt32BE(rate.length, off);
610
+ off += 4;
611
+ rate.copy(buf, off);
612
+ off += rate.length;
613
+ buf.writeBigUInt64BE(BigInt(fields.rateTimestamp), off);
614
+ return new Uint8Array(buf);
615
+ }
616
+ function signStreamReceipt(fields, secretKey) {
617
+ if (!(secretKey instanceof Uint8Array) || secretKey.length !== 32) {
618
+ throw new Error("receipt secretKey must be a 32-byte Uint8Array");
619
+ }
620
+ const digest = sha256(encodeReceiptSigningPayload(fields));
621
+ const sig = bytesToHex2(schnorr.sign(digest, secretKey));
622
+ return { ...fields, sig };
623
+ }
624
+ function verifyStreamReceipt(receipt, makerPubkey) {
625
+ if (typeof makerPubkey !== "string" || !HEX64_REGEX.test(makerPubkey)) {
626
+ return false;
627
+ }
628
+ if (typeof receipt.sig !== "string" || !SIG_REGEX.test(receipt.sig)) {
629
+ return false;
630
+ }
631
+ try {
632
+ const { sig: _sig, ...fields } = receipt;
633
+ const digest = sha256(encodeReceiptSigningPayload(fields));
634
+ return schnorr.verify(
635
+ hexToBytes(receipt.sig),
636
+ digest,
637
+ hexToBytes(makerPubkey)
638
+ );
639
+ } catch {
640
+ return false;
641
+ }
642
+ }
643
+ var ReceiptChainTracker = class {
644
+ #streamNonce;
645
+ #makerPubkey;
646
+ /** Verified receipts, kept sorted by ascending seq. */
647
+ #receipts = [];
648
+ #seqs = /* @__PURE__ */ new Set();
649
+ constructor(input) {
650
+ if (!isValidStreamNonce(input.streamNonce)) {
651
+ throw new Error("streamNonce must be 32-char lowercase hex");
652
+ }
653
+ if (typeof input.makerPubkey !== "string" || !HEX64_REGEX.test(input.makerPubkey)) {
654
+ throw new Error("makerPubkey must be a 64-char lowercase hex string");
655
+ }
656
+ this.#streamNonce = input.streamNonce;
657
+ this.#makerPubkey = input.makerPubkey;
658
+ }
659
+ get streamNonce() {
660
+ return this.#streamNonce;
661
+ }
662
+ /** Number of verified receipts accumulated so far. */
663
+ get size() {
664
+ return this.#receipts.length;
665
+ }
666
+ /**
667
+ * Verify + accumulate one receipt. On `{ok: false}` the receipt is NOT
668
+ * added and the chain is unchanged.
669
+ */
670
+ add(receipt) {
671
+ if (receipt.streamNonce !== this.#streamNonce) {
672
+ return {
673
+ ok: false,
674
+ code: "WRONG_STREAM_NONCE",
675
+ message: `receipt streamNonce ${receipt.streamNonce} does not match session ${this.#streamNonce}`
676
+ };
677
+ }
678
+ if (!verifyStreamReceipt(receipt, this.#makerPubkey)) {
679
+ return {
680
+ ok: false,
681
+ code: "BAD_SIGNATURE",
682
+ message: `receipt seq ${receipt.seq} signature does not verify against maker key ${this.#makerPubkey}`
683
+ };
684
+ }
685
+ if (this.#seqs.has(receipt.seq)) {
686
+ return {
687
+ ok: false,
688
+ code: "DUPLICATE_SEQ",
689
+ message: `duplicate receipt seq ${receipt.seq}`
690
+ };
691
+ }
692
+ let lo = 0;
693
+ let hi = this.#receipts.length;
694
+ while (lo < hi) {
695
+ const mid = lo + hi >>> 1;
696
+ const midReceipt = this.#receipts[mid];
697
+ if (midReceipt.seq < receipt.seq) lo = mid + 1;
698
+ else hi = mid;
699
+ }
700
+ const cumulative = BigInt(receipt.cumulativeDelivered);
701
+ const lower = lo > 0 ? this.#receipts[lo - 1] : void 0;
702
+ const higher = lo < this.#receipts.length ? this.#receipts[lo] : void 0;
703
+ if (lower && BigInt(lower.cumulativeDelivered) > cumulative) {
704
+ return {
705
+ ok: false,
706
+ code: "NON_MONOTONIC",
707
+ message: `receipt seq ${receipt.seq} cumulativeDelivered ${receipt.cumulativeDelivered} is below seq ${lower.seq}'s ${lower.cumulativeDelivered}`
708
+ };
709
+ }
710
+ if (higher && BigInt(higher.cumulativeDelivered) < cumulative) {
711
+ return {
712
+ ok: false,
713
+ code: "NON_MONOTONIC",
714
+ message: `receipt seq ${receipt.seq} cumulativeDelivered ${receipt.cumulativeDelivered} is above seq ${higher.seq}'s ${higher.cumulativeDelivered}`
715
+ };
716
+ }
717
+ this.#receipts.splice(lo, 0, receipt);
718
+ this.#seqs.add(receipt.seq);
719
+ return { ok: true };
720
+ }
721
+ /**
722
+ * Snapshot the accumulated chain: sorted receipts, the superseding latest,
723
+ * the signed total delivered, and any `seq` holes in `[1, latest.seq]`.
724
+ */
725
+ chain() {
726
+ const receipts = [...this.#receipts];
727
+ const latest = receipts[receipts.length - 1];
728
+ const holes = [];
729
+ if (latest) {
730
+ for (let seq = 1; seq <= latest.seq; seq++) {
731
+ if (!this.#seqs.has(seq)) holes.push(seq);
732
+ }
733
+ }
734
+ return {
735
+ streamNonce: this.#streamNonce,
736
+ receipts,
737
+ ...latest !== void 0 && { latest },
738
+ totalDelivered: latest?.cumulativeDelivered ?? "0",
739
+ holes
740
+ };
741
+ }
742
+ };
743
+ function serializeReceiptChain(chain) {
744
+ return JSON.stringify({
745
+ kind: "toon.stream-receipt-chain",
746
+ v: STREAM_RECEIPT_VERSION,
747
+ streamNonce: chain.streamNonce,
748
+ totalDelivered: chain.totalDelivered,
749
+ holes: chain.holes,
750
+ receipts: chain.receipts.map((r) => ({
751
+ v: r.v,
752
+ streamNonce: r.streamNonce,
753
+ seq: r.seq,
754
+ cumulativeDelivered: r.cumulativeDelivered,
755
+ rate: r.rate,
756
+ rateTimestamp: r.rateTimestamp,
757
+ sig: r.sig
758
+ }))
759
+ });
760
+ }
761
+ var DEFAULT_RECEIPT_SESSIONS_CAP = 1e4;
762
+ var BoundedReceiptSessions = class {
763
+ #map = /* @__PURE__ */ new Map();
764
+ #cap;
765
+ constructor(cap = DEFAULT_RECEIPT_SESSIONS_CAP) {
766
+ if (!Number.isInteger(cap) || cap <= 0) {
767
+ throw new Error(
768
+ `BoundedReceiptSessions cap must be a positive integer, got ${cap}`
769
+ );
770
+ }
771
+ this.#cap = cap;
772
+ }
773
+ get size() {
774
+ return this.#map.size;
775
+ }
776
+ get cap() {
777
+ return this.#cap;
778
+ }
779
+ get(streamNonce) {
780
+ const state = this.#map.get(streamNonce);
781
+ if (state === void 0) return void 0;
782
+ this.#map.delete(streamNonce);
783
+ this.#map.set(streamNonce, state);
784
+ return state;
785
+ }
786
+ set(streamNonce, state) {
787
+ this.#map.delete(streamNonce);
788
+ this.#map.set(streamNonce, state);
789
+ while (this.#map.size > this.#cap) {
790
+ const first = this.#map.keys().next();
791
+ if (first.done) break;
792
+ this.#map.delete(first.value);
793
+ }
794
+ return this;
795
+ }
796
+ delete(streamNonce) {
797
+ return this.#map.delete(streamNonce);
798
+ }
799
+ };
800
+ function issueSessionReceipt(input) {
801
+ const { sessions, streamNonce, deliveredAmount, rate, rateTimestamp } = input;
802
+ if (deliveredAmount < 0n) {
803
+ throw new Error(
804
+ `deliveredAmount must be non-negative, got ${deliveredAmount}`
805
+ );
806
+ }
807
+ const prev = sessions.get(streamNonce) ?? {
808
+ seq: 0,
809
+ cumulativeDelivered: 0n
810
+ };
811
+ const next = {
812
+ seq: prev.seq + 1,
813
+ cumulativeDelivered: prev.cumulativeDelivered + deliveredAmount
814
+ };
815
+ const receipt = signStreamReceipt(
816
+ {
817
+ v: STREAM_RECEIPT_VERSION,
818
+ streamNonce,
819
+ seq: next.seq,
820
+ cumulativeDelivered: next.cumulativeDelivered.toString(),
821
+ rate,
822
+ rateTimestamp
823
+ },
824
+ input.secretKey
825
+ );
826
+ sessions.set(streamNonce, next);
827
+ return receipt;
828
+ }
829
+
523
830
  // src/swap-handler.ts
524
831
  import { createHash } from "crypto";
525
832
  var DEFAULT_SEEN_PACKET_IDS_CAP = 1e4;
@@ -611,10 +918,10 @@ var BoundedSeenPacketIds = class {
611
918
  for (const k of this.#map.keys()) yield [k, k];
612
919
  }
613
920
  };
614
- var RATE_REGEX = /^(0|[1-9]\d*)(\.\d+)?$/;
921
+ var RATE_REGEX2 = /^(0|[1-9]\d*)(\.\d+)?$/;
615
922
  function applyRate(params) {
616
923
  const { sourceAmount, fromScale, toScale, rate } = params;
617
- if (!RATE_REGEX.test(rate)) {
924
+ if (!RATE_REGEX2.test(rate)) {
618
925
  throw new SwapHandlerError(`Invalid rate format: ${rate}`);
619
926
  }
620
927
  if (/^0(\.0+)?$/.test(rate)) {
@@ -713,7 +1020,12 @@ function createSwapHandler(config) {
713
1020
  "claimIssuer must implement issueClaim(params): Promise<IssueClaimResult>"
714
1021
  );
715
1022
  }
1023
+ if (config.receiptSecretKey !== void 0 && (!(config.receiptSecretKey instanceof Uint8Array) || config.receiptSecretKey.length !== 32)) {
1024
+ throw new SwapHandlerError("receiptSecretKey must be a 32-byte Uint8Array");
1025
+ }
716
1026
  const logger = config.logger ?? NOOP_LOGGER;
1027
+ const receiptSecretKey = config.receiptSecretKey ?? config.recipientSecretKey;
1028
+ const receiptSessions = config.receiptSessions ?? new BoundedReceiptSessions();
717
1029
  const seenPacketIds = config.seenPacketIds ?? new BoundedSeenPacketIds();
718
1030
  return async (ctx) => {
719
1031
  if (ctx.kind !== 1059) {
@@ -888,6 +1200,32 @@ function createSwapHandler(config) {
888
1200
  releaseReservation();
889
1201
  return ctx.reject("T00", "Internal error");
890
1202
  }
1203
+ let receipt;
1204
+ const streamNonceTag = findTagValue(rumor, "stream-nonce");
1205
+ if (streamNonceTag !== void 0) {
1206
+ if (!isValidStreamNonce(streamNonceTag)) {
1207
+ logger.warn({
1208
+ event: "swap_handler.invalid_stream_nonce",
1209
+ destination: ctx.destination
1210
+ });
1211
+ } else {
1212
+ try {
1213
+ receipt = issueSessionReceipt({
1214
+ sessions: receiptSessions,
1215
+ streamNonce: streamNonceTag,
1216
+ deliveredAmount: targetAmount,
1217
+ rate,
1218
+ rateTimestamp,
1219
+ secretKey: receiptSecretKey
1220
+ });
1221
+ } catch (err) {
1222
+ logger.error({
1223
+ event: "swap_handler.receipt_issue_failed",
1224
+ error: err instanceof Error ? err.message : String(err)
1225
+ });
1226
+ }
1227
+ }
1228
+ }
891
1229
  const claimBase64 = Buffer.from(ciphertext).toString("base64");
892
1230
  logger.info({
893
1231
  event: "swap_handler.claim_issued",
@@ -902,6 +1240,7 @@ function createSwapHandler(config) {
902
1240
  rateTimestamp
903
1241
  };
904
1242
  if (claimId !== void 0) metadata["claimId"] = claimId;
1243
+ if (receipt !== void 0) metadata["receipt"] = receipt;
905
1244
  if (settlementChannelId !== void 0 && settlementNonce !== void 0 && settlementCumulative !== void 0 && settlementRecipient !== void 0 && settlementSwapSigner !== void 0) {
906
1245
  metadata["channelId"] = settlementChannelId;
907
1246
  metadata["nonce"] = settlementNonce.toString();
@@ -928,8 +1267,8 @@ function computePacketId(senderPubkey, sourceAmount, rumor) {
928
1267
 
929
1268
  // src/stream-swap.ts
930
1269
  import { getPublicKey as getPublicKey3 } from "nostr-tools/pure";
931
- var RATE_REGEX2 = /^(0|[1-9]\d*)(\.\d+)?$/;
932
- var HEX64_REGEX = /^[0-9a-f]{64}$/;
1270
+ var RATE_REGEX3 = /^(0|[1-9]\d*)(\.\d+)?$/;
1271
+ var HEX64_REGEX2 = /^[0-9a-f]{64}$/;
933
1272
  var BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
934
1273
  function isBase64(s) {
935
1274
  if (s.length === 0 || s.length % 4 !== 0) return false;
@@ -965,26 +1304,29 @@ function buildSwapRumor(input) {
965
1304
  totalPackets,
966
1305
  nonce,
967
1306
  createdAt,
968
- chainRecipient
1307
+ chainRecipient,
1308
+ streamNonce
969
1309
  } = input;
1310
+ const tags = [
1311
+ ["swap-from", `${pair.from.assetCode}:${pair.from.chain}`],
1312
+ ["swap-to", `${pair.to.assetCode}:${pair.to.chain}`],
1313
+ ["amount", sourceAmount.toString()],
1314
+ ["seq", String(packetIndex), String(totalPackets)],
1315
+ ["nonce", Buffer.from(nonce).toString("hex")],
1316
+ ["chain-recipient", chainRecipient]
1317
+ ];
1318
+ if (streamNonce !== void 0) tags.push(["stream-nonce", streamNonce]);
970
1319
  return {
971
1320
  kind: 20032,
972
1321
  pubkey: senderPubkey,
973
1322
  content: "",
974
1323
  created_at: createdAt,
975
- tags: [
976
- ["swap-from", `${pair.from.assetCode}:${pair.from.chain}`],
977
- ["swap-to", `${pair.to.assetCode}:${pair.to.chain}`],
978
- ["amount", sourceAmount.toString()],
979
- ["seq", String(packetIndex), String(totalPackets)],
980
- ["nonce", Buffer.from(nonce).toString("hex")],
981
- ["chain-recipient", chainRecipient]
982
- ]
1324
+ tags
983
1325
  };
984
1326
  }
985
1327
  var EVM_CHANNEL_ID_REGEX = /^0x[0-9a-f]{64}$/;
986
1328
  var EVM_ADDRESS_REGEX = /^0x[0-9a-f]{40}$/;
987
- var DECIMAL_UINT_REGEX = /^(0|[1-9]\d*)$/;
1329
+ var DECIMAL_UINT_REGEX2 = /^(0|[1-9]\d*)$/;
988
1330
  var BASE58_REGEX = /^[1-9A-HJ-NP-Za-km-z]+$/;
989
1331
  function validateChainAddress(value, chain, kind) {
990
1332
  if (chain.startsWith("evm:")) {
@@ -1051,7 +1393,7 @@ function decodeFulfillMetadata(data, chain, opts) {
1051
1393
  "FULFILL metadata.claim is missing or not base64 string"
1052
1394
  );
1053
1395
  }
1054
- if (typeof ephemeralPubkey !== "string" || !HEX64_REGEX.test(ephemeralPubkey)) {
1396
+ if (typeof ephemeralPubkey !== "string" || !HEX64_REGEX2.test(ephemeralPubkey)) {
1055
1397
  throw new StreamSwapError(
1056
1398
  "FULFILL_DECODE_FAILED",
1057
1399
  "FULFILL metadata.ephemeralPubkey is missing or not 64-char hex"
@@ -1085,7 +1427,7 @@ function decodeFulfillMetadata(data, chain, opts) {
1085
1427
  );
1086
1428
  }
1087
1429
  if (hasRate) {
1088
- if (typeof tapeRate !== "string" || !RATE_REGEX2.test(tapeRate) || /^0(\.0+)?$/.test(tapeRate)) {
1430
+ if (typeof tapeRate !== "string" || !RATE_REGEX3.test(tapeRate) || /^0(\.0+)?$/.test(tapeRate)) {
1089
1431
  throw new StreamSwapError(
1090
1432
  "FULFILL_DECODE_FAILED",
1091
1433
  "FULFILL metadata.rate must be a positive decimal string"
@@ -1105,16 +1447,27 @@ function decodeFulfillMetadata(data, chain, opts) {
1105
1447
  "FULFILL metadata is missing the quote tape (rate + rateTimestamp) required when minExchangeRate is set"
1106
1448
  );
1107
1449
  }
1450
+ if (obj["receipt"] !== void 0) {
1451
+ try {
1452
+ result.receipt = parseStreamReceipt(obj["receipt"]);
1453
+ } catch (err) {
1454
+ throw new StreamSwapError(
1455
+ "FULFILL_DECODE_FAILED",
1456
+ `FULFILL metadata.receipt is malformed: ${err instanceof Error ? err.message : String(err)}`,
1457
+ { cause: err }
1458
+ );
1459
+ }
1460
+ }
1108
1461
  const channelId = obj["channelId"];
1109
1462
  if (typeof channelId === "string" && (!chain || validateChainAddress(channelId, chain, "channelId"))) {
1110
1463
  result.channelId = channelId;
1111
1464
  }
1112
1465
  const nonce = obj["nonce"];
1113
- if (typeof nonce === "string" && DECIMAL_UINT_REGEX.test(nonce)) {
1466
+ if (typeof nonce === "string" && DECIMAL_UINT_REGEX2.test(nonce)) {
1114
1467
  result.nonce = nonce;
1115
1468
  }
1116
1469
  const cumulativeAmount = obj["cumulativeAmount"];
1117
- if (typeof cumulativeAmount === "string" && DECIMAL_UINT_REGEX.test(cumulativeAmount)) {
1470
+ if (typeof cumulativeAmount === "string" && DECIMAL_UINT_REGEX2.test(cumulativeAmount)) {
1118
1471
  result.cumulativeAmount = cumulativeAmount;
1119
1472
  }
1120
1473
  const recipient = obj["recipient"];
@@ -1230,7 +1583,7 @@ function validateParams(params) {
1230
1583
  "senderSecretKey must be a 32-byte Uint8Array"
1231
1584
  );
1232
1585
  }
1233
- if (typeof params.swapPubkey !== "string" || !HEX64_REGEX.test(params.swapPubkey)) {
1586
+ if (typeof params.swapPubkey !== "string" || !HEX64_REGEX2.test(params.swapPubkey)) {
1234
1587
  throw new StreamSwapError(
1235
1588
  "INVALID_STATE",
1236
1589
  "swapPubkey must be a 64-char lowercase hex string"
@@ -1251,10 +1604,10 @@ function validateParams(params) {
1251
1604
  "pair.to must have { assetCode: string, assetScale: number, chain: string }"
1252
1605
  );
1253
1606
  }
1254
- if (typeof params.pair.rate !== "string" || !RATE_REGEX2.test(params.pair.rate)) {
1607
+ if (typeof params.pair.rate !== "string" || !RATE_REGEX3.test(params.pair.rate)) {
1255
1608
  throw new StreamSwapError(
1256
1609
  "INVALID_PAIR",
1257
- `pair.rate must match ${RATE_REGEX2}, got ${params.pair.rate}`
1610
+ `pair.rate must match ${RATE_REGEX3}, got ${params.pair.rate}`
1258
1611
  );
1259
1612
  }
1260
1613
  try {
@@ -1278,15 +1631,27 @@ function validateParams(params) {
1278
1631
  );
1279
1632
  }
1280
1633
  if (params.minExchangeRate !== void 0) {
1281
- if (typeof params.minExchangeRate !== "string" || !RATE_REGEX2.test(params.minExchangeRate) || /^0(\.0+)?$/.test(params.minExchangeRate)) {
1634
+ if (typeof params.minExchangeRate !== "string" || !RATE_REGEX3.test(params.minExchangeRate) || /^0(\.0+)?$/.test(params.minExchangeRate)) {
1282
1635
  throw new StreamSwapError(
1283
1636
  "INVALID_STATE",
1284
- `minExchangeRate must be a positive decimal string matching ${RATE_REGEX2}, got ${String(
1637
+ `minExchangeRate must be a positive decimal string matching ${RATE_REGEX3}, got ${String(
1285
1638
  params.minExchangeRate
1286
1639
  )}`
1287
1640
  );
1288
1641
  }
1289
1642
  }
1643
+ if (params.receiptPubkey !== void 0 && (typeof params.receiptPubkey !== "string" || !HEX64_REGEX2.test(params.receiptPubkey))) {
1644
+ throw new StreamSwapError(
1645
+ "INVALID_STATE",
1646
+ "receiptPubkey must be a 64-char lowercase hex string"
1647
+ );
1648
+ }
1649
+ if (params.requireReceipts !== void 0 && typeof params.requireReceipts !== "boolean") {
1650
+ throw new StreamSwapError(
1651
+ "INVALID_STATE",
1652
+ `requireReceipts must be a boolean, got ${String(params.requireReceipts)}`
1653
+ );
1654
+ }
1290
1655
  if (params.packetExpiryMs !== void 0 && (typeof params.packetExpiryMs !== "number" || !Number.isInteger(params.packetExpiryMs) || params.packetExpiryMs <= 0)) {
1291
1656
  throw new StreamSwapError(
1292
1657
  "INVALID_STATE",
@@ -1323,6 +1688,9 @@ function streamSwapControlled(params) {
1323
1688
  rate: params.pair.rate
1324
1689
  });
1325
1690
  const senderPubkey = getPublicKey3(params.senderSecretKey);
1691
+ const streamNonceBytes = new Uint8Array(16);
1692
+ getRandomValues(streamNonceBytes);
1693
+ const streamNonce = Buffer.from(streamNonceBytes).toString("hex");
1326
1694
  let streamState = "running";
1327
1695
  let resumeDeferred = null;
1328
1696
  const controller = {
@@ -1373,6 +1741,7 @@ function streamSwapControlled(params) {
1373
1741
  params,
1374
1742
  frozenPair,
1375
1743
  senderPubkey,
1744
+ streamNonce,
1376
1745
  logger,
1377
1746
  getState,
1378
1747
  setState,
@@ -1382,6 +1751,7 @@ function streamSwapControlled(params) {
1382
1751
  frozenPair,
1383
1752
  schedule,
1384
1753
  senderPubkey,
1754
+ streamNonce,
1385
1755
  logger,
1386
1756
  getState,
1387
1757
  setState,
@@ -1390,7 +1760,15 @@ function streamSwapControlled(params) {
1390
1760
  return { result, controller };
1391
1761
  }
1392
1762
  function buildAndWrapPacket(input) {
1393
- const { params, pair, senderPubkey, sourceAmount, seq, totalPackets } = input;
1763
+ const {
1764
+ params,
1765
+ pair,
1766
+ senderPubkey,
1767
+ sourceAmount,
1768
+ seq,
1769
+ totalPackets,
1770
+ streamNonce
1771
+ } = input;
1394
1772
  const nonce = new Uint8Array(16);
1395
1773
  getRandomValues(nonce);
1396
1774
  const rumor = buildSwapRumor({
@@ -1401,7 +1779,8 @@ function buildAndWrapPacket(input) {
1401
1779
  totalPackets,
1402
1780
  nonce,
1403
1781
  createdAt: Math.floor(Date.now() / 1e3),
1404
- chainRecipient: params.chainRecipient
1782
+ chainRecipient: params.chainRecipient,
1783
+ streamNonce
1405
1784
  });
1406
1785
  const packetExpiresAt = params.packetExpiryMs !== void 0 ? new Date(Date.now() + params.packetExpiryMs) : void 0;
1407
1786
  const wrapped = wrapSwapPacketToToon({
@@ -1491,6 +1870,47 @@ async function processAcceptedPacket(ctx, args) {
1491
1870
  return { status: "below-floor" };
1492
1871
  }
1493
1872
  }
1873
+ let verifiedReceipt;
1874
+ if (metadata.receipt !== void 0) {
1875
+ const receipt = metadata.receipt;
1876
+ let failure;
1877
+ if (metadata.rate !== void 0 && receipt.rate !== metadata.rate) {
1878
+ failure = `receipt.rate ${receipt.rate} does not match tape rate ${metadata.rate}`;
1879
+ } else if (metadata.rateTimestamp !== void 0 && receipt.rateTimestamp !== metadata.rateTimestamp) {
1880
+ failure = `receipt.rateTimestamp ${receipt.rateTimestamp} does not match tape rateTimestamp ${metadata.rateTimestamp}`;
1881
+ } else {
1882
+ const added = ctx.receiptTracker.add(receipt);
1883
+ if (!added.ok) failure = `${added.code}: ${added.message}`;
1884
+ }
1885
+ if (failure !== void 0) {
1886
+ logger.warn({
1887
+ event: "stream_swap.receipt_invalid",
1888
+ packetIndex,
1889
+ seq: receipt.seq,
1890
+ error: failure
1891
+ });
1892
+ ctx.rejections.push({
1893
+ packetIndex,
1894
+ sourceAmount,
1895
+ code: "RECEIPT_INVALID",
1896
+ message: `Stream receipt failed verification: ${failure}`
1897
+ });
1898
+ return { status: "receipt-invalid" };
1899
+ }
1900
+ verifiedReceipt = receipt;
1901
+ } else if (params.requireReceipts === true) {
1902
+ logger.warn({
1903
+ event: "stream_swap.receipt_missing",
1904
+ packetIndex
1905
+ });
1906
+ ctx.rejections.push({
1907
+ packetIndex,
1908
+ sourceAmount,
1909
+ code: "RECEIPT_MISSING",
1910
+ message: "FULFILL carried no stream receipt but requireReceipts is set (legacy maker without rfc-0039 receipt support?)"
1911
+ });
1912
+ return { status: "receipt-invalid" };
1913
+ }
1494
1914
  let claimBytes;
1495
1915
  try {
1496
1916
  const ciphertext = new Uint8Array(Buffer.from(metadata.claim, "base64"));
@@ -1566,6 +1986,7 @@ async function processAcceptedPacket(ctx, args) {
1566
1986
  accumulated.rate = metadata.rate;
1567
1987
  accumulated.rateTimestamp = metadata.rateTimestamp;
1568
1988
  }
1989
+ if (verifiedReceipt !== void 0) accumulated.receipt = verifiedReceipt;
1569
1990
  ctx.claims.push(accumulated);
1570
1991
  logger.debug({
1571
1992
  event: "stream_swap.packet_accepted",
@@ -1590,6 +2011,8 @@ async function processAcceptedPacket(ctx, args) {
1590
2011
  rate: metadata.rate,
1591
2012
  rateTimestamp: metadata.rateTimestamp
1592
2013
  },
2014
+ // Issue #84: surface the verified per-fulfill receipt to the callback.
2015
+ ...verifiedReceipt !== void 0 && { receipt: verifiedReceipt },
1593
2016
  state: ctx.getState() === "paused" ? "paused" : ctx.getState() === "stopped" ? "stopped" : "running"
1594
2017
  });
1595
2018
  try {
@@ -1644,10 +2067,13 @@ function finalizeResult(input) {
1644
2067
  cumulativeSource: cumulative.source,
1645
2068
  cumulativeTarget: cumulative.target,
1646
2069
  packetsSent: input.packetsSent,
1647
- packetsScheduled: input.packetsScheduled
2070
+ packetsScheduled: input.packetsScheduled,
2071
+ // Issue #84: the verified receipt chain — present on abort too,
2072
+ // covering exactly the packets that filled before the halt.
2073
+ receipts: input.ctx.receiptTracker.chain()
1648
2074
  };
1649
2075
  }
1650
- async function runLoop(params, pair, schedule, senderPubkey, logger, getState, setState, waitForResumeOrStop) {
2076
+ async function runLoop(params, pair, schedule, senderPubkey, streamNonce, logger, getState, setState, waitForResumeOrStop) {
1651
2077
  const ctx = {
1652
2078
  params,
1653
2079
  pair,
@@ -1656,7 +2082,11 @@ async function runLoop(params, pair, schedule, senderPubkey, logger, getState, s
1656
2082
  claims: [],
1657
2083
  rejections: [],
1658
2084
  errors: [],
1659
- cumulative: { source: 0n, target: 0n }
2085
+ cumulative: { source: 0n, target: 0n },
2086
+ receiptTracker: new ReceiptChainTracker({
2087
+ streamNonce,
2088
+ makerPubkey: params.receiptPubkey ?? params.swapPubkey
2089
+ })
1660
2090
  };
1661
2091
  let packetsSent = 0;
1662
2092
  let abortReason = "complete";
@@ -1697,7 +2127,8 @@ async function runLoop(params, pair, schedule, senderPubkey, logger, getState, s
1697
2127
  senderPubkey,
1698
2128
  sourceAmount,
1699
2129
  seq: packetIndex + 1,
1700
- totalPackets
2130
+ totalPackets,
2131
+ streamNonce
1701
2132
  });
1702
2133
  } catch (err) {
1703
2134
  logger.error({
@@ -1760,6 +2191,10 @@ async function runLoop(params, pair, schedule, senderPubkey, logger, getState, s
1760
2191
  abortReason = "below-floor";
1761
2192
  break packetLoop;
1762
2193
  }
2194
+ if (outcome.status === "receipt-invalid") {
2195
+ abortReason = "receipt-invalid";
2196
+ break packetLoop;
2197
+ }
1763
2198
  if (outcome.status === "callback-throw") {
1764
2199
  abortReason = "callback-throw";
1765
2200
  break packetLoop;
@@ -1795,7 +2230,7 @@ function classifyReject(code, message) {
1795
2230
  }
1796
2231
  return "reject";
1797
2232
  }
1798
- async function runAdaptiveLoop(params, pair, senderPubkey, logger, getState, setState, waitForResumeOrStop) {
2233
+ async function runAdaptiveLoop(params, pair, senderPubkey, streamNonce, logger, getState, setState, waitForResumeOrStop) {
1799
2234
  const controller = params.controller;
1800
2235
  const ctx = {
1801
2236
  params,
@@ -1805,7 +2240,11 @@ async function runAdaptiveLoop(params, pair, senderPubkey, logger, getState, set
1805
2240
  claims: [],
1806
2241
  rejections: [],
1807
2242
  errors: [],
1808
- cumulative: { source: 0n, target: 0n }
2243
+ cumulative: { source: 0n, target: 0n },
2244
+ receiptTracker: new ReceiptChainTracker({
2245
+ streamNonce,
2246
+ makerPubkey: params.receiptPubkey ?? params.swapPubkey
2247
+ })
1809
2248
  };
1810
2249
  let packetsSent = 0;
1811
2250
  let abortReason = "complete";
@@ -1872,7 +2311,8 @@ async function runAdaptiveLoop(params, pair, senderPubkey, logger, getState, set
1872
2311
  seq: packetIndex + 1,
1873
2312
  // Adaptive mode: the final packet count is unknown upfront —
1874
2313
  // `0` in the rumor's `seq` tag total position means "open".
1875
- totalPackets: 0
2314
+ totalPackets: 0,
2315
+ streamNonce
1876
2316
  });
1877
2317
  } catch (err) {
1878
2318
  logger.error({
@@ -1992,6 +2432,14 @@ async function runAdaptiveLoop(params, pair, senderPubkey, logger, getState, set
1992
2432
  });
1993
2433
  halt("below-floor");
1994
2434
  break;
2435
+ case "receipt-invalid":
2436
+ await observeSafe({
2437
+ resolution: "reject",
2438
+ rttMs: settled.rttMs,
2439
+ remaining
2440
+ });
2441
+ halt("receipt-invalid");
2442
+ break;
1995
2443
  case "callback-throw":
1996
2444
  await observeSafe({
1997
2445
  resolution: "fulfill",
@@ -2079,6 +2527,18 @@ export {
2079
2527
  unwrapSwapPacketFromToon,
2080
2528
  encryptFulfillClaim,
2081
2529
  decryptFulfillClaim,
2530
+ STREAM_RECEIPT_VERSION,
2531
+ STREAM_RECEIPT_SIGNING_TAG,
2532
+ isValidStreamNonce,
2533
+ parseStreamReceipt,
2534
+ encodeReceiptSigningPayload,
2535
+ signStreamReceipt,
2536
+ verifyStreamReceipt,
2537
+ ReceiptChainTracker,
2538
+ serializeReceiptChain,
2539
+ DEFAULT_RECEIPT_SESSIONS_CAP,
2540
+ BoundedReceiptSessions,
2541
+ issueSessionReceipt,
2082
2542
  SWAP_HANDLER_REJECT_CODES,
2083
2543
  SWAP_HANDLER_REJECT_MESSAGES,
2084
2544
  applyRate,
@@ -2088,4 +2548,4 @@ export {
2088
2548
  streamSwapControlled,
2089
2549
  __testing
2090
2550
  };
2091
- //# sourceMappingURL=chunk-7LBYFU4L.js.map
2551
+ //# sourceMappingURL=chunk-RAZRWSJF.js.map