@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.
@@ -938,6 +938,7 @@ __export(core_exports, {
938
938
  randomBytes: () => randomBytes2,
939
939
  randomHex: () => randomHex,
940
940
  randomUUID: () => randomUUID,
941
+ recoverPubkeyFromSignature: () => recoverPubkeyFromSignature,
941
942
  ripemd160: () => ripemd160,
942
943
  scanAddressesImpl: () => scanAddressesImpl,
943
944
  serializeEncrypted: () => serializeEncrypted,
@@ -4917,6 +4918,27 @@ function verifySignedMessage(message, signature, expectedPubkey) {
4917
4918
  return false;
4918
4919
  }
4919
4920
  }
4921
+ function recoverPubkeyFromSignature(message, signature) {
4922
+ if (signature.length !== 130) {
4923
+ throw new SphereError(
4924
+ `Invalid signature length: expected 130 hex chars, got ${signature.length}`,
4925
+ "SIGNING_ERROR"
4926
+ );
4927
+ }
4928
+ const v = parseInt(signature.slice(0, 2), 16) - 31;
4929
+ const r = signature.slice(2, 66);
4930
+ const s = signature.slice(66, 130);
4931
+ if (v < 0 || v > 3) {
4932
+ throw new SphereError(
4933
+ `Invalid recovery byte: v=${v} out of range [0..3]`,
4934
+ "SIGNING_ERROR"
4935
+ );
4936
+ }
4937
+ const hashHex = hashSignMessage(message);
4938
+ const hashBytes = Buffer.from(hashHex, "hex");
4939
+ const recovered = ec.recoverPubKey(hashBytes, { r, s }, v);
4940
+ return recovered.encode("hex", true);
4941
+ }
4920
4942
 
4921
4943
  // l1/crypto.ts
4922
4944
  var import_crypto_js3 = __toESM(require("crypto-js"), 1);
@@ -12164,6 +12186,132 @@ var PaymentsModule = class _PaymentsModule {
12164
12186
  };
12165
12187
  }
12166
12188
  }
