@ninsei-labs/sdk 0.52.0 → 0.54.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,13 +13,15 @@ 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, halfHex, norm, 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 * as flow from "./swap-flow.mjs";
20
+ import { fundingReceiptVerdict } from "../engine/evm/funding.js";
18
21
  import { vmOfChain, legFor } from "./legs/index.mjs";
19
22
  // THE VIEW HALVES - from the core's primitives: the sum of two halves is the key the wallet sees incoming payments
20
23
  // with. There is no arithmetic of the core's own here either (see order.mjs) - we use the same halves builder.
21
- import { primitives } from "./primitives.mjs";
24
+ import { keccak256, primitives, secp256k1 } from "./primitives.mjs";
22
25
  import { probeMoneroNode } from "./probes.mjs";
23
26
  // MARKING READY - A FINISHED STEP, NOT A SECOND IMPLEMENTATION OF IT: it requires the order expectations and checks
24
27
  // the slots against the chain BEFORE signing (actions.markReady). The watch must call exactly it, otherwise "we saw
@@ -27,7 +30,8 @@ import { createActions } from "./actions.mjs";
27
30
  // THE ONE RECOVERY KEY (sdk#76): the taker's halves come from it, not from a fresh random pair per swap.
28
31
  import {
29
32
  commitRecoveryIndex, createRecoveryKeyFile, importRecoveryKey, loadRecoveryKey,
30
- recoveryIndexTaken, recoveryKeyFileName, reserveRecoveryIndex, saveRecoveryKey, takerHalves,
33
+ recoveryIndexTaken, recoveryKeyFileName, recoverySaltOf, reserveRecoveryIndex, saveRecoveryKey,
34
+ newRecoverySalt, takerHalves, takerHalvesV2,
31
35
  } from "./recovery-key.mjs";
32
36
  // THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
33
37
  import * as xmrArrival from "./xmr-arrival.mjs";
@@ -98,7 +102,7 @@ const LOCKED_STATUS = "xmr_locked";
98
102
  // WHAT IS DELIBERATELY ABSENT HERE: a waiting policy of its own inside the step. How long to wait for the XMR to
99
103
  // arrive is the caller's decision, so the watch is exposed as a separate method (`swaps.watch`) with a timeout from
100
104
  // the configuration.
101
- const MARK_FIELDS = ["id", "network", "escrow", "quoteId", "providerId", "hashlock", "amount", "t0", "t1"];
105
+ const MARK_FIELDS = ["id", "network", "escrow", "quoteId", "providerId", "hashlock", "amount", "t0", "t1", "sdkVersion"];
102
106
  // THE FIELDS OF THE FACTORY WATCH RECORD - the same whitelist as the page client's
103
107
  // (www/js/core/chainSource.js:33, ALLOWED_SESSION_FIELDS). The body is assembled FROM IT anew, so neither the spend
104
108
  // half nor the spend key can get into the request, even if someone adds them to the record "just in case".
@@ -132,6 +136,7 @@ export function markBodyOf(swap, escrow, order = null) {
132
136
  amount: first(e.amountWei, o.amount, swap.amountWei),
133
137
  t0: first(e.t0, o.t0),
134
138
  t1: first(e.t1, o.t1),
139
+ sdkVersion: SDK_VERSION,
135
140
  };
136
141
  }
137
142
 
@@ -378,6 +383,48 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
378
383
  const all = Object.values((state && state.swaps) || {}).filter((s) => s && s.id);
379
384
  return all.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
380
385
  };
