@ninsei-labs/sdk 0.50.0 → 0.51.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.
@@ -385,6 +385,27 @@ function decodeOrderLog(log) {
385
385
  };
386
386
  }
387
387
 
388
+ // THE CLONE CARRIES THE ORDER, INCLUDING THE XMR AMOUNT. OrderCreated does not repeat `xmrAmount`, and the clone
389
+ // has no getter for it, but word 18 of the condition blob is in its code. Read it only from a clone of the exact
390
+ // shape: a contract of another size is not this order and must not yield a guessed number.
391
+ const CLONE_CODE_BYTES = 877;
392
+ const XMR_AMOUNT_WORD = 18;
393
+ const CLONE_PROXY_BYTES = 45;
394
+ const XMR_AMOUNT_OFFSET = CLONE_PROXY_BYTES + XMR_AMOUNT_WORD * 32;
395
+
396
+ function xmrAmountFromClone(code) {
397
+ if (typeof code !== "string" || !/^0x[0-9a-fA-F]+$/.test(code)) return { value: null, reason: "code" };
398
+ const bytes = (code.length - 2) / 2;
399
+ if (!Number.isInteger(bytes) || bytes !== CLONE_CODE_BYTES) return { value: null, reason: "not-clone" };
400
+ const word = code.slice(2 + XMR_AMOUNT_OFFSET * 2, 2 + (XMR_AMOUNT_OFFSET + 32) * 2);
401
+ if (word.length !== 64) return { value: null, reason: "not-clone" };
402
+ try {
403
+ return { value: BigInt("0x" + word).toString(), reason: null };
404
+ } catch {
405
+ return { value: null, reason: "code" };
406
+ }
407
+ }
408
+
388
409
  async function readLogs(evmLogs, request) {
389
410
  if (typeof evmLogs !== "function") throw bad("evmLogs");
390
411
  let logs;
@@ -397,6 +418,25 @@ async function readLogs(evmLogs, request) {
397
418
  return logs;
398
419
  }
399
420
 
421
+ /**
422
+ * Is this number already held by an order on chain?
423
+ *
424
+ * A browser store is per device. The same twelve words opened on a second device, or restored before any search, start
425
+ * at number 0 again; two swaps would then share the taker's halves, and a refund of one publishes a half the other's
426
+ * maker can use. The check is the factory's own `OrderCreated` log, matched by the spend commitment the number derives.
427
+ *
428
+ * The caller owns the log transport, as everywhere else in this module. An unreadable log request is a refusal, not
429
+ * "free": the number is not reserved on a guess.
430
+ */
431
+ export async function recoveryIndexTaken({ mnemonic, chainId, index, factory, evmLogs, fromBlock = 0, toBlock = "latest" } = {}) {
432
+ if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
433
+ const own = takerHalves({ mnemonic, chainId, index });
434
+ const logs = await readLogs(evmLogs, { address: factory, topics: [ORDER_CREATED_TOPIC], fromBlock, toBlock });
435
+ return logs.map(decodeOrderLog).filter(Boolean).some((order) =>
436
+ order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ||
437
+ order.commitHalfClaimer.toLowerCase() === own.spendCommitment.toLowerCase());
438
+ }
439
+
400
440
  // THE MONERO HEIGHT A RECOVERED ORDER STARTED AT. With one key there is no per-swap file to carry it, so it is
401
441
  // derived from the settlement chain: the timestamp of the block that holds OrderCreated, then the last Monero
402
442
  // block at or before it. A Monero block's timestamp may run ahead of wall time, hence the safety margin.
@@ -407,7 +447,7 @@ const MONERO_HEIGHT_SAFETY_BLOCKS = 60;
407
447
 
408
448
  function secondsOf(value) {
409
449
  const n = Number(value);
410
- if (!Number.isFinite(n) || n <= 0) return null;
450
+ if (!Number.isFinite(n) || n < 0) return null;
411
451
  return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
412
452
  }
413
453
 
@@ -460,11 +500,15 @@ async function moneroReadersFromNodes({ nodes = [], network = null, fetchImpl =
460
500
  return body.result;
461
501
  };
462
502
  return {
463
- tip: async () => node.height,
503
+ // `get_info.height` is the block COUNT, one past the top block. Asking a real node for a header at that height
504
+ // answers "greater than current top block"; the tip reader must name the top block itself.
505
+ tip: async () => Math.max(0, Number(node.height) - 1),
464
506
  headerTimestamp: async (height) => {
465
507
  const result = await call("get_block_header_by_height", { height });
466
508
  const ts = Number(result.block_header && result.block_header.timestamp);
467
- if (!Number.isFinite(ts) || ts <= 0) throw new Error("monero-header");
509
+ // GENESIS HAS TIMESTAMP 0. The binary search reads height 0 to prove the requested moment is before it; a
510
+ // `<= 0` refusal turned a legitimate genesis answer into "monero-header" and every old order lost its height.
511
+ if (!Number.isFinite(ts) || ts < 0) throw new Error("monero-header");
468
512
  return ts;
469
513
  },
470
514
  };
@@ -472,22 +516,28 @@ async function moneroReadersFromNodes({ nodes = [], network = null, fetchImpl =
472
516
 
473
517
  async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }) {
474
518
  if (order.blockNumber === null || order.blockNumber === undefined) {
475
- return { startHeight: null, startHeightReason: "no-block-number" };
519
+ return { startHeight: null, startHeightReason: "no-block-number", blockTime: null };
476
520
  }
477
- if (typeof evmBlockTime !== "function" || typeof moneroTip !== "function" || typeof moneroBlockTimestamp !== "function") {
478
- return { startHeight: null, startHeightReason: "no-height-readers" };
521
+ // NOTHING TO READ WITH: no settlement block time and no Monero readers. That is a missing seam, not a missing date.
522
+ if (typeof evmBlockTime !== "function" && typeof moneroTip !== "function" && typeof moneroBlockTimestamp !== "function") {
523
+ return { startHeight: null, startHeightReason: "no-height-readers", blockTime: null };
479
524
  }
525
+ // THE BLOCK TIME IS PART OF THE FOUND ORDER. The page used to read the block again just to show a date; the
526
+ // timestamp is already needed here for the Monero height, so it is returned with it.
480
527
  let timestamp = cache.get(order.blockNumber);
481
528
  if (timestamp === undefined) {
482
- timestamp = secondsOf(await evmBlockTime(order.blockNumber));
483
- if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time" };
529
+ timestamp = typeof evmBlockTime === "function" ? secondsOf(await evmBlockTime(order.blockNumber)) : null;
530
+ if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time", blockTime: null };
484
531
  cache.set(order.blockNumber, timestamp);
485
532
  }
533
+ if (typeof moneroTip !== "function" || typeof moneroBlockTimestamp !== "function") {
534
+ return { startHeight: null, startHeightReason: "no-height-readers", blockTime: timestamp };
535
+ }
486
536
  try {
487
537
  const height = await heightAtOrBefore(timestamp, { tip: moneroTip, headerTimestamp: moneroBlockTimestamp });
488
- return { startHeight: Math.max(0, height - MONERO_HEIGHT_SAFETY_BLOCKS), startHeightReason: null };
538
+ return { startHeight: Math.max(0, height - MONERO_HEIGHT_SAFETY_BLOCKS), startHeightReason: null, blockTime: timestamp };
489
539
  } catch {
490
- return { startHeight: null, startHeightReason: "monero-header" };
540
+ return { startHeight: null, startHeightReason: "monero-header", blockTime: timestamp };
491
541
  }
492
542
  }
493
543
 
@@ -499,14 +549,23 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
499
549
  * positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
500
550
  */
501
551
  export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
502
- fromBlock = 0, toBlock = "latest", gap = 5, maxIndex = 1000,
503
- evmBlockTime = null, moneroTip = null, moneroBlockTimestamp = null,
552
+ fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex = 1000,
553
+ evmBlockTime = null, evmCode = null, moneroTip = null, moneroBlockTimestamp = null,
504
554
  xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
505
555
  if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
506
556
  const chain = numberField(chainId, "chainId");
507
557
  const max = numberField(maxIndex, "maxIndex") || 1000;
508
558
  const missesAllowed = numberField(gap, "gap") || 5;
509
- const base = { address: factory, fromBlock, toBlock };
559
+ // THE CHAIN SAYS WHERE ITS ORDERS CAN START. An explicit fromBlock wins; otherwise the caller can pass the
560
+ // factory's deployment block (the SDK's chain registry exposes it as escrow.deploymentBlock). Zero remains the
561
+ // last resort for a caller that knows the factory existed at genesis.
562
+ if (deploymentBlock !== null && deploymentBlock !== undefined && !Number.isSafeInteger(Number(deploymentBlock))) {
563
+ throw bad("deploymentBlock");
564
+ }
565
+ const startBlock = fromBlock === null || fromBlock === undefined
566
+ ? (deploymentBlock === null || deploymentBlock === undefined ? 0 : Number(deploymentBlock))
567
+ : fromBlock;
568
+ const base = { address: factory, fromBlock: startBlock, toBlock };
510
569
  let logs = [];
511
570
  if (address) {
512
571
  const padded = topicAddress(address);
@@ -553,8 +612,37 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
553
612
  const readers = await moneroReadersFromNodes({ nodes: xmrNodes, network: moneroNetwork, fetchImpl });
554
613
  if (readers) { tip = tip || readers.tip; header = header || readers.headerTimestamp; }
555
614
  }
615
+ // THE ORDER IS NATIVE, AND THE PAYING TOKEN IS NOT ON THE CHAIN. The escrow receives the settlement network's
616
+ // native coin (`amount` is wei); which token the person swapped into it (USDC, USDT, ...) belongs to the DEX/quote
617
+ // record, not to OrderCreated or the clone. `payToken: null` plus a reason is the honest answer here; an invented
618
+ // "USDC" would be a claim the chain does not support.
619
+ for (const order of found) {
620
+ order.settlementToken = "native";
621
+ order.payToken = null;
622
+ order.payTokenReason = "not-on-chain";
623
+ }
556
624
  for (const order of found) {
557
625
  Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache }));
558
626
  }
