@unicitylabs/sphere-sdk 0.7.1-dev.3 → 0.7.2

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.
@@ -4837,6 +4837,27 @@ function verifySignedMessage(message, signature, expectedPubkey) {
4837
4837
  return false;
4838
4838
  }
4839
4839
  }
4840
+ function recoverPubkeyFromSignature(message, signature) {
4841
+ if (signature.length !== 130) {
4842
+ throw new SphereError(
4843
+ `Invalid signature length: expected 130 hex chars, got ${signature.length}`,
4844
+ "SIGNING_ERROR"
4845
+ );
4846
+ }
4847
+ const v = parseInt(signature.slice(0, 2), 16) - 31;
4848
+ const r = signature.slice(2, 66);
4849
+ const s = signature.slice(66, 130);
4850
+ if (v < 0 || v > 3) {
4851
+ throw new SphereError(
4852
+ `Invalid recovery byte: v=${v} out of range [0..3]`,
4853
+ "SIGNING_ERROR"
4854
+ );
4855
+ }
4856
+ const hashHex = hashSignMessage(message);
4857
+ const hashBytes = Buffer.from(hashHex, "hex");
4858
+ const recovered = ec.recoverPubKey(hashBytes, { r, s }, v);
4859
+ return recovered.encode("hex", true);
4860
+ }
4840
4861
 
4841
4862
  // l1/crypto.ts
4842
4863
  import CryptoJS3 from "crypto-js";
@@ -12084,6 +12105,132 @@ var PaymentsModule = class _PaymentsModule {
12084
12105
  };
12085
12106
  }
12086
12107
  }
12108
+ /**
12109
+ * Mint a fungible token directly to this wallet (genesis mint).
12110
+ *
12111
+ * Useful for test setups that need to seed a wallet with specific token
12112
+ * balances WITHOUT depending on the testnet faucet HTTP service. The
12113
+ * resulting token has the canonical CoinId bytes (passed in `coinIdHex`)
12114
+ * — when those bytes match a registered symbol in the TokenRegistry,
12115
+ * the token shows up under the symbol's name (e.g. "UCT"). There is no
12116
+ * cryptographic restriction on which key may issue a given CoinId; the
12117
+ * aggregator records the mint regardless of issuer identity.
12118
+ *
12119
+ * The flow:
12120
+ * 1. Generate a random TokenId.
12121
+ * 2. Build TokenCoinData with [(coinId, amount)].
12122
+ * 3. Build MintTransactionData with recipient = self (UnmaskedPredicate
12123
+ * from this wallet's signing service).
12124
+ * 4. Submit MintCommitment to the aggregator.
12125
+ * 5. Wait for the inclusion proof.
12126
+ * 6. Construct an SDK Token via Token.mint().
12127
+ * 7. Convert to wallet Token format and call addToken().
12128
+ *
12129
+ * @param coinIdHex - 64-char lowercase hex CoinId. Must match the bytes
12130
+ * used by the registered symbol if you want the wallet to recognize
12131
+ * the token as that symbol (e.g. UCT's coinId from the public registry).
12132
+ * @param amount - Amount in smallest units (multiply by 10^decimals
12133
+ * when converting from human values).
12134
+ * @returns Result with the resulting wallet Token and its on-chain id.
12135
+ */
12136
+ async mintFungibleToken(coinIdHex, amount) {
12137
+ this.ensureInitialized();
12138
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
12139
+ if (!stClient) {
12140
+ return { success: false, error: "State transition client not available" };
12141
+ }
12142
+ const trustBase = this.deps.oracle.getTrustBase?.();
12143
+ if (!trustBase) {
12144
+ return { success: false, error: "Trust base not available" };
12145
+ }
12146
+ try {
12147
+ const signingService = await this.createSigningService();
12148
+ const { TokenId: TokenId5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenId");
12149
+ const { TokenCoinData: TokenCoinData3 } = await import("@unicitylabs/state-transition-sdk/lib/token/fungible/TokenCoinData");
12150
+ const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
12151
+ const tokenTypeBytes = fromHex4("f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509");
12152
+ const tokenType = new TokenType3(tokenTypeBytes);
12153
+ const tokenIdBytes = new Uint8Array(32);
12154
+ crypto.getRandomValues(tokenIdBytes);
12155
+ const tokenId = new TokenId5(tokenIdBytes);
12156
+ const coinIdBytes = fromHex4(coinIdHex);
12157
+ const coinId = new CoinId4(coinIdBytes);
12158
+ const coinData = TokenCoinData3.create([[coinId, amount]]);
12159
+ const addressRef = await UnmaskedPredicateReference4.create(
12160
+ tokenType,
12161
+ signingService.algorithm,
12162
+ signingService.publicKey,
12163
+ HashAlgorithm5.SHA256
12164
+ );
12165
+ const ownerAddress = await addressRef.toAddress();
12166
+ const salt = new Uint8Array(32);
12167
+ crypto.getRandomValues(salt);
12168
+ const mintData = await MintTransactionData3.create(
12169
+ tokenId,
12170
+ tokenType,
12171
+ null,
12172
+ // tokenData: no metadata
12173
+ coinData,
12174
+ // fungible coin data
12175
+ ownerAddress,
12176
+ // recipient = self
12177
+ salt,
12178
+ null,
12179
+ // recipientDataHash
12180
+ null
12181
+ // reason: null (genesis, no burn predecessor)
12182
+ );
12183
+ const commitment = await MintCommitment3.create(mintData);
12184
+ const MAX_RETRIES = 3;
12185
+ let lastStatus;
12186
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
12187
+ const response = await stClient.submitMintCommitment(commitment);
12188
+ lastStatus = response.status;
12189
+ if (response.status === "SUCCESS" || response.status === "REQUEST_ID_EXISTS") break;
12190
+ if (attempt === MAX_RETRIES) {
12191
+ return { success: false, error: `Mint submit failed after ${MAX_RETRIES} attempts: ${response.status}` };
12192
+ }
12193
+ await new Promise((r) => setTimeout(r, 1e3 * attempt));
12194
+ }
12195
+ if (lastStatus !== "SUCCESS" && lastStatus !== "REQUEST_ID_EXISTS") {
12196
+ return { success: false, error: `Mint submit failed: ${lastStatus}` };
12197
+ }
12198
+ const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
12199
+ const genesisTransaction = commitment.toTransaction(inclusionProof);
12200
+ const predicate = await UnmaskedPredicate5.create(
12201
+ tokenId,
12202
+ tokenType,
12203
+ signingService,
12204
+ HashAlgorithm5.SHA256,
12205
+ salt
12206
+ );
12207
+ const tokenState = new TokenState5(predicate, null);
12208
+ const sdkToken = await SdkToken2.mint(trustBase, tokenState, genesisTransaction);
12209
+ const tokenIdHex = tokenId.toJSON();
12210
+ const symbol = this.getCoinSymbol(coinIdHex);
12211
+ const name = this.getCoinName(coinIdHex);
12212
+ const decimals = this.getCoinDecimals(coinIdHex);
12213
+ const iconUrl = this.getCoinIconUrl(coinIdHex);
12214
+ const uiToken = {
12215
+ id: tokenIdHex,
12216
+ coinId: coinIdHex,
12217
+ symbol,
12218
+ name,
12219
+ decimals,
12220
+ ...iconUrl !== void 0 ? { iconUrl } : {},
12221
+ amount: amount.toString(),
12222
+ status: "confirmed",
12223
+ createdAt: Date.now(),
12224
+ updatedAt: Date.now(),
12225
+ sdkData: JSON.stringify(sdkToken.toJSON())
12226
+ };
12227
+ await this.addToken(uiToken);
12228
+ return { success: true, token: uiToken, tokenId: tokenIdHex };
12229
+ } catch (err) {
12230
+ const msg = err instanceof Error ? err.message : String(err);
12231
+ return { success: false, error: `Local mint failed: ${msg}` };
12232
+ }
12233
+ }
12087
12234
  /**
12088
12235
  * Check if a nametag is available for minting
12089
12236
  * @param nametag - The nametag to check (e.g., "alice" or "@alice")
@@ -18294,6 +18441,24 @@ var AccountingModule = class _AccountingModule {
18294
18441
  dirtyLedgerEntries = /* @__PURE__ */ new Set();