386
+ // A START THAT ENDED WITH A SENT TRANSACTION AND NO RECEIPT IS RESUMED, NOT RESENT. The transaction hash and the
387
+ // predicted escrow are kept on the record; a retry of the same quote reads that receipt instead of building a
388
+ // second side and sending a second deposit.
389
+ const pendingFundingOf = (quoteId) => {
390
+ const id = String(quoteId || "");
391
+ if (!id) return null;
392
+ return records().find((swap) => String(swap.orderQuoteId || "") === id &&
393
+ (swap.fundingPending === true || (swap.escrow && swap.escrow.fundingPending === true))) || null;
394
+ };
395
+ async function resumePendingFunding(record, request) {
396
+ const swap = record.swap || record;
397
+ const escrowRecord = swap.escrow && typeof swap.escrow === "object" ? swap.escrow : {};
398
+ const txHash = escrowRecord.fundTx || swap.fundTx || null;
399
+ if (!txHash) {
400
+ return { id: swap.id || null, escrow: escrowRecord.address || null,
401
+ address: (record.swapWallet && record.swapWallet.address) || null,
402
+ step: "funding-pending", txHash: null, reason: "no-transaction-hash", next: "receipt" };
403
+ }
404
+ const wallet = request.wallet;
405
+ let receipt = null;
406
+ if (wallet && typeof wallet.receipt === "function") {
407
+ try { receipt = await wallet.receipt(txHash); } catch { receipt = null; }
408
+ }
409
+ const order = request.order && typeof request.order === "object" ? request.order : {};
410
+ const verdict = fundingReceiptVerdict(receipt, {
411
+ factory: order.factory || (request.orderQuote && request.orderQuote.factory) || null,
412
+ expectedEscrow: escrowRecord.address || null,
413
+ });
414
+ if (verdict.status === "reverted") throw new SdkError("contract-reverted", { step: "funding" });
415
+ if (verdict.status === "funded") {
416
+ swap.escrow = { ...escrowRecord, address: verdict.escrow, fundingPending: false,
417
+ evmBirthBlock: Number(verdict.birthBlock) > 0 ? Number(verdict.birthBlock) : (escrowRecord.evmBirthBlock ?? null) };
418
+ delete swap.fundingPending;
419
+ try { engine.swap.saveSwap(swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
420
+ return { id: swap.id || null, escrow: verdict.escrow,
421
+ address: (record.swapWallet && record.swapWallet.address) || null,
422
+ step: "xmr-incoming", server: null, watch: null, next: "watch" };
423
+ }
424
+ return { id: swap.id || null, escrow: escrowRecord.address || null,
425
+ address: (record.swapWallet && record.swapWallet.address) || null,
426
+ step: "funding-pending", txHash, reason: verdict.reason || "no-receipt", next: "receipt" };
427
+ }
381
428
 
382
429
  // THE GUARDS' DATA: what the core knows itself (the network, the order amount, the token allowance) plus the balance
383
430
  // 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 +494,8 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
447
494
  async function markOnServer(swap, escrow, order) {
448
495
  if (!http || typeof http.tryJson !== "function") throw new SdkError("bad-input", { field: "http", step: "mark" });
449
496
  const mark = markBodyOf(swap, escrow, order);
497
+ // THE BROWSER SDK LINE IS PART OF THE BODY. The node's body routes read it before any work; a mark request
498
+ // without it is refused as sdk-version-absent even though the swap is already funded.
450
499
  const body = JSON.stringify(Object.fromEntries(MARK_FIELDS.map((field) => [field, mark[field]])));
451
500
  const headers = { "content-type": "application/json" };
452
501
  // 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 +545,7 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
496
545
  restoreHeight,
497
546
  chain: swap.network || swap.chain || null,
498
547
  };
499
- const body = {};
548
+ const body = { sdkVersion: SDK_VERSION };
500
549
  for (const field of ALLOWED_SESSION_FIELDS) if (wanted[field] !== undefined && wanted[field] !== null) body[field] = wanted[field];
501
550
  // THE VIEW KEY IS NOT NEEDED BY THE SERVICE FOR THE DECISION ANY MORE: since the arrival is checked by the
502
551
  // client (verifyArrival), the backend may keep a watch only for notifications, and even that it can do from
@@ -744,11 +793,37 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
744
793
 
745
794
  async start(request) {
746
795
  if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
796
+ // SELL IS NOT A BUY WITH ANOTHER LABEL. The lock step sends the settlement deposit from the caller; on a sell
797
+ // the caller is the claimer and the node is the locker, so this path would ask the wallet to sign the wrong
798
+ // transaction. Refuse before the recovery key, the wallet or any network read; the sell client has its own
799
+ // flow and must not be simulated by this one.
800
+ if (request.side === "sell") {
801
+ throw new SdkError("bad-input", { field: "side", why: "sell-not-supported", surface: "swaps.start" });
802
+ }
747
803
  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" });
804
+ // THE PER-SWAP FILE PATH IS GONE. One key covers every swap, and a per-swap file issued before funding cannot
805
+ // name the escrow (it does not exist yet), so `sweep.plan` could not read the revealed half from it.
806
+ if (request.recoveryKey === undefined || request.recoveryKey === null) {
807
+ throw new SdkError("bad-input", { field: "recoveryKey", why: "required" });
808
+ }
751
809
  if (!request.receiveTo || typeof request.receiveTo !== "string") throw new SdkError("bad-input", { field: "receiveTo" });
810
+ // RESUME BEFORE ANY NEW SIDE OR SEND. The previous start may have sent the deposit and lost the receipt;
811
+ // reusing its hash is the only way to learn the escrow without a second payment.
812
+ const quoteForResume = (request.orderQuote && request.orderQuote.id) || (request.quote && request.quote.id) || null;
813
+ const pendingRecord = pendingFundingOf(quoteForResume);
814
+ if (pendingRecord) return await resumePendingFunding(pendingRecord, request);
815
+ // A NEW START USES A PER-ORDER SALT, NOT A RECOVERY INDEX. A v1 index is the same at every factory and on
816
+ // every device, so two devices can derive one half. When the caller brings no `recoverySalt`, the order's own
817
+ // salt is the client salt; a missing salt is drawn here and written into the order before its context is built.
818
+ // An explicit `recoveryIndex` keeps the legacy path for restoring an old order; it is not a new-start default.
819
+ const explicitRecoveryIndex = request.recoveryIndex !== undefined && request.recoveryIndex !== null;
820
+ if ((request.recoverySalt === undefined || request.recoverySalt === null) && !explicitRecoveryIndex) {
821
+ if (!request.order || typeof request.order !== "object") throw new SdkError("bad-input", { field: "order" });
822
+ if (request.order.salt === undefined || request.order.salt === null || request.order.salt === "") {
823
+ request.order = { ...request.order, salt: newRecoverySalt() };
824
+ }
825
+ request.recoverySalt = request.order.salt;
826
+ }
752
827
  // THE ORDER CONTEXT IS MANDATORY AND NOT INVENTED. It is assembled by the engine FROM THE VERY FIELDS the firm
753
828
  // quote's signature is bound to (orderContext.js, FIELDS), because the DLEQ proof proves a half FOR THESE TERMS.
754
829
  // Substituting an empty value here would mean issuing a key bound to nothing.
@@ -777,6 +852,7 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
777
852
  // THE RECORD IS CREATED TOGETHER WITH OUR SIDE: the order terms go into it, and the side is built by the engine's
778
853
  // builder (with the DLEQ proof and the encryption key), not by the core's own arithmetic.
779
854
  let keySide = null;
855
+ let derivedViewHalf = null;
780
856
  const makeRecord = typeof createRecord === "function" ? createRecord
781
857
  : async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order, side: keySide });
782
858
  const passphrase = typeof request.passphrase === "string" ? request.passphrase : "";
@@ -791,7 +867,7 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
791
867
  // number is reserved before the order is built, and it is retired only after the order exists on chain.
792
868
  // No per-swap file is assembled on this path.
793
869
  let recovery = null;
794
- if (wantsKey) {
870
+ {
795
871
  const storage = engine.store.storage();
796
872
  const supplied = typeof request.recoveryKey === "string"
797
873
  ? request.recoveryKey
@@ -802,37 +878,79 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
802
878
  }
803
879
  if (!saved && !supplied) throw new SdkError("bad-input", { field: "recoveryKey" });
804
880
  const key = saved || importRecoveryKey(supplied);
805
- if (!saved) saveRecoveryKey(storage, key);
881
+ // THE KEY IS SAVED, BUT NOT YET BACKED UP. `backupConfirmed` is its own fact: a key in storage is not a
882
+ // key the person has saved. Saving it here is needed for index reservations; funding waits for the flag.
883
+ if (!saved) saveRecoveryKey(storage, { ...key, backupConfirmed: false });
806
884
  const chainId = request.order.chainId;
807
885
  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" });
886
+ const saltRequested = request.recoverySalt !== undefined && request.recoverySalt !== null;
887
+ let derived;
888
+ if (saltRequested) {
889
+ // THE CLIENT OWNS THE SALT. A maker that chose it could hand the same salt to the same client twice, so
890
+ // the caller composes the signed quote with this value and passes it here. The order's own salt must be
891
+ // the same bytes; a mismatch means the quote is not the one being started.
892
+ if (explicit) throw new SdkError("bad-input", { field: "recoverySalt", why: "with-recovery-index" });
893
+ if (typeof request.order.factory !== "string" || !request.order.factory) {
894
+ throw new SdkError("bad-input", { field: "order.factory" });
895
+ }
896
+ let salt;
897
+ try { salt = recoverySaltOf(request.recoverySalt); } catch { throw new SdkError("bad-input", { field: "recoverySalt" }); }
898
+ let orderSalt;
899
+ try { orderSalt = recoverySaltOf(request.order.salt); } catch { throw new SdkError("bad-input", { field: "recoverySalt", why: "order-salt" }); }
900
+ if (salt !== orderSalt) throw new SdkError("bad-input", { field: "recoverySalt", why: "order-salt" });
901
+ derived = takerHalvesV2({ mnemonic: key.mnemonic, chainId, factory: request.order.factory, salt });
902
+ recovery = { storage, key, salt, derivation: "salt-v2", newKey: !saved || saved.backupConfirmed !== true };
903
+ } else {
904
+ let index = explicit ? Number(request.recoveryIndex) : reserveRecoveryIndex(storage, { chainId });
905
+ if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "recoveryIndex" });
906
+ // A BROWSER STORE IS PER DEVICE. The same words opened elsewhere start at the same number, so the factory's own
907
+ // log is asked before the number is used; a taken number is retired locally and the next one is reserved.
908
+ // Without a log seam the previous behaviour is kept - the caller cannot be asked to invent a chain read.
909
+ if (typeof chainLogs === "function" && request.order.factory) {
910
+ // THE CHAIN'S BIRTH HEIGHT IS THE DEFAULT START, NOT ZERO. The factory's own log cannot hold an order
911
+ // before it existed; asking from zero is tens of log queries per number on a public RPC. An explicit
912
+ // `fromBlock` still wins, including the literal zero.
913
+ const chain = config && Array.isArray(config.chains)
914
+ ? config.chains.find((entry) => Number(entry.chainId) === Number(chainId)) : null;
915
+ const deploymentBlock = chain && chain.escrow ? chain.escrow.deploymentBlock : null;
916
+ const fromBlock = request.fromBlock !== undefined
917
+ ? request.fromBlock
918
+ : (Number.isSafeInteger(deploymentBlock) ? deploymentBlock : 0);
919
+ let hops = 0;
920
+ while (await recoveryIndexTaken({ mnemonic: key.mnemonic, chainId, index, factory: request.order.factory,
921
+ evmLogs: chainLogs, fromBlock })) {
922
+ if (explicit) throw new SdkError("bad-input", { field: "recoveryIndex", why: "on-chain" });
923
+ commitRecoveryIndex(storage, index);
924
+ index = reserveRecoveryIndex(storage, { chainId });
925
+ hops += 1;
926
+ if (!Number.isSafeInteger(index) || index < 0 || hops > 1000) throw new SdkError("bad-input", { field: "recoveryIndex", why: "exhausted" });
927
+ }
831
928
  }
929
+ derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
930
+ recovery = { storage, key, index, derivation: "index-v1", newKey: !saved || saved.backupConfirmed !== true };
832
931
  }
