@ninsei-labs/sdk 0.51.2 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/swaps.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  // order and halves -> server -> watching the Monero address. A lock without a recovery file is a lock with nothing
6
6
  // to open it with if the browser is lost, so the confirmation stands BEFORE anything that locks funds.
7
7
  import { SdkError } from "./errors.mjs";
8
+ import { SDK_VERSION } from "./version.mjs";
8
9
  import * as engine from "./engine.mjs";
9
10
  // The name is NOT `record`: inside start there is a variable with that name, and referencing the module before it
10
11
  // ran into "Cannot access 'record' before initialization" - a refusal without a code, that is, a breakdown. A check
@@ -12,9 +13,10 @@ import * as engine from "./engine.mjs";
12
13
  import * as recordModule from "./record.mjs";
13
14
  // The order: our own side is built by the engine's builder, the counterparty's side is verified and accepted, and
14
15
  // the joint address is assembled from the halves. There are no formulas of our own in the core - see order.mjs.
15
- import { buildOwnSideFromHalves, contextOf, orderStep } from "./order.mjs";
16
+ import { buildOwnSideFromHalves, contextOf, normalizeCounterpartyView, orderStep } from "./order.mjs";
16
17
  // Locking the funds: sending goes through the already-finished step (`lock.send`), the wallet is an adapter from outside.
17
18
  import * as lock from "./lock.mjs";
19
+ import { fundingReceiptVerdict } from "../engine/evm/funding.js";
18
20
  import { vmOfChain, legFor } from "./legs/index.mjs";
19
21
  // THE VIEW HALVES - from the core's primitives: the sum of two halves is the key the wallet sees incoming payments
20
22
  // with. There is no arithmetic of the core's own here either (see order.mjs) - we use the same halves builder.
@@ -27,7 +29,8 @@ import { createActions } from "./actions.mjs";
27
29
  // THE ONE RECOVERY KEY (sdk#76): the taker's halves come from it, not from a fresh random pair per swap.
28
30
  import {
29
31
  commitRecoveryIndex, createRecoveryKeyFile, importRecoveryKey, loadRecoveryKey,
30
- recoveryIndexTaken, recoveryKeyFileName, reserveRecoveryIndex, saveRecoveryKey, takerHalves,
32
+ recoveryIndexTaken, recoveryKeyFileName, recoverySaltOf, reserveRecoveryIndex, saveRecoveryKey,
33
+ newRecoverySalt, takerHalves, takerHalvesV2,
31
34
  } from "./recovery-key.mjs";
32
35
  // THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
33
36
  import * as xmrArrival from "./xmr-arrival.mjs";
@@ -98,7 +101,7 @@ const LOCKED_STATUS = "xmr_locked";
98
101
  // WHAT IS DELIBERATELY ABSENT HERE: a waiting policy of its own inside the step. How long to wait for the XMR to
99
102
  // arrive is the caller's decision, so the watch is exposed as a separate method (`swaps.watch`) with a timeout from
100
103
  // the configuration.
101
- const MARK_FIELDS = ["id", "network", "escrow", "quoteId", "providerId", "hashlock", "amount", "t0", "t1"];
104
+ const MARK_FIELDS = ["id", "network", "escrow", "quoteId", "providerId", "hashlock", "amount", "t0", "t1", "sdkVersion"];
102
105
  // THE FIELDS OF THE FACTORY WATCH RECORD - the same whitelist as the page client's
103
106
  // (www/js/core/chainSource.js:33, ALLOWED_SESSION_FIELDS). The body is assembled FROM IT anew, so neither the spend
104
107
  // half nor the spend key can get into the request, even if someone adds them to the record "just in case".
@@ -132,6 +135,7 @@ export function markBodyOf(swap, escrow, order = null) {
132
135
  amount: first(e.amountWei, o.amount, swap.amountWei),
133
136
  t0: first(e.t0, o.t0),
134
137
  t1: first(e.t1, o.t1),
138
+ sdkVersion: SDK_VERSION,
135
139
  };
136
140
  }
137
141
 
@@ -378,6 +382,48 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
378
382
  const all = Object.values((state && state.swaps) || {}).filter((s) => s && s.id);
379
383
  return all.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
380
384
  };