18295
18442
  /** Count of unknown (not in invoiceTermsCache) invoice IDs in the ledger. */
18296
18443
  unknownLedgerCount = 0;
18444
+ /**
18445
+ * Per-unknown-invoice first-seen timestamp for TTL eviction.
18446
+ *
18447
+ * W1 (steelman round-4): without TTL, an attacker who can deliver 500
18448
+ * inbound transfers with synthesized memo invoiceIds permanently exhausts
18449
+ * the unknown-ledger cap, after which legitimate orphan transfers (out-of-
18450
+ * order delivery for real swaps) are silently dropped at the cap-check.
18451
+ *
18452
+ * Round-5 perf: gated by `unknownLedgerNextSweepMs` to amortize the
18453
+ * sweep cost. The naive every-call sweep is O(N) where N=cap=500;
18454
+ * combined with the per-token cleanup loop inside the sweep it became
18455
+ * O(N×M) on every transfer under flood. Now we sweep at most every
18456
+ * `UNKNOWN_LEDGER_SWEEP_INTERVAL_MS` (60s) UNLESS the cap is currently
18457
+ * full, in which case we sweep on each call (the only path that can
18458
+ * actually drop a legitimate orphan).
18459
+ */
18460
+ unknownLedgerFirstSeen = /* @__PURE__ */ new Map();
18461
+ unknownLedgerNextSweepMs = 0;
18297
18462
  /** W17: Tracks whether tokenScanState has been mutated since last flush. */
18298
18463
  tokenScanDirty = false;
18299
18464
  /** W2 fix: Serialization guard for _flushDirtyLedgerEntries. */
@@ -19284,6 +19449,7 @@ var AccountingModule = class _AccountingModule {
19284
19449
  }
19285
19450
  if (this.invoiceLedger.has(tokenId) && !this.invoiceTermsCache.has(tokenId)) {
19286
19451
  this.unknownLedgerCount = Math.max(0, this.unknownLedgerCount - 1);
19452
+ this.unknownLedgerFirstSeen.delete(tokenId);
19287
19453
  }
19288
19454
  this.invoiceTermsCache.set(tokenId, terms);