833
- const derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
834
932
  keySide = buildOwnSideFromHalves(request.order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf }).side;
835
- recovery = { storage, key, index, newKey: !saved };
933
+ // THE MAKER'S VIEW HALF IS DERIVED FROM THE KEY, NOT CHOSEN BY THE NODE. Without this binding a
934
+ // signed quote could carry another view half; the funded address would differ from the one the twelve
935
+ // words rebuild, and `sweep.fromOrder` would refuse after the funds were locked.
936
+ derivedViewHalf = { half: derived.makerViewHalf, point: derived.makerViewPoint };
937
+ }
938
+
939
+ // THE KEY PATH BINDS THE VIEW HALF BEFORE THE ORDER IS BUILT. The quote must echo the half derived from
940
+ // the same words and number; a missing or different half is refused, never silently replaced.
941
+ if (derivedViewHalf) {
942
+ const cp = request.counterparty && typeof request.counterparty === "object" ? request.counterparty : null;
943
+ // ONE NORMALISATION FOR THE GUARD AND THE BUILDER. Conflicting aliases are a refusal here, not a choice
944
+ // between two values; the point is derived from the half when the quote names only the half.
945
+ const view = normalizeCounterpartyView(cp);
946
+ if (view.half === null) throw new SdkError("quote-field-unbound", { step: "viewHalfClaimer", field: "viewHalfClaimer" });
947
+ let sameHalf = false;
948
+ try { sameHalf = BigInt(view.half) === BigInt(derivedViewHalf.half); } catch { sameHalf = false; }
949
+ if (!sameHalf) throw new SdkError("quote-refused", { step: "viewHalfClaimer", why: "not-the-derived-half" });
950
+ if (view.point !== null &&
951
+ String(view.point).replace(/^0x/, "").toLowerCase() !== String(derivedViewHalf.point).replace(/^0x/, "").toLowerCase()) {
952
+ throw new SdkError("quote-refused", { step: "viewHalfClaimer", why: "point-mismatch" });
953
+ }
836
954
  }