385
+ // A START THAT ENDED WITH A SENT TRANSACTION AND NO RECEIPT IS RESUMED, NOT RESENT. The transaction hash and the
386
+ // predicted escrow are kept on the record; a retry of the same quote reads that receipt instead of building a
387
+ // second side and sending a second deposit.
388
+ const pendingFundingOf = (quoteId) => {
389
+ const id = String(quoteId || "");
390
+ if (!id) return null;
391
+ return records().find((swap) => String(swap.orderQuoteId || "") === id &&
392
+ (swap.fundingPending === true || (swap.escrow && swap.escrow.fundingPending === true))) || null;
393
+ };
394
+ async function resumePendingFunding(record, request) {
395
+ const swap = record.swap || record;
396
+ const escrowRecord = swap.escrow && typeof swap.escrow === "object" ? swap.escrow : {};
397
+ const txHash = escrowRecord.fundTx || swap.fundTx || null;
398
+ if (!txHash) {
399
+ return { id: swap.id || null, escrow: escrowRecord.address || null,
400
+ address: (record.swapWallet && record.swapWallet.address) || null,
401
+ step: "funding-pending", txHash: null, reason: "no-transaction-hash", next: "receipt" };
402
+ }
403
+ const wallet = request.wallet;
404
+ let receipt = null;
405
+ if (wallet && typeof wallet.receipt === "function") {
406
+ try { receipt = await wallet.receipt(txHash); } catch { receipt = null; }
407
+ }
408
+ const order = request.order && typeof request.order === "object" ? request.order : {};
409
+ const verdict = fundingReceiptVerdict(receipt, {
410
+ factory: order.factory || (request.orderQuote && request.orderQuote.factory) || null,
411
+ expectedEscrow: escrowRecord.address || null,
412
+ });
413
+ if (verdict.status === "reverted") throw new SdkError("contract-reverted", { step: "funding" });
414
+ if (verdict.status === "funded") {
415
+ swap.escrow = { ...escrowRecord, address: verdict.escrow, fundingPending: false,
416
+ evmBirthBlock: Number(verdict.birthBlock) > 0 ? Number(verdict.birthBlock) : (escrowRecord.evmBirthBlock ?? null) };
417
+ delete swap.fundingPending;
418
+ try { engine.swap.saveSwap(swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
419
+ return { id: swap.id || null, escrow: verdict.escrow,
420
+ address: (record.swapWallet && record.swapWallet.address) || null,
421
+ step: "xmr-incoming", server: null, watch: null, next: "watch" };
422
+ }
423
+ return { id: swap.id || null, escrow: escrowRecord.address || null,
424
+ address: (record.swapWallet && record.swapWallet.address) || null,
425
+ step: "funding-pending", txHash, reason: verdict.reason || "no-receipt", next: "receipt" };
426
+ }
381
427
 
382
428
  // THE GUARDS' DATA: what the core knows itself (the network, the order amount, the token allowance) plus the balance
383
429
  // read by THE SAME wallet the lock will be signed with. Here too - a refusal WITH A CODE where there is no data at
@@ -447,6 +493,8 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
447
493
  async function markOnServer(swap, escrow, order) {
448
494
  if (!http || typeof http.tryJson !== "function") throw new SdkError("bad-input", { field: "http", step: "mark" });
449
495
  const mark = markBodyOf(swap, escrow, order);
496
+ // THE BROWSER SDK LINE IS PART OF THE BODY. The node's body routes read it before any work; a mark request
497
+ // without it is refused as sdk-version-absent even though the swap is already funded.
450
498
  const body = JSON.stringify(Object.fromEntries(MARK_FIELDS.map((field) => [field, mark[field]])));
451
499
  const headers = { "content-type": "application/json" };
452
500
  // THE PASS IS ASKED EVERY TIME, NOT CACHED: it is short-lived (an hour: app/server.mjs, AUTH_TOKEN_TTL_MS), and a
@@ -496,7 +544,7 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
496
544
  restoreHeight,
497
545
  chain: swap.network || swap.chain || null,
498
546
  };
499
- const body = {};
547
+ const body = { sdkVersion: SDK_VERSION };
500
548
  for (const field of ALLOWED_SESSION_FIELDS) if (wanted[field] !== undefined && wanted[field] !== null) body[field] = wanted[field];
501
549
  // THE VIEW KEY IS NOT NEEDED BY THE SERVICE FOR THE DECISION ANY MORE: since the arrival is checked by the
502
550
  // client (verifyArrival), the backend may keep a watch only for notifications, and even that it can do from
@@ -745,10 +793,29 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
745
793
  async start(request) {
746
794
  if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
747
795
  if (!request.quote || typeof request.quote !== "object") throw new SdkError("bad-input", { field: "quote" });
748
- // THE OLD PATH IS UNCHANGED; THE NEW ONE ASKS FOR A KEY INSTEAD OF A PER-SWAP FILE.
749
- const wantsKey = request.recoveryKey !== undefined && request.recoveryKey !== null;
750
- if (!wantsKey && typeof request.onRecoveryFile !== "function") throw new SdkError("bad-input", { field: "onRecoveryFile" });
796
+ // THE PER-SWAP FILE PATH IS GONE. One key covers every swap, and a per-swap file issued before funding cannot
797
+ // name the escrow (it does not exist yet), so `sweep.plan` could not read the revealed half from it.
798
+ if (request.recoveryKey === undefined || request.recoveryKey === null) {
799
+ throw new SdkError("bad-input", { field: "recoveryKey", why: "required" });
800
+ }
751
801
  if (!request.receiveTo || typeof request.receiveTo !== "string") throw new SdkError("bad-input", { field: "receiveTo" });
802
+ // RESUME BEFORE ANY NEW SIDE OR SEND. The previous start may have sent the deposit and lost the receipt;
803
+ // reusing its hash is the only way to learn the escrow without a second payment.
804
+ const quoteForResume = (request.orderQuote && request.orderQuote.id) || (request.quote && request.quote.id) || null;
805
+ const pendingRecord = pendingFundingOf(quoteForResume);
806
+ if (pendingRecord) return await resumePendingFunding(pendingRecord, request);
807
+ // A NEW START USES A PER-ORDER SALT, NOT A RECOVERY INDEX. A v1 index is the same at every factory and on
808
+ // every device, so two devices can derive one half. When the caller brings no `recoverySalt`, the order's own
809
+ // salt is the client salt; a missing salt is drawn here and written into the order before its context is built.
810
+ // An explicit `recoveryIndex` keeps the legacy path for restoring an old order; it is not a new-start default.
811
+ const explicitRecoveryIndex = request.recoveryIndex !== undefined && request.recoveryIndex !== null;
812
+ if ((request.recoverySalt === undefined || request.recoverySalt === null) && !explicitRecoveryIndex) {
813
+ if (!request.order || typeof request.order !== "object") throw new SdkError("bad-input", { field: "order" });
814
+ if (request.order.salt === undefined || request.order.salt === null || request.order.salt === "") {
815
+ request.order = { ...request.order, salt: newRecoverySalt() };
816
+ }
817
+ request.recoverySalt = request.order.salt;
818
+ }
752
819
  // THE ORDER CONTEXT IS MANDATORY AND NOT INVENTED. It is assembled by the engine FROM THE VERY FIELDS the firm
753
820
  // quote's signature is bound to (orderContext.js, FIELDS), because the DLEQ proof proves a half FOR THESE TERMS.
754
821
  // Substituting an empty value here would mean issuing a key bound to nothing.
@@ -777,6 +844,7 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
777
844
  // THE RECORD IS CREATED TOGETHER WITH OUR SIDE: the order terms go into it, and the side is built by the engine's
778
845
  // builder (with the DLEQ proof and the encryption key), not by the core's own arithmetic.
779
846
  let keySide = null;
847
+ let derivedViewHalf = null;
780
848
  const makeRecord = typeof createRecord === "function" ? createRecord
781
849
  : async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order, side: keySide });
782
850
  const passphrase = typeof request.passphrase === "string" ? request.passphrase : "";
@@ -791,7 +859,7 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
791
859
  // number is reserved before the order is built, and it is retired only after the order exists on chain.
792
860
  // No per-swap file is assembled on this path.
793
861
  let recovery = null;
794
- if (wantsKey) {
862
+ {
795
863
  const storage = engine.store.storage();
796
864
  const supplied = typeof request.recoveryKey === "string"
797
865
  ? request.recoveryKey
@@ -802,37 +870,79 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
802
870
  }
803
871
  if (!saved && !supplied) throw new SdkError("bad-input", { field: "recoveryKey" });
804
872
  const key = saved || importRecoveryKey(supplied);
805
- if (!saved) saveRecoveryKey(storage, key);
873
+ // THE KEY IS SAVED, BUT NOT YET BACKED UP. `backupConfirmed` is its own fact: a key in storage is not a
874
+ // key the person has saved. Saving it here is needed for index reservations; funding waits for the flag.
875
+ if (!saved) saveRecoveryKey(storage, { ...key, backupConfirmed: false });
806
876
  const chainId = request.order.chainId;
807
877
  const explicit = request.recoveryIndex !== undefined && request.recoveryIndex !== null;
808
- let index = explicit ? Number(request.recoveryIndex) : reserveRecoveryIndex(storage, { chainId });
809
- if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "recoveryIndex" });
810
- // A BROWSER STORE IS PER DEVICE. The same words opened elsewhere start at the same number, so the factory's own
811
- // log is asked before the number is used; a taken number is retired locally and the next one is reserved.
812
- // Without a log seam the previous behaviour is kept - the caller cannot be asked to invent a chain read.
813
- if (typeof chainLogs === "function" && request.order.factory) {
814
- // THE CHAIN'S BIRTH HEIGHT IS THE DEFAULT START, NOT ZERO. The factory's own log cannot hold an order
815
- // before it existed; asking from zero is tens of log queries per number on a public RPC. An explicit
816
- // `fromBlock` still wins, including the literal zero.
817
- const chain = config && Array.isArray(config.chains)
818
- ? config.chains.find((entry) => Number(entry.chainId) === Number(chainId)) : null;
819
- const deploymentBlock = chain && chain.escrow ? chain.escrow.deploymentBlock : null;
820
- const fromBlock = request.fromBlock !== undefined
821
- ? request.fromBlock
822
- : (Number.isSafeInteger(deploymentBlock) ? deploymentBlock : 0);
823
- let hops = 0;
824
- while (await recoveryIndexTaken({ mnemonic: key.mnemonic, chainId, index, factory: request.order.factory,
825
- evmLogs: chainLogs, fromBlock })) {
826
- if (explicit) throw new SdkError("bad-input", { field: "recoveryIndex", why: "on-chain" });
827
- commitRecoveryIndex(storage, index);
828
- index = reserveRecoveryIndex(storage, { chainId });
829
- hops += 1;
830
- if (!Number.isSafeInteger(index) || index < 0 || hops > 1000) throw new SdkError("bad-input", { field: "recoveryIndex", why: "exhausted" });
878
+ const saltRequested = request.recoverySalt !== undefined && request.recoverySalt !== null;
879
+ let derived;
880
+ if (saltRequested) {
881
+ // THE CLIENT OWNS THE SALT. A maker that chose it could hand the same salt to the same client twice, so
882
+ // the caller composes the signed quote with this value and passes it here. The order's own salt must be
883
+ // the same bytes; a mismatch means the quote is not the one being started.
884
+ if (explicit) throw new SdkError("bad-input", { field: "recoverySalt", why: "with-recovery-index" });
885
+ if (typeof request.order.factory !== "string" || !request.order.factory) {
886
+ throw new SdkError("bad-input", { field: "order.factory" });
831
887
  }
888
+ let salt;
889
+ try { salt = recoverySaltOf(request.recoverySalt); } catch { throw new SdkError("bad-input", { field: "recoverySalt" }); }
890
+ let orderSalt;
891
+ try { orderSalt = recoverySaltOf(request.order.salt); } catch { throw new SdkError("bad-input", { field: "recoverySalt", why: "order-salt" }); }
892
+ if (salt !== orderSalt) throw new SdkError("bad-input", { field: "recoverySalt", why: "order-salt" });
893
+ derived = takerHalvesV2({ mnemonic: key.mnemonic, chainId, factory: request.order.factory, salt });
894
+ recovery = { storage, key, salt, derivation: "salt-v2", newKey: !saved || saved.backupConfirmed !== true };
895
+ } else {
896
+ let index = explicit ? Number(request.recoveryIndex) : reserveRecoveryIndex(storage, { chainId });
897
+ if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "recoveryIndex" });
898
+ // A BROWSER STORE IS PER DEVICE. The same words opened elsewhere start at the same number, so the factory's own
899
+ // log is asked before the number is used; a taken number is retired locally and the next one is reserved.
900
+ // Without a log seam the previous behaviour is kept - the caller cannot be asked to invent a chain read.
901
+ if (typeof chainLogs === "function" && request.order.factory) {
902
+ // THE CHAIN'S BIRTH HEIGHT IS THE DEFAULT START, NOT ZERO. The factory's own log cannot hold an order
903
+ // before it existed; asking from zero is tens of log queries per number on a public RPC. An explicit
904
+ // `fromBlock` still wins, including the literal zero.
905
+ const chain = config && Array.isArray(config.chains)
906
+ ? config.chains.find((entry) => Number(entry.chainId) === Number(chainId)) : null;
907
+ const deploymentBlock = chain && chain.escrow ? chain.escrow.deploymentBlock : null;
908
+ const fromBlock = request.fromBlock !== undefined
909
+ ? request.fromBlock
910
+ : (Number.isSafeInteger(deploymentBlock) ? deploymentBlock : 0);
911
+ let hops = 0;
912
+ while (await recoveryIndexTaken({ mnemonic: key.mnemonic, chainId, index, factory: request.order.factory,
913
+ evmLogs: chainLogs, fromBlock })) {
914
+ if (explicit) throw new SdkError("bad-input", { field: "recoveryIndex", why: "on-chain" });
915
+ commitRecoveryIndex(storage, index);
916
+ index = reserveRecoveryIndex(storage, { chainId });
917
+ hops += 1;
918
+ if (!Number.isSafeInteger(index) || index < 0 || hops > 1000) throw new SdkError("bad-input", { field: "recoveryIndex", why: "exhausted" });
919
+ }
920
+ }
921
+ derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
922
+ recovery = { storage, key, index, derivation: "index-v1", newKey: !saved || saved.backupConfirmed !== true };
832
923
  }