12189
+ /**
12190
+ * Mint a fungible token directly to this wallet (genesis mint).
12191
+ *
12192
+ * Useful for test setups that need to seed a wallet with specific token
12193
+ * balances WITHOUT depending on the testnet faucet HTTP service. The
12194
+ * resulting token has the canonical CoinId bytes (passed in `coinIdHex`)
12195
+ * — when those bytes match a registered symbol in the TokenRegistry,
12196
+ * the token shows up under the symbol's name (e.g. "UCT"). There is no
12197
+ * cryptographic restriction on which key may issue a given CoinId; the
12198
+ * aggregator records the mint regardless of issuer identity.
12199
+ *
12200
+ * The flow:
12201
+ * 1. Generate a random TokenId.
12202
+ * 2. Build TokenCoinData with [(coinId, amount)].
12203
+ * 3. Build MintTransactionData with recipient = self (UnmaskedPredicate
12204
+ * from this wallet's signing service).
12205
+ * 4. Submit MintCommitment to the aggregator.
12206
+ * 5. Wait for the inclusion proof.
12207
+ * 6. Construct an SDK Token via Token.mint().
12208
+ * 7. Convert to wallet Token format and call addToken().
12209
+ *
12210
+ * @param coinIdHex - 64-char lowercase hex CoinId. Must match the bytes
12211
+ * used by the registered symbol if you want the wallet to recognize
12212
+ * the token as that symbol (e.g. UCT's coinId from the public registry).
12213
+ * @param amount - Amount in smallest units (multiply by 10^decimals
12214
+ * when converting from human values).
12215
+ * @returns Result with the resulting wallet Token and its on-chain id.
12216
+ */
12217
+ async mintFungibleToken(coinIdHex, amount) {
12218
+ this.ensureInitialized();
12219
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
12220
+ if (!stClient) {
12221
+ return { success: false, error: "State transition client not available" };
12222
+ }
12223
+ const trustBase = this.deps.oracle.getTrustBase?.();
12224
+ if (!trustBase) {
12225
+ return { success: false, error: "Trust base not available" };
12226
+ }
12227
+ try {
12228
+ const signingService = await this.createSigningService();
12229
+ const { TokenId: TokenId5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenId");
12230
+ const { TokenCoinData: TokenCoinData3 } = await import("@unicitylabs/state-transition-sdk/lib/token/fungible/TokenCoinData");
12231
+ const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
12232
+ const tokenTypeBytes = fromHex4("f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509");
12233
+ const tokenType = new import_TokenType3.TokenType(tokenTypeBytes);
12234
+ const tokenIdBytes = new Uint8Array(32);
12235
+ crypto.getRandomValues(tokenIdBytes);
12236
+ const tokenId = new TokenId5(tokenIdBytes);
12237
+ const coinIdBytes = fromHex4(coinIdHex);
12238
+ const coinId = new import_CoinId4.CoinId(coinIdBytes);
12239
+ const coinData = TokenCoinData3.create([[coinId, amount]]);
12240
+ const addressRef = await UnmaskedPredicateReference4.create(
12241
+ tokenType,
12242
+ signingService.algorithm,
12243
+ signingService.publicKey,
12244
+ import_HashAlgorithm5.HashAlgorithm.SHA256
12245
+ );
12246
+ const ownerAddress = await addressRef.toAddress();
12247
+ const salt = new Uint8Array(32);
12248
+ crypto.getRandomValues(salt);
12249
+ const mintData = await import_MintTransactionData3.MintTransactionData.create(
12250
+ tokenId,
12251
+ tokenType,
12252
+ null,
12253
+ // tokenData: no metadata
12254
+ coinData,
12255
+ // fungible coin data
12256
+ ownerAddress,
12257
+ // recipient = self
12258
+ salt,
12259
+ null,
12260
+ // recipientDataHash
12261
+ null
12262
+ // reason: null (genesis, no burn predecessor)
12263
+ );
12264
+ const commitment = await import_MintCommitment3.MintCommitment.create(mintData);
12265
+ const MAX_RETRIES = 3;
12266
+ let lastStatus;
12267
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
12268
+ const response = await stClient.submitMintCommitment(commitment);
12269
+ lastStatus = response.status;
12270
+ if (response.status === "SUCCESS" || response.status === "REQUEST_ID_EXISTS") break;
12271
+ if (attempt === MAX_RETRIES) {
12272
+ return { success: false, error: `Mint submit failed after ${MAX_RETRIES} attempts: ${response.status}` };
12273
+ }
12274
+ await new Promise((r) => setTimeout(r, 1e3 * attempt));
12275
+ }
12276
+ if (lastStatus !== "SUCCESS" && lastStatus !== "REQUEST_ID_EXISTS") {
12277
+ return { success: false, error: `Mint submit failed: ${lastStatus}` };
12278
+ }
12279
+ const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
12280
+ const genesisTransaction = commitment.toTransaction(inclusionProof);
12281
+ const predicate = await import_UnmaskedPredicate5.UnmaskedPredicate.create(
12282
+ tokenId,
12283
+ tokenType,
12284
+ signingService,
12285
+ import_HashAlgorithm5.HashAlgorithm.SHA256,
12286
+ salt
12287
+ );
12288
+ const tokenState = new import_TokenState5.TokenState(predicate, null);
12289
+ const sdkToken = await import_Token6.Token.mint(trustBase, tokenState, genesisTransaction);
12290
+ const tokenIdHex = tokenId.toJSON();
12291
+ const symbol = this.getCoinSymbol(coinIdHex);
12292
+ const name = this.getCoinName(coinIdHex);
12293
+ const decimals = this.getCoinDecimals(coinIdHex);
12294
+ const iconUrl = this.getCoinIconUrl(coinIdHex);
12295
+ const uiToken = {
12296
+ id: tokenIdHex,
12297
+ coinId: coinIdHex,
12298
+ symbol,
12299
+ name,
12300
+ decimals,
12301
+ ...iconUrl !== void 0 ? { iconUrl } : {},
12302
+ amount: amount.toString(),
12303
+ status: "confirmed",
12304
+ createdAt: Date.now(),
12305
+ updatedAt: Date.now(),
12306
+ sdkData: JSON.stringify(sdkToken.toJSON())
12307
+ };
12308
+ await this.addToken(uiToken);
12309
+ return { success: true, token: uiToken, tokenId: tokenIdHex };
12310
+ } catch (err) {
12311
+ const msg = err instanceof Error ? err.message : String(err);
12312
+ return { success: false, error: `Local mint failed: ${msg}` };
12313
+ }
12314
+ }
12167
12315
  /**
12168
12316
  * Check if a nametag is available for minting
12169
12317
  * @param nametag - The nametag to check (e.g., "alice" or "@alice")
@@ -18370,6 +18518,24 @@ var AccountingModule = class _AccountingModule {
18370
18518
  dirtyLedgerEntries = /* @__PURE__ */ new Set();
18371
18519
  /** Count of unknown (not in invoiceTermsCache) invoice IDs in the ledger. */
18372
18520
  unknownLedgerCount = 0;
18521
+ /**
18522
+ * Per-unknown-invoice first-seen timestamp for TTL eviction.
18523
+ *
18524
+ * W1 (steelman round-4): without TTL, an attacker who can deliver 500
18525
+ * inbound transfers with synthesized memo invoiceIds permanently exhausts
18526
+ * the unknown-ledger cap, after which legitimate orphan transfers (out-of-
18527
+ * order delivery for real swaps) are silently dropped at the cap-check.
18528
+ *
18529
+ * Round-5 perf: gated by `unknownLedgerNextSweepMs` to amortize the
18530
+ * sweep cost. The naive every-call sweep is O(N) where N=cap=500;
18531
+ * combined with the per-token cleanup loop inside the sweep it became
18532
+ * O(N×M) on every transfer under flood. Now we sweep at most every
18533
+ * `UNKNOWN_LEDGER_SWEEP_INTERVAL_MS` (60s) UNLESS the cap is currently
18534
+ * full, in which case we sweep on each call (the only path that can
18535
+ * actually drop a legitimate orphan).
18536
+ */
18537
+ unknownLedgerFirstSeen = /* @__PURE__ */ new Map();
18538
+ unknownLedgerNextSweepMs = 0;
18373
18539
  /** W17: Tracks whether tokenScanState has been mutated since last flush. */
18374
18540
  tokenScanDirty = false;