837
955
 
838
956
  let made = null;
@@ -885,32 +1003,10 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
885
1003
  record.swap.escrow = { ...(record.swap.escrow || {}), moneroRestoreHeight: measuredHeight };
886
1004
  try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-restore-height" }); }
887
1005
 
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;
1006
+ // 2a. THE KEY IS CONFIRMED ONCE. The same twelve words cover every swap, and a person who already saved them
1007
+ // is not asked again. A new key is shown before the money moves - no saved key, no funding.
892
1008
  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) {
1009
+ if (recovery.newKey) {
914
1010
  if (typeof request.onRecoveryKey !== "function") throw new SdkError("bad-input", { field: "onRecoveryKey" });
915
1011
  try {
916
1012
  file = {
@@ -918,15 +1014,23 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
918
1014
  contents: await createRecoveryKeyFile({ mnemonic: recovery.key.mnemonic, createdAt: recovery.key.createdAt, passphrase }),
919
1015
  };
920
1016
  } catch { throw new SdkError("recovery-failed", { step: "encrypt-key" }); }
921
- confirmed = await request.onRecoveryKey(file);
1017
+ const confirmed = await request.onRecoveryKey(file);
922
1018
  if (confirmed !== true) throw new SdkError("recovery-declined", { key: recovery.key.fingerprint });
1019
+ // ONLY THE PERSON'S TRUE ANSWER MAKES THE KEY "BACKED UP". The record already exists for reservations;
1020
+ // this flag is what a later start reads. A failed write is a refusal before any funds move.
1021
+ try {
1022
+ const stored = loadRecoveryKey(recovery.storage) || {};
1023
+ saveRecoveryKey(recovery.storage, { ...recovery.key, nextIndex: stored.nextIndex,
1024
+ reserved: stored.reserved, retired: stored.retired, backupConfirmed: true });
1025
+ recovery.key = { ...recovery.key, backupConfirmed: true };
1026
+ } catch { throw new SdkError("storage-unavailable", { step: "save-recovery-key" }); }
923
1027
  }
924
1028
 
925
1029
  // 4. LOCKING THE FUNDS. The wallet comes FROM OUTSIDE (an adapter with `send`/`receipt`): signing is possible only
926
1030
  // 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
1031
  // 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.
1032
+ // the wrong key. The recovery key has by now been shown and confirmed by the person - a lock without a saved
1033
+ // key is not done.
930
1034
  const wallet = request.wallet;
931
1035
  if (!wallet || typeof wallet.send !== "function" || typeof wallet.receipt !== "function") {
932
1036
  throw new SdkError("bad-input", { field: "wallet", missing: ["send", "receipt"], surface: "swaps.start", step: "lock" });
@@ -963,6 +1067,21 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
963
1067
  const lockRequest = { ...request.order, ...derived, amountWei: request.order.amount };
964
1068
  // THE SIGNED QUOTE: the fee, the registry address and the provider live in IT, not in the factory
965
1069
  // (decision #76). The caller brings it (the interface got it from the maker); without it the lock is not signed.
1070
+ // THE QUOTE MUST NAME THE SAME COUNTERPARTY HALF THAT WAS VERIFIED. `orderStep` verifies
1071
+ // `request.counterparty`, while funding encodes `request.orderQuote`; a caller that brings both can otherwise
1072
+ // prove one point and fund another. The same comparison is repeated in funding.js, at the money boundary.
1073
+ if (request.orderQuote && (builtSide || request.counterparty)) {
1074
+ const norm = (v) => String(v === null || v === undefined ? "" : v).replace(/^0x/i, "").toLowerCase();
1075
+ const verifiedCommit = builtSide ? builtSide.wallet.commitHalfClaimer
1076
+ : (request.counterparty.commitHalfClaimer || null);
1077
+ const verifiedPoint = builtSide ? builtSide.wallet.otherSpendPoint
1078
+ : (request.counterparty.edPointClaimer || request.counterparty.edPub || null);
1079
+ if (!verifiedCommit || !verifiedPoint ||
1080
+ norm(request.orderQuote.commitHalfClaimer) !== norm(verifiedCommit) ||
1081
+ norm(request.orderQuote.edPointClaimer) !== norm(verifiedPoint)) {
1082
+ throw new SdkError("quote-refused", { step: "quote-binding", why: "counterparty-half" });
1083
+ }
1084
+ }
966
1085
  lockRequest.quote = request.orderQuote || null;
967
1086
  let funded = null;
968
1087
  try { funded = await lock.send(lockRequest, wallet, { call: reader }); }
@@ -970,6 +1089,25 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
970
1089
  if (error && typeof error.code === "string") throw error;
971
1090
  throw new SdkError("server-unavailable", { step: "lock" });
972
1091
  }
1092
+ // A SENT TRANSACTION IS NOT A FUNDED ESCROW. Without a status-1 receipt and the expected OrderCreated, the
1093
+ // address is unknown and calling the step xmr-incoming would make the person wait for XMR at a guess. Keep
1094
+ // the swap pending: the transaction hash is the handle for a later receipt read.
1095
+ if (funded && funded.pending) {
1096
+ // THE TRANSACTION IS SENT; ONLY THE RECEIPT IS LATE. Keep the hash and the predicted escrow on the record
1097
+ // so this start and a later retry can read the receipt instead of building a second deposit.
1098
+ const pendingTx = funded.createHash || null;
1099
+ const predicted = funded.predictedEscrow || null;
1100
+ const escrowRecord = record.swap.escrow && typeof record.swap.escrow === "object" ? record.swap.escrow : {};
1101
+ record.swap.fundingPending = true;
1102
+ record.swap.escrow = { ...escrowRecord, address: predicted || escrowRecord.address || null,
1103
+ fundTx: pendingTx || escrowRecord.fundTx || null, fundingPending: true,
1104
+ fundingReason: funded.pendingReason || "no-receipt" };
1105
+ try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-pending" }); }
1106
+ return { id: record.swap.id || null, escrow: record.swap.escrow.address || null,
1107
+ address: (record.swapWallet && record.swapWallet.address) || null,
1108
+ step: "funding-pending", txHash: pendingTx,
1109
+ reason: funded.pendingReason || "no-receipt", predictedEscrow: predicted, next: "receipt" };
1110
+ }
973
1111
  // THE ESCROW ADDRESS IS TAKEN FROM THE RECEIPT (lock.send does this), not from a prediction. It is also written
974
1112
  // into the swap: otherwise the progress screen and the explorer link would show an address that is not on the
975
1113
  // chain (the page does this on its side: www/js/ui/views/confirm.js:463).
@@ -984,7 +1122,7 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
984
1122
  try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
985
1123
  // THE NUMBER IS SPENT ONLY NOW, WHEN THE ORDER EXISTS ON CHAIN. A failed or abandoned start keeps the
986
1124
  // reservation, so a retry uses the same number and the later search has no gap.
987
- if (recovery) {
1125
+ if (recovery && recovery.index !== undefined) {
988
1126
  try { commitRecoveryIndex(recovery.storage, recovery.index); } catch { /* the reservation stays; the key still works */ }
989
1127
  }
990
1128
  }
@@ -1016,7 +1154,8 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1016
1154
  *
1017
1155
  * The CLIENT checks the arrival from the Monero nodes (`verify`); there is no backend poll by default. The rate
1018
1156
  * 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.
1157
+ * A caller that explicitly configured `apiBase` may still read the old watch row, but that row is only an
1158
+ * observation: it never declares readiness or a decline.
1020
1159
  * IT ARRIVED - we return the FACT (the amount, the confirmations, the txid); WE DID NOT WAIT - a refusal with the
1021
1160
  * code `xmr-timeout`, separate from the others, so "the wait expired" is confused with neither a breakdown nor an
1022
1161
  * empty response. XMR ON THE ADDRESS BUT BARRED BY unlock_time is a THIRD answer with its own code `xmr-locked`
@@ -1136,10 +1275,9 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1136
1275
  last = verifyRequest
1137
1276
  ? { address: request.address || escrowAddress, expectedXmr: request.expectedXmr ?? null, status: null }
1138
1277
  : 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() });