833
- const derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
834
924
  keySide = buildOwnSideFromHalves(request.order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf }).side;
835
- recovery = { storage, key, index, newKey: !saved };
925
+ // THE MAKER'S VIEW HALF IS DERIVED FROM THE KEY, NOT CHOSEN BY THE NODE. Without this binding a
926
+ // signed quote could carry another view half; the funded address would differ from the one the twelve
927
+ // words rebuild, and `sweep.fromOrder` would refuse after the funds were locked.
928
+ derivedViewHalf = { half: derived.makerViewHalf, point: derived.makerViewPoint };
929
+ }
930
+
931
+ // THE KEY PATH BINDS THE VIEW HALF BEFORE THE ORDER IS BUILT. The quote must echo the half derived from
932
+ // the same words and number; a missing or different half is refused, never silently replaced.
933
+ if (derivedViewHalf) {
934
+ const cp = request.counterparty && typeof request.counterparty === "object" ? request.counterparty : null;
935
+ // ONE NORMALISATION FOR THE GUARD AND THE BUILDER. Conflicting aliases are a refusal here, not a choice
936
+ // between two values; the point is derived from the half when the quote names only the half.
937
+ const view = normalizeCounterpartyView(cp);
938
+ if (view.half === null) throw new SdkError("quote-field-unbound", { step: "viewHalfClaimer", field: "viewHalfClaimer" });
939
+ let sameHalf = false;
940
+ try { sameHalf = BigInt(view.half) === BigInt(derivedViewHalf.half); } catch { sameHalf = false; }
941
+ if (!sameHalf) throw new SdkError("quote-refused", { step: "viewHalfClaimer", why: "not-the-derived-half" });
942
+ if (view.point !== null &&
943
+ String(view.point).replace(/^0x/, "").toLowerCase() !== String(derivedViewHalf.point).replace(/^0x/, "").toLowerCase()) {
944
+ throw new SdkError("quote-refused", { step: "viewHalfClaimer", why: "point-mismatch" });
945
+ }
836
946
  }
