@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.
package/dist/index.js CHANGED
@@ -4927,6 +4927,27 @@ function verifySignedMessage(message, signature, expectedPubkey) {
4927
4927
  return false;
4928
4928
  }
4929
4929
  }
4930
+ function recoverPubkeyFromSignature(message, signature) {
4931
+ if (signature.length !== 130) {
4932
+ throw new SphereError(
4933
+ `Invalid signature length: expected 130 hex chars, got ${signature.length}`,
4934
+ "SIGNING_ERROR"
4935
+ );
4936
+ }
4937
+ const v = parseInt(signature.slice(0, 2), 16) - 31;
4938
+ const r = signature.slice(2, 66);
4939
+ const s = signature.slice(66, 130);
4940
+ if (v < 0 || v > 3) {
4941
+ throw new SphereError(
4942
+ `Invalid recovery byte: v=${v} out of range [0..3]`,
4943
+ "SIGNING_ERROR"
4944
+ );
4945
+ }
4946
+ const hashHex = hashSignMessage(message);
4947
+ const hashBytes = Buffer.from(hashHex, "hex");
4948
+ const recovered = ec.recoverPubKey(hashBytes, { r, s }, v);
4949
+ return recovered.encode("hex", true);
4950
+ }
4930
4951
 
4931
4952
  // l1/crypto.ts
4932
4953
  import CryptoJS3 from "crypto-js";
@@ -12347,6 +12368,132 @@ var PaymentsModule = class _PaymentsModule {
12347
12368
  };
12348
12369
  }
12349
12370
  }
12371
+ /**
12372
+ * Mint a fungible token directly to this wallet (genesis mint).
12373
+ *
12374
+ * Useful for test setups that need to seed a wallet with specific token
12375
+ * balances WITHOUT depending on the testnet faucet HTTP service. The
12376
+ * resulting token has the canonical CoinId bytes (passed in `coinIdHex`)
12377
+ * — when those bytes match a registered symbol in the TokenRegistry,
12378
+ * the token shows up under the symbol's name (e.g. "UCT"). There is no
12379
+ * cryptographic restriction on which key may issue a given CoinId; the
12380
+ * aggregator records the mint regardless of issuer identity.
12381
+ *
12382
+ * The flow:
12383
+ * 1. Generate a random TokenId.
12384
+ * 2. Build TokenCoinData with [(coinId, amount)].
12385
+ * 3. Build MintTransactionData with recipient = self (UnmaskedPredicate
12386
+ * from this wallet's signing service).
12387
+ * 4. Submit MintCommitment to the aggregator.
12388
+ * 5. Wait for the inclusion proof.
12389
+ * 6. Construct an SDK Token via Token.mint().
12390
+ * 7. Convert to wallet Token format and call addToken().
12391
+ *
12392
+ * @param coinIdHex - 64-char lowercase hex CoinId. Must match the bytes
12393
+ * used by the registered symbol if you want the wallet to recognize
12394
+ * the token as that symbol (e.g. UCT's coinId from the public registry).
12395
+ * @param amount - Amount in smallest units (multiply by 10^decimals
12396
+ * when converting from human values).
12397
+ * @returns Result with the resulting wallet Token and its on-chain id.
12398
+ */
12399
+ async mintFungibleToken(coinIdHex, amount) {
12400
+ this.ensureInitialized();
12401
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
12402
+ if (!stClient) {
12403
+ return { success: false, error: "State transition client not available" };
12404
+ }
12405
+ const trustBase = this.deps.oracle.getTrustBase?.();
12406
+ if (!trustBase) {
12407
+ return { success: false, error: "Trust base not available" };
12408
+ }
12409
+ try {
12410
+ const signingService = await this.createSigningService();
12411
+ const { TokenId: TokenId5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenId");
12412
+ const { TokenCoinData: TokenCoinData3 } = await import("@unicitylabs/state-transition-sdk/lib/token/fungible/TokenCoinData");
12413
+ const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
12414
+ const tokenTypeBytes = fromHex4("f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509");
12415
+ const tokenType = new TokenType3(tokenTypeBytes);
12416
+ const tokenIdBytes = new Uint8Array(32);
12417
+ crypto.getRandomValues(tokenIdBytes);
12418
+ const tokenId = new TokenId5(tokenIdBytes);
12419
+ const coinIdBytes = fromHex4(coinIdHex);
12420
+ const coinId = new CoinId4(coinIdBytes);
12421
+ const coinData = TokenCoinData3.create([[coinId, amount]]);
12422
+ const addressRef = await UnmaskedPredicateReference4.create(
12423
+ tokenType,
12424
+ signingService.algorithm,
12425
+ signingService.publicKey,
12426
+ HashAlgorithm5.SHA256
12427
+ );
12428
+ const ownerAddress = await addressRef.toAddress();
12429
+ const salt = new Uint8Array(32);
12430
+ crypto.getRandomValues(salt);
12431
+ const mintData = await MintTransactionData3.create(
12432
+ tokenId,
12433
+ tokenType,
12434
+ null,
12435
+ // tokenData: no metadata
12436
+ coinData,
12437
+ // fungible coin data
12438
+ ownerAddress,
12439
+ // recipient = self
12440
+ salt,
12441
+ null,
12442
+ // recipientDataHash
12443
+ null
12444
+ // reason: null (genesis, no burn predecessor)
12445
+ );
12446
+ const commitment = await MintCommitment3.create(mintData);
12447
+ const MAX_RETRIES = 3;
12448
+ let lastStatus;
12449
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
12450
+ const response = await stClient.submitMintCommitment(commitment);
12451
+ lastStatus = response.status;
12452
+ if (response.status === "SUCCESS" || response.status === "REQUEST_ID_EXISTS") break;
12453
+ if (attempt === MAX_RETRIES) {
12454
+ return { success: false, error: `Mint submit failed after ${MAX_RETRIES} attempts: ${response.status}` };
12455
+ }
12456
+ await new Promise((r) => setTimeout(r, 1e3 * attempt));
12457
+ }
12458
+ if (lastStatus !== "SUCCESS" && lastStatus !== "REQUEST_ID_EXISTS") {
12459
+ return { success: false, error: `Mint submit failed: ${lastStatus}` };
12460
+ }
12461
+ const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
12462
+ const genesisTransaction = commitment.toTransaction(inclusionProof);
12463
+ const predicate = await UnmaskedPredicate5.create(
12464
+ tokenId,
12465
+ tokenType,
12466
+ signingService,
12467
+ HashAlgorithm5.SHA256,
12468
+ salt
12469
+ );
12470
+ const tokenState = new TokenState5(predicate, null);
12471
+ const sdkToken = await SdkToken2.mint(trustBase, tokenState, genesisTransaction);
12472
+ const tokenIdHex = tokenId.toJSON();
12473
+ const symbol = this.getCoinSymbol(coinIdHex);
12474
+ const name = this.getCoinName(coinIdHex);
12475
+ const decimals = this.getCoinDecimals(coinIdHex);
12476
+ const iconUrl = this.getCoinIconUrl(coinIdHex);
12477
+ const uiToken = {
12478
+ id: tokenIdHex,
12479
+ coinId: coinIdHex,
12480
+ symbol,
12481
+ name,
12482
+ decimals,
12483
+ ...iconUrl !== void 0 ? { iconUrl } : {},
12484
+ amount: amount.toString(),
12485
+ status: "confirmed",
12486
+ createdAt: Date.now(),
12487
+ updatedAt: Date.now(),
12488
+ sdkData: JSON.stringify(sdkToken.toJSON())
12489
+ };
12490
+ await this.addToken(uiToken);
12491
+ return { success: true, token: uiToken, tokenId: tokenIdHex };
12492
+ } catch (err) {
12493
+ const msg = err instanceof Error ? err.message : String(err);
12494
+ return { success: false, error: `Local mint failed: ${msg}` };
12495
+ }
12496
+ }
12350
12497
  /**
12351
12498
  * Check if a nametag is available for minting
12352
12499
  * @param nametag - The nametag to check (e.g., "alice" or "@alice")
@@ -18557,6 +18704,24 @@ var AccountingModule = class _AccountingModule {
18557
18704
  dirtyLedgerEntries = /* @__PURE__ */ new Set();
