@ninsei-labs/sdk 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/swaps.mjs CHANGED
@@ -13,14 +13,15 @@ import * as engine from "./engine.mjs";
13
13
  import * as recordModule from "./record.mjs";
14
14
  // The order: our own side is built by the engine's builder, the counterparty's side is verified and accepted, and
15
15
  // the joint address is assembled from the halves. There are no formulas of our own in the core - see order.mjs.
16
- import { buildOwnSideFromHalves, contextOf, normalizeCounterpartyView, orderStep } from "./order.mjs";
16
+ import { buildOwnSideFromHalves, contextOf, halfHex, norm, normalizeCounterpartyView, orderStep } from "./order.mjs";
17
17
  // Locking the funds: sending goes through the already-finished step (`lock.send`), the wallet is an adapter from outside.
18
18
  import * as lock from "./lock.mjs";
19
+ import * as flow from "./swap-flow.mjs";
19
20
  import { fundingReceiptVerdict } from "../engine/evm/funding.js";
20
21
  import { vmOfChain, legFor } from "./legs/index.mjs";
21
22
  // THE VIEW HALVES - from the core's primitives: the sum of two halves is the key the wallet sees incoming payments
22
23
  // with. There is no arithmetic of the core's own here either (see order.mjs) - we use the same halves builder.
23
- import { primitives } from "./primitives.mjs";
24
+ import { keccak256, primitives, secp256k1 } from "./primitives.mjs";
24
25
  import { probeMoneroNode } from "./probes.mjs";
25
26
  // MARKING READY - A FINISHED STEP, NOT A SECOND IMPLEMENTATION OF IT: it requires the order expectations and checks
26
27
  // the slots against the chain BEFORE signing (actions.markReady). The watch must call exactly it, otherwise "we saw
@@ -792,6 +793,13 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
792
793
 
793
794
  async start(request) {
794
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
+ }
795
803
  if (!request.quote || typeof request.quote !== "object") throw new SdkError("bad-input", { field: "quote" });
796
804
  // THE PER-SWAP FILE PATH IS GONE. One key covers every swap, and a per-swap file issued before funding cannot
797
805
  // name the escrow (it does not exist yet), so `sweep.plan` could not read the revealed half from it.
@@ -1334,5 +1342,194 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1334
1342
  await new Promise((resolve) => setTimeout(resolve, pollMs));
1335
1343
  }
1336
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
+ },
1337
1534
  };
1338
1535
  }
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.53.0";
9
+ export const SDK_VERSION = "0.54.0";