1278
+ // THE DECLINE IS THE ESCROW'S EVENT, NOT THE SERVICE'S WORD. The legacy backend's `declined` status used
1279
+ // to become a settlement and end the wait; it no longer does. A service answer is at most a notification.
1280
+ const declineNow = declineOf({ logs: chainDecline.logs, escrow: escrowAddress, at: Date.now() });
1143
1281
  if (declineNow.declined) {
1144
1282
  settleDecline(record, declineNow.at);
1145
1283
  return declineResult({ source: declineNow.source, at: declineNow.at, waitedMs: Date.now() - started,
@@ -1188,18 +1326,13 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1188
1326
  return verified;
1189
1327
  }
1190
1328
  if (last.seen) {
1191
- const result = { seen: true, id, address: last.address, status: last.status,
1329
+ // THE SERVICE MAY OBSERVE MONEY, BUT IT DOES NOT DECLARE AN ARRIVAL. Readiness hands a foreign address
1330
+ // the settlement, and only the client's own two-node check may do that. The observation is returned with
1331
+ // `seen: false` and the next step named; no mark is ever signed from this body.
1332
+ return { seen: false, observed: { id: last.id, address: last.address, status: last.status,
1192
1333
  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;
1334
+ txids: last.txids }, id, address: last.address, expectedXmr: last.expectedXmr,
1335
+ next: "verify", source: "service" };
1203
1336
  }
1204
1337
  if (Date.now() - started >= timeoutMs) {
1205
1338
  throw new SdkError("xmr-timeout", { id, address: last.address, expectedXmr: last.expectedXmr,
@@ -1209,5 +1342,194 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1209
1342
  await new Promise((resolve) => setTimeout(resolve, pollMs));
1210
1343
  }
1211
1344
  },
1345
+
1346
+ /**
1347
+ * START A SELL FROM A VERIFIED RELAY OFFER. The offer is the result of `quotes.firmRelay` for the bid side;
1348
+ * the record keeps the one-time address, the escrow, our half and the order expectations, so a reload can
1349
+ * continue with `get` + `sellWatch` + `sellClaim`. This method sends nothing.
1350
+ */
1351
+ async startSell(request) {
1352
+ if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
1353
+ const offer = request.offer && typeof request.offer === "object" ? request.offer : null;
1354
+ if (!offer) throw new SdkError("bad-input", { field: "offer" });
1355
+ const order = offer.order && typeof offer.order === "object" ? offer.order : null;
1356
+ const quote = offer.quote && typeof offer.quote === "object" ? offer.quote : null;
1357
+ if (!order || !quote) throw new SdkError("bad-input", { field: "offer", missing: ["order", "quote"] });
1358
+ if (typeof offer.jointAddress !== "string" || !offer.jointAddress) throw new SdkError("bad-input", { field: "offer.jointAddress" });
1359
+ if (typeof offer.escrow !== "string" || !offer.escrow) throw new SdkError("bad-input", { field: "offer.escrow" });
1360
+ // THE RECORD'S ROLE IS THE BID ROLE. The node is the locker and the person is the claimer; a quote whose
1361
+ // addresses or factory disagree with the order is not the offer this record describes.
1362
+ const sameValue = (a, b) => String(a || "").replace(/^0x/, "").toLowerCase() === String(b || "").replace(/^0x/, "").toLowerCase();
1363
+ if (!sameValue(quote.locker, order.locker) || !sameValue(quote.claimer, order.claimer)
1364
+ || !sameValue(quote.provider, order.locker)
1365
+ || !sameValue(quote.factory, order.factory) || Number(quote.chainId) !== Number(order.chainId)) {
1366
+ throw new SdkError("quote-refused", { step: "sell-record", why: "terms" });
1367
+ }
1368
+ // THE RECORD IS FOUND BEFORE A SECOND ONE IS MADE. The same offer retried after a reload yields the same
1369
+ // one-time address and escrow; a second record would be a second promise about one deal.
1370
+ const quoteId = quote.id || quote.nonce || null;
1371
+ const existing = records().find((swap) => swap.side === "sell" &&
1372
+ ((swap.escrow && String(swap.escrow.address || "").toLowerCase() === String(offer.escrow).toLowerCase()) ||
1373
+ (quoteId !== null && String(swap.orderQuoteId || "") === String(quoteId))));
1374
+ if (existing) {
1375
+ // A RETRY IS THE SAME OFFER, NOT A NEW ONE THAT HAPPENS TO NAME THE SAME ESCROW. The stored fields are
1376
+ // the commitment the first call verified; a changed point or commitment is refused before it is resumed.
1377
+ const stored = existing.quote && typeof existing.quote === "object" ? existing.quote : {};
1378
+ const same = (a, b) => String(a || "").replace(/^0x/, "").toLowerCase() === String(b || "").replace(/^0x/, "").toLowerCase();
1379
+ if (!same(stored.commitHalfClaimer, quote.commitHalfClaimer)
1380
+ || !same(stored.edPointClaimer, quote.edPointClaimer)
1381
+ || !same(existing.escrow && existing.escrow.moneroAddress, offer.jointAddress)) {
1382
+ throw new SdkError("quote-refused", { step: "sell-record", why: "offer-changed" });
1383
+ }
1384
+ return { id: existing.id, side: "sell", escrow: (existing.escrow && existing.escrow.address) || offer.escrow,
1385
+ address: (existing.escrow && existing.escrow.moneroAddress) || offer.jointAddress,
1386
+ xmrAmount: (existing.escrow && existing.escrow.xmrAmount) || quote.xmrAmount,
1387
+ claimTip: existing.claimTip || quote.claimTip, orderQuote: existing.orderQuote || offer.orderQuote,
1388
+ resumed: true, next: "send-xmr" };
1389
+ }
1390
+ const storage = engine.store.storage();
1391
+ const supplied = typeof request.recoveryKey === "string"
1392
+ ? request.recoveryKey
1393
+ : request.recoveryKey && typeof request.recoveryKey.mnemonic === "string" ? request.recoveryKey.mnemonic : null;
1394
+ const saved = loadRecoveryKey(storage);
1395
+ if (saved && supplied && saved.mnemonic !== supplied) throw new SdkError("bad-input", { field: "recoveryKey", why: "different-key" });
1396
+ if (!saved && !supplied) throw new SdkError("bad-input", { field: "recoveryKey" });
1397
+ const key = saved || importRecoveryKey(supplied);
1398
+ if (!saved) saveRecoveryKey(storage, { ...key, backupConfirmed: false });
1399
+ const chainId = Number(order.chainId);
1400
+ if (!Number.isSafeInteger(chainId) || chainId <= 0) throw new SdkError("bad-input", { field: "order.chainId" });
1401
+ if (!order.factory || order.salt === undefined || order.salt === null) throw new SdkError("bad-input", { field: "order" });
1402
+ let derived = null;
1403
+ if (offer.recovery && offer.recovery.kind === "index-v1") {
1404
+ const index = Number(offer.recovery.index);
1405
+ if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "offer.recovery" });
1406
+ derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
1407
+ } else {
1408
+ let salt = null;
1409
+ try { salt = recoverySaltOf(offer.recovery && offer.recovery.salt ? offer.recovery.salt : order.salt); }
1410
+ catch { throw new SdkError("bad-input", { field: "offer.recovery" }); }
1411
+ derived = takerHalvesV2({ mnemonic: key.mnemonic, chainId, factory: order.factory, salt });
1412
+ }
1413
+ const own = buildOwnSideFromHalves(order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf });
1414
+ // THE OFFER MUST NAME THE HALF THIS KEY ACTUALLY PRODUCES. The relay already checked the proof; the record
1415
+ // needs the same point and commitment, otherwise a later claim would reveal a half the escrow rejects.
1416
+ const ownCommit = (() => {
1417
+ try {
1418
+ const point = secp256k1.Point.fromHex(own.side.proof.XA);
1419
+ return "0x" + Array.from(keccak256(point.toBytes(false).slice(1)), (b) => b.toString(16).padStart(2, "0")).join("");
1420
+ } catch { return null; }
1421
+ })();
1422
+ const low160 = (v) => String(v || "").replace(/^0x/, "").toLowerCase().slice(-40);
1423
+ if (!ownCommit || low160(ownCommit) !== low160(quote.commitHalfClaimer)) {
1424
+ throw new SdkError("quote-refused", { step: "sell-record", why: "commitment" });
1425
+ }
1426
+ if (String(own.spendPoint).toLowerCase() !== String(quote.edPointClaimer || "").toLowerCase()) {
1427
+ throw new SdkError("quote-refused", { step: "sell-record", why: "spend-point" });
1428
+ }
1429
+ if (offer.edViewPointClaimer && String(own.viewPoint).toLowerCase() !== String(offer.edViewPointClaimer).toLowerCase()) {
1430
+ throw new SdkError("quote-refused", { step: "sell-record", why: "view-point" });
1431
+ }
1432
+ const made = recordModule.createRecord({
1433
+ side: "sell", network: String(order.chainId),
1434
+ payToken: order.asset || quote.asset || undefined,
1435
+ payAmount: quote.amount, xmrAmount: Number(quote.xmrAmount) / 1e12,
1436
+ orderQuoteId: quoteId || undefined,
1437
+ receiveAddress: order.claimer,
1438
+ makerId: quote.provider || null,
1439
+ }, { order, side: own.side });
1440
+ const swap = made.swap;
1441
+ swap.side = "sell";
1442
+ swap.order = order;
1443
+ swap.orderQuote = offer.orderQuote || null;
1444
+ swap.quote = quote;
1445
+ swap.claimTip = quote.claimTip || null;
1446
+ swap.expect = {
1447
+ chainId, factory: order.factory, locker: order.locker, claimer: order.claimer, amount: String(order.amount),
1448
+ readyBy: order.readyBy, t1: order.t1, salt: order.salt,
1449
+ commitHalfLocker: (offer.nodeHalf && offer.nodeHalf.commitHalf) || null,
1450
+ edPointLocker: (offer.nodeHalf && offer.nodeHalf.edPoint) || null,
1451
+ edViewPointLocker: offer.edViewPointLocker || null,
1452
+ commitHalfClaimer: quote.commitHalfClaimer || null,
1453
+ edPointClaimer: quote.edPointClaimer || null,
1454
+ };
1455
+ swap.escrow = { ...(swap.escrow || {}),
1456
+ address: offer.escrow, moneroAddress: offer.jointAddress,
1457
+ half: halfHex(own.side.half), viewHalf: halfHex(own.side.viewHalf),
1458
+ edPointClaimer: own.spendPoint, edViewPointClaimer: norm(own.side.viewPub),
1459
+ commitHalfClaimer: quote.commitHalfClaimer,
1460
+ edPointLocker: (offer.nodeHalf && offer.nodeHalf.edPoint) || null,
1461
+ edViewPointLocker: offer.edViewPointLocker || null,
1462
+ commitHalfLocker: (offer.nodeHalf && offer.nodeHalf.commitHalf) || null,
1463
+ orderContext: offer.context || null, xmrAmount: quote.xmrAmount || null,
1464
+ };
1465
+ try { engine.swap.saveSwap(swap); } catch { throw new SdkError("storage-unavailable", { step: "save-swap" }); }
1466
+ return { id: swap.id, side: "sell", escrow: offer.escrow, address: offer.jointAddress,
1467
+ xmrAmount: quote.xmrAmount, claimTip: quote.claimTip || null, orderQuote: offer.orderQuote || null,
1468
+ resumed: false, next: "send-xmr" };
1469
+ },
1470
+
1471
+ /**
1472
+ * CLAIM THE ETH ON A SELL. The half and the expectations are in the record made by `startSell`; the sender
1473
+ * must be the claimer's own wallet.
1474
+ */
1475
+ async sellClaim(request, wallet) {
1476
+ if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
1477
+ const id = String(request.id || "").trim();
1478
+ if (!id) throw new SdkError("bad-input", { field: "id" });
1479
+ let swap = null;
1480
+ try { swap = engine.swap.getSwap(id); } catch { swap = null; }
1481
+ if (!swap || swap.side !== "sell") throw new SdkError("bad-input", { field: "id", why: "sell-not-found" });
1482
+ const escrow = request.escrow || (swap.escrow && swap.escrow.address) || null;
1483
+ const halfClaimer = request.halfClaimer || (swap.escrow && swap.escrow.half) || null;
1484
+ if (!escrow || !halfClaimer) throw new SdkError("bad-input", { field: "escrow", missing: ["escrow", "halfClaimer"] });
1485
+ const expect = request.expect || swap.expect || null;
1486
+ if (!expect) throw new SdkError("bad-input", { field: "expect", why: "not-in-record" });
1487
+ return actions.claim({ escrow, halfClaimer, claimer: swap.order && swap.order.claimer,
1488
+ expect, onStep: request.onStep }, wallet);
1489
+ },
1490
+
1491
+ /**
1492
+ * WAIT FOR THE NODE'S SIDE ON A SELL. The client sends XMR to the one-time joint address; the node (the
1493
+ * locker) must mark the order ready before the claimer can take the ETH. This reads the escrow itself - no
1494
+ * backend watcher - and returns as soon as the chain says ready, claimed or refunded.
1495
+ */
1496
+ async sellWatch(request) {
1497
+ if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
1498
+ const escrow = String(request.escrow || "").trim();
1499
+ if (!escrow) throw new SdkError("bad-input", { field: "escrow" });
1500
+ const timeoutMs = Number.isFinite(Number(request.timeoutMs)) && Number(request.timeoutMs) > 0
1501
+ ? Number(request.timeoutMs) : limitOf("watchTimeoutMs", 1_200_000);
1502
+ const pollMs = Number.isFinite(Number(request.pollMs)) && Number(request.pollMs) > 0
1503
+ ? Number(request.pollMs) : limitOf("watchPollMs", 6_000);
1504
+ const started = Date.now();
1505
+ let lastStatus = null;
1506
+ let lastError = null;
1507
+ for (;;) {
1508
+ try { lastStatus = await flow.orderStatus(escrow, { call: reader }); }
1509
+ catch (error) { lastError = (error && error.code) || "status-unread"; lastStatus = null; }
1510
+ if (lastStatus && (lastStatus.isClaimed || lastStatus.isRefunded)) {
1511
+ return { escrow, state: lastStatus.isClaimed ? "claimed" : "refunded",
1512
+ funded: lastStatus.isFunded, claimed: lastStatus.isClaimed, refunded: lastStatus.isRefunded,
1513
+ ready: null, balanceWei: String(lastStatus.balance), waitedMs: Date.now() - started };
1514
+ }
1515
+ if (lastStatus && lastStatus.isFunded) {
1516
+ let ready = null;
1517
+ try {
1518
+ const raw = await reader({ to: escrow, data: engine.evm.encodeRead("ready") });
1519
+ const hex = String(raw || "").replace(/^0x/, "");
1520
+ ready = hex.length > 0 && BigInt("0x" + hex) !== 0n;
1521
+ } catch (error) { lastError = (error && error.code) || "ready-unread"; }
1522
+ if (ready === true) {
1523
+ return { escrow, state: "ready", funded: true, claimed: false, refunded: false,
1524
+ ready: true, balanceWei: String(lastStatus.balance), waitedMs: Date.now() - started };
1525
+ }
1526
+ }
1527
+ if (Date.now() - started >= timeoutMs) {
1528
+ throw new SdkError("sell-timeout", { escrow, waitedMs: Date.now() - started, timeoutMs,
1529
+ status: lastStatus, error: lastError });
1530
+ }
1531
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
1532
+ }
1533
+ },
1212
1534
  };
1213
1535
  }