18558
18705
  /** Count of unknown (not in invoiceTermsCache) invoice IDs in the ledger. */
18559
18706
  unknownLedgerCount = 0;
18707
+ /**
18708
+ * Per-unknown-invoice first-seen timestamp for TTL eviction.
18709
+ *
18710
+ * W1 (steelman round-4): without TTL, an attacker who can deliver 500
18711
+ * inbound transfers with synthesized memo invoiceIds permanently exhausts
18712
+ * the unknown-ledger cap, after which legitimate orphan transfers (out-of-
18713
+ * order delivery for real swaps) are silently dropped at the cap-check.
18714
+ *
18715
+ * Round-5 perf: gated by `unknownLedgerNextSweepMs` to amortize the
18716
+ * sweep cost. The naive every-call sweep is O(N) where N=cap=500;
18717
+ * combined with the per-token cleanup loop inside the sweep it became
18718
+ * O(N×M) on every transfer under flood. Now we sweep at most every
18719
+ * `UNKNOWN_LEDGER_SWEEP_INTERVAL_MS` (60s) UNLESS the cap is currently
18720
+ * full, in which case we sweep on each call (the only path that can
18721
+ * actually drop a legitimate orphan).
18722
+ */
18723
+ unknownLedgerFirstSeen = /* @__PURE__ */ new Map();
18724
+ unknownLedgerNextSweepMs = 0;
18560
18725
  /** W17: Tracks whether tokenScanState has been mutated since last flush. */
18561
18726
  tokenScanDirty = false;
18562
18727
  /** W2 fix: Serialization guard for _flushDirtyLedgerEntries. */
@@ -19547,6 +19712,7 @@ var AccountingModule = class _AccountingModule {
19547
19712
  }
19548
19713
  if (this.invoiceLedger.has(tokenId) && !this.invoiceTermsCache.has(tokenId)) {
19549
19714
  this.unknownLedgerCount = Math.max(0, this.unknownLedgerCount - 1);
19715
+ this.unknownLedgerFirstSeen.delete(tokenId);
19550
19716
  }
19551
19717
  this.invoiceTermsCache.set(tokenId, terms);
19552
19718
  this._addToHashIndex(tokenId);
@@ -19815,6 +19981,29 @@ var AccountingModule = class _AccountingModule {
19815
19981
  closed: this.closedInvoices.has(invoiceId)
19816
19982
  };
19817
19983
  }