19289
19455
  this._addToHashIndex(tokenId);
@@ -19552,6 +19718,29 @@ var AccountingModule = class _AccountingModule {
19552
19718
  closed: this.closedInvoices.has(invoiceId)
19553
19719
  };
19554
19720
  }
19721
+ /**
19722
+ * Return the set of token IDs that are currently linked to the given
19723
+ * invoice. Populated by both the on-chain `_processTokenTransactions`
19724
+ * path (tokens with `inv:` references) and the transport-memo orphan
19725
+ * buffering path in `_handleIncomingTransfer`.
19726
+ *
19727
+ * Used by callers that want to scope per-invoice operations (e.g.
19728
+ * SwapModule.verifyPayout's L3 validation) to only the tokens that
19729
+ * cover this invoice — avoiding false negatives when the wallet
19730
+ * contains unrelated tokens of the same currency in unconfirmed or
19731
+ * spent state.
19732
+ *
19733
+ * Returns an empty set if no tokens are currently linked.
19734
+ */
19735
+ getTokenIdsForInvoice(invoiceId) {
19736
+ const result = /* @__PURE__ */ new Set();
19737
+ for (const [tokenId, invoiceIds] of this.tokenInvoiceMap) {
19738
+ if (invoiceIds.has(invoiceId)) {
19739
+ result.add(tokenId);
19740
+ }
19741
+ }
19742
+ return result;
19743
+ }
19555
19744
  /**
19556
19745
  * Explicitly close an invoice. Only target parties may close (§8.3).
19557
19746
  *
@@ -19871,6 +20060,7 @@ var AccountingModule = class _AccountingModule {
19871
20060
  ledger.set(entryKey, forwardRef);
19872
20061
  this.dirtyLedgerEntries.add(invoiceId);
19873
20062
  this.balanceCache.delete(invoiceId);
20063
+ await this._persistProvisionalAndVerify(invoiceId, "payInvoice");
19874
20064
  }
19875
20065
  return result;
19876
20066
  } finally {
@@ -20038,6 +20228,7 @@ var AccountingModule = class _AccountingModule {
20038
20228
  this.dirtyLedgerEntries.add(invoiceId);
20039
20229
  }
20040
20230
  this.balanceCache.delete(invoiceId);
20231
+ await this._persistProvisionalAndVerify(invoiceId, "returnInvoicePayment");
20041
20232
  }
20042
20233
  return result;
20043
20234
  } finally {
@@ -21117,9 +21308,30 @@ var AccountingModule = class _AccountingModule {
21117
21308
  continue;
21118
21309
  }
21119
21310
  innerMap.set(entryKey, ref);
21120
- if (!ref.transferId.startsWith("provisional:") && ref.transferId.includes(":")) {
21311
+ const HEX_64 = /^[a-f0-9]{64}$/i;
21312
+ if (entryKey.startsWith("mt:")) {
21313
+ const firstColon = entryKey.indexOf(":");
21314
+ const secondColon = entryKey.indexOf(":", firstColon + 1);
21315
+ if (secondColon > firstColon + 1) {
21316
+ const tokenIdFromKey2 = entryKey.slice(firstColon + 1, secondColon);
21317
+ if (HEX_64.test(tokenIdFromKey2)) {
21318
+ this._addToTokenInvoiceMap(tokenIdFromKey2, invoiceId);
21319
+ }
21320
+ }
21321
+ } else if (entryKey.startsWith("synthetic:")) {
21322
+ const afterPrefix = entryKey.slice("synthetic:".length);
21323
+ const tokenIdEnd = afterPrefix.indexOf(":");
21324
+ if (tokenIdEnd > 0) {
21325
+ const tokenId = afterPrefix.slice(0, tokenIdEnd);
21326
+ if (HEX_64.test(tokenId) && ref.transferId !== tokenId) {
21327
+ this._addToTokenInvoiceMap(tokenId, invoiceId);
21328
+ }
21329
+ }
21330
+ } else if (!ref.transferId.startsWith("provisional:") && ref.transferId.includes(":")) {
21121
21331
  const tokenIdFromRef = ref.transferId.slice(0, ref.transferId.indexOf(":"));
21122
- this._addToTokenInvoiceMap(tokenIdFromRef, invoiceId);
21332
+ if (HEX_64.test(tokenIdFromRef)) {
21333
+ this._addToTokenInvoiceMap(tokenIdFromRef, invoiceId);
21334
+ }
21123
21335
  }
21124
21336
  }
21125
21337
  } catch (err) {
@@ -21320,7 +21532,14 @@ var AccountingModule = class _AccountingModule {
21320
21532
  }
21321
21533
  }
21322
21534
  for (const [existingKey, existingRef] of ledger) {
21323
- if (existingKey.startsWith("synthetic:") && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21535
+ if ((existingKey.startsWith("synthetic:") || existingKey.startsWith("synthetic-tx:")) && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21536
+ keysToDelete.push(existingKey);
21537
+ break;
21538
+ }
21539
+ }
21540
+ const mtPrefix = `mt:${tokenId}:`;
21541
+ for (const [existingKey, existingRef] of ledger) {
21542
+ if (existingKey.startsWith(mtPrefix) && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21324
21543
  keysToDelete.push(existingKey);
21325
21544
  break;
21326
21545
  }
@@ -21437,6 +21656,49 @@ var AccountingModule = class _AccountingModule {
21437
21656
  });
21438
21657
  }
21439
21658
  }
21659
+ /**
21660
+ * Synchronously persist any pending provisional ledger entry for `invoiceId`
21661
+ * before returning to the caller. Used by `payInvoice` and
21662
+ * `returnInvoicePayment` to make the in-memory provisional entry durable
21663
+ * inside the same per-invoice gate that wrote it, closing the
21664
+ * crash-mid-conclude race that produces over-coverage on receivers.
21665
+ *
21666
+ * Implementation:
21667
+ * 1. Schedule a flush via the existing `_flushPromise` chain (so
21668
+ * concurrent `_handleTokenChange` callers waiting on the chain
21669
+ * observe ours as part of the sequence).
21670
+ * 2. Await OUR flush directly — NOT `_drainFlushPromise()`, which would
21671
+ * spin while concurrent token changes keep extending the chain and
21672
+ * hold the per-invoice gate for an unbounded number of additional
21673
+ * flushes. We only need OUR provisional entry durable.
21674
+ * 3. `_flushDirtyLedgerEntries` swallows per-invoice `storage.set`
21675
+ * rejections internally (sets a local `step1Failed` flag), leaving
21676
+ * the dirty entry on the set without re-throwing. So we post-check
21677
+ * `dirtyLedgerEntries.has(invoiceId)` and throw a `STORAGE_ERROR`
21678
+ * `SphereError` if our entry is still dirty — propagating to the
21679
+ * caller so they learn about the durability failure rather than
21680
+ * receiving a silent "success" return that lies on disk.
21681
+ *
21682
+ * @param invoiceId The invoice whose provisional entry must be durable.
21683
+ * @param callContext Used in the error message so the caller is named
21684
+ * ('payInvoice' / 'returnInvoicePayment') without
21685
+ * forcing a stack-trace inspection.
21686
+ */
21687
+ async _persistProvisionalAndVerify(invoiceId, callContext) {
21688
+ const flushTrigger = (this._flushPromise ?? Promise.resolve()).then(() => this._flushDirtyLedgerEntries());
21689
+ const tracked = flushTrigger.catch(() => {
21690
+ }).finally(() => {
21691
+ if (this._flushPromise === tracked) this._flushPromise = null;
21692
+ });
21693
+ this._flushPromise = tracked;
21694
+ await flushTrigger;
21695
+ if (this.dirtyLedgerEntries.has(invoiceId)) {
21696
+ throw new SphereError(
21697
+ `${callContext}: provisional ledger entry for invoice ${invoiceId} failed to persist \u2014 caller should retry`,
21698
+ "STORAGE_ERROR"
21699
+ );
21700
+ }
21701
+ }
21440
21702
  // ===========================================================================
