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