19984
+ /**
19985
+ * Return the set of token IDs that are currently linked to the given
19986
+ * invoice. Populated by both the on-chain `_processTokenTransactions`
19987
+ * path (tokens with `inv:` references) and the transport-memo orphan
19988
+ * buffering path in `_handleIncomingTransfer`.
19989
+ *
19990
+ * Used by callers that want to scope per-invoice operations (e.g.
19991
+ * SwapModule.verifyPayout's L3 validation) to only the tokens that
19992
+ * cover this invoice — avoiding false negatives when the wallet
19993
+ * contains unrelated tokens of the same currency in unconfirmed or
19994
+ * spent state.
19995
+ *
19996
+ * Returns an empty set if no tokens are currently linked.
19997
+ */
19998
+ getTokenIdsForInvoice(invoiceId) {
19999
+ const result = /* @__PURE__ */ new Set();
20000
+ for (const [tokenId, invoiceIds] of this.tokenInvoiceMap) {
20001
+ if (invoiceIds.has(invoiceId)) {
20002
+ result.add(tokenId);
20003
+ }
20004
+ }
20005
+ return result;
20006
+ }
19818
20007
  /**
19819
20008
  * Explicitly close an invoice. Only target parties may close (§8.3).
19820
20009
  *
@@ -20134,6 +20323,7 @@ var AccountingModule = class _AccountingModule {
20134
20323
  ledger.set(entryKey, forwardRef);
20135
20324
  this.dirtyLedgerEntries.add(invoiceId);
20136
20325
  this.balanceCache.delete(invoiceId);
20326
+ await this._persistProvisionalAndVerify(invoiceId, "payInvoice");
20137
20327
  }
20138
20328
  return result;
20139
20329
  } finally {
@@ -20301,6 +20491,7 @@ var AccountingModule = class _AccountingModule {
20301
20491
  this.dirtyLedgerEntries.add(invoiceId);
20302
20492
  }
20303
20493
  this.balanceCache.delete(invoiceId);
20494
+ await this._persistProvisionalAndVerify(invoiceId, "returnInvoicePayment");
20304
20495
  }
20305
20496
  return result;
20306
20497
  } finally {
@@ -21380,9 +21571,30 @@ var AccountingModule = class _AccountingModule {
21380
21571
  continue;
21381
21572
  }
21382
21573
  innerMap.set(entryKey, ref);
21383
- if (!ref.transferId.startsWith("provisional:") && ref.transferId.includes(":")) {
21574
+ const HEX_64 = /^[a-f0-9]{64}$/i;
21575
+ if (entryKey.startsWith("mt:")) {
21576
+ const firstColon = entryKey.indexOf(":");
21577
+ const secondColon = entryKey.indexOf(":", firstColon + 1);
21578
+ if (secondColon > firstColon + 1) {
21579
+ const tokenIdFromKey2 = entryKey.slice(firstColon + 1, secondColon);
21580
+ if (HEX_64.test(tokenIdFromKey2)) {
21581
+ this._addToTokenInvoiceMap(tokenIdFromKey2, invoiceId);
21582
+ }
21583
+ }
21584
+ } else if (entryKey.startsWith("synthetic:")) {
21585
+ const afterPrefix = entryKey.slice("synthetic:".length);
21586
+ const tokenIdEnd = afterPrefix.indexOf(":");
21587
+ if (tokenIdEnd > 0) {
21588
+ const tokenId = afterPrefix.slice(0, tokenIdEnd);
21589
+ if (HEX_64.test(tokenId) && ref.transferId !== tokenId) {
21590
+ this._addToTokenInvoiceMap(tokenId, invoiceId);
21591
+ }
21592
+ }
21593
+ } else if (!ref.transferId.startsWith("provisional:") && ref.transferId.includes(":")) {
21384
21594
  const tokenIdFromRef = ref.transferId.slice(0, ref.transferId.indexOf(":"));
21385
- this._addToTokenInvoiceMap(tokenIdFromRef, invoiceId);
21595
+ if (HEX_64.test(tokenIdFromRef)) {
21596
+ this._addToTokenInvoiceMap(tokenIdFromRef, invoiceId);
21597
+ }
21386
21598
  }
21387
21599
  }
21388
21600
  } catch (err) {
@@ -21583,7 +21795,14 @@ var AccountingModule = class _AccountingModule {
21583
21795
  }
21584
21796
  }
21585
21797
  for (const [existingKey, existingRef] of ledger) {
21586
- if (existingKey.startsWith("synthetic:") && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21798
+ if ((existingKey.startsWith("synthetic:") || existingKey.startsWith("synthetic-tx:")) && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21799
+ keysToDelete.push(existingKey);
21800
+ break;
21801
+ }
21802
+ }
21803
+ const mtPrefix = `mt:${tokenId}:`;
21804
+ for (const [existingKey, existingRef] of ledger) {
21805
+ if (existingKey.startsWith(mtPrefix) && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21587
21806
  keysToDelete.push(existingKey);
21588
21807
  break;
21589
21808
  }
@@ -21700,6 +21919,49 @@ var AccountingModule = class _AccountingModule {
21700
21919
  });
21701
21920
  }
21702
21921
  }
21922
+ /**
21923
+ * Synchronously persist any pending provisional ledger entry for `invoiceId`
21924
+ * before returning to the caller. Used by `payInvoice` and
21925
+ * `returnInvoicePayment` to make the in-memory provisional entry durable
21926
+ * inside the same per-invoice gate that wrote it, closing the
21927
+ * crash-mid-conclude race that produces over-coverage on receivers.
21928
+ *
21929
+ * Implementation:
21930
+ * 1. Schedule a flush via the existing `_flushPromise` chain (so
21931
+ * concurrent `_handleTokenChange` callers waiting on the chain
21932
+ * observe ours as part of the sequence).
21933
+ * 2. Await OUR flush directly — NOT `_drainFlushPromise()`, which would
21934
+ * spin while concurrent token changes keep extending the chain and
21935
+ * hold the per-invoice gate for an unbounded number of additional
21936
+ * flushes. We only need OUR provisional entry durable.
21937
+ * 3. `_flushDirtyLedgerEntries` swallows per-invoice `storage.set`
21938
+ * rejections internally (sets a local `step1Failed` flag), leaving
21939
+ * the dirty entry on the set without re-throwing. So we post-check
21940
+ * `dirtyLedgerEntries.has(invoiceId)` and throw a `STORAGE_ERROR`
21941
+ * `SphereError` if our entry is still dirty — propagating to the
21942
+ * caller so they learn about the durability failure rather than
21943
+ * receiving a silent "success" return that lies on disk.
21944
+ *
21945
+ * @param invoiceId The invoice whose provisional entry must be durable.
21946
+ * @param callContext Used in the error message so the caller is named
21947
+ * ('payInvoice' / 'returnInvoicePayment') without
21948
+ * forcing a stack-trace inspection.
21949
+ */
21950
+ async _persistProvisionalAndVerify(invoiceId, callContext) {
21951
+ const flushTrigger = (this._flushPromise ?? Promise.resolve()).then(() => this._flushDirtyLedgerEntries());
21952
+ const tracked = flushTrigger.catch(() => {
21953
+ }).finally(() => {
21954
+ if (this._flushPromise === tracked) this._flushPromise = null;
21955
+ });
21956
+ this._flushPromise = tracked;
21957
+ await flushTrigger;
21958
+ if (this.dirtyLedgerEntries.has(invoiceId)) {
21959
+ throw new SphereError(
21960
+ `${callContext}: provisional ledger entry for invoice ${invoiceId} failed to persist \u2014 caller should retry`,
21961
+ "STORAGE_ERROR"
21962
+ );
21963
+ }
21964
+ }
21703
21965
  // ===========================================================================