18375
18541
  /** W2 fix: Serialization guard for _flushDirtyLedgerEntries. */
@@ -19360,6 +19526,7 @@ var AccountingModule = class _AccountingModule {
19360
19526
  }
19361
19527
  if (this.invoiceLedger.has(tokenId) && !this.invoiceTermsCache.has(tokenId)) {
19362
19528
  this.unknownLedgerCount = Math.max(0, this.unknownLedgerCount - 1);
19529
+ this.unknownLedgerFirstSeen.delete(tokenId);
19363
19530
  }
19364
19531
  this.invoiceTermsCache.set(tokenId, terms);
19365
19532
  this._addToHashIndex(tokenId);
@@ -19628,6 +19795,29 @@ var AccountingModule = class _AccountingModule {
19628
19795
  closed: this.closedInvoices.has(invoiceId)
19629
19796
  };
19630
19797
  }
19798
+ /**
19799
+ * Return the set of token IDs that are currently linked to the given
19800
+ * invoice. Populated by both the on-chain `_processTokenTransactions`
19801
+ * path (tokens with `inv:` references) and the transport-memo orphan
19802
+ * buffering path in `_handleIncomingTransfer`.
19803
+ *
19804
+ * Used by callers that want to scope per-invoice operations (e.g.
19805
+ * SwapModule.verifyPayout's L3 validation) to only the tokens that
19806
+ * cover this invoice — avoiding false negatives when the wallet
19807
+ * contains unrelated tokens of the same currency in unconfirmed or
19808
+ * spent state.
19809
+ *
19810
+ * Returns an empty set if no tokens are currently linked.
19811
+ */
19812
+ getTokenIdsForInvoice(invoiceId) {
19813
+ const result = /* @__PURE__ */ new Set();
19814
+ for (const [tokenId, invoiceIds] of this.tokenInvoiceMap) {
19815
+ if (invoiceIds.has(invoiceId)) {
19816
+ result.add(tokenId);
19817
+ }
19818
+ }
19819
+ return result;
19820
+ }
19631
19821
  /**
19632
19822
  * Explicitly close an invoice. Only target parties may close (§8.3).
19633
19823
  *
@@ -19947,6 +20137,7 @@ var AccountingModule = class _AccountingModule {
19947
20137
  ledger.set(entryKey, forwardRef);
19948
20138
  this.dirtyLedgerEntries.add(invoiceId);
19949
20139
  this.balanceCache.delete(invoiceId);
20140
+ await this._persistProvisionalAndVerify(invoiceId, "payInvoice");
19950
20141
  }
19951
20142
  return result;
19952
20143
  } finally {
@@ -20114,6 +20305,7 @@ var AccountingModule = class _AccountingModule {
20114
20305
  this.dirtyLedgerEntries.add(invoiceId);
20115
20306
  }
20116
20307
  this.balanceCache.delete(invoiceId);
20308
+ await this._persistProvisionalAndVerify(invoiceId, "returnInvoicePayment");
20117
20309
  }
20118
20310
  return result;
20119
20311
  } finally {
@@ -21193,9 +21385,30 @@ var AccountingModule = class _AccountingModule {
21193
21385
  continue;
21194
21386
  }
21195
21387
  innerMap.set(entryKey, ref);
21196
- if (!ref.transferId.startsWith("provisional:") && ref.transferId.includes(":")) {
21388
+ const HEX_64 = /^[a-f0-9]{64}$/i;
21389
+ if (entryKey.startsWith("mt:")) {
21390
+ const firstColon = entryKey.indexOf(":");
21391
+ const secondColon = entryKey.indexOf(":", firstColon + 1);
21392
+ if (secondColon > firstColon + 1) {
21393
+ const tokenIdFromKey2 = entryKey.slice(firstColon + 1, secondColon);
21394
+ if (HEX_64.test(tokenIdFromKey2)) {
21395
+ this._addToTokenInvoiceMap(tokenIdFromKey2, invoiceId);
21396
+ }
21397
+ }
21398
+ } else if (entryKey.startsWith("synthetic:")) {
21399
+ const afterPrefix = entryKey.slice("synthetic:".length);
21400
+ const tokenIdEnd = afterPrefix.indexOf(":");
21401
+ if (tokenIdEnd > 0) {
21402
+ const tokenId = afterPrefix.slice(0, tokenIdEnd);
21403
+ if (HEX_64.test(tokenId) && ref.transferId !== tokenId) {
21404
+ this._addToTokenInvoiceMap(tokenId, invoiceId);
21405
+ }
21406
+ }
21407
+ } else if (!ref.transferId.startsWith("provisional:") && ref.transferId.includes(":")) {
21197
21408
  const tokenIdFromRef = ref.transferId.slice(0, ref.transferId.indexOf(":"));
21198
- this._addToTokenInvoiceMap(tokenIdFromRef, invoiceId);
21409
+ if (HEX_64.test(tokenIdFromRef)) {
21410
+ this._addToTokenInvoiceMap(tokenIdFromRef, invoiceId);
21411
+ }
21199
21412
  }
21200
21413
  }
21201
21414
  } catch (err) {
@@ -21396,7 +21609,14 @@ var AccountingModule = class _AccountingModule {
21396
21609
  }
21397
21610
  }
21398
21611
  for (const [existingKey, existingRef] of ledger) {
21399
- if (existingKey.startsWith("synthetic:") && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21612
+ if ((existingKey.startsWith("synthetic:") || existingKey.startsWith("synthetic-tx:")) && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21613
+ keysToDelete.push(existingKey);
21614
+ break;
21615
+ }
21616
+ }
21617
+ const mtPrefix = `mt:${tokenId}:`;
21618
+ for (const [existingKey, existingRef] of ledger) {
21619
+ if (existingKey.startsWith(mtPrefix) && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21400
21620
  keysToDelete.push(existingKey);
21401
21621
  break;
21402
21622
  }
@@ -21513,6 +21733,49 @@ var AccountingModule = class _AccountingModule {
21513
21733
  });
21514
21734
  }
21515
21735
  }
21736
+ /**
21737
+ * Synchronously persist any pending provisional ledger entry for `invoiceId`
21738
+ * before returning to the caller. Used by `payInvoice` and
21739
+ * `returnInvoicePayment` to make the in-memory provisional entry durable
21740
+ * inside the same per-invoice gate that wrote it, closing the
21741
+ * crash-mid-conclude race that produces over-coverage on receivers.
21742
+ *
21743
+ * Implementation:
21744
+ * 1. Schedule a flush via the existing `_flushPromise` chain (so
21745
+ * concurrent `_handleTokenChange` callers waiting on the chain
21746
+ * observe ours as part of the sequence).
21747
+ * 2. Await OUR flush directly — NOT `_drainFlushPromise()`, which would
21748
+ * spin while concurrent token changes keep extending the chain and
21749
+ * hold the per-invoice gate for an unbounded number of additional
21750
+ * flushes. We only need OUR provisional entry durable.
21751
+ * 3. `_flushDirtyLedgerEntries` swallows per-invoice `storage.set`
21752
+ * rejections internally (sets a local `step1Failed` flag), leaving
21753
+ * the dirty entry on the set without re-throwing. So we post-check
21754
+ * `dirtyLedgerEntries.has(invoiceId)` and throw a `STORAGE_ERROR`
21755
+ * `SphereError` if our entry is still dirty — propagating to the
21756
+ * caller so they learn about the durability failure rather than
21757
+ * receiving a silent "success" return that lies on disk.
21758
+ *
21759
+ * @param invoiceId The invoice whose provisional entry must be durable.
21760
+ * @param callContext Used in the error message so the caller is named
21761
+ * ('payInvoice' / 'returnInvoicePayment') without
21762
+ * forcing a stack-trace inspection.
21763
+ */
21764
+ async _persistProvisionalAndVerify(invoiceId, callContext) {
21765
+ const flushTrigger = (this._flushPromise ?? Promise.resolve()).then(() => this._flushDirtyLedgerEntries());
21766
+ const tracked = flushTrigger.catch(() => {
21767
+ }).finally(() => {
21768
+ if (this._flushPromise === tracked) this._flushPromise = null;
21769
+ });
21770
+ this._flushPromise = tracked;
21771
+ await flushTrigger;
21772
+ if (this.dirtyLedgerEntries.has(invoiceId)) {
21773
+ throw new SphereError(
21774
+ `${callContext}: provisional ledger entry for invoice ${invoiceId} failed to persist \u2014 caller should retry`,
21775
+ "STORAGE_ERROR"
21776
+ );
21777
+ }
21778
+ }
21516
21779
  // ===========================================================================