627
+ // THE XMR AMOUNT IS IN THE CLONE, NOT IN THE LOG. A caller without a code reader gets null and a named reason;
628
+ // an unreadable or differently-shaped contract does the same. This is data, not a withdrawal verdict.
629
+ for (const order of found) {
630
+ if (typeof evmCode !== "function") {
631
+ order.xmrAmount = null;
632
+ order.xmrAmountReason = "no-code-reader";
633
+ continue;
634
+ }
635
+ let code;
636
+ try {
637
+ code = await evmCode(order.escrow);
638
+ } catch {
639
+ order.xmrAmount = null;
640
+ order.xmrAmountReason = "code";
641
+ continue;
642
+ }
643
+ const read = xmrAmountFromClone(code);
644
+ order.xmrAmount = read.value;
645
+ order.xmrAmountReason = read.reason;
646
+ }
559
647
  return { swaps: found, scanned };
560
648
  }
package/src/swaps.mjs CHANGED
@@ -27,7 +27,7 @@ import { createActions } from "./actions.mjs";
27
27
  // THE ONE RECOVERY KEY (sdk#76): the taker's halves come from it, not from a fresh random pair per swap.
28
28
  import {
29
29
  commitRecoveryIndex, createRecoveryKeyFile, importRecoveryKey, loadRecoveryKey,
30
- recoveryKeyFileName, reserveRecoveryIndex, saveRecoveryKey, takerHalves,
30
+ recoveryIndexTaken, recoveryKeyFileName, reserveRecoveryIndex, saveRecoveryKey, takerHalves,
31
31
  } from "./recovery-key.mjs";
