@toon-protocol/sdk 2.0.1 → 2.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.
@@ -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) {
@@ -788,8 +1100,20 @@ function createSwapHandler(config) {
788
1100
  seenPacketIds.delete(packetId);
789
1101
  };
790
1102
  let rate;
1103
+ let rateTimestamp;
791
1104
  try {
792
- rate = config.rateProvider ? await config.rateProvider(pair) : pair.rate;
1105
+ const provided = config.rateProvider ? await config.rateProvider(pair) : pair.rate;
1106
+ if (typeof provided === "string") {
1107
+ rate = provided;
1108
+ rateTimestamp = Date.now();
1109
+ } else if (provided !== null && typeof provided === "object" && typeof provided.rate === "string" && typeof provided.rateTimestamp === "number" && Number.isInteger(provided.rateTimestamp) && provided.rateTimestamp > 0) {
1110
+ rate = provided.rate;
1111
+ rateTimestamp = provided.rateTimestamp;
1112
+ } else {
1113
+ throw new SwapHandlerError(
1114
+ "rateProvider must return a decimal-string rate or { rate, rateTimestamp }"
1115
+ );
1116
+ }
793
1117
  } catch (err) {
794
1118
  logger.error({
795
1119
  event: "swap_handler.rate_provider_failed",
@@ -876,6 +1200,32 @@ function createSwapHandler(config) {
876
1200
  releaseReservation();
877
1201
  return ctx.reject("T00", "Internal error");
878
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
+ }
879
1229
  const claimBase64 = Buffer.from(ciphertext).toString("base64");
880
1230
  logger.info({
881
1231
  event: "swap_handler.claim_issued",
@@ -885,9 +1235,12 @@ function createSwapHandler(config) {
885
1235
  const metadata = {
886
1236
  claim: claimBase64,
887
1237
  ephemeralPubkey,
888
- targetAmount: targetAmount.toString()
1238
+ targetAmount: targetAmount.toString(),
1239
+ rate,
1240
+ rateTimestamp
889
1241
  };
890
1242
  if (claimId !== void 0) metadata["claimId"] = claimId;
1243
+ if (receipt !== void 0) metadata["receipt"] = receipt;
891
1244
  if (settlementChannelId !== void 0 && settlementNonce !== void 0 && settlementCumulative !== void 0 && settlementRecipient !== void 0 && settlementSwapSigner !== void 0) {
892
1245
  metadata["channelId"] = settlementChannelId;
893
1246
  metadata["nonce"] = settlementNonce.toString();
@@ -914,8 +1267,8 @@ function computePacketId(senderPubkey, sourceAmount, rumor) {
914
1267
 
915
1268
  // src/stream-swap.ts
916
1269
  import { getPublicKey as getPublicKey3 } from "nostr-tools/pure";
917
- var RATE_REGEX2 = /^(0|[1-9]\d*)(\.\d+)?$/;
918
- var HEX64_REGEX = /^[0-9a-f]{64}$/;
1270
+ var RATE_REGEX3 = /^(0|[1-9]\d*)(\.\d+)?$/;
1271
+ var HEX64_REGEX2 = /^[0-9a-f]{64}$/;
919
1272
  var BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
920
1273
  function isBase64(s) {
921
1274
  if (s.length === 0 || s.length % 4 !== 0) return false;
@@ -951,26 +1304,29 @@ function buildSwapRumor(input) {
951
1304
  totalPackets,
952
1305
  nonce,
953
1306
  createdAt,
954
- chainRecipient
1307
+ chainRecipient,
1308
+ streamNonce
955
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]);
956
1319
  return {
957
1320
  kind: 20032,
958
1321
  pubkey: senderPubkey,
959
1322
  content: "",
960
1323
  created_at: createdAt,
961
- tags: [
962
- ["swap-from", `${pair.from.assetCode}:${pair.from.chain}`],
963
- ["swap-to", `${pair.to.assetCode}:${pair.to.chain}`],
964
- ["amount", sourceAmount.toString()],
965
- ["seq", String(packetIndex), String(totalPackets)],
966
- ["nonce", Buffer.from(nonce).toString("hex")],
967
- ["chain-recipient", chainRecipient]
968
- ]
1324
+ tags
969
1325
  };
970
1326
  }
971
1327
  var EVM_CHANNEL_ID_REGEX = /^0x[0-9a-f]{64}$/;
972
1328
  var EVM_ADDRESS_REGEX = /^0x[0-9a-f]{40}$/;
973
- var DECIMAL_UINT_REGEX = /^(0|[1-9]\d*)$/;
1329
+ var DECIMAL_UINT_REGEX2 = /^(0|[1-9]\d*)$/;
974
1330
  var BASE58_REGEX = /^[1-9A-HJ-NP-Za-km-z]+$/;
975
1331
  function validateChainAddress(value, chain, kind) {
976
1332
  if (chain.startsWith("evm:")) {
@@ -992,7 +1348,7 @@ function validateChainAddress(value, chain, kind) {
992
1348
  }
993
1349
  return value.length > 0;
994
1350
  }
995
- function decodeFulfillMetadata(data, chain) {
1351
+ function decodeFulfillMetadata(data, chain, opts) {
996
1352
  if (data === void 0 || data === null || data === "") {
997
1353
  throw new StreamSwapError("FULFILL_DECODE_FAILED", "FULFILL data missing");
998
1354
  }
@@ -1037,7 +1393,7 @@ function decodeFulfillMetadata(data, chain) {
1037
1393
  "FULFILL metadata.claim is missing or not base64 string"
1038
1394
  );
1039
1395
  }
1040
- if (typeof ephemeralPubkey !== "string" || !HEX64_REGEX.test(ephemeralPubkey)) {
1396
+ if (typeof ephemeralPubkey !== "string" || !HEX64_REGEX2.test(ephemeralPubkey)) {
1041
1397
  throw new StreamSwapError(
1042
1398
  "FULFILL_DECODE_FAILED",
1043
1399
  "FULFILL metadata.ephemeralPubkey is missing or not 64-char hex"
@@ -1060,16 +1416,58 @@ function decodeFulfillMetadata(data, chain) {
1060
1416
  }
1061
1417
  result.targetAmount = ta;
1062
1418
  }
1419
+ const tapeRate = obj["rate"];
1420
+ const tapeTimestamp = obj["rateTimestamp"];
1421
+ const hasRate = tapeRate !== void 0;
1422
+ const hasTimestamp = tapeTimestamp !== void 0;
1423
+ if (hasRate !== hasTimestamp) {
1424
+ throw new StreamSwapError(
1425
+ "FULFILL_DECODE_FAILED",
1426
+ "FULFILL metadata quote tape is malformed: rate and rateTimestamp must travel together"
1427
+ );
1428
+ }
1429
+ if (hasRate) {
1430
+ if (typeof tapeRate !== "string" || !RATE_REGEX3.test(tapeRate) || /^0(\.0+)?$/.test(tapeRate)) {
1431
+ throw new StreamSwapError(
1432
+ "FULFILL_DECODE_FAILED",
1433
+ "FULFILL metadata.rate must be a positive decimal string"
1434
+ );
1435
+ }
1436
+ if (typeof tapeTimestamp !== "number" || !Number.isInteger(tapeTimestamp) || tapeTimestamp <= 0) {
1437
+ throw new StreamSwapError(
1438
+ "FULFILL_DECODE_FAILED",
1439
+ "FULFILL metadata.rateTimestamp must be a positive integer (unix ms)"
1440
+ );
1441
+ }
1442
+ result.rate = tapeRate;
1443
+ result.rateTimestamp = tapeTimestamp;
1444
+ } else if (opts?.requireQuoteTape === true) {
1445
+ throw new StreamSwapError(
1446
+ "FULFILL_DECODE_FAILED",
1447
+ "FULFILL metadata is missing the quote tape (rate + rateTimestamp) required when minExchangeRate is set"
1448
+ );
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
+ }
1063
1461
  const channelId = obj["channelId"];
1064
1462
  if (typeof channelId === "string" && (!chain || validateChainAddress(channelId, chain, "channelId"))) {
1065
1463
  result.channelId = channelId;
1066
1464
  }
1067
1465
  const nonce = obj["nonce"];
1068
- if (typeof nonce === "string" && DECIMAL_UINT_REGEX.test(nonce)) {
1466
+ if (typeof nonce === "string" && DECIMAL_UINT_REGEX2.test(nonce)) {
1069
1467
  result.nonce = nonce;
1070
1468
  }
1071
1469
  const cumulativeAmount = obj["cumulativeAmount"];
1072
- if (typeof cumulativeAmount === "string" && DECIMAL_UINT_REGEX.test(cumulativeAmount)) {
1470
+ if (typeof cumulativeAmount === "string" && DECIMAL_UINT_REGEX2.test(cumulativeAmount)) {
1073
1471
  result.cumulativeAmount = cumulativeAmount;
1074
1472
  }
1075
1473
  const recipient = obj["recipient"];
@@ -1082,6 +1480,18 @@ function decodeFulfillMetadata(data, chain) {
1082
1480
  }
1083
1481
  return result;
1084
1482
  }
1483
+ function compareDecimalRates(a, b) {
1484
+ const split = (s) => {
1485
+ const dot = s.indexOf(".");
1486
+ return dot === -1 ? { int: s, frac: "" } : { int: s.slice(0, dot), frac: s.slice(dot + 1) };
1487
+ };
1488
+ const pa = split(a);
1489
+ const pb = split(b);
1490
+ const scale = Math.max(pa.frac.length, pb.frac.length);
1491
+ const av = BigInt(pa.int + pa.frac.padEnd(scale, "0"));
1492
+ const bv = BigInt(pb.int + pb.frac.padEnd(scale, "0"));
1493
+ return av < bv ? -1 : av > bv ? 1 : 0;
1494
+ }
1085
1495
  var Deferred = class {
1086
1496
  promise;
1087
1497
  resolve;
@@ -1110,13 +1520,25 @@ function validateParams(params) {
1110
1520
  }
1111
1521
  const hasCount = params.packetCount !== void 0;
1112
1522
  const hasAmounts = params.packetAmounts !== void 0;
1113
- if (hasCount === hasAmounts) {
1523
+ const hasController = params.controller !== void 0;
1524
+ const chunkingModes = [hasCount, hasAmounts, hasController].filter(
1525
+ Boolean
1526
+ ).length;
1527
+ if (chunkingModes !== 1) {
1114
1528
  throw new StreamSwapError(
1115
1529
  "INVALID_CHUNKING",
1116
- "Exactly one of packetCount or packetAmounts must be provided"
1530
+ "Exactly one of packetCount, packetAmounts, or controller must be provided"
1117
1531
  );
1118
1532
  }
1119
- if (hasCount) {
1533
+ if (hasController) {
1534
+ const c = params.controller;
1535
+ if (typeof c.nextDelta !== "function" || typeof c.observe !== "function" || typeof c.window !== "number") {
1536
+ throw new StreamSwapError(
1537
+ "INVALID_STATE",
1538
+ "controller must implement nextDelta(remaining), observe(observation), and window"
1539
+ );
1540
+ }
1541
+ } else if (hasCount) {
1120
1542
  const c = params.packetCount;
1121
1543
  if (!Number.isInteger(c) || c <= 0) {
1122
1544
  throw new StreamSwapError(
@@ -1161,7 +1583,7 @@ function validateParams(params) {
1161
1583
  "senderSecretKey must be a 32-byte Uint8Array"
1162
1584
  );
1163
1585
  }
1164
- if (typeof params.swapPubkey !== "string" || !HEX64_REGEX.test(params.swapPubkey)) {
1586
+ if (typeof params.swapPubkey !== "string" || !HEX64_REGEX2.test(params.swapPubkey)) {
1165
1587
  throw new StreamSwapError(
1166
1588
  "INVALID_STATE",
1167
1589
  "swapPubkey must be a 64-char lowercase hex string"
@@ -1182,10 +1604,10 @@ function validateParams(params) {
1182
1604
  "pair.to must have { assetCode: string, assetScale: number, chain: string }"
1183
1605
  );
1184
1606
  }
1185
- 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)) {
1186
1608
  throw new StreamSwapError(
1187
1609
  "INVALID_PAIR",
1188
- `pair.rate must match ${RATE_REGEX2}, got ${params.pair.rate}`
1610
+ `pair.rate must match ${RATE_REGEX3}, got ${params.pair.rate}`
1189
1611
  );
1190
1612
  }
1191
1613
  try {
@@ -1208,6 +1630,34 @@ function validateParams(params) {
1208
1630
  `rateDeviationThreshold must be a non-negative finite number, got ${params.rateDeviationThreshold}`
1209
1631
  );
1210
1632
  }
1633
+ if (params.minExchangeRate !== void 0) {
1634
+ if (typeof params.minExchangeRate !== "string" || !RATE_REGEX3.test(params.minExchangeRate) || /^0(\.0+)?$/.test(params.minExchangeRate)) {
1635
+ throw new StreamSwapError(
1636
+ "INVALID_STATE",
1637
+ `minExchangeRate must be a positive decimal string matching ${RATE_REGEX3}, got ${String(
1638
+ params.minExchangeRate
1639
+ )}`
1640
+ );
1641
+ }
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
+ }
1655
+ if (params.packetExpiryMs !== void 0 && (typeof params.packetExpiryMs !== "number" || !Number.isInteger(params.packetExpiryMs) || params.packetExpiryMs <= 0)) {
1656
+ throw new StreamSwapError(
1657
+ "INVALID_STATE",
1658
+ `packetExpiryMs must be a positive integer (ms), got ${params.packetExpiryMs}`
1659
+ );
1660
+ }
1211
1661
  if (typeof params.chainRecipient !== "string" || params.chainRecipient.length === 0) {
1212
1662
  throw new StreamSwapError(
1213
1663
  "INVALID_STATE",
@@ -1231,13 +1681,16 @@ async function streamSwap(params) {
1231
1681
  function streamSwapControlled(params) {
1232
1682
  validateParams(params);
1233
1683
  const logger = params.logger ?? NOOP_LOGGER2;
1234
- const schedule = params.packetAmounts ? [...params.packetAmounts] : chunkAmount(params.totalAmount, params.packetCount);
1684
+ const schedule = params.packetAmounts ? [...params.packetAmounts] : params.packetCount !== void 0 ? chunkAmount(params.totalAmount, params.packetCount) : [];
1235
1685
  const frozenPair = Object.freeze({
1236
1686
  from: Object.freeze({ ...params.pair.from }),
1237
1687
  to: Object.freeze({ ...params.pair.to }),
1238
1688
  rate: params.pair.rate
1239
1689
  });
1240
1690
  const senderPubkey = getPublicKey3(params.senderSecretKey);
1691
+ const streamNonceBytes = new Uint8Array(16);
1692
+ getRandomValues(streamNonceBytes);
1693
+ const streamNonce = Buffer.from(streamNonceBytes).toString("hex");
1241
1694
  let streamState = "running";
1242
1695
  let resumeDeferred = null;
1243
1696
  const controller = {
@@ -1274,30 +1727,367 @@ function streamSwapControlled(params) {
1274
1727
  return streamState;
1275
1728
  }
1276
1729
  };
1277
- const result = runLoop(
1730
+ const getState = () => streamState;
1731
+ const setState = (v) => {
1732
+ streamState = v;
1733
+ };
1734
+ const waitForResumeOrStop = () => {
1735
+ if (streamState !== "paused")
1736
+ return Promise.resolve("resume");
1737
+ if (!resumeDeferred) resumeDeferred = new Deferred();
1738
+ return resumeDeferred.promise;
1739
+ };
1740
+ const result = params.controller ? runAdaptiveLoop(
1741
+ params,
1742
+ frozenPair,
1743
+ senderPubkey,
1744
+ streamNonce,
1745
+ logger,
1746
+ getState,
1747
+ setState,
1748
+ waitForResumeOrStop
1749
+ ) : runLoop(
1278
1750
  params,
1279
1751
  frozenPair,
1280
1752
  schedule,
1281
1753
  senderPubkey,
1754
+ streamNonce,
1282
1755
  logger,
1283
- () => streamState,
1284
- (v) => {
1285
- streamState = v;
1286
- },
1287
- () => {
1288
- if (streamState !== "paused") return Promise.resolve("resume");
1289
- if (!resumeDeferred) resumeDeferred = new Deferred();
1290
- return resumeDeferred.promise;
1291
- }
1756
+ getState,
1757
+ setState,
1758
+ waitForResumeOrStop
1292
1759
  );
1293
1760
  return { result, controller };
1294
1761
  }
1295
- async function runLoop(params, pair, schedule, senderPubkey, logger, getState, setState, waitForResumeOrStop) {
1296
- const claims = [];
1297
- const rejections = [];
1298
- const errors = [];
1299
- let cumulativeSource = 0n;
1300
- let cumulativeTarget = 0n;
1762
+ function buildAndWrapPacket(input) {
1763
+ const {
1764
+ params,
1765
+ pair,
1766
+ senderPubkey,
1767
+ sourceAmount,
1768
+ seq,
1769
+ totalPackets,
1770
+ streamNonce
1771
+ } = input;
1772
+ const nonce = new Uint8Array(16);
1773
+ getRandomValues(nonce);
1774
+ const rumor = buildSwapRumor({
1775
+ senderPubkey,
1776
+ pair,
1777
+ sourceAmount,
1778
+ packetIndex: seq,
1779
+ totalPackets,
1780
+ nonce,
1781
+ createdAt: Math.floor(Date.now() / 1e3),
1782
+ chainRecipient: params.chainRecipient,
1783
+ streamNonce
1784
+ });
1785
+ const packetExpiresAt = params.packetExpiryMs !== void 0 ? new Date(Date.now() + params.packetExpiryMs) : void 0;
1786
+ const wrapped = wrapSwapPacketToToon({
1787
+ rumor,
1788
+ senderSecretKey: params.senderSecretKey,
1789
+ recipientPubkey: params.swapPubkey,
1790
+ destination: params.swapIlpAddress,
1791
+ amount: sourceAmount,
1792
+ ...packetExpiresAt !== void 0 && { expiresAt: packetExpiresAt }
1793
+ });
1794
+ const toonData = new Uint8Array(
1795
+ Buffer.from(wrapped.ilpPrepare.data, "base64")
1796
+ );
1797
+ return {
1798
+ toonData,
1799
+ ...packetExpiresAt !== void 0 && { packetExpiresAt }
1800
+ };
1801
+ }
1802
+ async function processAcceptedPacket(ctx, args) {
1803
+ const { params, pair, logger } = ctx;
1804
+ const { packetIndex, totalForProgress, sourceAmount, data } = args;
1805
+ let metadata;
1806
+ try {
1807
+ metadata = decodeFulfillMetadata(data, pair.to.chain, {
1808
+ requireQuoteTape: params.minExchangeRate !== void 0
1809
+ });
1810
+ } catch (err) {
1811
+ logger.error({
1812
+ event: "stream_swap.fulfill_decode_failed",
1813
+ packetIndex,
1814
+ error: err instanceof Error ? err.message : String(err)
1815
+ });
1816
+ ctx.errors.push({
1817
+ packetIndex,
1818
+ cause: err instanceof Error ? err : new Error(String(err))
1819
+ });
1820
+ return { status: "error" };
1821
+ }
1822
+ const isEvmTarget = pair.to.chain.startsWith("evm:");
1823
+ const recipientMatches = metadata.recipient === void 0 || (isEvmTarget ? metadata.recipient.toLowerCase() === params.chainRecipient.toLowerCase() : metadata.recipient === params.chainRecipient);
1824
+ if (!recipientMatches) {
1825
+ logger.warn({
1826
+ event: "stream_swap.recipient_mismatch",
1827
+ packetIndex,
1828
+ expected: params.chainRecipient,
1829
+ actual: metadata.recipient
1830
+ });
1831
+ ctx.rejections.push({
1832
+ packetIndex,
1833
+ sourceAmount,
1834
+ code: "SWAP_RECIPIENT_MISMATCH",
1835
+ message: `Swap echoed recipient ${metadata.recipient} but sender expected ${params.chainRecipient}`
1836
+ });
1837
+ return { status: "recipient-mismatch" };
1838
+ }
1839
+ if (params.minExchangeRate !== void 0) {
1840
+ const tapeRate = metadata.rate;
1841
+ const floorTargetAmount = applyRate({
1842
+ sourceAmount,
1843
+ fromScale: pair.from.assetScale,
1844
+ toScale: pair.to.assetScale,
1845
+ rate: params.minExchangeRate
1846
+ });
1847
+ const deliveredTargetAmount = metadata.targetAmount !== void 0 ? BigInt(metadata.targetAmount) : applyRate({
1848
+ sourceAmount,
1849
+ fromScale: pair.from.assetScale,
1850
+ toScale: pair.to.assetScale,
1851
+ rate: tapeRate
1852
+ });
1853
+ const tapeBelowFloor = compareDecimalRates(tapeRate, params.minExchangeRate) < 0;
1854
+ if (tapeBelowFloor || deliveredTargetAmount < floorTargetAmount) {
1855
+ logger.warn({
1856
+ event: "stream_swap.below_floor",
1857
+ packetIndex,
1858
+ rate: tapeRate,
1859
+ rateTimestamp: metadata.rateTimestamp,
1860
+ minExchangeRate: params.minExchangeRate,
1861
+ targetAmount: deliveredTargetAmount.toString(),
1862
+ floorTargetAmount: floorTargetAmount.toString()
1863
+ });
1864
+ ctx.rejections.push({
1865
+ packetIndex,
1866
+ sourceAmount,
1867
+ code: "BELOW_FLOOR",
1868
+ message: `Packet fill below minExchangeRate floor: rate ${tapeRate}, targetAmount ${deliveredTargetAmount} < floor ${params.minExchangeRate} (${floorTargetAmount})`
1869
+ });
1870
+ return { status: "below-floor" };
1871
+ }
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
+ }
1914
+ let claimBytes;
1915
+ try {
1916
+ const ciphertext = new Uint8Array(Buffer.from(metadata.claim, "base64"));
1917
+ claimBytes = decryptFulfillClaim({
1918
+ ciphertext,
1919
+ ephemeralPubkey: metadata.ephemeralPubkey,
1920
+ recipientSecretKey: params.senderSecretKey
1921
+ });
1922
+ } catch (err) {
1923
+ logger.error({
1924
+ event: "stream_swap.decrypt_failed",
1925
+ packetIndex,
1926
+ error: err instanceof Error ? err.message : String(err)
1927
+ });
1928
+ ctx.errors.push({
1929
+ packetIndex,
1930
+ cause: err instanceof Error ? err : new Error(String(err))
1931
+ });
1932
+ return { status: "error" };
1933
+ }
1934
+ if (claimBytes.length === 0) {
1935
+ logger.warn({
1936
+ event: "stream_swap.empty_claim_bytes",
1937
+ packetIndex
1938
+ });
1939
+ }
1940
+ const expectedTargetAmount = applyRate({
1941
+ sourceAmount,
1942
+ fromScale: pair.from.assetScale,
1943
+ toScale: pair.to.assetScale,
1944
+ rate: pair.rate
1945
+ });
1946
+ const targetAmount = metadata.targetAmount !== void 0 ? BigInt(metadata.targetAmount) : expectedTargetAmount;
1947
+ let rateDeviation = 0;
1948
+ if (expectedTargetAmount > 0n) {
1949
+ const diff = targetAmount >= expectedTargetAmount ? targetAmount - expectedTargetAmount : expectedTargetAmount - targetAmount;
1950
+ const scaled = diff * 1000000n / expectedTargetAmount;
1951
+ rateDeviation = Number(scaled) / 1e6;
1952
+ }
1953
+ const advertisedRate = parseFloat(pair.rate);
1954
+ let effectiveRate;
1955
+ if (targetAmount === expectedTargetAmount) {
1956
+ effectiveRate = advertisedRate;
1957
+ } else {
1958
+ const signedDeviation = targetAmount >= expectedTargetAmount ? rateDeviation : -rateDeviation;
1959
+ effectiveRate = advertisedRate * (1 + signedDeviation);
1960
+ }
1961
+ if (!Number.isFinite(effectiveRate)) {
1962
+ effectiveRate = advertisedRate;
1963
+ }
1964
+ ctx.cumulative.source += sourceAmount;
1965
+ ctx.cumulative.target += targetAmount;
1966
+ const accumulated = {
1967
+ packetIndex,
1968
+ sourceAmount,
1969
+ targetAmount,
1970
+ claimBytes,
1971
+ swapEphemeralPubkey: metadata.ephemeralPubkey,
1972
+ pair,
1973
+ receivedAt: Date.now()
1974
+ };
1975
+ if (metadata.claimId !== void 0) accumulated.claimId = metadata.claimId;
1976
+ if (metadata.channelId !== void 0)
1977
+ accumulated.channelId = metadata.channelId;
1978
+ if (metadata.nonce !== void 0) accumulated.nonce = metadata.nonce;
1979
+ if (metadata.cumulativeAmount !== void 0)
1980
+ accumulated.cumulativeAmount = metadata.cumulativeAmount;
1981
+ if (metadata.recipient !== void 0)
1982
+ accumulated.recipient = metadata.recipient;
1983
+ if (metadata.swapSignerAddress !== void 0)
1984
+ accumulated.swapSignerAddress = metadata.swapSignerAddress;
1985
+ if (metadata.rate !== void 0) {
1986
+ accumulated.rate = metadata.rate;
1987
+ accumulated.rateTimestamp = metadata.rateTimestamp;
1988
+ }
1989
+ if (verifiedReceipt !== void 0) accumulated.receipt = verifiedReceipt;
1990
+ ctx.claims.push(accumulated);
1991
+ logger.debug({
1992
+ event: "stream_swap.packet_accepted",
1993
+ packetIndex,
1994
+ sourceAmount: sourceAmount.toString(),
1995
+ targetAmount: targetAmount.toString()
1996
+ });
1997
+ if (params.onPacket) {
1998
+ const progress = Object.freeze({
1999
+ index: packetIndex,
2000
+ total: totalForProgress,
2001
+ sourceAmount,
2002
+ targetAmount,
2003
+ advertisedRate: pair.rate,
2004
+ effectiveRate,
2005
+ rateDeviation,
2006
+ cumulativeSource: ctx.cumulative.source,
2007
+ cumulativeTarget: ctx.cumulative.target,
2008
+ // Issue #82 quote tape: surface the maker's fresh per-packet quote
2009
+ // to the callback (the adaptive-controller seam, toon#83).
2010
+ ...metadata.rate !== void 0 && {
2011
+ rate: metadata.rate,
2012
+ rateTimestamp: metadata.rateTimestamp
2013
+ },
2014
+ // Issue #84: surface the verified per-fulfill receipt to the callback.
2015
+ ...verifiedReceipt !== void 0 && { receipt: verifiedReceipt },
2016
+ state: ctx.getState() === "paused" ? "paused" : ctx.getState() === "stopped" ? "stopped" : "running"
2017
+ });
2018
+ try {
2019
+ const maybePromise = params.onPacket(progress);
2020
+ if (maybePromise && typeof maybePromise.then === "function") {
2021
+ await maybePromise;
2022
+ }
2023
+ } catch (err) {
2024
+ logger.warn({
2025
+ event: "stream_swap.callback_threw",
2026
+ packetIndex,
2027
+ error: err instanceof Error ? err.message : String(err)
2028
+ });
2029
+ ctx.errors.push({
2030
+ packetIndex,
2031
+ cause: err instanceof Error ? err : new Error(String(err))
2032
+ });
2033
+ return { status: "callback-throw" };
2034
+ }
2035
+ }
2036
+ return {
2037
+ status: "accepted",
2038
+ targetAmount,
2039
+ rateDeviation,
2040
+ ...metadata.rate !== void 0 && {
2041
+ rate: metadata.rate,
2042
+ rateTimestamp: metadata.rateTimestamp
2043
+ }
2044
+ };
2045
+ }
2046
+ function finalizeResult(input) {
2047
+ const { claims, rejections, errors, cumulative } = input.ctx;
2048
+ let { abortReason } = input;
2049
+ let finalState;
2050
+ if (abortReason === "aborted" || abortReason === "stopped") {
2051
+ finalState = "stopped";
2052
+ } else if (claims.length === 0 && (rejections.length > 0 || errors.length > 0)) {
2053
+ finalState = "failed";
2054
+ if (abortReason === "complete" && rejections.length > 0 && errors.length === 0) {
2055
+ abortReason = "all-rejected";
2056
+ }
2057
+ } else {
2058
+ finalState = "completed";
2059
+ }
2060
+ input.setState(finalState);
2061
+ return {
2062
+ state: finalState,
2063
+ claims,
2064
+ rejections,
2065
+ errors,
2066
+ abortReason,
2067
+ cumulativeSource: cumulative.source,
2068
+ cumulativeTarget: cumulative.target,
2069
+ packetsSent: input.packetsSent,
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()
2074
+ };
2075
+ }
2076
+ async function runLoop(params, pair, schedule, senderPubkey, streamNonce, logger, getState, setState, waitForResumeOrStop) {
2077
+ const ctx = {
2078
+ params,
2079
+ pair,
2080
+ logger,
2081
+ getState,
2082
+ claims: [],
2083
+ rejections: [],
2084
+ errors: [],
2085
+ cumulative: { source: 0n, target: 0n },
2086
+ receiptTracker: new ReceiptChainTracker({
2087
+ streamNonce,
2088
+ makerPubkey: params.receiptPubkey ?? params.swapPubkey
2089
+ })
2090
+ };
1301
2091
  let packetsSent = 0;
1302
2092
  let abortReason = "complete";
1303
2093
  const totalPackets = schedule.length;
@@ -1329,40 +2119,30 @@ async function runLoop(params, pair, schedule, senderPubkey, logger, getState, s
1329
2119
  `schedule[${packetIndex}] is undefined; schedule was mutated mid-stream`
1330
2120
  );
1331
2121
  }
1332
- const nonce = new Uint8Array(16);
1333
- getRandomValues(nonce);
1334
- const rumor = buildSwapRumor({
1335
- senderPubkey,
1336
- pair,
1337
- sourceAmount,
1338
- packetIndex: packetIndex + 1,
1339
- totalPackets,
1340
- nonce,
1341
- createdAt: Math.floor(Date.now() / 1e3),
1342
- chainRecipient: params.chainRecipient
1343
- });
1344
- let toonData;
2122
+ let built;
1345
2123
  try {
1346
- const wrapped = wrapSwapPacketToToon({
1347
- rumor,
1348
- senderSecretKey: params.senderSecretKey,
1349
- recipientPubkey: params.swapPubkey,
1350
- destination: params.swapIlpAddress,
1351
- amount: sourceAmount
2124
+ built = buildAndWrapPacket({
2125
+ params,
2126
+ pair,
2127
+ senderPubkey,
2128
+ sourceAmount,
2129
+ seq: packetIndex + 1,
2130
+ totalPackets,
2131
+ streamNonce
1352
2132
  });
1353
- toonData = new Uint8Array(Buffer.from(wrapped.ilpPrepare.data, "base64"));
1354
2133
  } catch (err) {
1355
2134
  logger.error({
1356
2135
  event: "stream_swap.wrap_failed",
1357
2136
  packetIndex,
1358
2137
  error: err instanceof Error ? err.message : String(err)
1359
2138
  });
1360
- errors.push({
2139
+ ctx.errors.push({
1361
2140
  packetIndex,
1362
2141
  cause: err instanceof Error ? err : new Error(String(err))
1363
2142
  });
1364
2143
  continue;
1365
2144
  }
2145
+ const { toonData, packetExpiresAt } = built;
1366
2146
  let sendResult;
1367
2147
  try {
1368
2148
  sendResult = await params.client.sendSwapPacket({
@@ -1370,7 +2150,8 @@ async function runLoop(params, pair, schedule, senderPubkey, logger, getState, s
1370
2150
  amount: sourceAmount,
1371
2151
  toonData,
1372
2152
  timeout: params.packetTimeoutMs ?? 3e4,
1373
- claim: params.claim
2153
+ claim: params.claim,
2154
+ ...packetExpiresAt !== void 0 && { expiresAt: packetExpiresAt }
1374
2155
  });
1375
2156
  packetsSent += 1;
1376
2157
  } catch (err) {
@@ -1379,7 +2160,7 @@ async function runLoop(params, pair, schedule, senderPubkey, logger, getState, s
1379
2160
  packetIndex,
1380
2161
  error: err instanceof Error ? err.message : String(err)
1381
2162
  });
1382
- errors.push({
2163
+ ctx.errors.push({
1383
2164
  packetIndex,
1384
2165
  cause: err instanceof Error ? err : new Error(String(err))
1385
2166
  });
@@ -1394,187 +2175,307 @@ async function runLoop(params, pair, schedule, senderPubkey, logger, getState, s
1394
2175
  code,
1395
2176
  message
1396
2177
  });
1397
- rejections.push({ packetIndex, sourceAmount, code, message });
2178
+ ctx.rejections.push({ packetIndex, sourceAmount, code, message });
2179
+ continue;
2180
+ }
2181
+ const outcome = await processAcceptedPacket(ctx, {
2182
+ packetIndex,
2183
+ totalForProgress: totalPackets,
2184
+ sourceAmount,
2185
+ data: sendResult.data
2186
+ });
2187
+ if (outcome.status === "error" || outcome.status === "recipient-mismatch") {
1398
2188
  continue;
1399
2189
  }
1400
- let metadata;
2190
+ if (outcome.status === "below-floor") {
2191
+ abortReason = "below-floor";
2192
+ break packetLoop;
2193
+ }
2194
+ if (outcome.status === "receipt-invalid") {
2195
+ abortReason = "receipt-invalid";
2196
+ break packetLoop;
2197
+ }
2198
+ if (outcome.status === "callback-throw") {
2199
+ abortReason = "callback-throw";
2200
+ break packetLoop;
2201
+ }
2202
+ if (isAborted()) {
2203
+ abortReason = "aborted";
2204
+ break;
2205
+ }
2206
+ if (getState() === "stopped") {
2207
+ abortReason = "stopped";
2208
+ break;
2209
+ }
2210
+ if (params.rateDeviationThreshold !== void 0 && outcome.rateDeviation > params.rateDeviationThreshold) {
2211
+ abortReason = "rate-deviation";
2212
+ break;
2213
+ }
2214
+ }
2215
+ return finalizeResult({
2216
+ ctx,
2217
+ abortReason,
2218
+ packetsSent,
2219
+ packetsScheduled: totalPackets,
2220
+ setState
2221
+ });
2222
+ }
2223
+ function classifySendError(err) {
2224
+ return /timeout|timed out|expire/i.test(err.message) ? "timeout" : "error";
2225
+ }
2226
+ function classifyReject(code, message) {
2227
+ if (code === "T99" || /stale[_-]?rate/i.test(message)) return "reject-stale";
2228
+ if (code.startsWith("R") || /timeout|timed out|expire/i.test(message)) {
2229
+ return "timeout";
2230
+ }
2231
+ return "reject";
2232
+ }
2233
+ async function runAdaptiveLoop(params, pair, senderPubkey, streamNonce, logger, getState, setState, waitForResumeOrStop) {
2234
+ const controller = params.controller;
2235
+ const ctx = {
2236
+ params,
2237
+ pair,
2238
+ logger,
2239
+ getState,
2240
+ claims: [],
2241
+ rejections: [],
2242
+ errors: [],
2243
+ cumulative: { source: 0n, target: 0n },
2244
+ receiptTracker: new ReceiptChainTracker({
2245
+ streamNonce,
2246
+ makerPubkey: params.receiptPubkey ?? params.swapPubkey
2247
+ })
2248
+ };
2249
+ let packetsSent = 0;
2250
+ let abortReason = "complete";
2251
+ let halted = false;
2252
+ let remaining = params.totalAmount;
2253
+ let nextIndex = 0;
2254
+ const isAborted = () => params.signal?.aborted === true;
2255
+ const halt = (reason) => {
2256
+ if (!halted) {
2257
+ halted = true;
2258
+ abortReason = reason;
2259
+ }
2260
+ };
2261
+ const observeSafe = async (obs) => {
1401
2262
  try {
1402
- metadata = decodeFulfillMetadata(sendResult.data, pair.to.chain);
2263
+ await controller.observe(obs);
1403
2264
  } catch (err) {
1404
- logger.error({
1405
- event: "stream_swap.fulfill_decode_failed",
1406
- packetIndex,
2265
+ logger.warn({
2266
+ event: "stream_swap.controller_observe_failed",
1407
2267
  error: err instanceof Error ? err.message : String(err)
1408
2268
  });
1409
- errors.push({
1410
- packetIndex,
1411
- cause: err instanceof Error ? err : new Error(String(err))
1412
- });
1413
- continue;
1414
2269
  }
1415
- const isEvmTarget = pair.to.chain.startsWith("evm:");
1416
- const recipientMatches = metadata.recipient === void 0 || (isEvmTarget ? metadata.recipient.toLowerCase() === params.chainRecipient.toLowerCase() : metadata.recipient === params.chainRecipient);
1417
- if (!recipientMatches) {
1418
- logger.warn({
1419
- event: "stream_swap.recipient_mismatch",
1420
- packetIndex,
1421
- expected: params.chainRecipient,
1422
- actual: metadata.recipient
1423
- });
1424
- rejections.push({
1425
- packetIndex,
1426
- sourceAmount,
1427
- code: "SWAP_RECIPIENT_MISMATCH",
1428
- message: `Swap echoed recipient ${metadata.recipient} but sender expected ${params.chainRecipient}`
1429
- });
1430
- continue;
2270
+ };
2271
+ const inflight = /* @__PURE__ */ new Map();
2272
+ for (; ; ) {
2273
+ if (!halted && isAborted()) halt("aborted");
2274
+ if (!halted && getState() === "stopped") halt("stopped");
2275
+ if (!halted && getState() === "paused" && inflight.size === 0) {
2276
+ const resumedBy = await waitForResumeOrStop();
2277
+ if (resumedBy === "stop" || getState() === "stopped") halt("stopped");
2278
+ else if (isAborted()) halt("aborted");
2279
+ }
2280
+ if (!halted && getState() === "running") {
2281
+ const rawWindow = controller.window;
2282
+ const window = Number.isFinite(rawWindow) && rawWindow >= 1 ? Math.floor(rawWindow) : 1;
2283
+ while (remaining > 0n && inflight.size < window) {
2284
+ let delta;
2285
+ try {
2286
+ delta = controller.nextDelta(remaining);
2287
+ } catch (err) {
2288
+ logger.error({
2289
+ event: "stream_swap.controller_next_delta_failed",
2290
+ error: err instanceof Error ? err.message : String(err)
2291
+ });
2292
+ ctx.errors.push({
2293
+ packetIndex: nextIndex,
2294
+ cause: err instanceof Error ? err : new Error(String(err))
2295
+ });
2296
+ halt("callback-throw");
2297
+ break;
2298
+ }
2299
+ if (typeof delta !== "bigint" || delta < 1n) delta = 1n;
2300
+ if (delta > remaining) delta = remaining;
2301
+ const packetIndex = nextIndex;
2302
+ nextIndex += 1;
2303
+ remaining -= delta;
2304
+ let built;
2305
+ try {
2306
+ built = buildAndWrapPacket({
2307
+ params,
2308
+ pair,
2309
+ senderPubkey,
2310
+ sourceAmount: delta,
2311
+ seq: packetIndex + 1,
2312
+ // Adaptive mode: the final packet count is unknown upfront —
2313
+ // `0` in the rumor's `seq` tag total position means "open".
2314
+ totalPackets: 0,
2315
+ streamNonce
2316
+ });
2317
+ } catch (err) {
2318
+ logger.error({
2319
+ event: "stream_swap.wrap_failed",
2320
+ packetIndex,
2321
+ error: err instanceof Error ? err.message : String(err)
2322
+ });
2323
+ ctx.errors.push({
2324
+ packetIndex,
2325
+ cause: err instanceof Error ? err : new Error(String(err))
2326
+ });
2327
+ continue;
2328
+ }
2329
+ const { toonData, packetExpiresAt } = built;
2330
+ const sentAt = Date.now();
2331
+ const promise = (async () => {
2332
+ try {
2333
+ const result = await params.client.sendSwapPacket({
2334
+ destination: params.swapIlpAddress,
2335
+ amount: delta,
2336
+ toonData,
2337
+ timeout: params.packetTimeoutMs ?? 3e4,
2338
+ claim: params.claim,
2339
+ ...packetExpiresAt !== void 0 && {
2340
+ expiresAt: packetExpiresAt
2341
+ }
2342
+ });
2343
+ return {
2344
+ index: packetIndex,
2345
+ sourceAmount: delta,
2346
+ rttMs: Date.now() - sentAt,
2347
+ result
2348
+ };
2349
+ } catch (err) {
2350
+ return {
2351
+ index: packetIndex,
2352
+ sourceAmount: delta,
2353
+ rttMs: Date.now() - sentAt,
2354
+ error: err instanceof Error ? err : new Error(String(err))
2355
+ };
2356
+ }
2357
+ })();
2358
+ inflight.set(packetIndex, promise);
2359
+ }
1431
2360
  }
1432
- let claimBytes;
1433
- try {
1434
- const ciphertext = new Uint8Array(Buffer.from(metadata.claim, "base64"));
1435
- claimBytes = decryptFulfillClaim({
1436
- ciphertext,
1437
- ephemeralPubkey: metadata.ephemeralPubkey,
1438
- recipientSecretKey: params.senderSecretKey
1439
- });
1440
- } catch (err) {
2361
+ if (inflight.size === 0) {
2362
+ if (halted || remaining <= 0n) break;
2363
+ if (getState() === "paused") continue;
2364
+ break;
2365
+ }
2366
+ const settled = await Promise.race(inflight.values());
2367
+ inflight.delete(settled.index);
2368
+ if (settled.error) {
1441
2369
  logger.error({
1442
- event: "stream_swap.decrypt_failed",
1443
- packetIndex,
1444
- error: err instanceof Error ? err.message : String(err)
2370
+ event: "stream_swap.send_failed",
2371
+ packetIndex: settled.index,
2372
+ error: settled.error.message
1445
2373
  });
1446
- errors.push({
1447
- packetIndex,
1448
- cause: err instanceof Error ? err : new Error(String(err))
2374
+ ctx.errors.push({ packetIndex: settled.index, cause: settled.error });
2375
+ await observeSafe({
2376
+ resolution: classifySendError(settled.error),
2377
+ rttMs: settled.rttMs,
2378
+ remaining
1449
2379
  });
1450
2380
  continue;
1451
2381
  }
1452
- if (claimBytes.length === 0) {
2382
+ packetsSent += 1;
2383
+ const sendResult = settled.result;
2384
+ if (!sendResult.accepted) {
2385
+ const code = sendResult.code ?? "F00";
2386
+ const message = sendResult.message ?? "rejected";
1453
2387
  logger.warn({
1454
- event: "stream_swap.empty_claim_bytes",
1455
- packetIndex
2388
+ event: "stream_swap.packet_rejected",
2389
+ packetIndex: settled.index,
2390
+ code,
2391
+ message
1456
2392
  });
2393
+ ctx.rejections.push({
2394
+ packetIndex: settled.index,
2395
+ sourceAmount: settled.sourceAmount,
2396
+ code,
2397
+ message
2398
+ });
2399
+ await observeSafe({
2400
+ resolution: classifyReject(code, message),
2401
+ rttMs: settled.rttMs,
2402
+ remaining
2403
+ });
2404
+ continue;
1457
2405
  }
1458
- const expectedTargetAmount = applyRate({
1459
- sourceAmount,
1460
- fromScale: pair.from.assetScale,
1461
- toScale: pair.to.assetScale,
1462
- rate: pair.rate
1463
- });
1464
- const targetAmount = metadata.targetAmount !== void 0 ? BigInt(metadata.targetAmount) : expectedTargetAmount;
1465
- let rateDeviation = 0;
1466
- if (expectedTargetAmount > 0n) {
1467
- const diff = targetAmount >= expectedTargetAmount ? targetAmount - expectedTargetAmount : expectedTargetAmount - targetAmount;
1468
- const scaled = diff * 1000000n / expectedTargetAmount;
1469
- rateDeviation = Number(scaled) / 1e6;
1470
- }
1471
- const advertisedRate = parseFloat(pair.rate);
1472
- let effectiveRate;
1473
- if (targetAmount === expectedTargetAmount) {
1474
- effectiveRate = advertisedRate;
1475
- } else {
1476
- const signedDeviation = targetAmount >= expectedTargetAmount ? rateDeviation : -rateDeviation;
1477
- effectiveRate = advertisedRate * (1 + signedDeviation);
1478
- }
1479
- if (!Number.isFinite(effectiveRate)) {
1480
- effectiveRate = advertisedRate;
1481
- }
1482
- cumulativeSource += sourceAmount;
1483
- cumulativeTarget += targetAmount;
1484
- const accumulated = {
1485
- packetIndex,
1486
- sourceAmount,
1487
- targetAmount,
1488
- claimBytes,
1489
- swapEphemeralPubkey: metadata.ephemeralPubkey,
1490
- pair,
1491
- receivedAt: Date.now()
1492
- };
1493
- if (metadata.claimId !== void 0) accumulated.claimId = metadata.claimId;
1494
- if (metadata.channelId !== void 0)
1495
- accumulated.channelId = metadata.channelId;
1496
- if (metadata.nonce !== void 0) accumulated.nonce = metadata.nonce;
1497
- if (metadata.cumulativeAmount !== void 0)
1498
- accumulated.cumulativeAmount = metadata.cumulativeAmount;
1499
- if (metadata.recipient !== void 0)
1500
- accumulated.recipient = metadata.recipient;
1501
- if (metadata.swapSignerAddress !== void 0)
1502
- accumulated.swapSignerAddress = metadata.swapSignerAddress;
1503
- claims.push(accumulated);
1504
- logger.debug({
1505
- event: "stream_swap.packet_accepted",
1506
- packetIndex,
1507
- sourceAmount: sourceAmount.toString(),
1508
- targetAmount: targetAmount.toString()
2406
+ const outcome = await processAcceptedPacket(ctx, {
2407
+ packetIndex: settled.index,
2408
+ totalForProgress: nextIndex,
2409
+ sourceAmount: settled.sourceAmount,
2410
+ data: sendResult.data
1509
2411
  });
1510
- if (params.onPacket) {
1511
- const progress = Object.freeze({
1512
- index: packetIndex,
1513
- total: totalPackets,
1514
- sourceAmount,
1515
- targetAmount,
1516
- advertisedRate: pair.rate,
1517
- effectiveRate,
1518
- rateDeviation,
1519
- cumulativeSource,
1520
- cumulativeTarget,
1521
- state: getState() === "paused" ? "paused" : getState() === "stopped" ? "stopped" : "running"
1522
- });
1523
- try {
1524
- const maybePromise = params.onPacket(progress);
1525
- if (maybePromise && typeof maybePromise.then === "function") {
1526
- await maybePromise;
1527
- }
1528
- } catch (err) {
1529
- logger.warn({
1530
- event: "stream_swap.callback_threw",
1531
- packetIndex,
1532
- error: err instanceof Error ? err.message : String(err)
2412
+ switch (outcome.status) {
2413
+ case "error":
2414
+ await observeSafe({
2415
+ resolution: "error",
2416
+ rttMs: settled.rttMs,
2417
+ remaining
1533
2418
  });
1534
- errors.push({
1535
- packetIndex,
1536
- cause: err instanceof Error ? err : new Error(String(err))
2419
+ break;
2420
+ case "recipient-mismatch":
2421
+ await observeSafe({
2422
+ resolution: "reject",
2423
+ rttMs: settled.rttMs,
2424
+ remaining
2425
+ });
2426
+ break;
2427
+ case "below-floor":
2428
+ await observeSafe({
2429
+ resolution: "reject",
2430
+ rttMs: settled.rttMs,
2431
+ remaining
1537
2432
  });
1538
- abortReason = "callback-throw";
1539
- break packetLoop;
2433
+ halt("below-floor");
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;
2443
+ case "callback-throw":
2444
+ await observeSafe({
2445
+ resolution: "fulfill",
2446
+ rttMs: settled.rttMs,
2447
+ remaining
2448
+ });
2449
+ halt("callback-throw");
2450
+ break;
2451
+ case "accepted": {
2452
+ await observeSafe({
2453
+ resolution: "fulfill",
2454
+ rttMs: settled.rttMs,
2455
+ remaining,
2456
+ sourceAmount: settled.sourceAmount,
2457
+ targetAmount: outcome.targetAmount,
2458
+ ...outcome.rate !== void 0 && {
2459
+ rate: outcome.rate,
2460
+ rateTimestamp: outcome.rateTimestamp
2461
+ }
2462
+ });
2463
+ if (isAborted()) halt("aborted");
2464
+ else if (getState() === "stopped") halt("stopped");
2465
+ else if (params.rateDeviationThreshold !== void 0 && outcome.rateDeviation > params.rateDeviationThreshold) {
2466
+ halt("rate-deviation");
2467
+ }
2468
+ break;
1540
2469
  }
1541
2470
  }
1542
- if (isAborted()) {
1543
- abortReason = "aborted";
1544
- break;
1545
- }
1546
- if (getState() === "stopped") {
1547
- abortReason = "stopped";
1548
- break;
1549
- }
1550
- if (params.rateDeviationThreshold !== void 0 && rateDeviation > params.rateDeviationThreshold) {
1551
- abortReason = "rate-deviation";
1552
- break;
1553
- }
1554
2471
  }
1555
- let finalState;
1556
- if (abortReason === "aborted" || abortReason === "stopped") {
1557
- finalState = "stopped";
1558
- } else if (claims.length === 0 && (rejections.length > 0 || errors.length > 0)) {
1559
- finalState = "failed";
1560
- if (abortReason === "complete" && rejections.length > 0 && errors.length === 0) {
1561
- abortReason = "all-rejected";
1562
- }
1563
- } else {
1564
- finalState = "completed";
1565
- }
1566
- setState(finalState);
1567
- return {
1568
- state: finalState,
1569
- claims,
1570
- rejections,
1571
- errors,
2472
+ return finalizeResult({
2473
+ ctx,
1572
2474
  abortReason,
1573
- cumulativeSource,
1574
- cumulativeTarget,
1575
2475
  packetsSent,
1576
- packetsScheduled: totalPackets
1577
- };
2476
+ packetsScheduled: nextIndex,
2477
+ setState
2478
+ });
1578
2479
  }
1579
2480
  function getRandomValues(buf) {
1580
2481
  const g = globalThis;
@@ -1599,7 +2500,8 @@ function getRandomValues(buf) {
1599
2500
  var __testing = {
1600
2501
  chunkAmount,
1601
2502
  decodeFulfillMetadata,
1602
- buildSwapRumor
2503
+ buildSwapRumor,
2504
+ compareDecimalRates
1603
2505
  };
1604
2506
 
1605
2507
  export {
@@ -1625,6 +2527,18 @@ export {
1625
2527
  unwrapSwapPacketFromToon,
1626
2528
  encryptFulfillClaim,
1627
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,
1628
2542
  SWAP_HANDLER_REJECT_CODES,
1629
2543
  SWAP_HANDLER_REJECT_MESSAGES,
1630
2544
  applyRate,
@@ -1634,4 +2548,4 @@ export {
1634
2548
  streamSwapControlled,
1635
2549
  __testing
1636
2550
  };
1637
- //# sourceMappingURL=chunk-Z6S56VPV.js.map
2551
+ //# sourceMappingURL=chunk-RAZRWSJF.js.map