21704
21966
  // Internal: Event handlers
21705
21967
  // ===========================================================================
@@ -21760,13 +22022,96 @@ var AccountingModule = class _AccountingModule {
21760
22022
  }
21761
22023
  }
21762
22024
  if (!this.invoiceTermsCache.has(invoiceId)) {
21763
- const syntheticRef = this._buildSyntheticTransferRef(
21764
- transfer,
21765
- invoiceId,
21766
- paymentDirection,
21767
- confirmed
21768
- );
21769
- deps.emitEvent("invoice:unknown_reference", { invoiceId, transfer: syntheticRef });
22025
+ let gracefullyGraduated = false;
22026
+ await this.withInvoiceGate(invoiceId, async () => {
22027
+ if (this.invoiceTermsCache.has(invoiceId)) {
22028
+ gracefullyGraduated = true;
22029
+ return;
22030
+ }
22031
+ const syntheticRef = this._buildSyntheticTransferRef(
22032
+ transfer,
22033
+ invoiceId,
22034
+ paymentDirection,
22035
+ confirmed
22036
+ );
22037
+ deps.emitEvent("invoice:unknown_reference", { invoiceId, transfer: syntheticRef });
22038
+ const MAX_UNKNOWN_INVOICE_IDS = 500;
22039
+ const UNKNOWN_LEDGER_TTL_MS = 30 * 60 * 1e3;
22040
+ const MAX_ORPHAN_ENTRIES_PER_INVOICE = 50;
22041
+ const UNKNOWN_LEDGER_SWEEP_INTERVAL_MS = 6e4;
22042
+ const nowMs = Date.now();
22043
+ const capFull = this.unknownLedgerCount >= MAX_UNKNOWN_INVOICE_IDS;
22044
+ const sweepDue = nowMs >= this.unknownLedgerNextSweepMs;
22045
+ if (this.unknownLedgerFirstSeen.size > 0 && (capFull || sweepDue)) {
22046
+ this.unknownLedgerNextSweepMs = nowMs + UNKNOWN_LEDGER_SWEEP_INTERVAL_MS;
22047
+ const expiredIds = [];
22048
+ for (const [unkId, firstSeen] of this.unknownLedgerFirstSeen) {
22049
+ if (nowMs - firstSeen > UNKNOWN_LEDGER_TTL_MS) {
22050
+ expiredIds.push(unkId);
22051
+ }
22052
+ }
22053
+ for (const expiredId of expiredIds) {
22054
+ if (!this.invoiceTermsCache.has(expiredId) && this.invoiceLedger.has(expiredId)) {
22055
+ this.invoiceLedger.delete(expiredId);
22056
+ this.unknownLedgerCount = Math.max(0, this.unknownLedgerCount - 1);
22057
+ for (const [tokenId, invoiceSet] of this.tokenInvoiceMap) {
22058
+ if (invoiceSet.has(expiredId)) {
22059
+ invoiceSet.delete(expiredId);
22060
+ if (invoiceSet.size === 0) this.tokenInvoiceMap.delete(tokenId);
22061
+ }
22062
+ }
22063
+ }
22064
+ this.unknownLedgerFirstSeen.delete(expiredId);
22065
+ }
22066
+ }
22067
+ if (!this.invoiceLedger.has(invoiceId)) {
22068
+ if (this.unknownLedgerCount >= MAX_UNKNOWN_INVOICE_IDS) {
22069
+ return;
22070
+ }
22071
+ this.invoiceLedger.set(invoiceId, /* @__PURE__ */ new Map());
22072
+ this.unknownLedgerCount++;
22073
+ this.unknownLedgerFirstSeen.set(invoiceId, nowMs);
22074
+ }
22075
+ const orphanLedger = this.invoiceLedger.get(invoiceId);
22076
+ let mtEntryCount = 0;
22077
+ for (const k of orphanLedger.keys()) {
22078
+ if (k.startsWith("mt:")) mtEntryCount++;
22079
+ }
22080
+ if (mtEntryCount >= MAX_ORPHAN_ENTRIES_PER_INVOICE) {
22081
+ return;
22082
+ }
22083
+ for (const token of transfer.tokens) {
22084
+ if (!token.id) continue;
22085
+ let onChainAttributed = false;
22086
+ const tokenKeyPrefix = `${token.id}:`;
22087
+ for (const existingKey of orphanLedger.keys()) {
22088
+ if (existingKey.startsWith(tokenKeyPrefix) && !existingKey.startsWith("mt:")) {
22089
+ onChainAttributed = true;
22090
+ break;
22091
+ }
22092
+ }
22093
+ if (!onChainAttributed) {
22094
+ if (mtEntryCount >= MAX_ORPHAN_ENTRIES_PER_INVOICE) {
22095
+ break;
22096
+ }
22097
+ const orphanKey = `mt:${token.id}:${transfer.id}`;
22098
+ if (!orphanLedger.has(orphanKey)) {
22099
+ orphanLedger.set(orphanKey, syntheticRef);
22100
+ mtEntryCount++;
22101
+ }
22102
+ }
22103
+ if (!this.tokenInvoiceMap.has(token.id)) {
22104
+ this.tokenInvoiceMap.set(token.id, /* @__PURE__ */ new Set());
22105
+ }
22106
+ this.tokenInvoiceMap.get(token.id).add(invoiceId);
22107
+ }
22108
+ this.dirtyLedgerEntries.add(invoiceId);
22109
+ this.balanceCache.delete(invoiceId);
22110
+ await this._flushDirtyLedgerEntries();
22111
+ });
22112
+ if (gracefullyGraduated) {
22113
+ await this._processInvoiceTransferEvent(transfer, invoiceId, paymentDirection, confirmed);
22114
+ }
21770
22115
  return;
21771
22116
  }