32
32
  // THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
33
33
  import * as xmrArrival from "./xmr-arrival.mjs";
@@ -804,10 +804,23 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
804
804
  const key = saved || importRecoveryKey(supplied);
805
805
  if (!saved) saveRecoveryKey(storage, key);
806
806
  const chainId = request.order.chainId;
807
- const index = request.recoveryIndex === undefined || request.recoveryIndex === null
808
- ? reserveRecoveryIndex(storage, { chainId })
809
- : Number(request.recoveryIndex);
807
+ const explicit = request.recoveryIndex !== undefined && request.recoveryIndex !== null;
808
+ let index = explicit ? Number(request.recoveryIndex) : reserveRecoveryIndex(storage, { chainId });
810
809
  if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "recoveryIndex" });
810
+ // A BROWSER STORE IS PER DEVICE. The same words opened elsewhere start at the same number, so the factory's own
811
+ // log is asked before the number is used; a taken number is retired locally and the next one is reserved.
812
+ // Without a log seam the previous behaviour is kept - the caller cannot be asked to invent a chain read.
813
+ if (typeof chainLogs === "function" && request.order.factory) {
814
+ let hops = 0;
815
+ while (await recoveryIndexTaken({ mnemonic: key.mnemonic, chainId, index, factory: request.order.factory,
816
+ evmLogs: chainLogs, fromBlock: request.fromBlock === undefined ? 0 : request.fromBlock })) {
817
+ if (explicit) throw new SdkError("bad-input", { field: "recoveryIndex", why: "on-chain" });
818
+ commitRecoveryIndex(storage, index);
819
+ index = reserveRecoveryIndex(storage, { chainId });
820
+ hops += 1;
821
+ if (!Number.isSafeInteger(index) || index < 0 || hops > 1000) throw new SdkError("bad-input", { field: "recoveryIndex", why: "exhausted" });
822
+ }
823
+ }
811
824
  const derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