21517
21780
  // Internal: Event handlers
21518
21781
  // ===========================================================================
@@ -21573,13 +21836,96 @@ var AccountingModule = class _AccountingModule {
21573
21836
  }
21574
21837
  }
21575
21838
  if (!this.invoiceTermsCache.has(invoiceId)) {
21576
- const syntheticRef = this._buildSyntheticTransferRef(
21577
- transfer,
21578
- invoiceId,
21579
- paymentDirection,
21580
- confirmed
21581
- );
21582
- deps.emitEvent("invoice:unknown_reference", { invoiceId, transfer: syntheticRef });
21839
+ let gracefullyGraduated = false;
21840
+ await this.withInvoiceGate(invoiceId, async () => {
21841
+ if (this.invoiceTermsCache.has(invoiceId)) {
21842
+ gracefullyGraduated = true;
21843
+ return;
21844
+ }
21845
+ const syntheticRef = this._buildSyntheticTransferRef(
21846
+ transfer,
21847
+ invoiceId,
21848
+ paymentDirection,
21849
+ confirmed
21850
+ );
21851
+ deps.emitEvent("invoice:unknown_reference", { invoiceId, transfer: syntheticRef });
21852
+ const MAX_UNKNOWN_INVOICE_IDS = 500;
21853
+ const UNKNOWN_LEDGER_TTL_MS = 30 * 60 * 1e3;
21854
+ const MAX_ORPHAN_ENTRIES_PER_INVOICE = 50;
21855
+ const UNKNOWN_LEDGER_SWEEP_INTERVAL_MS = 6e4;
21856
+ const nowMs = Date.now();
21857
+ const capFull = this.unknownLedgerCount >= MAX_UNKNOWN_INVOICE_IDS;
21858
+ const sweepDue = nowMs >= this.unknownLedgerNextSweepMs;
21859
+ if (this.unknownLedgerFirstSeen.size > 0 && (capFull || sweepDue)) {
21860
+ this.unknownLedgerNextSweepMs = nowMs + UNKNOWN_LEDGER_SWEEP_INTERVAL_MS;
21861
+ const expiredIds = [];
21862
+ for (const [unkId, firstSeen] of this.unknownLedgerFirstSeen) {
21863
+ if (nowMs - firstSeen > UNKNOWN_LEDGER_TTL_MS) {
21864
+ expiredIds.push(unkId);
21865
+ }
21866
+ }
21867
+ for (const expiredId of expiredIds) {
21868
+ if (!this.invoiceTermsCache.has(expiredId) && this.invoiceLedger.has(expiredId)) {
21869
+ this.invoiceLedger.delete(expiredId);
21870
+ this.unknownLedgerCount = Math.max(0, this.unknownLedgerCount - 1);
21871
+ for (const [tokenId, invoiceSet] of this.tokenInvoiceMap) {
21872
+ if (invoiceSet.has(expiredId)) {
21873
+ invoiceSet.delete(expiredId);
21874
+ if (invoiceSet.size === 0) this.tokenInvoiceMap.delete(tokenId);
21875
+ }
21876
+ }
21877
+ }
21878
+ this.unknownLedgerFirstSeen.delete(expiredId);
21879
+ }
21880
+ }
21881
+ if (!this.invoiceLedger.has(invoiceId)) {
21882
+ if (this.unknownLedgerCount >= MAX_UNKNOWN_INVOICE_IDS) {
21883
+ return;
21884
+ }
21885
+ this.invoiceLedger.set(invoiceId, /* @__PURE__ */ new Map());
21886
+ this.unknownLedgerCount++;
21887
+ this.unknownLedgerFirstSeen.set(invoiceId, nowMs);
21888
+ }
21889
+ const orphanLedger = this.invoiceLedger.get(invoiceId);
21890
+ let mtEntryCount = 0;
21891
+ for (const k of orphanLedger.keys()) {
21892
+ if (k.startsWith("mt:")) mtEntryCount++;
21893
+ }
21894
+ if (mtEntryCount >= MAX_ORPHAN_ENTRIES_PER_INVOICE) {
21895
+ return;
21896
+ }
21897
+ for (const token of transfer.tokens) {
21898
+ if (!token.id) continue;
21899
+ let onChainAttributed = false;
21900
+ const tokenKeyPrefix = `${token.id}:`;
21901
+ for (const existingKey of orphanLedger.keys()) {
21902
+ if (existingKey.startsWith(tokenKeyPrefix) && !existingKey.startsWith("mt:")) {
21903
+ onChainAttributed = true;
21904
+ break;
21905
+ }
21906
+ }
21907
+ if (!onChainAttributed) {
21908
+ if (mtEntryCount >= MAX_ORPHAN_ENTRIES_PER_INVOICE) {
21909
+ break;
21910
+ }
21911
+ const orphanKey = `mt:${token.id}:${transfer.id}`;
21912
+ if (!orphanLedger.has(orphanKey)) {
21913
+ orphanLedger.set(orphanKey, syntheticRef);
21914
+ mtEntryCount++;
21915
+ }
21916
+ }
21917
+ if (!this.tokenInvoiceMap.has(token.id)) {
21918
+ this.tokenInvoiceMap.set(token.id, /* @__PURE__ */ new Set());
21919
+ }
21920
+ this.tokenInvoiceMap.get(token.id).add(invoiceId);
21921
+ }
21922
+ this.dirtyLedgerEntries.add(invoiceId);
21923
+ this.balanceCache.delete(invoiceId);
21924
+ await this._flushDirtyLedgerEntries();
21925
+ });
21926
+ if (gracefullyGraduated) {
21927
+ await this._processInvoiceTransferEvent(transfer, invoiceId, paymentDirection, confirmed);
21928
+ }
21583
21929
  return;
21584
21930
  }
