@ninsei-labs/sdk 0.53.0 → 0.55.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.
@@ -0,0 +1,110 @@
1
+ // THE VIEW MATERIAL A RELAY REQUEST CARRIES.
2
+ //
3
+ // The relay `firm/1` exchange does not send a Monero view scalar in clear text. The client chooses the view
4
+ // halves, keeps them in its recovery material, and encrypts only the node's share to the X25519 key in the
5
+ // signed public features. The profile is fixed by the relay wire spec: HPKE RFC 9180 base mode, DHKEM(X25519,
6
+ // HKDF-SHA256), HKDF-SHA256 and ChaCha20-Poly1305.
7
+ //
8
+ // This module owns the profile, not the request shape. It names one encryption and one decryption, both taking
9
+ // the application `info` as bytes, so the caller cannot accidentally use a different suite or a different
10
+ // interpretation of the context. The relay client builds `info` with `relayViewInfo`.
11
+ import { CipherSuite, DhkemX25519HkdfSha256, HkdfSha256 } from "@hpke/core";
12
+ import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
13
+ import { SdkError } from "./errors.mjs";
14
+
15
+ const utf8 = (value) => new TextEncoder().encode(String(value));
16
+
17
+ const bytesOf = (value) => {
18
+ if (value instanceof Uint8Array) return value;
19
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
20
+ throw new SdkError("bad-input", { field: "viewEnvelope" });
21
+ };
22
+
23
+ const bytesToHex = (bytes) => {
24
+ let out = "0x";
25
+ for (const byte of bytesOf(bytes)) out += byte.toString(16).padStart(2, "0");
26
+ return out;
27
+ };
28
+
29
+ const hexToBytes = (value, width, field) => {
30
+ const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
31
+ if (!new RegExp("^[0-9a-fA-F]{" + (width * 2) + "}$").test(text)) {
32
+ throw new SdkError("bad-input", { field });
33
+ }
34
+ const out = new Uint8Array(width);
35
+ for (let i = 0; i < width; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
36
+ return out;
37
+ };
38
+
39
+ const suite = () => new CipherSuite({
40
+ kem: new DhkemX25519HkdfSha256(),
41
+ kdf: new HkdfSha256(),
42
+ aead: new Chacha20Poly1305(),
43
+ });
44
+
45
+ // The relay binds encryption to the exact terms, the client half and both view points. The prefix and the
46
+ // order of the five words are part of the wire format; a different order still decrypts, which is why it is
47
+ // built here once instead of at each call site.
48
+ export const RELAY_VIEW_INFO_PREFIX = "ninsei-relay-view-v1";
49
+
50
+ export function relayViewInfo({ termsDigest, commitHalf, edPoint, edViewPointClient, edViewPointNode } = {}) {
51
+ const words = [
52
+ ["termsDigest", termsDigest],
53
+ ["commitHalf", commitHalf],
54
+ ["edPoint", edPoint],
55
+ ["edViewPointClient", edViewPointClient],
56
+ ["edViewPointNode", edViewPointNode],
57
+ ].map(([field, value]) => hexToBytes(value, 32, field));
58
+ const prefix = utf8(RELAY_VIEW_INFO_PREFIX);
59
+ const out = new Uint8Array(prefix.length + words.reduce((n, word) => n + word.length, 0));
60
+ out.set(prefix, 0);
61
+ let at = prefix.length;
62
+ for (const word of words) { out.set(word, at); at += word.length; }
63
+ return out;
64
+ }
65
+
66
+ // One sender context, one Seal. The relay spec forbids reusing an ephemeral key for another encryption, so
67
+ // the context is created here and never exposed.
68
+ export async function sealViewEnvelope({ hpkeKey, info, plaintext } = {}) {
69
+ // The two plaintext lengths are part of the wire contract: ask carries the node's view scalar, bid carries
70
+ // the node's scalar followed by the client's. A caller that passes another length is refused before any
71
+ // network or cryptographic work.
72
+ const publicKey = hexToBytes(hpkeKey, 32, "hpkeKey");
73
+ const body = bytesOf(plaintext);
74
+ if (body.length !== 32 && body.length !== 64) {
75
+ throw new SdkError("bad-input", { field: "plaintext" });
76
+ }
77
+ if (!(info instanceof Uint8Array)) throw new SdkError("bad-input", { field: "info" });
78
+ const ctx = suite();
79
+ let key = null;
80
+ try {
81
+ key = await ctx.kem.deserializePublicKey(publicKey);
82
+ const sender = await ctx.createSenderContext({ recipientPublicKey: key, info });
83
+ const ciphertext = bytesOf(await sender.seal(body));
84
+ return { enc: bytesToHex(sender.enc), ciphertext: bytesToHex(ciphertext) };
85
+ } catch (error) {
86
+ if (error instanceof SdkError) throw error;
87
+ throw new SdkError("bad-input", { field: "hpkeKey" });
88
+ }
89
+ }
90
+
91
+ // The recipient side exists for checks and for a node that embeds this package. It is not needed by a client
92
+ // that only asks for a quote, but keeping both halves in one module makes a symmetric test possible.
93
+ export async function openViewEnvelope({ privateKey, enc, ciphertext, info } = {}) {
94
+ const secret = hexToBytes(privateKey, 32, "privateKey");
95
+ const encapsulated = hexToBytes(enc, 32, "enc");
96
+ const text = String(ciphertext === undefined || ciphertext === null ? "" : ciphertext).replace(/^0x/, "");
97
+ const width = text.length / 2;
98
+ if (width !== 48 && width !== 80) throw new SdkError("bad-input", { field: "ciphertext" });
99
+ const body = hexToBytes(ciphertext, width, "ciphertext");
100
+ if (!(info instanceof Uint8Array)) throw new SdkError("bad-input", { field: "info" });
101
+ const ctx = suite();
102
+ try {
103
+ const key = await ctx.kem.deserializePrivateKey(secret, true);
104
+ const recipient = await ctx.createRecipientContext({ recipientKey: key, enc: encapsulated, info });
105
+ return bytesOf(await recipient.open(body));
106
+ } catch (error) {
107
+ if (error instanceof SdkError) throw error;
108
+ throw new SdkError("bad-input", { field: "ciphertext" });
109
+ }
110
+ }
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
@@ -294,6 +295,10 @@ const recordInputOf = (request) => {
294
295
  payToken: quote.asset ? String(quote.asset).toLowerCase() : undefined,
295
296
  payAmount: quote.amount !== undefined ? quote.amount : undefined,
296
297
  xmrAmount: quote.size !== undefined ? quote.size : undefined,
298
+ // THE SIGNED INTEGER, NOT THE DISPLAY NUMBER. `quote.size` is a rounded view of the XMR amount; the signed
299
+ // order's `xmrAmount` is the exact atomic value the maker will pay, and the arrival check compares that one.
300
+ expectedAtomic: request.order && request.order.xmrAmount !== undefined && request.order.xmrAmount !== null
301
+ ? request.order.xmrAmount : undefined,
297
302
  // THE ORDER-QUOTE ID IS PART OF THE RECORD, NOT A DETAIL OF THE SCREEN (a defect found): our service uses it to
298
303
  // tie the created escrow to the provider node's record, and without it the node cannot take the ETH
299
304
  // (sdk/src/swap-flow.mjs: quoteId: quote.id -> swap.orderQuoteId; markBodyOf reads exactly this field).
@@ -792,6 +797,13 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
792
797
 
793
798
  async start(request) {
794
799
  if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
800
+ // SELL IS NOT A BUY WITH ANOTHER LABEL. The lock step sends the settlement deposit from the caller; on a sell
801
+ // the caller is the claimer and the node is the locker, so this path would ask the wallet to sign the wrong
802
+ // transaction. Refuse before the recovery key, the wallet or any network read; the sell client has its own
803
+ // flow and must not be simulated by this one.
804
+ if (request.side === "sell") {
805
+ throw new SdkError("bad-input", { field: "side", why: "sell-not-supported", surface: "swaps.start" });
806
+ }
795
807
  if (!request.quote || typeof request.quote !== "object") throw new SdkError("bad-input", { field: "quote" });
796
808
  // THE PER-SWAP FILE PATH IS GONE. One key covers every swap, and a per-swap file issued before funding cannot
797
809
  // name the escrow (it does not exist yet), so `sweep.plan` could not read the revealed half from it.
@@ -1334,5 +1346,194 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1334
1346
  await new Promise((resolve) => setTimeout(resolve, pollMs));
1335
1347
  }
1336
1348
  },
1349
+
1350
+ /**
1351
+ * START A SELL FROM A VERIFIED RELAY OFFER. The offer is the result of `quotes.firmRelay` for the bid side;
1352
+ * the record keeps the one-time address, the escrow, our half and the order expectations, so a reload can
1353
+ * continue with `get` + `sellWatch` + `sellClaim`. This method sends nothing.
1354
+ */
1355
+ async startSell(request) {
1356
+ if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
1357
+ const offer = request.offer && typeof request.offer === "object" ? request.offer : null;
1358
+ if (!offer) throw new SdkError("bad-input", { field: "offer" });
1359
+ const order = offer.order && typeof offer.order === "object" ? offer.order : null;
1360
+ const quote = offer.quote && typeof offer.quote === "object" ? offer.quote : null;
1361
+ if (!order || !quote) throw new SdkError("bad-input", { field: "offer", missing: ["order", "quote"] });
1362
+ if (typeof offer.jointAddress !== "string" || !offer.jointAddress) throw new SdkError("bad-input", { field: "offer.jointAddress" });
1363
+ if (typeof offer.escrow !== "string" || !offer.escrow) throw new SdkError("bad-input", { field: "offer.escrow" });
1364
+ // THE RECORD'S ROLE IS THE BID ROLE. The node is the locker and the person is the claimer; a quote whose
1365
+ // addresses or factory disagree with the order is not the offer this record describes.
1366
+ const sameValue = (a, b) => String(a || "").replace(/^0x/, "").toLowerCase() === String(b || "").replace(/^0x/, "").toLowerCase();
1367
+ if (!sameValue(quote.locker, order.locker) || !sameValue(quote.claimer, order.claimer)
1368
+ || !sameValue(quote.provider, order.locker)
1369
+ || !sameValue(quote.factory, order.factory) || Number(quote.chainId) !== Number(order.chainId)) {
1370
+ throw new SdkError("quote-refused", { step: "sell-record", why: "terms" });
1371
+ }
1372
+ // THE RECORD IS FOUND BEFORE A SECOND ONE IS MADE. The same offer retried after a reload yields the same
1373
+ // one-time address and escrow; a second record would be a second promise about one deal.
1374
+ const quoteId = quote.id || quote.nonce || null;
1375
+ const existing = records().find((swap) => swap.side === "sell" &&
1376
+ ((swap.escrow && String(swap.escrow.address || "").toLowerCase() === String(offer.escrow).toLowerCase()) ||
1377
+ (quoteId !== null && String(swap.orderQuoteId || "") === String(quoteId))));
1378
+ if (existing) {
1379
+ // A RETRY IS THE SAME OFFER, NOT A NEW ONE THAT HAPPENS TO NAME THE SAME ESCROW. The stored fields are
1380
+ // the commitment the first call verified; a changed point or commitment is refused before it is resumed.
1381
+ const stored = existing.quote && typeof existing.quote === "object" ? existing.quote : {};
1382
+ const same = (a, b) => String(a || "").replace(/^0x/, "").toLowerCase() === String(b || "").replace(/^0x/, "").toLowerCase();
1383
+ if (!same(stored.commitHalfClaimer, quote.commitHalfClaimer)
1384
+ || !same(stored.edPointClaimer, quote.edPointClaimer)
1385
+ || !same(existing.escrow && existing.escrow.moneroAddress, offer.jointAddress)) {
1386
+ throw new SdkError("quote-refused", { step: "sell-record", why: "offer-changed" });
1387
+ }
1388
+ return { id: existing.id, side: "sell", escrow: (existing.escrow && existing.escrow.address) || offer.escrow,
1389
+ address: (existing.escrow && existing.escrow.moneroAddress) || offer.jointAddress,
1390
+ xmrAmount: (existing.escrow && existing.escrow.xmrAmount) || quote.xmrAmount,
1391
+ claimTip: existing.claimTip || quote.claimTip, orderQuote: existing.orderQuote || offer.orderQuote,
1392
+ resumed: true, next: "send-xmr" };
1393
+ }
1394
+ const storage = engine.store.storage();
1395
+ const supplied = typeof request.recoveryKey === "string"
1396
+ ? request.recoveryKey
1397
+ : request.recoveryKey && typeof request.recoveryKey.mnemonic === "string" ? request.recoveryKey.mnemonic : null;
1398
+ const saved = loadRecoveryKey(storage);
1399
+ if (saved && supplied && saved.mnemonic !== supplied) throw new SdkError("bad-input", { field: "recoveryKey", why: "different-key" });
1400
+ if (!saved && !supplied) throw new SdkError("bad-input", { field: "recoveryKey" });
1401
+ const key = saved || importRecoveryKey(supplied);
1402
+ if (!saved) saveRecoveryKey(storage, { ...key, backupConfirmed: false });
1403
+ const chainId = Number(order.chainId);
1404
+ if (!Number.isSafeInteger(chainId) || chainId <= 0) throw new SdkError("bad-input", { field: "order.chainId" });
1405
+ if (!order.factory || order.salt === undefined || order.salt === null) throw new SdkError("bad-input", { field: "order" });
1406
+ let derived = null;
1407
+ if (offer.recovery && offer.recovery.kind === "index-v1") {
1408
+ const index = Number(offer.recovery.index);
1409
+ if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "offer.recovery" });
1410
+ derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
1411
+ } else {
1412
+ let salt = null;
1413
+ try { salt = recoverySaltOf(offer.recovery && offer.recovery.salt ? offer.recovery.salt : order.salt); }
1414
+ catch { throw new SdkError("bad-input", { field: "offer.recovery" }); }
1415
+ derived = takerHalvesV2({ mnemonic: key.mnemonic, chainId, factory: order.factory, salt });
1416
+ }
1417
+ const own = buildOwnSideFromHalves(order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf });
1418
+ // THE OFFER MUST NAME THE HALF THIS KEY ACTUALLY PRODUCES. The relay already checked the proof; the record
1419
+ // needs the same point and commitment, otherwise a later claim would reveal a half the escrow rejects.
1420
+ const ownCommit = (() => {
1421
+ try {
1422
+ const point = secp256k1.Point.fromHex(own.side.proof.XA);
1423
+ return "0x" + Array.from(keccak256(point.toBytes(false).slice(1)), (b) => b.toString(16).padStart(2, "0")).join("");
1424
+ } catch { return null; }
1425
+ })();
1426
+ const low160 = (v) => String(v || "").replace(/^0x/, "").toLowerCase().slice(-40);
1427
+ if (!ownCommit || low160(ownCommit) !== low160(quote.commitHalfClaimer)) {
1428
+ throw new SdkError("quote-refused", { step: "sell-record", why: "commitment" });
1429
+ }
1430
+ if (String(own.spendPoint).toLowerCase() !== String(quote.edPointClaimer || "").toLowerCase()) {
1431
+ throw new SdkError("quote-refused", { step: "sell-record", why: "spend-point" });
1432
+ }
1433
+ if (offer.edViewPointClaimer && String(own.viewPoint).toLowerCase() !== String(offer.edViewPointClaimer).toLowerCase()) {
1434
+ throw new SdkError("quote-refused", { step: "sell-record", why: "view-point" });
1435
+ }
1436
+ const made = recordModule.createRecord({
1437
+ side: "sell", network: String(order.chainId),
1438
+ payToken: order.asset || quote.asset || undefined,
1439
+ payAmount: quote.amount, xmrAmount: Number(quote.xmrAmount) / 1e12,
1440
+ orderQuoteId: quoteId || undefined,
1441
+ receiveAddress: order.claimer,
1442
+ makerId: quote.provider || null,
1443
+ }, { order, side: own.side });
1444
+ const swap = made.swap;
1445
+ swap.side = "sell";
1446
+ swap.order = order;
1447
+ swap.orderQuote = offer.orderQuote || null;
1448
+ swap.quote = quote;
1449
+ swap.claimTip = quote.claimTip || null;
1450
+ swap.expect = {
1451
+ chainId, factory: order.factory, locker: order.locker, claimer: order.claimer, amount: String(order.amount),
1452
+ readyBy: order.readyBy, t1: order.t1, salt: order.salt,
1453
+ commitHalfLocker: (offer.nodeHalf && offer.nodeHalf.commitHalf) || null,
1454
+ edPointLocker: (offer.nodeHalf && offer.nodeHalf.edPoint) || null,
1455
+ edViewPointLocker: offer.edViewPointLocker || null,
1456
+ commitHalfClaimer: quote.commitHalfClaimer || null,
1457
+ edPointClaimer: quote.edPointClaimer || null,
1458
+ };
1459
+ swap.escrow = { ...(swap.escrow || {}),
1460
+ address: offer.escrow, moneroAddress: offer.jointAddress,
1461
+ half: halfHex(own.side.half), viewHalf: halfHex(own.side.viewHalf),
1462
+ edPointClaimer: own.spendPoint, edViewPointClaimer: norm(own.side.viewPub),
1463
+ commitHalfClaimer: quote.commitHalfClaimer,
1464
+ edPointLocker: (offer.nodeHalf && offer.nodeHalf.edPoint) || null,
1465
+ edViewPointLocker: offer.edViewPointLocker || null,
1466
+ commitHalfLocker: (offer.nodeHalf && offer.nodeHalf.commitHalf) || null,
1467
+ orderContext: offer.context || null, xmrAmount: quote.xmrAmount || null,
1468
+ };
1469
+ try { engine.swap.saveSwap(swap); } catch { throw new SdkError("storage-unavailable", { step: "save-swap" }); }
1470
+ return { id: swap.id, side: "sell", escrow: offer.escrow, address: offer.jointAddress,
1471
+ xmrAmount: quote.xmrAmount, claimTip: quote.claimTip || null, orderQuote: offer.orderQuote || null,
1472
+ resumed: false, next: "send-xmr" };
1473
+ },
1474
+
1475
+ /**
1476
+ * CLAIM THE ETH ON A SELL. The half and the expectations are in the record made by `startSell`; the sender
1477
+ * must be the claimer's own wallet.
1478
+ */
1479
+ async sellClaim(request, wallet) {
1480
+ if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
1481
+ const id = String(request.id || "").trim();
1482
+ if (!id) throw new SdkError("bad-input", { field: "id" });
1483
+ let swap = null;
1484
+ try { swap = engine.swap.getSwap(id); } catch { swap = null; }
1485
+ if (!swap || swap.side !== "sell") throw new SdkError("bad-input", { field: "id", why: "sell-not-found" });
1486
+ const escrow = request.escrow || (swap.escrow && swap.escrow.address) || null;
1487
+ const halfClaimer = request.halfClaimer || (swap.escrow && swap.escrow.half) || null;
1488
+ if (!escrow || !halfClaimer) throw new SdkError("bad-input", { field: "escrow", missing: ["escrow", "halfClaimer"] });
1489
+ const expect = request.expect || swap.expect || null;
1490
+ if (!expect) throw new SdkError("bad-input", { field: "expect", why: "not-in-record" });
1491
+ return actions.claim({ escrow, halfClaimer, claimer: swap.order && swap.order.claimer,
1492
+ expect, onStep: request.onStep }, wallet);
1493
+ },
1494
+
1495
+ /**
1496
+ * WAIT FOR THE NODE'S SIDE ON A SELL. The client sends XMR to the one-time joint address; the node (the
1497
+ * locker) must mark the order ready before the claimer can take the ETH. This reads the escrow itself - no
1498
+ * backend watcher - and returns as soon as the chain says ready, claimed or refunded.
1499
+ */
1500
+ async sellWatch(request) {
1501
+ if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
1502
+ const escrow = String(request.escrow || "").trim();
1503
+ if (!escrow) throw new SdkError("bad-input", { field: "escrow" });
1504
+ const timeoutMs = Number.isFinite(Number(request.timeoutMs)) && Number(request.timeoutMs) > 0
1505
+ ? Number(request.timeoutMs) : limitOf("watchTimeoutMs", 1_200_000);
1506
+ const pollMs = Number.isFinite(Number(request.pollMs)) && Number(request.pollMs) > 0
1507
+ ? Number(request.pollMs) : limitOf("watchPollMs", 6_000);
1508
+ const started = Date.now();
1509
+ let lastStatus = null;
1510
+ let lastError = null;
1511
+ for (;;) {
1512
+ try { lastStatus = await flow.orderStatus(escrow, { call: reader }); }
1513
+ catch (error) { lastError = (error && error.code) || "status-unread"; lastStatus = null; }
1514
+ if (lastStatus && (lastStatus.isClaimed || lastStatus.isRefunded)) {
1515
+ return { escrow, state: lastStatus.isClaimed ? "claimed" : "refunded",
1516
+ funded: lastStatus.isFunded, claimed: lastStatus.isClaimed, refunded: lastStatus.isRefunded,
1517
+ ready: null, balanceWei: String(lastStatus.balance), waitedMs: Date.now() - started };
1518
+ }
1519
+ if (lastStatus && lastStatus.isFunded) {
1520
+ let ready = null;
1521
+ try {
1522
+ const raw = await reader({ to: escrow, data: engine.evm.encodeRead("ready") });
1523
+ const hex = String(raw || "").replace(/^0x/, "");
1524
+ ready = hex.length > 0 && BigInt("0x" + hex) !== 0n;
1525
+ } catch (error) { lastError = (error && error.code) || "ready-unread"; }
1526
+ if (ready === true) {
1527
+ return { escrow, state: "ready", funded: true, claimed: false, refunded: false,
1528
+ ready: true, balanceWei: String(lastStatus.balance), waitedMs: Date.now() - started };
1529
+ }
1530
+ }
1531
+ if (Date.now() - started >= timeoutMs) {
1532
+ throw new SdkError("sell-timeout", { escrow, waitedMs: Date.now() - started, timeoutMs,
1533
+ status: lastStatus, error: lastError });
1534
+ }
1535
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
1536
+ }
1537
+ },
1337
1538
  };
1338
1539
  }
package/src/sweep.mjs CHANGED
@@ -85,7 +85,16 @@ const payloadFromOrder = async (request) => {
85
85
  ownSpendHalf: halves.spendHalf, ownViewHalf: halves.viewHalf,
86
86
  otherSpendPoint, otherViewPoint, network: moneroNetwork,
87
87
  });
88
- const escrow = { address: order.escrow, readyBy: order.readyBy, t1: order.t1 };
88
+ // THE SIGNED XMR AMOUNT TRAVELS WITH THE ESCROW. A recovery file rebuilt from the chain must compare the
89
+ // arrival against the exact integer the order carries, not against the rounded display number.
90
+ const signedAtomic = (() => {
91
+ try {
92
+ const atomic = BigInt(String(order.xmrAmount));
93
+ return atomic > 0n ? atomic.toString() : null;
94
+ } catch { return null; }
95
+ })();
96
+ const escrow = { address: order.escrow, readyBy: order.readyBy, t1: order.t1,
97
+ ...(signedAtomic ? { expectedAtomic: signedAtomic } : {}) };
89
98
  const payload = engine.recoveryFile.buildPayload({
90
99
  swap: {
91
100
  id: order.escrow, side, network, moneroNetwork,
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.55.0";