812
825
  keySide = buildOwnSideFromHalves(request.order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf }).side;
813
826
  recovery = { storage, key, index, newKey: !saved };
@@ -0,0 +1,14 @@
1
+ // THE REVEALED HALF IS A 32-BYTE SCALAR, NOT AN ADDRESS.
2
+ //
3
+ // `revealedHalf()` returns one ABI word: 32 bytes of the ed25519 scalar the settlement published. The withdrawal path
4
+ // used to read it as an address (`hex.slice(24)`), keeping only the last 20 bytes; `(s_a + s_b)·G` then never matched the
5
+ // order's spend point and every real withdrawal was refused. The parser is separate from the chain read so a guard can
6
+ // hand it the exact 32-byte word without a wallet or an RPC.
7
+ const WORD_RE = /^[0-9a-f]{64}$/;
8
+
9
+ export function revealedHalfWord(word) {
10
+ const raw = String(word === undefined || word === null ? "" : word).trim();
11
+ const hex = (raw.startsWith("0x") || raw.startsWith("0X") ? raw.slice(2) : raw).toLowerCase();
12
+ if (!WORD_RE.test(hex) || /^0+$/.test(hex)) return null;
13
+ return "0x" + hex;
14
+ }
@@ -142,7 +142,7 @@ export async function restoreWallet(request = {}) {
142
142
  nodeUrl: requestWindow.nodeUrl, fromHeight, toHeight: requestWindow.toHeight, fetchImpl: request.fetchImpl,
143
143
  });
144
144
  }