21585
21931
  await this._processInvoiceTransferEvent(transfer, invoiceId, paymentDirection, confirmed);
@@ -22015,7 +22361,8 @@ var AccountingModule = class _AccountingModule {
22015
22361
  }
22016
22362
  const existingLedger = this.invoiceLedger.get(invoiceId);
22017
22363
  const firstTokenId = transfer.tokens.find((t) => t.id)?.id;
22018
- const syntheticKey = firstTokenId ? `synthetic:${firstTokenId}::${syntheticRef.coinId}` : `synthetic:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22364
+ const syntheticKey = firstTokenId ? `synthetic:${firstTokenId}::${syntheticRef.coinId}` : `synthetic-tx:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22365
+ let mutated = false;
22019
22366
  if (!existingLedger.has(syntheticKey)) {
22020
22367
  let hasRealEntry = false;
22021
22368
  for (const tok of transfer.tokens) {
@@ -22030,8 +22377,25 @@ var AccountingModule = class _AccountingModule {
22030
22377
  }
22031
22378
  if (!hasRealEntry) {
22032
22379
  existingLedger.set(syntheticKey, { ...syntheticRef });
22380
+ mutated = true;
22381
+ }
22382
+ }
22383
+ for (const tok of transfer.tokens) {
22384
+ if (!tok.id) continue;
22385
+ if (!this.tokenInvoiceMap.has(tok.id)) {
22386
+ this.tokenInvoiceMap.set(tok.id, /* @__PURE__ */ new Set());
22387
+ mutated = true;
22388
+ }
22389
+ const beforeSize = this.tokenInvoiceMap.get(tok.id).size;
22390
+ this.tokenInvoiceMap.get(tok.id).add(invoiceId);
22391
+ if (this.tokenInvoiceMap.get(tok.id).size !== beforeSize) {
22392
+ mutated = true;
22033
22393
  }
22034
22394
  }
22395
+ if (mutated) {
22396
+ this.dirtyLedgerEntries.add(invoiceId);
22397
+ this.balanceCache.delete(invoiceId);
22398
+ }
22035
22399
  deps.emitEvent("invoice:payment", {
22036
22400
  invoiceId,
22037
22401
  transfer: syntheticRef,
@@ -22181,7 +22545,8 @@ var AccountingModule = class _AccountingModule {
22181
22545
  this.invoiceLedger.set(invoiceId, /* @__PURE__ */ new Map());
22182
22546
  }
22183
22547
  const hLedger = this.invoiceLedger.get(invoiceId);
22184
- const hKey = entry.tokenId ? `synthetic:${entry.tokenId}::${syntheticRef.coinId}` : `synthetic:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22548
+ const hKey = entry.tokenId ? `synthetic:${entry.tokenId}::${syntheticRef.coinId}` : `synthetic-tx:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22549
+ let hMutated = false;
22185
22550
  if (!hLedger.has(hKey)) {
22186
22551
  let hasRealEntry = false;
22187
22552
  if (entry.tokenId) {
@@ -22194,8 +22559,24 @@ var AccountingModule = class _AccountingModule {
22194
22559
  }
22195
22560
  if (!hasRealEntry) {
22196
22561
  hLedger.set(hKey, { ...syntheticRef });
22562
+ hMutated = true;
22197
22563
  }
22198
22564
  }
22565
+ if (entry.tokenId) {
22566
+ if (!this.tokenInvoiceMap.has(entry.tokenId)) {
22567
+ this.tokenInvoiceMap.set(entry.tokenId, /* @__PURE__ */ new Set());
22568
+ hMutated = true;
22569
+ }
22570
+ const beforeSize = this.tokenInvoiceMap.get(entry.tokenId).size;
22571
+ this.tokenInvoiceMap.get(entry.tokenId).add(invoiceId);
22572
+ if (this.tokenInvoiceMap.get(entry.tokenId).size !== beforeSize) {
22573
+ hMutated = true;
22574
+ }
22575
+ }
22576
+ if (hMutated) {
22577
+ this.dirtyLedgerEntries.add(invoiceId);
22578
+ this.balanceCache.delete(invoiceId);
22579
+ }
22199
22580
  deps.emitEvent("invoice:payment", {
22200
22581
  invoiceId,
22201
22582
  transfer: syntheticRef,
@@ -24726,17 +25107,63 @@ var SwapModule = class {
24726
25107
  for (const addr of allAddresses) {
24727
25108
  myDirectAddresses.add(addr.directAddress);
24728
25109
  }
24729
- let assetIndex;
24730
- if (myDirectAddresses.has(swap.manifest.party_a_address)) {
24731
- assetIndex = 0;
24732
- } else if (myDirectAddresses.has(swap.manifest.party_b_address)) {
24733
- assetIndex = 1;
25110
+ const matchesPartyA = myDirectAddresses.has(swap.manifest.party_a_address);
25111
+ const matchesPartyB = myDirectAddresses.has(swap.manifest.party_b_address);
25112
+ if (matchesPartyA && matchesPartyB) {
25113
+ throw new SphereError(
25114
+ "Ambiguous party identity: local wallet matches both party_a_address and party_b_address",
25115
+ "SWAP_DEPOSIT_FAILED"
25116
+ );
25117
+ }
25118
+ let myExpectedCurrency;
25119
+ if (matchesPartyA) {
25120
+ myExpectedCurrency = swap.manifest.party_a_currency_to_change;
25121
+ } else if (matchesPartyB) {
25122
+ myExpectedCurrency = swap.manifest.party_b_currency_to_change;
24734
25123
  } else {
24735
25124
  throw new SphereError(
24736
25125
  "Local wallet address does not match either party in the swap manifest",
24737
25126
  "SWAP_DEPOSIT_FAILED"
24738
25127
  );
24739
25128
  }
25129
+ if (!myExpectedCurrency || myExpectedCurrency === "") {
25130
+ throw new SphereError(
25131
+ "Manifest currency_to_change is empty for this party",
25132
+ "SWAP_DEPOSIT_FAILED"
25133
+ );
25134
+ }
25135
+ const invoiceRefForAssetLookup = deps.accounting.getInvoice(swap.depositInvoiceId);
25136
+ if (!invoiceRefForAssetLookup) {
25137
+ throw new SphereError(
25138
+ "Deposit invoice not yet imported into accounting module",
25139
+ "SWAP_WRONG_STATE"
25140
+ );
25141
+ }
25142
+ const depositTarget = invoiceRefForAssetLookup.terms.targets[0];
25143
+ if (!depositTarget) {
25144
+ throw new SphereError(
25145
+ "Deposit invoice has no targets",
25146
+ "SWAP_DEPOSIT_FAILED"
25147
+ );
25148
+ }
25149
+ const assetIndex = depositTarget.assets.findIndex(
25150
+ (a) => a.coin !== void 0 && coinIdsMatch(a.coin[0], myExpectedCurrency)
25151
+ );
25152
+ if (assetIndex < 0) {
25153
+ throw new SphereError(
25154
+ `No asset matching expected currency ${myExpectedCurrency} found in deposit invoice`,
25155
+ "SWAP_DEPOSIT_FAILED"
25156
+ );
25157
+ }
25158
+ for (let i = assetIndex + 1; i < depositTarget.assets.length; i += 1) {
25159
+ const a = depositTarget.assets[i];
25160
+ if (a?.coin !== void 0 && coinIdsMatch(a.coin[0], myExpectedCurrency)) {
25161
+ throw new SphereError(
25162
+ `Ambiguous asset match in deposit invoice: slots ${assetIndex} and ${i} both match currency ${myExpectedCurrency}`,
25163
+ "SWAP_DEPOSIT_FAILED"
25164
+ );
25165
+ }
25166
+ }
24740
25167
  return this.withSwapGate(swapId, async () => {
24741
25168
  if (swap.progress !== "announced") {
24742
25169
  throw new SphereError(
@@ -24842,7 +25269,6 @@ var SwapModule = class {
24842
25269
  swap.updatedAt = Date.now();
24843
25270
  this.clearLocalTimer(swap.swapId);
24844
25271
  this.terminalSwapIds.add(swap.swapId);
24845
- const entryIdx = this._storedTerminalEntries.length;
24846
25272
  this._storedTerminalEntries.push({
24847
25273
  swapId: swap.swapId,
24848
25274
  progress: "failed",
@@ -24857,7 +25283,13 @@ var SwapModule = class {
24857
25283
  swap.error = prevError;
24858
25284
  swap.updatedAt = prevUpdatedAt;
24859
25285
  this.terminalSwapIds.delete(swap.swapId);
24860
- this._storedTerminalEntries.splice(entryIdx, 1);
25286
+ for (let i = this._storedTerminalEntries.length - 1; i >= 0; i--) {
25287
+ const entry = this._storedTerminalEntries[i];
25288
+ if (entry.swapId === swap.swapId && entry.progress === "failed") {
25289
+ this._storedTerminalEntries.splice(i, 1);
25290
+ break;
25291
+ }
25292
+ }
24861
25293
  logger.warn(LOG_TAG3, `failPayout: persistSwap failed for ${swapId}; fraud detection will retry on next load:`, persistErr);
24862
25294
  throw persistErr;
24863
25295
  }
@@ -24901,9 +25333,25 @@ var SwapModule = class {
24901
25333
  if (!targetStatus.coinAssets[0].isCovered) {
24902
25334
  return returnFalse();
24903
25335
  }
24904
- if (BigInt(targetStatus.coinAssets[0].netCoveredAmount) < BigInt(expectedAmount)) {
25336
+ let netCoveredAmount;
25337
+ let expectedAmountBigInt;
25338
+ try {
25339
+ netCoveredAmount = BigInt(targetStatus.coinAssets[0].netCoveredAmount);
25340
+ expectedAmountBigInt = BigInt(expectedAmount);
25341
+ } catch (parseErr) {
25342
+ return failPayout(
25343
+ `MALFORMED_AMOUNT: failed to parse coverage amounts (netCoveredAmount=${targetStatus.coinAssets[0].netCoveredAmount}, expectedAmount=${expectedAmount}): ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`
25344
+ );
25345
+ }
25346
+ if (netCoveredAmount < expectedAmountBigInt) {
24905
25347
  return returnFalse();
24906
25348
  }
25349
+ if (netCoveredAmount > expectedAmountBigInt) {
25350
+ const surplus = netCoveredAmount - expectedAmountBigInt;
25351
+ return failPayout(
25352
+ `OVER_COVERAGE: net=${netCoveredAmount.toString()}, expected=${expectedAmount}, surplus=${surplus.toString()} \u2014 surplus refund expected via auto-return; settlement halted`
25353
+ );
25354
+ }
24907
25355
  const escrowAddr = swap.deal.escrowAddress ?? this.config.defaultEscrowAddress;
24908
25356
  if (escrowAddr) {
24909
25357
  const escrowPeer = await deps.resolve(escrowAddr);
@@ -24913,8 +25361,28 @@ var SwapModule = class {
24913
25361
  }
24914
25362
  const validationResult = await deps.payments.validate();
24915
25363
  if (validationResult.invalid.length > 0) {
24916
- logger.warn(LOG_TAG3, `verifyPayout for ${swapId.slice(0, 12)}: L3 validation found ${validationResult.invalid.length} invalid token(s) \u2014 retry after wallet sync`);
24917
- return returnFalse();
25364
+ const payoutTokenIds = deps.accounting.getTokenIdsForInvoice?.(swap.payoutInvoiceId) ?? /* @__PURE__ */ new Set();
25365
+ if (payoutTokenIds.size === 0) {
25366
+ logger.warn(
25367
+ LOG_TAG3,
25368
+ `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`
25369
+ );
25370
+ return returnFalse();
25371
+ }
25372
+ const relevantInvalid = validationResult.invalid.filter(
25373
+ (t) => payoutTokenIds.has(t.id)
25374
+ );
25375
+ if (relevantInvalid.length > 0) {
25376
+ logger.warn(
25377
+ LOG_TAG3,
25378
+ `verifyPayout for ${swapId.slice(0, 12)}: L3 validation found ${relevantInvalid.length} invalid token(s) covering this payout invoice \u2014 retry after wallet sync`
25379
+ );
25380
+ return returnFalse();
25381
+ }
25382
+ logger.debug(
25383
+ LOG_TAG3,
25384
+ `verifyPayout for ${swapId.slice(0, 12)}: ${validationResult.invalid.length} unrelated invalid token(s) ignored (not linked to this payout invoice)`
25385
+ );
24918
25386
  }
24919
25387
  if (swap.progress === "completed") {
24920
25388
  swap.payoutVerified = true;
@@ -25093,8 +25561,22 @@ var SwapModule = class {
25093
25561
  * @param dm - The incoming direct message.
25094
25562
  */
25095
25563
  handleIncomingDM(dm) {
25564
+ if (dm.content.startsWith("{") && dm.content.includes('"invoice_delivery"')) {
25565
+ logger.warn(
25566
+ LOG_TAG3,
25567
+ `diag_swap_dm_arrived sender=${dm.senderPubkey.slice(0, 16)} length=${dm.content.length}`
25568
+ );
25569
+ }
25096
25570
  const parsed = parseSwapDM(dm.content);
25097
- if (!parsed) return;
25571
+ if (!parsed) {
25572
+ if (dm.content.startsWith("{") && dm.content.includes('"invoice_delivery"')) {
25573
+ logger.warn(
25574
+ LOG_TAG3,
25575
+ `diag_swap_dm_parse_rejected sender=${dm.senderPubkey.slice(0, 16)} prefix=${dm.content.slice(0, 80)}`
25576
+ );
25577
+ }
25578
+ return;
25579
+ }
25098
25580
  void (async () => {
25099
25581
  try {
25100
25582
  switch (parsed.kind) {
@@ -25460,16 +25942,43 @@ var SwapModule = class {
25460
25942
  // invoice_delivery (§12.4.2 + §12.4.3)
25461
25943
  // ---------------------------------------------------------------
25462
25944
  case "invoice_delivery": {
25463
- if (!swapId) return;
25945
+ logger.warn(
25946
+ LOG_TAG3,
25947
+ `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)}`
25948
+ );
25949
+ if (!swapId) {
25950
+ logger.warn(LOG_TAG3, "diag_invoice_delivery_dropped reason=no_swap_id");
25951
+ return;
25952
+ }
25464
25953
  const swap = this.swaps.get(swapId);
25465
- if (!swap) return;
25466
- if (!this.isFromExpectedEscrow(dm.senderPubkey, swap)) return;
25954
+ if (!swap) {
25955
+ logger.warn(
25956
+ LOG_TAG3,
25957
+ `diag_invoice_delivery_dropped reason=swap_not_in_map swap_id=${swapId.slice(0, 16)} known_swap_ids_count=${this.swaps.size}`
25958
+ );
25959
+ return;
25960
+ }
25961
+ if (!this.isFromExpectedEscrow(dm.senderPubkey, swap)) {
25962
+ logger.warn(
25963
+ LOG_TAG3,
25964
+ `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)}`
25965
+ );
25966
+ return;
25967
+ }
25467
25968
  const deps = this.deps;
25468
25969
  if (msg.invoice_type === "deposit") {
25970
+ logger.warn(
25971
+ LOG_TAG3,
25972
+ `diag_invoice_delivery_proceeding_to_import swap_id=${swapId.slice(0, 16)} progress=${swap.progress} invoice_id=${(msg.invoice_id ?? "").slice(0, 16)}`
25973
+ );
25469
25974
  await this.withSwapGate(swapId, async () => {
25470
25975
  if (isTerminalProgress(swap.progress)) return;
25471
25976
  try {
25472
25977
  await deps.accounting.importInvoice(msg.invoice_token);
25978
+ logger.warn(
25979
+ LOG_TAG3,
25980
+ `diag_invoice_imported swap_id=${swapId.slice(0, 16)} invoice_id=${(msg.invoice_id ?? "").slice(0, 16)} type=deposit`
25981
+ );
25473
25982
  } catch (err) {
25474
25983
  if (err instanceof SphereError && err.code === "INVOICE_ALREADY_EXISTS") {
25475
25984
  logger.debug(LOG_TAG3, `Deposit invoice for swap ${swapId} already imported \u2014 relay re-delivery, continuing`);
@@ -30424,6 +30933,7 @@ async function runCustomCheck(name, checkFn, timeoutMs) {
30424
30933
  randomBytes,
30425
30934
  randomHex,
30426
30935
  randomUUID,
30936
+ recoverPubkeyFromSignature,
30427
30937
  ripemd160,
30428
30938
  scanAddressesImpl,
30429
30939
  serializeEncrypted,