21441
21703
  // Internal: Event handlers
21442
21704
  // ===========================================================================
@@ -21497,13 +21759,96 @@ var AccountingModule = class _AccountingModule {
21497
21759
  }
21498
21760
  }
21499
21761
  if (!this.invoiceTermsCache.has(invoiceId)) {
21500
- const syntheticRef = this._buildSyntheticTransferRef(
21501
- transfer,
21502
- invoiceId,
21503
- paymentDirection,
21504
- confirmed
21505
- );
21506
- deps.emitEvent("invoice:unknown_reference", { invoiceId, transfer: syntheticRef });
21762
+ let gracefullyGraduated = false;
21763
+ await this.withInvoiceGate(invoiceId, async () => {
21764
+ if (this.invoiceTermsCache.has(invoiceId)) {
21765
+ gracefullyGraduated = true;
21766
+ return;
21767
+ }
21768
+ const syntheticRef = this._buildSyntheticTransferRef(
21769
+ transfer,
21770
+ invoiceId,
21771
+ paymentDirection,
21772
+ confirmed
21773
+ );
21774
+ deps.emitEvent("invoice:unknown_reference", { invoiceId, transfer: syntheticRef });
21775
+ const MAX_UNKNOWN_INVOICE_IDS = 500;
21776
+ const UNKNOWN_LEDGER_TTL_MS = 30 * 60 * 1e3;
21777
+ const MAX_ORPHAN_ENTRIES_PER_INVOICE = 50;
21778
+ const UNKNOWN_LEDGER_SWEEP_INTERVAL_MS = 6e4;
21779
+ const nowMs = Date.now();
21780
+ const capFull = this.unknownLedgerCount >= MAX_UNKNOWN_INVOICE_IDS;
21781
+ const sweepDue = nowMs >= this.unknownLedgerNextSweepMs;
21782
+ if (this.unknownLedgerFirstSeen.size > 0 && (capFull || sweepDue)) {
21783
+ this.unknownLedgerNextSweepMs = nowMs + UNKNOWN_LEDGER_SWEEP_INTERVAL_MS;
21784
+ const expiredIds = [];
21785
+ for (const [unkId, firstSeen] of this.unknownLedgerFirstSeen) {
21786
+ if (nowMs - firstSeen > UNKNOWN_LEDGER_TTL_MS) {
21787
+ expiredIds.push(unkId);
21788
+ }
21789
+ }
21790
+ for (const expiredId of expiredIds) {
21791
+ if (!this.invoiceTermsCache.has(expiredId) && this.invoiceLedger.has(expiredId)) {
21792
+ this.invoiceLedger.delete(expiredId);
21793
+ this.unknownLedgerCount = Math.max(0, this.unknownLedgerCount - 1);
21794
+ for (const [tokenId, invoiceSet] of this.tokenInvoiceMap) {
21795
+ if (invoiceSet.has(expiredId)) {
21796
+ invoiceSet.delete(expiredId);
21797
+ if (invoiceSet.size === 0) this.tokenInvoiceMap.delete(tokenId);
21798
+ }
21799
+ }
21800
+ }
21801
+ this.unknownLedgerFirstSeen.delete(expiredId);
21802
+ }
21803
+ }
21804
+ if (!this.invoiceLedger.has(invoiceId)) {
21805
+ if (this.unknownLedgerCount >= MAX_UNKNOWN_INVOICE_IDS) {
21806
+ return;
21807
+ }
21808
+ this.invoiceLedger.set(invoiceId, /* @__PURE__ */ new Map());
21809
+ this.unknownLedgerCount++;
21810
+ this.unknownLedgerFirstSeen.set(invoiceId, nowMs);
21811
+ }
21812
+ const orphanLedger = this.invoiceLedger.get(invoiceId);
21813
+ let mtEntryCount = 0;
21814
+ for (const k of orphanLedger.keys()) {
21815
+ if (k.startsWith("mt:")) mtEntryCount++;
21816
+ }
21817
+ if (mtEntryCount >= MAX_ORPHAN_ENTRIES_PER_INVOICE) {
21818
+ return;
21819
+ }
21820
+ for (const token of transfer.tokens) {
21821
+ if (!token.id) continue;
21822
+ let onChainAttributed = false;
21823
+ const tokenKeyPrefix = `${token.id}:`;
21824
+ for (const existingKey of orphanLedger.keys()) {
21825
+ if (existingKey.startsWith(tokenKeyPrefix) && !existingKey.startsWith("mt:")) {
21826
+ onChainAttributed = true;
21827
+ break;
21828
+ }
21829
+ }
21830
+ if (!onChainAttributed) {
21831
+ if (mtEntryCount >= MAX_ORPHAN_ENTRIES_PER_INVOICE) {
21832
+ break;
21833
+ }
21834
+ const orphanKey = `mt:${token.id}:${transfer.id}`;
21835
+ if (!orphanLedger.has(orphanKey)) {
21836
+ orphanLedger.set(orphanKey, syntheticRef);
21837
+ mtEntryCount++;
21838
+ }
21839
+ }
21840
+ if (!this.tokenInvoiceMap.has(token.id)) {
21841
+ this.tokenInvoiceMap.set(token.id, /* @__PURE__ */ new Set());
21842
+ }
21843
+ this.tokenInvoiceMap.get(token.id).add(invoiceId);
21844
+ }
21845
+ this.dirtyLedgerEntries.add(invoiceId);
21846
+ this.balanceCache.delete(invoiceId);
21847
+ await this._flushDirtyLedgerEntries();
21848
+ });
21849
+ if (gracefullyGraduated) {
21850
+ await this._processInvoiceTransferEvent(transfer, invoiceId, paymentDirection, confirmed);
21851
+ }
21507
21852
  return;
21508
21853
  }