837
947
 
838
948
  let made = null;
@@ -885,32 +995,10 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
885
995
  record.swap.escrow = { ...(record.swap.escrow || {}), moneroRestoreHeight: measuredHeight };
886
996
  try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-restore-height" }); }
887
997
 
888
- // 2a. THE KEY IS CONFIRMED ONCE; THE OLD PATH STILL ASSEMBLES A PER-SWAP FILE. On the key path there is
889
- // no file per swap: the same twelve words cover every swap, and a person who already saved them is not
890
- // asked again. A new key is shown before the money moves - no saved key, no funding.
891
- let payload = null;
998
+ // 2a. THE KEY IS CONFIRMED ONCE. The same twelve words cover every swap, and a person who already saved them
999
+ // is not asked again. A new key is shown before the money moves - no saved key, no funding.
892
1000
  let file = null;
893
- let confirmed = true;
894
- if (!recovery) {
895
- // THE RECOVERY FILE IS ASSEMBLED AND CHECKED BEFORE THE PERSON. A file that will not open is worse than no
896
- // file: the person will rely on it. So we check on our side, before the screen.
897
- try {
898
- payload = engine.recoveryFile.buildPayload({
899
- swap: record.swap, swapWallet: record.swapWallet, escrow: record.escrow,
900
- receiveAddress: record.receiveTo || request.receiveTo, restoreHeight: record.restoreHeight ?? null,
901
- });
902
- engine.recoveryFile.validatePayload(payload);
903
- } catch { throw new SdkError("recovery-failed", { step: "build-payload" }); }
904
-
905
- try {
906
- file = { name: engine.recoveryFile.recoveryFileName(payload.swapId), contents: await engine.recoveryFile.createRecoveryFile(payload, passphrase) };
907
- } catch { throw new SdkError("recovery-failed", { step: "encrypt" }); }
908
-
909
- // 3. THE PERSON'S CONFIRMATION - THE GATE. No action that locks funds goes earlier: otherwise the lock would end
910
- // up without a key if the browser is lost. "Did not answer" is a refusal, not consent.
911
- confirmed = await request.onRecoveryFile(file);
912
- if (confirmed !== true) throw new SdkError("recovery-declined", { swapId: payload.swapId });
913
- } else if (recovery.newKey) {
1001
+ if (recovery.newKey) {
914
1002
  if (typeof request.onRecoveryKey !== "function") throw new SdkError("bad-input", { field: "onRecoveryKey" });
915
1003
  try {
916
1004
  file = {
@@ -918,15 +1006,23 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
918
1006
  contents: await createRecoveryKeyFile({ mnemonic: recovery.key.mnemonic, createdAt: recovery.key.createdAt, passphrase }),
919
1007
  };
920
1008
  } catch { throw new SdkError("recovery-failed", { step: "encrypt-key" }); }
921
- confirmed = await request.onRecoveryKey(file);
1009
+ const confirmed = await request.onRecoveryKey(file);
922
1010
  if (confirmed !== true) throw new SdkError("recovery-declined", { key: recovery.key.fingerprint });
1011
+ // ONLY THE PERSON'S TRUE ANSWER MAKES THE KEY "BACKED UP". The record already exists for reservations;
1012
+ // this flag is what a later start reads. A failed write is a refusal before any funds move.
1013
+ try {
1014
+ const stored = loadRecoveryKey(recovery.storage) || {};
1015
+ saveRecoveryKey(recovery.storage, { ...recovery.key, nextIndex: stored.nextIndex,
1016
+ reserved: stored.reserved, retired: stored.retired, backupConfirmed: true });
1017
+ recovery.key = { ...recovery.key, backupConfirmed: true };
1018
+ } catch { throw new SdkError("storage-unavailable", { step: "save-recovery-key" }); }
923
1019
  }
924
1020
 
925
1021
  // 4. LOCKING THE FUNDS. The wallet comes FROM OUTSIDE (an adapter with `send`/`receipt`): signing is possible only
926
1022
  // in it, and the core keeps no path into the chain of its own. The absence of a wallet is a REFUSAL WITH A CODE,
927
1023
  // not a substitution of something by default: a lock without a signature would either not happen or go out with
928
- // the wrong key. The recovery file has by now been assembled and confirmed by the person - a lock without a file
929
- // is not done.
1024
+ // the wrong key. The recovery key has by now been shown and confirmed by the person - a lock without a saved
1025
+ // key is not done.
930
1026
  const wallet = request.wallet;
931
1027
  if (!wallet || typeof wallet.send !== "function" || typeof wallet.receipt !== "function") {
932
1028
  throw new SdkError("bad-input", { field: "wallet", missing: ["send", "receipt"], surface: "swaps.start", step: "lock" });
@@ -963,6 +1059,21 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
963
1059
  const lockRequest = { ...request.order, ...derived, amountWei: request.order.amount };
964
1060
  // THE SIGNED QUOTE: the fee, the registry address and the provider live in IT, not in the factory
965
1061
  // (decision #76). The caller brings it (the interface got it from the maker); without it the lock is not signed.
1062
+ // THE QUOTE MUST NAME THE SAME COUNTERPARTY HALF THAT WAS VERIFIED. `orderStep` verifies
1063
+ // `request.counterparty`, while funding encodes `request.orderQuote`; a caller that brings both can otherwise
1064
+ // prove one point and fund another. The same comparison is repeated in funding.js, at the money boundary.
1065
+ if (request.orderQuote && (builtSide || request.counterparty)) {
1066
+ const norm = (v) => String(v === null || v === undefined ? "" : v).replace(/^0x/i, "").toLowerCase();
1067
+ const verifiedCommit = builtSide ? builtSide.wallet.commitHalfClaimer
1068
+ : (request.counterparty.commitHalfClaimer || null);
1069
+ const verifiedPoint = builtSide ? builtSide.wallet.otherSpendPoint
1070
+ : (request.counterparty.edPointClaimer || request.counterparty.edPub || null);
1071
+ if (!verifiedCommit || !verifiedPoint ||
1072
+ norm(request.orderQuote.commitHalfClaimer) !== norm(verifiedCommit) ||
1073
+ norm(request.orderQuote.edPointClaimer) !== norm(verifiedPoint)) {
1074
+ throw new SdkError("quote-refused", { step: "quote-binding", why: "counterparty-half" });
1075
+ }
1076
+ }
966
1077
  lockRequest.quote = request.orderQuote || null;
967
1078
  let funded = null;
968
1079
  try { funded = await lock.send(lockRequest, wallet, { call: reader }); }
@@ -970,6 +1081,25 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
970
1081
  if (error && typeof error.code === "string") throw error;
971
1082
  throw new SdkError("server-unavailable", { step: "lock" });
972
1083
  }
1084
+ // A SENT TRANSACTION IS NOT A FUNDED ESCROW. Without a status-1 receipt and the expected OrderCreated, the
1085
+ // address is unknown and calling the step xmr-incoming would make the person wait for XMR at a guess. Keep
1086
+ // the swap pending: the transaction hash is the handle for a later receipt read.
1087
+ if (funded && funded.pending) {
1088
+ // THE TRANSACTION IS SENT; ONLY THE RECEIPT IS LATE. Keep the hash and the predicted escrow on the record
1089
+ // so this start and a later retry can read the receipt instead of building a second deposit.
1090
+ const pendingTx = funded.createHash || null;
1091
+ const predicted = funded.predictedEscrow || null;
1092
+ const escrowRecord = record.swap.escrow && typeof record.swap.escrow === "object" ? record.swap.escrow : {};
1093
+ record.swap.fundingPending = true;
1094
+ record.swap.escrow = { ...escrowRecord, address: predicted || escrowRecord.address || null,
1095
+ fundTx: pendingTx || escrowRecord.fundTx || null, fundingPending: true,
1096
+ fundingReason: funded.pendingReason || "no-receipt" };
1097
+ try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-pending" }); }
1098
+ return { id: record.swap.id || null, escrow: record.swap.escrow.address || null,
1099
+ address: (record.swapWallet && record.swapWallet.address) || null,
1100
+ step: "funding-pending", txHash: pendingTx,
1101
+ reason: funded.pendingReason || "no-receipt", predictedEscrow: predicted, next: "receipt" };
1102
+ }
973
1103
  // THE ESCROW ADDRESS IS TAKEN FROM THE RECEIPT (lock.send does this), not from a prediction. It is also written
974
1104
  // into the swap: otherwise the progress screen and the explorer link would show an address that is not on the
975
1105
  // chain (the page does this on its side: www/js/ui/views/confirm.js:463).
@@ -984,7 +1114,7 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
984
1114
  try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
985
1115
  // THE NUMBER IS SPENT ONLY NOW, WHEN THE ORDER EXISTS ON CHAIN. A failed or abandoned start keeps the
986
1116
  // reservation, so a retry uses the same number and the later search has no gap.
987
- if (recovery) {
1117
+ if (recovery && recovery.index !== undefined) {
988
1118
  try { commitRecoveryIndex(recovery.storage, recovery.index); } catch { /* the reservation stays; the key still works */ }
989
1119
  }
990
1120
  }
@@ -1016,7 +1146,8 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1016
1146
  *
1017
1147
  * The CLIENT checks the arrival from the Monero nodes (`verify`); there is no backend poll by default. The rate
1018
1148
  * and the timeout come from the configuration (config.limits.watchPollMs/watchTimeoutMs) and are overridable here.
1019
- * A caller that explicitly configured `apiBase` may use the old watch-row flow instead.
1149
+ * A caller that explicitly configured `apiBase` may still read the old watch row, but that row is only an
1150
+ * observation: it never declares readiness or a decline.
1020
1151
  * IT ARRIVED - we return the FACT (the amount, the confirmations, the txid); WE DID NOT WAIT - a refusal with the
1021
1152
  * code `xmr-timeout`, separate from the others, so "the wait expired" is confused with neither a breakdown nor an
1022
1153
  * empty response. XMR ON THE ADDRESS BUT BARRED BY unlock_time is a THIRD answer with its own code `xmr-locked`
@@ -1136,10 +1267,9 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1136
1267
  last = verifyRequest
1137
1268
  ? { address: request.address || escrowAddress, expectedXmr: request.expectedXmr ?? null, status: null }
1138
1269
  : incomingOf(body);
1139
- // THE DECLINE IS DECIDED BEFORE THE ARRIVAL IS LOOKED AT, and before the "foreign response" refusal below:
1140
- // a declined swap's answer carries no `receivedXmr` at all, and refusing it as a bad response would name
1141
- // the wrong thing (the state is known - it is a decline, not a broken answer).
1142
- const declineNow = declineOf({ logs: chainDecline.logs, status: body, escrow: escrowAddress, at: Date.now() });
1270
+ // THE DECLINE IS THE ESCROW'S EVENT, NOT THE SERVICE'S WORD. The legacy backend's `declined` status used
1271
+ // to become a settlement and end the wait; it no longer does. A service answer is at most a notification.
1272
+ const declineNow = declineOf({ logs: chainDecline.logs, escrow: escrowAddress, at: Date.now() });
1143
1273
  if (declineNow.declined) {
1144
1274
  settleDecline(record, declineNow.at);
1145
1275
  return declineResult({ source: declineNow.source, at: declineNow.at, waitedMs: Date.now() - started,
@@ -1188,18 +1318,13 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1188
1318
  return verified;
1189
1319
  }
1190
1320
  if (last.seen) {
1191
- const result = { seen: true, id, address: last.address, status: last.status,
1321
+ // THE SERVICE MAY OBSERVE MONEY, BUT IT DOES NOT DECLARE AN ARRIVAL. Readiness hands a foreign address
1322
+ // the settlement, and only the client's own two-node check may do that. The observation is returned with
1323
+ // `seen: false` and the next step named; no mark is ever signed from this body.
1324
+ return { seen: false, observed: { id: last.id, address: last.address, status: last.status,
1192
1325
  expectedXmr: last.expectedXmr, receivedXmr: last.receivedXmr, confirmations: last.confirmations,
1193
- txids: last.txids, at: Date.now() };
1194
- if (wantExpect && wantWallet) {
1195
- result.marked = await actions.markReady(
1196
- { escrow: request.escrow || last.address, expect: request.expect, onStep: request.onStep }, request.wallet);
1197
- } else {
1198
- // WITHOUT EXPECTATIONS AND A WALLET, THE WATCH HONESTLY NAMES THE NEXT STEP, rather than pretending the mark
1199
- // has already been placed.
1200
- result.next = "mark-ready";
1201
- }
1202
- return result;
1326
+ txids: last.txids }, id, address: last.address, expectedXmr: last.expectedXmr,
1327
+ next: "verify", source: "service" };
1203
1328
  }
1204
1329
  if (Date.now() - started >= timeoutMs) {
1205
1330
  throw new SdkError("xmr-timeout", { id, address: last.address, expectedXmr: last.expectedXmr,
@@ -0,0 +1,32 @@
1
+ // THE FEE OF A WITHDRAWAL IS THE SUM OF EVERY SIGNED TRANSACTION, not the first one.
2
+ //
3
+ // A sweep normally signs one transaction, but the wallet may split the inputs: every transaction pays its own fee
4
+ // and the relay sends all of them. Showing only the first fee understates what leaves, and subtracting only it
5
+ // overstates what the recipient receives.
6
+ //
7
+ // The answer is null when any signed transaction cannot report a fee: a partial total would be a claim the wallet
8
+ // did not make.
9
+ export function signedFeeTotal(signed) {
10
+ const list = Array.isArray(signed) ? signed : [signed];
11
+ if (!list.length) return null;
12
+ let total = 0n;
13
+ for (const tx of list) {
14
+ if (!tx || typeof tx.getFee !== "function") return null;
15
+ let value;
16
+ try {
17
+ value = tx.getFee();
18
+ } catch {
19
+ return null;
20
+ }
21
+ if (value === null || value === undefined) return null;
22
+ let amount;
23
+ try {
24
+ amount = BigInt(value);
25
+ } catch {
26
+ return null;
27
+ }
28
+ if (amount < 0n) return null;
29
+ total += amount;
30
+ }
31
+ return total.toString();
32
+ }
@@ -62,6 +62,23 @@ const heightOf = async (driver, name) => {
62
62
  }
63
63
  };
64
64
 
65
+ // AN INCOMPLETE SCAN IS A NAMED OUTCOME, NOT A ZERO BALANCE. The wallet library's sync is best-effort: a node
66
+ // that refuses block downloads leaves a wallet at height 0, and a caller that only reads `unlocked` shows "no
67
+ // XMR". The heights and the sync error decide the verdict here, so the page does not have to guess.
68
+ const scanCompleteness = async (driver, { syncError = null, rescanError = null } = {}) => {
69
+ const walletHeight = await heightOf(driver, "getHeight");
70
+ const nodeHeight = await heightOf(driver, "getDaemonHeight");
71
+ let reason = null;
72
+ // A FAILED RESCAN IS NOT A COMPLETE SCAN even when the heights happen to be equal: the wallet was already at the
73
+ // tip, the rescan was the only operation that would have walked the restored range, and its failure leaves the
74
+ // old range unscanned. It outranks the sync error because it names why the sync was a no-op.
75
+ if (rescanError) reason = "rescan-failed";
76
+ else if (syncError) reason = "sync-failed";
77
+ else if (walletHeight === null || nodeHeight === null) reason = "no-heights";
78
+ else if (walletHeight < nodeHeight) reason = "behind-node";
79
+ return { scanComplete: reason === null, ...(reason === null ? {} : { scanIncompleteReason: reason }), walletHeight, nodeHeight };
80
+ };
81
+
65
82
  // THE BLOCK WINDOW AROUND THE LOCK. A full rescan of mainnet takes hours; the lock can only be in the blocks from
66
83
  // the order's start to just after its t1. The caller may pass the hashes, or a node URL and the SDK asks for them.
67
84
  export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl = globalThis.fetch, sleep = defaultSleep, attempts = 3 } = {}) {
@@ -202,10 +219,11 @@ export async function restoreWallet(request = {}) {
202
219
  }
203
220
  windowReport.txs = hashes.length;
204
221
  if (hashes.length && typeof driver.scanTxs === "function") {
222
+ let windowSyncError = null;
205
223
  try {
206
224
  await step("setRestoreHeight", () => driver.setRestoreHeight(tipHeight));
207
225
  await step("scanTxs", () => driver.scanTxs(hashes));
208
- try { await wallet.sync(); } catch { /* the balance below decides */ }
226
+ try { await wallet.sync(); } catch (error) { windowSyncError = error; }
209
227
  windowTotal = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
210
228
  windowUnlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
211
229
  windowReport.used = true;
@@ -257,9 +275,10 @@ export async function restoreWallet(request = {}) {
257
275
  // A WINDOW WITH COINS IS THE ANSWER. A window with none falls through to the full scan unless the caller
258
276
  // explicitly turned the fallback off.
259
277
  if (windowReport.used && (windowTotal > 0n || windowUnlocked > 0n || requestWindow.fallback === false)) {
278
+ const completeness = await scanCompleteness(driver, { syncError: windowSyncError });
260
279
  return {
261
- total: windowTotal.toString(), unlocked: windowUnlocked.toString(), scanHeight, walletHeight: await heightOf(driver, "getHeight"),
262
- nodeHeight: await heightOf(driver, "getDaemonHeight"), passes: 1, lockedPass: false, window: windowReport,
280
+ total: windowTotal.toString(), unlocked: windowUnlocked.toString(), scanHeight,
281
+ ...completeness, passes: 1, lockedPass: false, window: windowReport,
263
282
  };
264
283
  }
265
284
  } else if (hashes.length) {
@@ -280,12 +299,14 @@ export async function restoreWallet(request = {}) {
280
299
  // A RESCAN IS BEST-EFFORT, LIKE THE SYNC BELOW. A wallet built from keys already carries the height it was opened
281
300
  // with, and a library that cannot rescan a chain (monero-ts against a regtest node answers "failed to get hashes")
282
301
  // would otherwise stop a withdrawal the sync can still serve. The balance below is the verdict, not the rescan.
283
- try { await step("rescanBlockchain", () => driver.rescanBlockchain()); } catch { /* the balance decides */ }
302
+ let rescanError = null;
303
+ try { await step("rescanBlockchain", () => driver.rescanBlockchain()); } catch (error) { rescanError = error; }
284
304
 
285
305
  // A FAILED SYNC IS BEST-EFFORT, exactly as in the signing step: what blocks a withdrawal is the spendable
286
306
  // remainder, and it is read below. The library throws low-level while scanning a chain it cannot read; that must
287
- // not pre-empt the verdict.
288
- try { await wallet.sync(); } catch { /* the remainder decides */ }
307
+ // not pre-empt the verdict. The error is remembered and named in `scanIncompleteReason`.
308
+ let syncError = null;
309
+ try { await wallet.sync(); } catch (error) { syncError = error; }
289
310
 
290
311
  let total = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
291
312
  let unlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
@@ -298,18 +319,18 @@ export async function restoreWallet(request = {}) {
298
319
  if (rescanWhenLocked && total > 0n && unlocked === 0n && typeof driver.rescanSpent === "function" && passes < PASSES) {
299
320
  lockedPass = true;
300
321
  await step("rescanSpent", () => driver.rescanSpent());
301
- try { await wallet.sync(); } catch { /* as above */ }
322
+ try { await wallet.sync(); } catch (error) { syncError = error; }
302
323
  total = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
303
324
  unlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
304
325
  passes += 1;
305
326
  }
306
327
 
328
+ const completeness = await scanCompleteness(driver, { syncError, rescanError });
307
329
  return {
308
330
  total: total.toString(),
309
331
  unlocked: unlocked.toString(),
310
332
  scanHeight,
311
- walletHeight: await heightOf(driver, "getHeight"),
312
- nodeHeight: await heightOf(driver, "getDaemonHeight"),
333
+ ...completeness,
313
334
  passes,
314
335
  // Whether the second pass RAN - not whether it helped. A reader must be able to tell "nothing was locked" from
315
336
  // "it was locked and stayed locked".
package/src/sweep.mjs CHANGED
@@ -11,8 +11,20 @@ import { restoreWallet } from "./sweep-restore.mjs";
11
11
  import { moneroWalletAdapter } from "./adapters/monero-wallet.mjs";
12
12
  import { checkDestination } from "./destination.mjs";
13
13
  import { revealedHalfWord } from "./sweep-half.mjs";
14
- import { takerHalves } from "./recovery-key.mjs";
14
+ import { takerHalves, takerHalvesV2 } from "./recovery-key.mjs";
15
15
  import { jointAddress } from "./order.mjs";
16
+ import { formatXmr } from "../engine/core/xmrAmount.js";
17
+
18
+ // THE XMR AMOUNT IS PART OF THE RECOVERY FILE. A found order carries the amount the provider signed, in atomic
19
+ // units; the file keeps it in whole XMR, the unit every other payload user reads. An order without the amount
20
+ // stays null rather than silently falling back to the ETH side or to zero.
21
+ const xmrAmountOf = (value) => {
22
+ if (value === null || value === undefined || value === "") return null;
23
+ const text = String(value);
24
+ if (/^\d+$/.test(text)) return formatXmr(BigInt(text));
25
+ const numeric = Number(text);
26
+ return Number.isFinite(numeric) && numeric > 0 ? text : null;
27
+ };
16
28
 
17
29
  // The same boundary as when the file was issued: a passphrase shorter than twelve characters will not open the file.
18
30
  const MIN_PASSPHRASE = 12;
@@ -34,8 +46,6 @@ const payloadFromOrder = async (request) => {
34
46
  if (typeof request.mnemonic !== "string" || !request.mnemonic.trim()) throw new SdkError("bad-input", { field: "mnemonic" });
35
47
  const chainId = Number(request.chainId);
36
48
  if (!Number.isSafeInteger(chainId) || chainId <= 0) throw new SdkError("bad-input", { field: "chainId" });
37
- const index = Number(order.index);
38
- if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "order.index" });
39
49
  const network = typeof request.network === "string" ? request.network.trim() : "";
40
50
  const moneroNetwork = typeof request.moneroNetwork === "string" ? request.moneroNetwork.trim() : "";
41
51
  if (!network) throw new SdkError("bad-input", { field: "network" });
@@ -47,7 +57,20 @@ const payloadFromOrder = async (request) => {
47
57
 
48
58
  const side = order.side === "locker" ? "buy" : order.side === "claimer" ? "sell" : null;
49
59
  if (!side) throw new SdkError("bad-input", { field: "order.side" });
50
- const halves = takerHalves({ mnemonic: request.mnemonic, chainId, index });
60
+ // TWO DERIVATIONS, BOTH LIVE. A v1 order has an index; a v2 order has the public salt from OrderCreated and
61
+ // the factory in its binding. The page may hand either shape back; reading `index` from a v2 order would invent
62
+ // a number and rebuild the wrong address.
63
+ let halves;
64
+ if (order.derivation === "salt-v2") {
65
+ if (typeof order.factory !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(order.factory)) {
66
+ throw new SdkError("bad-input", { field: "order.factory" });
67
+ }
68
+ halves = takerHalvesV2({ mnemonic: request.mnemonic, chainId, factory: order.factory, salt: order.salt });
69
+ } else {
70
+ const index = Number(order.index);
71
+ if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "order.index" });
72
+ halves = takerHalves({ mnemonic: request.mnemonic, chainId, index });
73
+ }
51
74
  const buy = side === "buy";
52
75
  if (bare(buy ? order.edPointLocker : order.edPointClaimer) !== bare(halves.spendPoint)) {
53
76
  throw new SdkError("bad-input", { field: "order", why: "spend-point" });
@@ -67,7 +90,7 @@ const payloadFromOrder = async (request) => {
67
90
  swap: {
68
91
  id: order.escrow, side, network, moneroNetwork,
69
92
  createdAt: order.blockTime ? Number(order.blockTime) * 1000 : null,
70
- payToken: null, payAmount: null, xmrAmount: null, rate: null, maker: null, quoteSignature: null,
93
+ payToken: null, payAmount: null, xmrAmount: xmrAmountOf(order.xmrAmount), rate: null, maker: null, quoteSignature: null,
71
94
  escrow, timeline: null,
72
95
  },
73
96
  swapWallet: {