21772
22117
  await this._processInvoiceTransferEvent(transfer, invoiceId, paymentDirection, confirmed);
@@ -22202,7 +22547,8 @@ var AccountingModule = class _AccountingModule {
22202
22547
  }
22203
22548
  const existingLedger = this.invoiceLedger.get(invoiceId);
22204
22549
  const firstTokenId = transfer.tokens.find((t) => t.id)?.id;
22205
- const syntheticKey = firstTokenId ? `synthetic:${firstTokenId}::${syntheticRef.coinId}` : `synthetic:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22550
+ const syntheticKey = firstTokenId ? `synthetic:${firstTokenId}::${syntheticRef.coinId}` : `synthetic-tx:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22551
+ let mutated = false;
22206
22552
  if (!existingLedger.has(syntheticKey)) {
22207
22553
  let hasRealEntry = false;
22208
22554
  for (const tok of transfer.tokens) {
@@ -22217,8 +22563,25 @@ var AccountingModule = class _AccountingModule {
22217
22563
  }
22218
22564
  if (!hasRealEntry) {
22219
22565
  existingLedger.set(syntheticKey, { ...syntheticRef });
22566
+ mutated = true;
22220
22567
  }
22221
22568
  }
22569
+ for (const tok of transfer.tokens) {
22570
+ if (!tok.id) continue;
22571
+ if (!this.tokenInvoiceMap.has(tok.id)) {
22572
+ this.tokenInvoiceMap.set(tok.id, /* @__PURE__ */ new Set());
22573
+ mutated = true;
22574
+ }
22575
+ const beforeSize = this.tokenInvoiceMap.get(tok.id).size;
22576
+ this.tokenInvoiceMap.get(tok.id).add(invoiceId);
22577
+ if (this.tokenInvoiceMap.get(tok.id).size !== beforeSize) {
22578
+ mutated = true;
22579
+ }
22580
+ }
22581
+ if (mutated) {
22582
+ this.dirtyLedgerEntries.add(invoiceId);
22583
+ this.balanceCache.delete(invoiceId);
22584
+ }
22222
22585
  deps.emitEvent("invoice:payment", {
22223
22586
  invoiceId,
22224
22587
  transfer: syntheticRef,
@@ -22368,7 +22731,8 @@ var AccountingModule = class _AccountingModule {
22368
22731
  this.invoiceLedger.set(invoiceId, /* @__PURE__ */ new Map());
22369
22732
  }
22370
22733
  const hLedger = this.invoiceLedger.get(invoiceId);
22371
- const hKey = entry.tokenId ? `synthetic:${entry.tokenId}::${syntheticRef.coinId}` : `synthetic:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22734
+ const hKey = entry.tokenId ? `synthetic:${entry.tokenId}::${syntheticRef.coinId}` : `synthetic-tx:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22735
+ let hMutated = false;
22372
22736
  if (!hLedger.has(hKey)) {
22373
22737
  let hasRealEntry = false;
22374
22738
  if (entry.tokenId) {
@@ -22381,7 +22745,23 @@ var AccountingModule = class _AccountingModule {
22381
22745
  }
22382
22746
  if (!hasRealEntry) {
22383
22747
  hLedger.set(hKey, { ...syntheticRef });
22748
+ hMutated = true;
22749
+ }
22750
+ }
22751
+ if (entry.tokenId) {
22752
+ if (!this.tokenInvoiceMap.has(entry.tokenId)) {
22753
+ this.tokenInvoiceMap.set(entry.tokenId, /* @__PURE__ */ new Set());
22754
+ hMutated = true;
22384
22755
  }
22756
+ const beforeSize = this.tokenInvoiceMap.get(entry.tokenId).size;
22757
+ this.tokenInvoiceMap.get(entry.tokenId).add(invoiceId);
22758
+ if (this.tokenInvoiceMap.get(entry.tokenId).size !== beforeSize) {
22759
+ hMutated = true;
22760
+ }
22761
+ }
22762
+ if (hMutated) {
22763
+ this.dirtyLedgerEntries.add(invoiceId);
22764
+ this.balanceCache.delete(invoiceId);
22385
22765
  }
22386
22766
  deps.emitEvent("invoice:payment", {
22387
22767
  invoiceId,
@@ -24928,17 +25308,63 @@ var SwapModule = class {
24928
25308
  for (const addr of allAddresses) {
24929
25309
  myDirectAddresses.add(addr.directAddress);
24930
25310
  }
24931
- let assetIndex;
24932
- if (myDirectAddresses.has(swap.manifest.party_a_address)) {
24933
- assetIndex = 0;
24934
- } else if (myDirectAddresses.has(swap.manifest.party_b_address)) {
24935
- assetIndex = 1;
25311
+ const matchesPartyA = myDirectAddresses.has(swap.manifest.party_a_address);
25312
+ const matchesPartyB = myDirectAddresses.has(swap.manifest.party_b_address);
25313
+ if (matchesPartyA && matchesPartyB) {
25314
+ throw new SphereError(
25315
+ "Ambiguous party identity: local wallet matches both party_a_address and party_b_address",
25316
+ "SWAP_DEPOSIT_FAILED"
25317
+ );
25318
+ }
25319
+ let myExpectedCurrency;
25320
+ if (matchesPartyA) {
25321
+ myExpectedCurrency = swap.manifest.party_a_currency_to_change;
25322
+ } else if (matchesPartyB) {
25323
+ myExpectedCurrency = swap.manifest.party_b_currency_to_change;
24936
25324
  } else {
24937
25325
  throw new SphereError(
24938
25326
  "Local wallet address does not match either party in the swap manifest",
24939
25327
  "SWAP_DEPOSIT_FAILED"
24940
25328
  );
24941
25329
  }
25330
+ if (!myExpectedCurrency || myExpectedCurrency === "") {
25331
+ throw new SphereError(
25332
+ "Manifest currency_to_change is empty for this party",
25333
+ "SWAP_DEPOSIT_FAILED"
25334
+ );
25335
+ }
25336
+ const invoiceRefForAssetLookup = deps.accounting.getInvoice(swap.depositInvoiceId);
25337
+ if (!invoiceRefForAssetLookup) {
25338
+ throw new SphereError(
25339
+ "Deposit invoice not yet imported into accounting module",
25340
+ "SWAP_WRONG_STATE"
25341
+ );
25342
+ }
25343
+ const depositTarget = invoiceRefForAssetLookup.terms.targets[0];
25344
+ if (!depositTarget) {
25345
+ throw new SphereError(
25346
+ "Deposit invoice has no targets",
25347
+ "SWAP_DEPOSIT_FAILED"
25348
+ );
25349
+ }
25350
+ const assetIndex = depositTarget.assets.findIndex(
25351
+ (a) => a.coin !== void 0 && coinIdsMatch(a.coin[0], myExpectedCurrency)
25352
+ );
25353
+ if (assetIndex < 0) {
25354
+ throw new SphereError(
25355
+ `No asset matching expected currency ${myExpectedCurrency} found in deposit invoice`,
25356
+ "SWAP_DEPOSIT_FAILED"
25357
+ );
25358
+ }
25359
+ for (let i = assetIndex + 1; i < depositTarget.assets.length; i += 1) {
25360
+ const a = depositTarget.assets[i];
25361
+ if (a?.coin !== void 0 && coinIdsMatch(a.coin[0], myExpectedCurrency)) {
25362
+ throw new SphereError(
25363
+ `Ambiguous asset match in deposit invoice: slots ${assetIndex} and ${i} both match currency ${myExpectedCurrency}`,
25364
+ "SWAP_DEPOSIT_FAILED"
25365
+ );
25366
+ }
25367
+ }
24942
25368
  return this.withSwapGate(swapId, async () => {
24943
25369
  if (swap.progress !== "announced") {
24944
25370
  throw new SphereError(
@@ -25044,7 +25470,6 @@ var SwapModule = class {
25044
25470
  swap.updatedAt = Date.now();
25045
25471
  this.clearLocalTimer(swap.swapId);
25046
25472
  this.terminalSwapIds.add(swap.swapId);
25047
- const entryIdx = this._storedTerminalEntries.length;
25048
25473
  this._storedTerminalEntries.push({
25049
25474
  swapId: swap.swapId,
25050
25475
  progress: "failed",
@@ -25059,7 +25484,13 @@ var SwapModule = class {
25059
25484
  swap.error = prevError;
25060
25485
  swap.updatedAt = prevUpdatedAt;
25061
25486
  this.terminalSwapIds.delete(swap.swapId);
25062
- this._storedTerminalEntries.splice(entryIdx, 1);
25487
+ for (let i = this._storedTerminalEntries.length - 1; i >= 0; i--) {
25488
+ const entry = this._storedTerminalEntries[i];
25489
+ if (entry.swapId === swap.swapId && entry.progress === "failed") {
25490
+ this._storedTerminalEntries.splice(i, 1);
25491
+ break;
25492
+ }
25493
+ }
25063
25494
  logger.warn(LOG_TAG3, `failPayout: persistSwap failed for ${swapId}; fraud detection will retry on next load:`, persistErr);
25064
25495
  throw persistErr;
25065
25496
  }
@@ -25103,9 +25534,25 @@ var SwapModule = class {
25103
25534
  if (!targetStatus.coinAssets[0].isCovered) {
25104
25535
  return returnFalse();
25105
25536
  }
25106
- if (BigInt(targetStatus.coinAssets[0].netCoveredAmount) < BigInt(expectedAmount)) {
25537
+ let netCoveredAmount;
25538
+ let expectedAmountBigInt;
25539
+ try {
25540
+ netCoveredAmount = BigInt(targetStatus.coinAssets[0].netCoveredAmount);
25541
+ expectedAmountBigInt = BigInt(expectedAmount);
25542
+ } catch (parseErr) {
25543
+ return failPayout(
25544
+ `MALFORMED_AMOUNT: failed to parse coverage amounts (netCoveredAmount=${targetStatus.coinAssets[0].netCoveredAmount}, expectedAmount=${expectedAmount}): ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`
25545
+ );
25546
+ }
25547
+ if (netCoveredAmount < expectedAmountBigInt) {
25107
25548
  return returnFalse();
25108
25549
  }
25550
+ if (netCoveredAmount > expectedAmountBigInt) {
25551
+ const surplus = netCoveredAmount - expectedAmountBigInt;
25552
+ return failPayout(
25553
+ `OVER_COVERAGE: net=${netCoveredAmount.toString()}, expected=${expectedAmount}, surplus=${surplus.toString()} \u2014 surplus refund expected via auto-return; settlement halted`
25554
+ );
25555
+ }
25109
25556
  const escrowAddr = swap.deal.escrowAddress ?? this.config.defaultEscrowAddress;
25110
25557
  if (escrowAddr) {
25111
25558
  const escrowPeer = await deps.resolve(escrowAddr);
@@ -25115,8 +25562,28 @@ var SwapModule = class {
25115
25562
  }
25116
25563
  const validationResult = await deps.payments.validate();
25117
25564
  if (validationResult.invalid.length > 0) {
25118
- logger.warn(LOG_TAG3, `verifyPayout for ${swapId.slice(0, 12)}: L3 validation found ${validationResult.invalid.length} invalid token(s) \u2014 retry after wallet sync`);
25119
- return returnFalse();
25565
+ const payoutTokenIds = deps.accounting.getTokenIdsForInvoice?.(swap.payoutInvoiceId) ?? /* @__PURE__ */ new Set();
25566
+ if (payoutTokenIds.size === 0) {
25567
+ logger.warn(
25568
+ LOG_TAG3,
25569
+ `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`
25570
+ );
25571
+ return returnFalse();
25572
+ }
25573
+ const relevantInvalid = validationResult.invalid.filter(
25574
+ (t) => payoutTokenIds.has(t.id)
25575
+ );
25576
+ if (relevantInvalid.length > 0) {
25577
+ logger.warn(
25578
+ LOG_TAG3,
25579
+ `verifyPayout for ${swapId.slice(0, 12)}: L3 validation found ${relevantInvalid.length} invalid token(s) covering this payout invoice \u2014 retry after wallet sync`
25580
+ );
25581
+ return returnFalse();
25582
+ }
25583
+ logger.debug(
25584
+ LOG_TAG3,
25585
+ `verifyPayout for ${swapId.slice(0, 12)}: ${validationResult.invalid.length} unrelated invalid token(s) ignored (not linked to this payout invoice)`
25586
+ );
25120
25587
  }
25121
25588
  if (swap.progress === "completed") {
25122
25589
  swap.payoutVerified = true;
@@ -25295,8 +25762,22 @@ var SwapModule = class {
25295
25762
  * @param dm - The incoming direct message.
25296
25763
  */
25297
25764
  handleIncomingDM(dm) {
25765
+ if (dm.content.startsWith("{") && dm.content.includes('"invoice_delivery"')) {
25766
+ logger.warn(
25767
+ LOG_TAG3,
25768
+ `diag_swap_dm_arrived sender=${dm.senderPubkey.slice(0, 16)} length=${dm.content.length}`
25769
+ );
25770
+ }
25298
25771
  const parsed = parseSwapDM(dm.content);
25299
- if (!parsed) return;
25772
+ if (!parsed) {
25773
+ if (dm.content.startsWith("{") && dm.content.includes('"invoice_delivery"')) {
25774
+ logger.warn(
25775
+ LOG_TAG3,
25776
+ `diag_swap_dm_parse_rejected sender=${dm.senderPubkey.slice(0, 16)} prefix=${dm.content.slice(0, 80)}`
25777
+ );
25778
+ }
25779
+ return;
25780
+ }
25300
25781
  void (async () => {
25301
25782
  try {
25302
25783
  switch (parsed.kind) {
@@ -25662,16 +26143,43 @@ var SwapModule = class {
25662
26143
  // invoice_delivery (§12.4.2 + §12.4.3)
25663
26144
  // ---------------------------------------------------------------
25664
26145
  case "invoice_delivery": {
25665
- if (!swapId) return;
26146
+ logger.warn(
26147
+ LOG_TAG3,
26148
+ `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)}`
26149
+ );
26150
+ if (!swapId) {
26151
+ logger.warn(LOG_TAG3, "diag_invoice_delivery_dropped reason=no_swap_id");
26152
+ return;
26153
+ }
25666
26154
  const swap = this.swaps.get(swapId);
25667
- if (!swap) return;
25668
- if (!this.isFromExpectedEscrow(dm.senderPubkey, swap)) return;
26155
+ if (!swap) {
26156
+ logger.warn(
26157
+ LOG_TAG3,
26158
+ `diag_invoice_delivery_dropped reason=swap_not_in_map swap_id=${swapId.slice(0, 16)} known_swap_ids_count=${this.swaps.size}`
26159
+ );
26160
+ return;
26161
+ }
26162
+ if (!this.isFromExpectedEscrow(dm.senderPubkey, swap)) {
26163
+ logger.warn(
26164
+ LOG_TAG3,
26165
+ `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)}`
26166
+ );
26167
+ return;
26168
+ }
25669
26169
  const deps = this.deps;
25670
26170
  if (msg.invoice_type === "deposit") {
26171
+ logger.warn(
26172
+ LOG_TAG3,
26173
+ `diag_invoice_delivery_proceeding_to_import swap_id=${swapId.slice(0, 16)} progress=${swap.progress} invoice_id=${(msg.invoice_id ?? "").slice(0, 16)}`
26174
+ );
25671
26175
  await this.withSwapGate(swapId, async () => {
25672
26176
  if (isTerminalProgress(swap.progress)) return;
25673
26177
  try {
25674
26178
  await deps.accounting.importInvoice(msg.invoice_token);
26179
+ logger.warn(
26180
+ LOG_TAG3,
26181
+ `diag_invoice_imported swap_id=${swapId.slice(0, 16)} invoice_id=${(msg.invoice_id ?? "").slice(0, 16)} type=deposit`
26182
+ );
25675
26183
  } catch (err) {
25676
26184
  if (err instanceof SphereError && err.code === "INVOICE_ALREADY_EXISTS") {
25677
26185
  logger.debug(LOG_TAG3, `Deposit invoice for swap ${swapId} already imported \u2014 relay re-delivery, continuing`);
@@ -26310,6 +26818,65 @@ init_constants();
26310
26818
  init_errors();
26311
26819
  init_logger();
26312
26820
  import CryptoJS6 from "crypto-js";
26821
+ var DEFAULT_ITERATIONS = 1e5;
26822
+ var KEY_SIZE = 256;
26823
+ var SALT_SIZE = 16;
26824
+ var IV_SIZE = 16;
26825
+ function deriveKey(password, salt, iterations) {
26826
+ return CryptoJS6.PBKDF2(password, salt, {
26827
+ keySize: KEY_SIZE / 32,
26828
+ // WordArray uses 32-bit words
26829
+ iterations,
26830
+ hasher: CryptoJS6.algo.SHA256
26831
+ });
26832
+ }
26833
+ function encrypt2(plaintext, password, options = {}) {
26834
+ const iterations = options.iterations ?? DEFAULT_ITERATIONS;
26835
+ const data = typeof plaintext === "string" ? plaintext : JSON.stringify(plaintext);
26836
+ const salt = CryptoJS6.lib.WordArray.random(SALT_SIZE);
26837
+ const iv = CryptoJS6.lib.WordArray.random(IV_SIZE);
26838
+ const key = deriveKey(password, salt, iterations);
26839
+ const encrypted = CryptoJS6.AES.encrypt(data, key, {
26840
+ iv,
26841
+ mode: CryptoJS6.mode.CBC,
26842
+ padding: CryptoJS6.pad.Pkcs7
26843
+ });
26844
+ return {
26845
+ ciphertext: encrypted.ciphertext.toString(CryptoJS6.enc.Base64),
26846
+ iv: iv.toString(CryptoJS6.enc.Hex),
26847
+ salt: salt.toString(CryptoJS6.enc.Hex),
26848
+ algorithm: "aes-256-cbc",
26849
+ kdf: "pbkdf2",
26850
+ iterations
26851
+ };
26852
+ }
26853
+ function decrypt2(encryptedData, password) {
26854
+ const salt = CryptoJS6.enc.Hex.parse(encryptedData.salt);
26855
+ const iv = CryptoJS6.enc.Hex.parse(encryptedData.iv);
26856
+ const key = deriveKey(password, salt, encryptedData.iterations);
26857
+ const ciphertext = CryptoJS6.enc.Base64.parse(encryptedData.ciphertext);
26858
+ const cipherParams = CryptoJS6.lib.CipherParams.create({
26859
+ ciphertext
26860
+ });
26861
+ const decrypted = CryptoJS6.AES.decrypt(cipherParams, key, {
26862
+ iv,
26863
+ mode: CryptoJS6.mode.CBC,
26864
+ padding: CryptoJS6.pad.Pkcs7
26865
+ });
26866
+ const result = decrypted.toString(CryptoJS6.enc.Utf8);
26867
+ if (!result) {
26868
+ throw new SphereError("Decryption failed: invalid password or corrupted data", "DECRYPTION_ERROR");
26869
+ }
26870
+ return result;
26871
+ }
26872
+ function decryptJson(encryptedData, password) {
26873
+ const decrypted = decrypt2(encryptedData, password);
26874
+ try {
26875
+ return JSON.parse(decrypted);
26876
+ } catch {
26877
+ throw new SphereError("Decryption failed: invalid JSON data", "DECRYPTION_ERROR");
26878
+ }
26879
+ }
26313
26880
  function encryptSimple(plaintext, password) {
26314
26881
  return CryptoJS6.AES.encrypt(plaintext, password).toString();
26315
26882
  }
@@ -26336,6 +26903,12 @@ function decryptWithSalt(ciphertext, password, salt) {
26336
26903
  return null;
26337
26904
  }
26338
26905
  }
26906
+ function encryptMnemonic(mnemonic, password) {
26907
+ return encryptSimple(mnemonic, password);
26908
+ }
26909
+ function decryptMnemonic(encryptedMnemonic, password) {
26910
+ return decryptSimple(encryptedMnemonic, password);
26911
+ }
26339
26912
 
26340
26913
  // core/scan.ts
26341
26914
  init_logger();
@@ -31168,6 +31741,7 @@ export {
31168
31741
  buildTxfStorageData,
31169
31742
  bytesToHex3 as bytesToHex,
31170
31743
  checkNetworkHealth,
31744
+ coinIdsMatch,
31171
31745
  computeSwapId,
31172
31746
  countCommittedTransactions,
31173
31747
  createAddress,
@@ -31186,22 +31760,34 @@ export {
31186
31760
  createSwapModule,
31187
31761
  createTokenValidator,
31188
31762
  decodeBech32,
31763
+ decrypt2 as decrypt,
31189
31764
  decryptCMasterKey,
31765
+ decryptJson,
31766
+ decryptMnemonic,
31190
31767
  decryptNametag2 as decryptNametag,
31191
31768
  decryptPrivateKey,
31769
+ decryptSimple,
31192
31770
  decryptTextFormatKey,
31771
+ decryptWallet,
31772
+ decryptWithSalt,
31193
31773
  deriveAddressInfo,
31194
31774
  deriveChildKey,
31195
31775
  deriveKeyAtPath,
31196
31776
  doubleSha256,
31197
31777
  encodeBech32,
31778
+ encrypt2 as encrypt,
31779
+ encryptMnemonic,
31198
31780
  encryptNametag,
31781
+ encryptSimple,
31782
+ encryptWallet,
31199
31783
  extractFromText,
31200
31784
  findPattern,
31201
31785
  forkedKeyFromTokenIdAndState,
31202
31786
  formatAmount,
31787
+ generateAddressFromMasterKey,
31203
31788
  generateMasterKey,
31204
31789
  generateMnemonic2 as generateMnemonic,
31790
+ generatePrivateKey,
31205
31791
  getAddressHrp,
31206
31792
  getAddressId,
31207
31793
  getAddressStorageKey,
@@ -31224,6 +31810,7 @@ export {
31224
31810
  hashNametag,
31225
31811
  hashSignMessage,
31226
31812
  hexToBytes2 as hexToBytes,
31813
+ hexToWIF,
31227
31814
  identityFromMnemonicSync,
31228
31815
  initSphere,
31229
31816
  isArchivedKey,
@@ -31253,6 +31840,7 @@ export {
31253
31840
  logger,
31254
31841
  mnemonicToSeedSync2 as mnemonicToSeedSync,
31255
31842
  normalizeAddress,
31843
+ normalizeCoinId,
31256
31844
  normalizeNametag3 as normalizeNametag,
31257
31845
  normalizeSdkTokenToStorage,
31258
31846
  objectToTxf,
@@ -31266,6 +31854,7 @@ export {
31266
31854
  randomBytes2 as randomBytes,
31267
31855
  randomHex,
31268
31856
  randomUUID,
31857
+ recoverPubkeyFromSignature,
31269
31858
  ripemd160,
31270
31859
  sha2562 as sha256,
31271
31860
  signMessage,