@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/CHANGELOG.md +266 -0
- package/README.md +83 -5
- package/engine/atomic/order-worker.js +29 -2
- package/engine/atomic/order.js +24 -0
- package/engine/core/store.js +60 -12
- package/engine/evm/funding.js +65 -18
- package/engine/monero/arrivalRead.js +3 -22
- package/engine/monero/halvesSweep.js +5 -1
- package/engine/monero/scan.js +144 -33
- package/index.d.ts +154 -26
- package/package.json +35 -3
- package/src/adapters/monero-wallet.mjs +13 -6
- package/src/adapters/monero.mjs +5 -7
- package/src/codes.mjs +4 -0
- package/src/config.mjs +6 -3
- package/src/decline.mjs +6 -1
- package/src/feeEstimate.mjs +20 -13
- package/src/index.mjs +7 -4
- package/src/legs/index.mjs +8 -0
- package/src/legs/profiles.mjs +51 -0
- package/src/lock.mjs +4 -6
- package/src/order.mjs +53 -7
- package/src/public/networks.d.ts +14 -1
- package/src/public/recovery.d.ts +53 -5
- package/src/public/recovery.mjs +6 -0
- package/src/public/xmr.d.ts +26 -8
- package/src/quotes.mjs +410 -3
- package/src/recovery-key.mjs +217 -39
- package/src/recovery.mjs +9 -1
- package/src/relayFeatures.mjs +191 -0
- package/src/relayFirmClient.mjs +86 -0
- package/src/relayQuote.mjs +209 -0
- package/src/relayRequest.mjs +188 -0
- package/src/relayTerms.mjs +145 -0
- package/src/relayView.mjs +110 -0
- package/src/swap-flow.mjs +3 -2
- package/src/swaps.mjs +402 -80
- package/src/sweep-fees.mjs +32 -0
- package/src/sweep-restore.mjs +30 -9
- package/src/sweep.mjs +28 -5
- package/src/version.mjs +1 -1
- package/src/xmr-arrival.mjs +76 -32
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,
|
|
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
|
|
749
|
-
|
|
750
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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
|
|
889
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1140
|
-
// a
|
|
1141
|
-
|
|
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
|
-
|
|
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,
|
|
1194
|
-
|
|
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
|
}
|