21509
21854
  await this._processInvoiceTransferEvent(transfer, invoiceId, paymentDirection, confirmed);
@@ -21939,7 +22284,8 @@ var AccountingModule = class _AccountingModule {
21939
22284
  }
21940
22285
  const existingLedger = this.invoiceLedger.get(invoiceId);
21941
22286
  const firstTokenId = transfer.tokens.find((t) => t.id)?.id;
21942
- const syntheticKey = firstTokenId ? `synthetic:${firstTokenId}::${syntheticRef.coinId}` : `synthetic:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22287
+ const syntheticKey = firstTokenId ? `synthetic:${firstTokenId}::${syntheticRef.coinId}` : `synthetic-tx:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22288
+ let mutated = false;
21943
22289
  if (!existingLedger.has(syntheticKey)) {
21944
22290
  let hasRealEntry = false;
21945
22291
  for (const tok of transfer.tokens) {
@@ -21954,8 +22300,25 @@ var AccountingModule = class _AccountingModule {
21954
22300
  }
21955
22301
  if (!hasRealEntry) {
21956
22302
  existingLedger.set(syntheticKey, { ...syntheticRef });
22303
+ mutated = true;
22304
+ }
22305
+ }
22306
+ for (const tok of transfer.tokens) {
22307
+ if (!tok.id) continue;
22308
+ if (!this.tokenInvoiceMap.has(tok.id)) {
22309
+ this.tokenInvoiceMap.set(tok.id, /* @__PURE__ */ new Set());
22310
+ mutated = true;
22311
+ }
22312
+ const beforeSize = this.tokenInvoiceMap.get(tok.id).size;
22313
+ this.tokenInvoiceMap.get(tok.id).add(invoiceId);
22314
+ if (this.tokenInvoiceMap.get(tok.id).size !== beforeSize) {
22315
+ mutated = true;
21957
22316
  }
21958
22317
  }
22318
+ if (mutated) {
22319
+ this.dirtyLedgerEntries.add(invoiceId);
22320
+ this.balanceCache.delete(invoiceId);
22321
+ }
21959
22322
  deps.emitEvent("invoice:payment", {
21960
22323
  invoiceId,
21961
22324
  transfer: syntheticRef,
@@ -22105,7 +22468,8 @@ var AccountingModule = class _AccountingModule {
22105
22468
  this.invoiceLedger.set(invoiceId, /* @__PURE__ */ new Map());
22106
22469
  }
22107
22470
  const hLedger = this.invoiceLedger.get(invoiceId);
22108
- const hKey = entry.tokenId ? `synthetic:${entry.tokenId}::${syntheticRef.coinId}` : `synthetic:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22471
+ const hKey = entry.tokenId ? `synthetic:${entry.tokenId}::${syntheticRef.coinId}` : `synthetic-tx:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22472
+ let hMutated = false;
22109
22473
  if (!hLedger.has(hKey)) {
22110
22474
  let hasRealEntry = false;
22111
22475
  if (entry.tokenId) {
@@ -22118,8 +22482,24 @@ var AccountingModule = class _AccountingModule {
22118
22482
  }
22119
22483
  if (!hasRealEntry) {
22120
22484
  hLedger.set(hKey, { ...syntheticRef });
22485
+ hMutated = true;
22121
22486
  }
22122
22487
  }
22488
+ if (entry.tokenId) {
22489
+ if (!this.tokenInvoiceMap.has(entry.tokenId)) {
22490
+ this.tokenInvoiceMap.set(entry.tokenId, /* @__PURE__ */ new Set());
22491
+ hMutated = true;
22492
+ }
22493
+ const beforeSize = this.tokenInvoiceMap.get(entry.tokenId).size;
22494
+ this.tokenInvoiceMap.get(entry.tokenId).add(invoiceId);
22495
+ if (this.tokenInvoiceMap.get(entry.tokenId).size !== beforeSize) {
22496
+ hMutated = true;
22497
+ }
22498
+ }
22499
+ if (hMutated) {
22500
+ this.dirtyLedgerEntries.add(invoiceId);
22501
+ this.balanceCache.delete(invoiceId);
22502
+ }
22123
22503
  deps.emitEvent("invoice:payment", {
22124
22504
  invoiceId,
22125
22505
  transfer: syntheticRef,
@@ -24650,17 +25030,63 @@ var SwapModule = class {
24650
25030
  for (const addr of allAddresses) {
24651
25031
  myDirectAddresses.add(addr.directAddress);
24652
25032
  }
24653
- let assetIndex;
24654
- if (myDirectAddresses.has(swap.manifest.party_a_address)) {
24655
- assetIndex = 0;
24656
- } else if (myDirectAddresses.has(swap.manifest.party_b_address)) {
24657
- assetIndex = 1;
25033
+ const matchesPartyA = myDirectAddresses.has(swap.manifest.party_a_address);
25034
+ const matchesPartyB = myDirectAddresses.has(swap.manifest.party_b_address);
25035
+ if (matchesPartyA && matchesPartyB) {
25036
+ throw new SphereError(
25037
+ "Ambiguous party identity: local wallet matches both party_a_address and party_b_address",
25038
+ "SWAP_DEPOSIT_FAILED"
25039
+ );
25040
+ }
25041
+ let myExpectedCurrency;
25042
+ if (matchesPartyA) {
25043
+ myExpectedCurrency = swap.manifest.party_a_currency_to_change;
25044
+ } else if (matchesPartyB) {
25045
+ myExpectedCurrency = swap.manifest.party_b_currency_to_change;
24658
25046
  } else {
24659
25047
  throw new SphereError(
24660
25048
  "Local wallet address does not match either party in the swap manifest",
24661
25049
  "SWAP_DEPOSIT_FAILED"
24662
25050
  );
24663
25051
  }
25052
+ if (!myExpectedCurrency || myExpectedCurrency === "") {
25053
+ throw new SphereError(
25054
+ "Manifest currency_to_change is empty for this party",
25055
+ "SWAP_DEPOSIT_FAILED"
25056
+ );
25057
+ }
25058
+ const invoiceRefForAssetLookup = deps.accounting.getInvoice(swap.depositInvoiceId);
25059
+ if (!invoiceRefForAssetLookup) {
25060
+ throw new SphereError(
25061
+ "Deposit invoice not yet imported into accounting module",
25062
+ "SWAP_WRONG_STATE"
25063
+ );
25064
+ }
25065
+ const depositTarget = invoiceRefForAssetLookup.terms.targets[0];
25066
+ if (!depositTarget) {
25067
+ throw new SphereError(
25068
+ "Deposit invoice has no targets",
25069
+ "SWAP_DEPOSIT_FAILED"
25070
+ );
25071
+ }
25072
+ const assetIndex = depositTarget.assets.findIndex(
25073
+ (a) => a.coin !== void 0 && coinIdsMatch(a.coin[0], myExpectedCurrency)
25074
+ );
25075
+ if (assetIndex < 0) {
25076
+ throw new SphereError(
25077
+ `No asset matching expected currency ${myExpectedCurrency} found in deposit invoice`,
25078
+ "SWAP_DEPOSIT_FAILED"
25079
+ );
25080
+ }
25081
+ for (let i = assetIndex + 1; i < depositTarget.assets.length; i += 1) {
25082
+ const a = depositTarget.assets[i];
25083
+ if (a?.coin !== void 0 && coinIdsMatch(a.coin[0], myExpectedCurrency)) {
25084
+ throw new SphereError(
25085
+ `Ambiguous asset match in deposit invoice: slots ${assetIndex} and ${i} both match currency ${myExpectedCurrency}`,
25086
+ "SWAP_DEPOSIT_FAILED"
25087
+ );
25088
+ }
25089
+ }
24664
25090
  return this.withSwapGate(swapId, async () => {
24665
25091
  if (swap.progress !== "announced") {
24666
25092
  throw new SphereError(
@@ -24766,7 +25192,6 @@ var SwapModule = class {
24766
25192
  swap.updatedAt = Date.now();
24767
25193
  this.clearLocalTimer(swap.swapId);
24768
25194
  this.terminalSwapIds.add(swap.swapId);
24769
- const entryIdx = this._storedTerminalEntries.length;
24770
25195
  this._storedTerminalEntries.push({
24771
25196
  swapId: swap.swapId,
24772
25197
  progress: "failed",
@@ -24781,7 +25206,13 @@ var SwapModule = class {
24781
25206
  swap.error = prevError;
24782
25207
  swap.updatedAt = prevUpdatedAt;
24783
25208
  this.terminalSwapIds.delete(swap.swapId);
24784
- this._storedTerminalEntries.splice(entryIdx, 1);
25209
+ for (let i = this._storedTerminalEntries.length - 1; i >= 0; i--) {
25210
+ const entry = this._storedTerminalEntries[i];
25211
+ if (entry.swapId === swap.swapId && entry.progress === "failed") {
25212
+ this._storedTerminalEntries.splice(i, 1);
25213
+ break;
25214
+ }
25215
+ }
24785
25216
  logger.warn(LOG_TAG3, `failPayout: persistSwap failed for ${swapId}; fraud detection will retry on next load:`, persistErr);
24786
25217
  throw persistErr;
24787
25218
  }
@@ -24825,9 +25256,25 @@ var SwapModule = class {
24825
25256
  if (!targetStatus.coinAssets[0].isCovered) {
24826
25257
  return returnFalse();
24827
25258
  }
24828
- if (BigInt(targetStatus.coinAssets[0].netCoveredAmount) < BigInt(expectedAmount)) {
25259
+ let netCoveredAmount;
25260
+ let expectedAmountBigInt;
25261
+ try {
25262
+ netCoveredAmount = BigInt(targetStatus.coinAssets[0].netCoveredAmount);
25263
+ expectedAmountBigInt = BigInt(expectedAmount);
25264
+ } catch (parseErr) {
25265
+ return failPayout(
25266
+ `MALFORMED_AMOUNT: failed to parse coverage amounts (netCoveredAmount=${targetStatus.coinAssets[0].netCoveredAmount}, expectedAmount=${expectedAmount}): ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`
25267
+ );
25268
+ }
25269
+ if (netCoveredAmount < expectedAmountBigInt) {
24829
25270
  return returnFalse();
24830
25271
  }
25272
+ if (netCoveredAmount > expectedAmountBigInt) {
25273
+ const surplus = netCoveredAmount - expectedAmountBigInt;
25274
+ return failPayout(
25275
+ `OVER_COVERAGE: net=${netCoveredAmount.toString()}, expected=${expectedAmount}, surplus=${surplus.toString()} \u2014 surplus refund expected via auto-return; settlement halted`
25276
+ );
25277
+ }
24831
25278
  const escrowAddr = swap.deal.escrowAddress ?? this.config.defaultEscrowAddress;
24832
25279
  if (escrowAddr) {
24833
25280
  const escrowPeer = await deps.resolve(escrowAddr);
@@ -24837,8 +25284,28 @@ var SwapModule = class {
24837
25284
  }
24838
25285
  const validationResult = await deps.payments.validate();
24839
25286
  if (validationResult.invalid.length > 0) {
24840
- logger.warn(LOG_TAG3, `verifyPayout for ${swapId.slice(0, 12)}: L3 validation found ${validationResult.invalid.length} invalid token(s) \u2014 retry after wallet sync`);
24841
- return returnFalse();
25287
+ const payoutTokenIds = deps.accounting.getTokenIdsForInvoice?.(swap.payoutInvoiceId) ?? /* @__PURE__ */ new Set();
25288
+ if (payoutTokenIds.size === 0) {
25289
+ logger.warn(
25290
+ LOG_TAG3,
25291
+ `verifyPayout for ${swapId.slice(0, 12)}: ${validationResult.invalid.length} invalid token(s) but tokenInvoiceMap is empty for this payout invoice \u2014 failing closed until reverse index rebuilds`
25292
+ );
25293
+ return returnFalse();
25294
+ }
25295
+ const relevantInvalid = validationResult.invalid.filter(
25296
+ (t) => payoutTokenIds.has(t.id)
25297
+ );
25298
+ if (relevantInvalid.length > 0) {
25299
+ logger.warn(
25300
+ LOG_TAG3,
25301
+ `verifyPayout for ${swapId.slice(0, 12)}: L3 validation found ${relevantInvalid.length} invalid token(s) covering this payout invoice \u2014 retry after wallet sync`
25302
+ );
25303
+ return returnFalse();
25304
+ }
25305
+ logger.debug(
25306
+ LOG_TAG3,
25307
+ `verifyPayout for ${swapId.slice(0, 12)}: ${validationResult.invalid.length} unrelated invalid token(s) ignored (not linked to this payout invoice)`
25308
+ );
24842
25309
  }
24843
25310
  if (swap.progress === "completed") {
24844
25311
  swap.payoutVerified = true;
@@ -25017,8 +25484,22 @@ var SwapModule = class {
25017
25484
  * @param dm - The incoming direct message.
25018
25485
  */
25019
25486
  handleIncomingDM(dm) {
25487
+ if (dm.content.startsWith("{") && dm.content.includes('"invoice_delivery"')) {
25488
+ logger.warn(
25489
+ LOG_TAG3,
25490
+ `diag_swap_dm_arrived sender=${dm.senderPubkey.slice(0, 16)} length=${dm.content.length}`
25491
+ );
25492
+ }
25020
25493
  const parsed = parseSwapDM(dm.content);
25021
- if (!parsed) return;
25494
+ if (!parsed) {
25495
+ if (dm.content.startsWith("{") && dm.content.includes('"invoice_delivery"')) {
25496
+ logger.warn(
25497
+ LOG_TAG3,
25498
+ `diag_swap_dm_parse_rejected sender=${dm.senderPubkey.slice(0, 16)} prefix=${dm.content.slice(0, 80)}`
25499
+ );
25500
+ }
25501
+ return;
25502
+ }
25022
25503
  void (async () => {
25023
25504
  try {
25024
25505
  switch (parsed.kind) {
@@ -25384,16 +25865,43 @@ var SwapModule = class {
25384
25865
  // invoice_delivery (§12.4.2 + §12.4.3)
25385
25866
  // ---------------------------------------------------------------
25386
25867
  case "invoice_delivery": {
25387
- if (!swapId) return;
25868
+ logger.warn(
25869
+ LOG_TAG3,
25870
+ `diag_invoice_delivery_received swap_id=${swapId?.slice(0, 16)} sender=${dm.senderPubkey.slice(0, 16)} invoice_type=${msg.invoice_type} invoice_id=${msg.invoice_id?.slice(0, 16)}`
25871
+ );
25872
+ if (!swapId) {
25873
+ logger.warn(LOG_TAG3, "diag_invoice_delivery_dropped reason=no_swap_id");
25874
+ return;
25875
+ }
25388
25876
  const swap = this.swaps.get(swapId);
25389
- if (!swap) return;
25390
- if (!this.isFromExpectedEscrow(dm.senderPubkey, swap)) return;
25877
+ if (!swap) {
25878
+ logger.warn(
25879
+ LOG_TAG3,
25880
+ `diag_invoice_delivery_dropped reason=swap_not_in_map swap_id=${swapId.slice(0, 16)} known_swap_ids_count=${this.swaps.size}`
25881
+ );
25882
+ return;
25883
+ }
25884
+ if (!this.isFromExpectedEscrow(dm.senderPubkey, swap)) {
25885
+ logger.warn(
25886
+ LOG_TAG3,
25887
+ `diag_invoice_delivery_dropped reason=not_expected_escrow swap_id=${swapId.slice(0, 16)} sender=${dm.senderPubkey.slice(0, 16)} expected_escrow_pubkey=${swap.escrowPubkey?.slice(0, 16)} expected_escrow_addr=${swap.escrowDirectAddress?.slice(0, 24)}`
25888
+ );
25889
+ return;
25890
+ }
25391
25891
  const deps = this.deps;
25392
25892
  if (msg.invoice_type === "deposit") {
25893
+ logger.warn(
25894
+ LOG_TAG3,
25895
+ `diag_invoice_delivery_proceeding_to_import swap_id=${swapId.slice(0, 16)} progress=${swap.progress} invoice_id=${(msg.invoice_id ?? "").slice(0, 16)}`
25896
+ );
25393
25897
  await this.withSwapGate(swapId, async () => {
25394
25898
  if (isTerminalProgress(swap.progress)) return;
25395
25899
  try {
25396
25900
  await deps.accounting.importInvoice(msg.invoice_token);
25901
+ logger.warn(
25902
+ LOG_TAG3,
25903
+ `diag_invoice_imported swap_id=${swapId.slice(0, 16)} invoice_id=${(msg.invoice_id ?? "").slice(0, 16)} type=deposit`
25904
+ );
25397
25905
  } catch (err) {
25398
25906
  if (err instanceof SphereError && err.code === "INVOICE_ALREADY_EXISTS") {
25399
25907
  logger.debug(LOG_TAG3, `Deposit invoice for swap ${swapId} already imported \u2014 relay re-delivery, continuing`);
@@ -30347,6 +30855,7 @@ export {
30347
30855
  randomBytes2 as randomBytes,
30348
30856
  randomHex,
30349
30857
  randomUUID,
30858
+ recoverPubkeyFromSignature,
30350
30859
  ripemd160,
30351
30860
  scanAddressesImpl,
30352
30861
  serializeEncrypted,