145
- windowReport = { used: false, skipped: null, fromHeight, toHeight: Number(requestWindow.toHeight),
145
+ windowReport = { used: false, skipped: null, fallback: false, fromHeight, toHeight: Number(requestWindow.toHeight),
146
146
  blocks: Math.max(0, Number(requestWindow.toHeight) - fromHeight + 1), txs: hashes.length };
147
147
  if (hashes.length && typeof driver.scanTxs === "function") {
148
148
  await step("setRestoreHeight", () => driver.setRestoreHeight(tipHeight));
@@ -192,6 +192,9 @@ export async function restoreWallet(request = {}) {
192
192
  }
193
193
  }
194
194
 
195
+ // THE WINDOW DID NOT ANSWER: the full scan below is the fallback. Say so on the report, so the caller does not
196
+ // have to infer it from `used: false` and a balance.
197
+ if (windowReport) windowReport.fallback = true;
195
198
  await step("setRestoreHeight", () => driver.setRestoreHeight(scanHeight));
196
199
  // A RESCAN IS REQUIRED, NOT OPTIONAL: without it the height only applies to a wallet that has not scanned yet,
197
200
  // and a wallet restored from a key has. A driver that cannot rescan is the caller's mistake, named here.
package/src/sweep.mjs CHANGED
@@ -10,6 +10,9 @@ import * as engine from "./engine.mjs";
10
10
  import { restoreWallet } from "./sweep-restore.mjs";
11
11
  import { moneroWalletAdapter } from "./adapters/monero-wallet.mjs";
12
12
  import { checkDestination } from "./destination.mjs";
13
+ import { revealedHalfWord } from "./sweep-half.mjs";
14
+ import { takerHalves } from "./recovery-key.mjs";
15
+ import { jointAddress } from "./order.mjs";
13
16
 
14
17
  // The same boundary as when the file was issued: a passphrase shorter than twelve characters will not open the file.
15
18
  const MIN_PASSPHRASE = 12;
@@ -19,6 +22,71 @@ const REVEALED_HALF_SELECTOR = "0x3f267bb6";
19
22
 
20
23
  const contentsOf = (file) => (typeof file === "string" ? file : file && typeof file.contents === "string" ? file.contents : null);
21
24
 
25
+ const bare = (value) => String(value == null ? "" : value).replace(/^0x/i, "").toLowerCase();
26
+
27
+ // A FOUND ORDER AND THE KEY, WITHOUT A FILE. The recovery page used to write the order into the SDK's own payload
28
+ // (buildPayload) in memory; that rule belongs here, next to the withdrawal it feeds. The page keeps the order and
29
+ // the key; this function decides which side is the person, checks the chain's points against the key, builds the
30
+ // joint address and returns the same payload a recovery file would carry.
31
+ const payloadFromOrder = async (request) => {
32
+ const order = request && request.order;
33
+ if (!order || typeof order !== "object") throw new SdkError("bad-input", { field: "order" });
34
+ if (typeof request.mnemonic !== "string" || !request.mnemonic.trim()) throw new SdkError("bad-input", { field: "mnemonic" });
35
+ const chainId = Number(request.chainId);
36
+ 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
+ const network = typeof request.network === "string" ? request.network.trim() : "";
40
+ const moneroNetwork = typeof request.moneroNetwork === "string" ? request.moneroNetwork.trim() : "";
41
+ if (!network) throw new SdkError("bad-input", { field: "network" });
42
+ if (!moneroNetwork) throw new SdkError("bad-input", { field: "moneroNetwork" });
43
+ const passphrase = typeof request.passphrase === "string" ? request.passphrase : "";
44
+ if (passphrase && passphrase.length < MIN_PASSPHRASE) {
45
+ throw new SdkError("bad-input", { field: "passphrase", min: MIN_PASSPHRASE });
46
+ }
47
+
48
+ const side = order.side === "locker" ? "buy" : order.side === "claimer" ? "sell" : null;
49
+ if (!side) throw new SdkError("bad-input", { field: "order.side" });
50
+ const halves = takerHalves({ mnemonic: request.mnemonic, chainId, index });
51
+ const buy = side === "buy";
52
+ if (bare(buy ? order.edPointLocker : order.edPointClaimer) !== bare(halves.spendPoint)) {
53
+ throw new SdkError("bad-input", { field: "order", why: "spend-point" });
54
+ }
55
+ if (bare(order.edViewPointLocker) !== bare(buy ? halves.viewPoint : halves.makerViewPoint)) {
56
+ throw new SdkError("bad-input", { field: "order", why: buy ? "own-view-point" : "maker-view-point" });
57
+ }
58
+
59
+ const otherSpendPoint = "0x" + bare(buy ? order.edPointClaimer : order.edPointLocker);
60
+ const otherViewPoint = "0x" + bare(halves.makerViewPoint);
61
+ const joint = jointAddress({
62
+ ownSpendHalf: halves.spendHalf, ownViewHalf: halves.viewHalf,
63
+ otherSpendPoint, otherViewPoint, network: moneroNetwork,
64
+ });
65
+ const escrow = { address: order.escrow, readyBy: order.readyBy, t1: order.t1 };
66
+ const payload = engine.recoveryFile.buildPayload({
67
+ swap: {
68
+ id: order.escrow, side, network, moneroNetwork,
69
+ createdAt: order.blockTime ? Number(order.blockTime) * 1000 : null,
70
+ payToken: null, payAmount: null, xmrAmount: null, rate: null, maker: null, quoteSignature: null,
71
+ escrow, timeline: null,
72
+ },
73
+ swapWallet: {
74
+ source: "halves", network: moneroNetwork, address: joint.address,
75
+ spendHalf: halves.spendHalf, viewHalf: halves.viewHalf,
76
+ otherSpendPoint, otherViewPoint, otherViewHalf: halves.makerViewHalf,
77
+ escrowAddress: order.escrow,
78
+ },
79
+ escrow,
80
+ receiveAddress: null,
81
+ restoreHeight: Number(order.startHeight) > 0 ? Number(order.startHeight) : null,
82
+ });
83
+ return {
84
+ payload,
85
+ contents: await engine.recoveryFile.createRecoveryFile(payload, passphrase),
86
+ address: joint.address,
87
+ };
88
+ };
89
+
22
90
  // A LOW-LEVEL WALLET ERROR IS STILL A REFUSAL, AND EVERY REFUSAL CARRIES A CODE.
23
91
  //
24
92
  // The library brings its own exceptions with no code at all - monero-ts answers "failed to get hashes" while
@@ -68,6 +136,13 @@ export function createSweep({ http, config, call = null }) {
68
136
  const NETWORK_ENUM = { mainnet: "MAINNET", stagenet: "STAGENET", testnet: "TESTNET" };
69
137
 
70
138
  const api = {
139
+ // BUILD THE WITHDRAWAL MATERIAL FROM A FOUND ORDER AND THE KEY. No recovery file, no temporary payload in the
140
+ // page: the returned `contents` is the same file `plan`/`keys` already accept, and `payload` is for callers
141
+ // that want the fields directly.
142
+ async fromOrder(request = {}) {
143
+ return await payloadFromOrder(request);
144
+ },
145
+
71
146
  // BUILD THE WITHDRAWAL WALLET from the library namespace the caller has. The page used to pass a `createWallet`
72
147
  // callback that mapped the enum itself; that mapping is here now, so a caller passes `moneroTs` and the keys.
73
148
  wallet(request = {}) {
@@ -164,10 +239,10 @@ export function createSweep({ http, config, call = null }) {
164
239
  if (escrowAddress && typeof call === "function") {
165
240
  try {
166
241
  const word = String((await call({ to: escrowAddress, data: REVEALED_HALF_SELECTOR })) || "");
167
- const hex = word.startsWith("0x") ? word.slice(2) : word;
168
- if (/^[0-9a-fA-F]{64}$/.test(hex) && !/^0+$/.test(hex)) {
242
+ const half = revealedHalfWord(word);
243
+ if (half !== null) {
169
244
  revealed = true;
170
- revealedValue = "0x" + hex.slice(24);
245
+ revealedValue = half;
171
246
  }
172
247
  } catch { revealed = false; }
173
248
  }
@@ -276,8 +351,13 @@ export function createSweep({ http, config, call = null }) {
276
351
  async sign(request) {
277
352
  const plan = await api.plan(request);
278
353
  if (!request || typeof request.to !== "string" || !request.to) throw new SdkError("bad-input", { field: "to" });
279
- if (typeof request.connectWallet !== "function") throw new SdkError("bad-input", { field: "connectWallet" });
280
- const wallet = await request.connectWallet(plan);
354
+ // ONE ADAPTER FOR RESTORE AND SIGN. A caller that already holds the opened and synced wallet passes it as
355
+ // `wallet`; a caller that wants the SDK to build one passes `connectWallet`.
356
+ const wallet = request.wallet !== undefined && request.wallet !== null
357
+ ? request.wallet
358
+ : (typeof request.connectWallet === "function"
359
+ ? await request.connectWallet(plan)
360
+ : (() => { throw new SdkError("bad-input", { field: "connectWallet" }); })());
281
361
  const role = ["open", "sync", "unlockedBalance", "sweepUnlocked", "relay", "close"];
282
362
  const missing = role.filter((name) => !wallet || typeof wallet[name] !== "function");
283
363
  if (missing.length) throw new SdkError("bad-input", { field: "wallet", missing });
package/src/version.mjs CHANGED
@@ -6,4 +6,4 @@
6
6
  // tools/sync-version.mjs. Do not edit it by hand - set the version in package.json and run `npm run sync:version`.
7
7
  // The `version` check in tools/check.mjs refuses a mismatch, and refuses a runtime read of package.json here.
8
8
 
9
- export const SDK_VERSION = "0.50.0";
9
+ export const SDK_VERSION = "0.51.0";