@ninsei-labs/sdk 0.51.2 → 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.
@@ -22,11 +22,18 @@ export const FACTORY_METHODS = {
22
22
  createOrderAndFund: "0x3a504547",
23
23
  };
24
24
 
25
- // The OrderCreated event topic. It grew the registry address, the provider and the signing key:
26
- // cast keccak "OrderCreated(address,address,address,bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint64,uint64,bytes32,address,address,address)"
25
+ // The OrderCreated event topic. It grew the registry address, the provider and the signing key, and now carries
26
+ // `xmrAmount` in bulk (#32):
27
+ // cast keccak "OrderCreated(address,address,address,bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,uint64,uint64,bytes32,address,address,address)"
27
28
  // The topic lives in TWO places and both must match; a check pins them.
28
29
  export const ORDER_CREATED_TOPIC =
30
+ "0x09add13cca47b227c377447295bfaa00ef3169bfed0f5513d06f8be04af78aad";
31
+
32
+ // THE PREVIOUS EVENT, KEPT FOR ORDERS ALREADY ON CHAIN. It has no `xmrAmount`, so those orders still fall back to
33
+ // reading word 18 of the clone's code. A new order uses the topic above.
34
+ export const ORDER_CREATED_TOPIC_V1 =
29
35
  "0xa22cdabaed3cce4f51dde71626a108ffe9c7bc4216fd14d5e719f892e2fd8ab0";
36
+ export const ORDER_CREATED_TOPICS = Object.freeze([ORDER_CREATED_TOPIC, ORDER_CREATED_TOPIC_V1]);
30
37
 
31
38
  // QUOTE FIELDS IN CONTRACT ORDER. The order is part of the ABI: a swap gives another call. One list for both
32
39
  // functions, so they cannot diverge.
@@ -125,18 +125,45 @@ export const GAS_CREATE_ORDER_AND_FUND = 700_000n;
125
125
 
126
126
  // The ACTUAL escrow address from the receipt. Returns null when the event is absent (the transaction is not in a
127
127
  // block, or the factory is another) - then the caller must say so aloud rather than substitute a guess.
128
- export function escrowFromReceipt(receipt) {
128
+ export function escrowFromReceipt(receipt, { factory = null, expectedEscrow = null } = {}) {
129
129
  if (!receipt || !Array.isArray(receipt.logs)) return null;
130
+ const wantFactory = factory ? String(factory).toLowerCase() : null;
131
+ const wantEscrow = expectedEscrow ? String(expectedEscrow).toLowerCase() : null;
130
132
  for (const log of receipt.logs) {
133
+ // THE EVENT MUST COME FROM THE FACTORY THAT WAS CALLED. Another contract can emit the same topic; accepting
134
+ // it would let a receipt from a different order name the escrow that gets saved and used for the withdrawal.
135
+ if (wantFactory && String(log && log.address || "").toLowerCase() !== wantFactory) continue;
131
136
  const topics = log.topics || [];
132
137
  if (String(topics[0]).toLowerCase() !== ORDER_CREATED_TOPIC) continue;
133
138
  if (topics.length < 2) continue;
134
139
  const addr = "0x" + String(topics[1]).slice(26).toLowerCase();
135
- if (/^0x[0-9a-f]{40}$/.test(addr) && !/^0x0{40}$/.test(addr)) return addr;
140
+ if (!/^0x[0-9a-f]{40}$/.test(addr) || /^0x0{40}$/.test(addr)) continue;
141
+ // THE PREDICTED ADDRESS IS THE EXPECTED ONE. A different escrow in the same factory is not this order; the
142
+ // caller must not save it as funded.
143
+ if (wantEscrow && addr !== wantEscrow) continue;
144
+ return addr;
136
145
  }
137
146
  return null;
138
147
  }
139
148
 
149
+ // A RECEIPT IS A VERDICT, NOT A PRESENCE CHECK. `status: 0` is a reverted transaction; a receipt with no
150
+ // OrderCreated from the expected factory is not a funded escrow; a missing receipt is pending. Only status 1 plus
151
+ // the expected event is funded, and the caller saves the escrow only on that answer.
152
+ export function fundingReceiptVerdict(receipt, { factory = null, expectedEscrow = null } = {}) {
153
+ if (!receipt || typeof receipt !== "object") return { status: "pending", reason: "no-receipt" };
154
+ if (receipt.status === undefined || receipt.status === null || receipt.status === "") {
155
+ return { status: "pending", reason: "no-status" };
156
+ }
157
+ const status = Number(receipt.status);
158
+ if (!Number.isFinite(status)) return { status: "pending", reason: "bad-status" };
159
+ if (status === 0) return { status: "reverted", reason: "status-0" };
160
+ if (status !== 1) return { status: "pending", reason: "bad-status" };
161
+ const escrow = escrowFromReceipt(receipt, { factory, expectedEscrow });
162
+ if (!escrow) return { status: "pending", reason: "no-order-created" };
163
+ const blockNumber = Number(receipt.blockNumber);
164
+ return { status: "funded", escrow, birthBlock: Number.isFinite(blockNumber) ? blockNumber : null };
165
+ }
166
+
140
167
  // Wait for a receipt: right after signing there is none, which is normal. Waiting forever is not allowed - we say so honestly.
141
168
  async function waitReceipt(receiptReader, hash, { tries = 12, delayMs = 1500 } = {}) {
142
169
  for (let i = 0; i < tries; i += 1) {
@@ -184,8 +211,13 @@ export async function fundOrder({ factory, locker, claimer, commitHalfLocker, co
184
211
  // THE DEPOSITOR'S FIELDS GO AS SEPARATE FACTORY ARGUMENTS (they are not in the signed set).
185
212
  const quoteEnc = { ...quote, commitHalfLocker: terms.commitHalfLocker, edPointLocker: terms.edPointLocker, edViewPointLocker: terms.edViewPointLocker };
186
213
  // QUOTE AND TERMS ARE ABOUT ONE THING: a divergence moves the escrow address and signs the wrong order.
187
- for (const name of ["locker", "claimer", "amount", "readyBy", "t1", "salt"]) {
188
- if (String(quote[name]).toLowerCase() !== String(terms[name]).toLowerCase()) {
214
+ // THE COUNTERPARTY'S SPEND HALF IS PART OF THAT ONE THING. The proof is checked against the counterparty side,
215
+ // while the factory encodes the signed quote; without comparing these two fields a quote that commits to one
216
+ // point can be funded beside a proof for another, and the claim reveals a half that does not open the address.
217
+ const sameValue = (a, b) => String(a === undefined || a === null ? "" : a).replace(/^0x/i, "").toLowerCase()
218
+ === String(b === undefined || b === null ? "" : b).replace(/^0x/i, "").toLowerCase();
219
+ for (const name of ["locker", "claimer", "amount", "readyBy", "t1", "salt", "commitHalfClaimer", "edPointClaimer"]) {
220
+ if (!sameValue(quote[name], terms[name])) {
189
221
  throw new Error("the quote and the order terms diverge on field " + name);
190
222
  }
191
223
  }
@@ -260,20 +292,29 @@ export async function fundOrder({ factory, locker, claimer, commitHalfLocker, co
260
292
  // independent of the Monero node. Declared outside the branch on purpose: a variable declared inside a block is
261
293
  // not visible outside, and it must always be returned.
262
294
  let birthBlock = null;
295
+ let verdict = { status: "pending", reason: "no-receipt-reader" };
263
296
  if (typeof receipt === "function") {
264
297
  const rc = await waitReceipt(receipt, createHash);
265
- actualEscrow = escrowFromReceipt(rc);
266
- if (rc && Number.isFinite(Number(rc.blockNumber))) birthBlock = Number(rc.blockNumber);
267
- if (!rc) {
268
- onStep?.("no receipt yet - using the predicted address");
269
- } else if (!actualEscrow) {
270
- onStep?.("no OrderCreated in the receipt - using the predicted address");
271
- } else if (actualEscrow !== String(escrow).toLowerCase()) {
272
- console.warn("[escrow] receipt address " + actualEscrow + " != predicted " + escrow + " - using the receipt one");
273
- onStep?.("receipt address differs from predicted - using the receipt one");
298
+ verdict = fundingReceiptVerdict(rc, { factory, expectedEscrow: escrow });
299
+ if (verdict.status === "reverted") {
300
+ const error = new Error("the funding transaction reverted");
301
+ error.code = "contract-reverted";
302
+ error.receiptStatus = 0;
303
+ throw error;
304
+ }
305
+ if (verdict.status === "funded") {
306
+ actualEscrow = verdict.escrow;
307
+ birthBlock = verdict.birthBlock;
308
+ if (actualEscrow !== String(escrow).toLowerCase()) {
309
+ console.warn("[escrow] receipt address " + actualEscrow + " != predicted " + escrow + " - using the receipt one");
310
+ onStep?.("receipt address differs from predicted - using the receipt one");
311
+ }
312
+ } else if (!rc) {
313
+ onStep?.("no receipt yet - funding is pending, not funded");
314
+ } else {
315
+ onStep?.("receipt has no matching OrderCreated - funding is pending, not funded");
274
316
  }
275
317
  }
276
- const escrowToUse = actualEscrow || escrow;
277
318
 
278
319
  // 3) THE SECOND TRANSACTION IS GONE: the money left together with order creation. The name lockHash is kept on
279
320
  // purpose - callers look at it, and renaming would touch them with no benefit. THERE IS AND WILL BE NO TOP-UP:
@@ -282,10 +323,16 @@ export async function fundOrder({ factory, locker, claimer, commitHalfLocker, co
282
323
  // created without money.
283
324
  const lockHash = createHash;
284
325
 
285
- // escrow is the ACTUAL address (from the receipt, if there was one); predictedEscrow is kept so a divergence is
286
- // visible outside, not only in the log.
326
+ // ONLY A STATUS-1 RECEIPT WITH THE EXPECTED ORDER CREATED IS FUNDED. A missing receipt, a receipt without the
327
+ // event, or an event from another factory keeps `escrow` null and is returned as pending; the caller must not
328
+ // save it, retire a recovery number for it, or call it xmr-incoming.
287
329
  // THE FEE GOES OUTWARD: it went into the transaction and the deal record must keep it - otherwise the record
288
330
  // cannot tell how much the person deposited, and it would diverge from what the chain shows.
289
- return { feeWei: fee.feeWei, feeTotalWei: totalValueWei, feeBps: fee.bps, feeWasLegacyFactory: Boolean(fee.legacy),
290
- escrow: escrowToUse, predictedEscrow: escrow, escrowMismatch: Boolean(actualEscrow && actualEscrow !== String(escrow).toLowerCase()), salt, birthBlock, terms, createHash, lockHash };
331
+ const common = { feeWei: fee.feeWei, feeTotalWei: totalValueWei, feeBps: fee.bps, feeWasLegacyFactory: Boolean(fee.legacy),
332
+ predictedEscrow: escrow, salt, terms, createHash, lockHash };
333
+ if (verdict.status !== "funded") {
334
+ return { ...common, escrow: null, birthBlock: null, funded: false, pending: true, pendingReason: verdict.reason };
335
+ }
336
+ return { ...common, escrow: actualEscrow, escrowMismatch: actualEscrow !== String(escrow).toLowerCase(),
337
+ birthBlock, funded: true, pending: false };
291
338
  }
@@ -71,25 +71,6 @@ export function viewKeyMatchesAddress(viewScalar, expectedViewPubHex, halves) {
71
71
  return got === want;
72
72
  }
73
73
 
74
- /** A boundary up to this value is a BLOCK HEIGHT; from it on, a unix TIME. Monero's own convention. */
75
- export const UNLOCK_TIME_BLOCK_MAX = 500_000_000;
76
-
77
- /**
78
- * IS THE COIN SPENDABLE AT THIS MOMENT, AND IF NOT, WHAT IS THE BOUNDARY.
79
- *
80
- * `unlocked` / `locked_height` / `locked_time`, with the boundary named in `untilHeight` or `untilTime`. A height
81
- * that is not known is NOT "unlocked": without a height the answer stays locked, because the caller has no fact
82
- * that would open it. `nowSec` defaults to the wall clock, and is a parameter so a check can pin it.
83
- */
84
- export function unlockStateOf(unlockTime, { height = null, nowSec = null } = {}) {
85
- const u = Number(unlockTime) || 0;
86
- if (u <= 0) return { code: "unlocked", locked: false, untilHeight: null, untilTime: null };
87
- if (u >= UNLOCK_TIME_BLOCK_MAX) {
88
- const now = nowSec === null || nowSec === undefined ? Math.floor(Date.now() / 1000) : Number(nowSec);
89
- const open = Number.isFinite(now) && now >= u;
90
- return { code: open ? "unlocked" : "locked_time", locked: !open, untilHeight: null, untilTime: u };
91
- }
92
- const h = height === null || height === undefined ? null : Number(height);
93
- const open = h !== null && Number.isFinite(h) && h >= u;
94
- return { code: open ? "unlocked" : "locked_height", locked: !open, untilHeight: u, untilTime: null };
95
- }
74
+ // THE UNLOCK RULE HAS ONE HOME: ./unlock.js. This module re-exports it for the public xmr entry instead of keeping
75
+ // a second copy; a second copy is a second truth about whether money can be spent.
76
+ export { UNLOCK_TIME_BLOCK_MAX, unlockStateOf } from "./unlock.js";
@@ -15,7 +15,11 @@ export function spendKeysFromRevealedHalf({
15
15
  for (const [name, v] of Object.entries({ ownSpendHalf, ownViewHalf, otherSpendPoint, otherViewHalf, revealedHalf })) {
16
16
  if (v === undefined || v === null || v === "") throw new Error("not set: " + name);
17
17
  }
18
- const sum = halves.combineHalves(BigInt(ownSpendHalf), BigInt(revealedHalf));
18
+ // THE CONTRACT ACCEPTS THE REAL HALF PLUS THE SECP256K1 ORDER. `claim` and `refund` do not reject `h + n` (the
19
+ // escrow only hashes the word), but adding n is not the same point on edwards25519. Reduce the public word by the
20
+ // secp order first: the small real half is unchanged, and the alias lands on the same scalar.
21
+ const revealed = BigInt(revealedHalf) % halves.SECP_ORDER;
22
+ const sum = halves.combineHalves(BigInt(ownSpendHalf), revealed);
19
23
  const sumPub = halves.publicHalves(sum).ed;
20
24
  // The expected spend key is built FROM OWN HALF AND THE COUNTERPARTY POINT (what was in the order).
21
25
  // The half from the file arrives as a string, so it must be converted - forgetting this crashed the XMR
@@ -33,6 +33,25 @@ export function keccak(bytes) { return keccak_256(bytes); }
33
33
  // corrupt the scalar, the derivation and the sum - an "invisible arrival".
34
34
  export const ED_ORDER = ed25519.Point.CURVE().n;
35
35
 
36
+ // THE SECOND PEDERSEN GENERATOR, FROM THE MONERO SOURCE. `rct::H` is a fixed point whose discrete logarithm to
37
+ // the base point is unknown: C = mask*G + amount*H. The bytes are the compressed point from
38
+ // monero/src/ringct/rctTypes.h (`static const key H`), not a value recomputed from a remembered string.
39
+ // It is the same point the DLEQ module calls Galt - one constant, two uses, no second derivation.
40
+ export const PEDERSEN_H = "8b655970153799af2aeadc9ff1add0ea6c7251d54154cfa92c173a0dd39c1f94";
41
+ const PEDERSEN_H_POINT = ed25519.Point.fromHex(PEDERSEN_H);
42
+
43
+ function scalarPoint(point, scalar) {
44
+ const mod = ((BigInt(scalar) % ED_ORDER) + ED_ORDER) % ED_ORDER;
45
+ return mod === 0n ? ed25519.Point.ZERO : point.multiply(mod);
46
+ }
47
+
48
+ /** The Pedersen commitment C = mask*G + amount*H, compressed. Used to check a decoded amount against `outPk`. */
49
+ export function amountMaskCommitment(amount, mask) {
50
+ const value = BigInt(amount);
51
+ if (value < 0n) throw new Error("the amount must not be negative");
52
+ return scalarPoint(ed25519.Point.BASE, mask).add(scalarPoint(PEDERSEN_H_POINT, value)).toHex();
53
+ }
54
+
36
55
  export function hexToBytes(hex) {
37
56
  const t = String(hex).trim().replace(/^0x/, "");
38
57
  if (t.length % 2) throw new Error("odd hex length");
@@ -138,32 +157,52 @@ export function txPublicKeyFromExtra(extra) {
138
157
  return null;
139
158
  }
140
159
 
141
- // Amount decryption. version: 2 = current scheme (xor of the first 8 bytes), 1 = old (scalar subtraction).
160
+ // Amount and mask decryption. THE ENCODING IS CHOSEN BY THE RingCT TYPE, not by a boolean:
161
+ // types 0-3 (old): every ecdhInfo entry carries a 32-byte amount and a 32-byte mask;
162
+ // types 4-6 (Bulletproof2, CLSAG, BulletproofPlus): the entry carries only the 8-byte amount, and the
163
+ // receiver DERIVES the mask as Hs("commitment_mask" || sharedSec). A transaction from a current node has no
164
+ // `ecdhInfo.mask` at all.
142
165
  //
143
166
  // THE SECRET IS THE DERIVATION SCALAR, NOT THE DERIVATION. Easy to get wrong: both are 32 bytes, but what goes
144
167
  // into genAmountEncodingFactor is the scalar (derivation_to_scalar), written LEAST-SIGNIFICANT first. With the
145
168
  // derivation the result looks plausible and is WRONG (checked on live numbers).
146
- export function decodeAmount(ecdhAmountHex, scalarLE, rctVersion) {
147
- const enc = hexToBytes(ecdhAmountHex);
148
- if (Number(rctVersion) >= 2) {
149
- // Multiplier: keccak("amount" || scalar_LE), 38 bytes - order checked against the source.
150
- const label = new TextEncoder().encode("amount");
151
- const buf = new Uint8Array(label.length + scalarLE.length);
152
- buf.set(label, 0); buf.set(scalarLE, label.length);
169
+ //
170
+ // THE TWO OLD FACTORS ARE DIFFERENT: amount -= Hs(Hs(sharedSec)), mask -= Hs(sharedSec). Using the amount's
171
+ // double hash for the mask (the first version) makes every old-encoding commitment fail.
172
+ function compactEncoding(rctType) { return Number(rctType) >= 4; }
173
+ function oldFactor(scalarLE, label) {
174
+ const base = hashToScalar(scalarLE);
175
+ if (label === "mask") return base;
176
+ return hashToScalar(hexToBytes(base.toString(16).padStart(64, "0")));
177
+ }
178
+ function decodeField(ecdhHex, scalarLE, rctType, label) {
179
+ if (compactEncoding(rctType)) {
180
+ if (label === "mask") return hashToScalar(new TextEncoder().encode("commitment_mask"), scalarLE);
181
+ const enc = hexToBytes(ecdhHex);
182
+ const prefix = new TextEncoder().encode(label);
183
+ const buf = new Uint8Array(prefix.length + scalarLE.length);
184
+ buf.set(prefix, 0); buf.set(scalarLE, prefix.length);
153
185
  const factor = keccak(buf);
154
186
  const out = enc.slice();
155
- for (let i = 0; i < 8; i++) out[i] ^= factor[i]; // xor of the FIRST 8 bytes only
156
- // And again LEAST-SIGNIFICANT first: Monero amounts are little-endian too.
187
+ for (let i = 0; i < out.length && i < factor.length; i++) out[i] ^= factor[i];
157
188
  return scalarFromBytesLE(out);
158
189
  }
159
- const s2 = hashToScalar(hexToBytes(hashToScalar(hexToBytes(bytesToHex(rctVersion === 1 ? scalarLE : scalarLE))).toString(16).padStart(64, "0")));
160
- const amt = BigInt("0x" + bytesToHex(enc)) % ED_ORDER;
161
- return (amt - s2 + ED_ORDER) % ED_ORDER;
190
+ const enc = hexToBytes(ecdhHex);
191
+ const value = BigInt("0x" + bytesToHex(enc)) % ED_ORDER;
192
+ return (value - oldFactor(scalarLE, label) + ED_ORDER) % ED_ORDER;
193
+ }
194
+
195
+ export function decodeAmount(ecdhAmountHex, scalarLE, rctType) {
196
+ return decodeField(ecdhAmountHex, scalarLE, rctType, "amount");
197
+ }
198
+
199
+ export function decodeMask(ecdhMaskHex, scalarLE, rctType) {
200
+ return decodeField(ecdhMaskHex, scalarLE, rctType, "mask");
162
201
  }
163
202
 
164
203
  // MAIN: parse a transaction and return OUR outputs (their indices, keys and amounts).
165
- // txView - { txPublicKey, vout: [{key, index}], ecdhAmounts: [...], rctVersion }
166
- export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, outputs, ecdhAmounts = [], rctVersion = 2 }) {
204
+ // txView - { txPublicKey, vout: [{key, index}], ecdhAmounts: [...], ecdhMasks: [...], outPk: [...], rctVersion }
205
+ export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, outputs, ecdhAmounts = [], ecdhMasks = [], outPk = [], rctVersion = 2 }) {
167
206
  if (!txPublicKey) throw new Error("the transaction has no public key (extra tag 0x01) - nothing to parse");
168
207
  // The key form is tried EXPLICITLY; the winner's name goes up so the log shows what was used. If none matched,
169
208
  // the first meaningful form is returned, not nothing.
@@ -178,7 +217,20 @@ export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, output
178
217
  if (derived.toLowerCase() === String(o.key).toLowerCase()) {
179
218
  // The amount decryption uses THIS output's SCALAR, least-significant first.
180
219
  const amount = ecdhAmounts[o.index] ? decodeAmount(ecdhAmounts[o.index], scalarToBytesLE(scalar), rctVersion) : null;
181
- found.push({ index: o.index, key: o.key, amountAtomic: amount });
220
+ // THE COMMITMENT CHECK: outPk[i] = mask*G + amount*H. A node can flip a bit of the encrypted amount
221
+ // without any key; the commitment is the only figure it cannot forge. A missing or mismatching
222
+ // commitment is named on the output instead of being accepted as an arrival.
223
+ // COMPACT TYPES HAVE NO mask FIELD: it is derived from the same scalar. Old types carry it.
224
+ const mask = compactEncoding(rctVersion)
225
+ ? decodeMask(null, scalarToBytesLE(scalar), rctVersion)
226
+ : (ecdhMasks[o.index] ? decodeMask(ecdhMasks[o.index], scalarToBytesLE(scalar), rctVersion) : null);
227
+ let amountVerified = false;
228
+ if (amount !== null && mask !== null && outPk[o.index]) {
229
+ try {
230
+ amountVerified = amountMaskCommitment(amount, mask).toLowerCase() === String(outPk[o.index]).replace(/^0x/, "").toLowerCase();
231
+ } catch { amountVerified = false; }
232
+ }
233
+ found.push({ index: o.index, key: o.key, amountAtomic: amount, maskAtomic: mask, amountVerified });
182
234
  }
183
235
  }
184
236
  if (found.length) return { derivation: bytesToHex(derivation), outputs: found, viewKeyForm: form };
@@ -200,31 +252,69 @@ export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, output
200
252
  // ============================================================================================
201
253
 
202
254
 
203
- export async function fetchBlock(daemonUrl, height) {
204
- const res = await fetch(`${String(daemonUrl).replace(/\/+$/, "")}/json_rpc`, {
255
+ // A trailing-slash trim without a backtracking regular expression: the daemon URL is caller input, and CodeQL
256
+ // flags `/+$` on a long run of slashes as a possible denial of service.
257
+ const baseUrl = (value) => {
258
+ let out = String(value);
259
+ while (out.endsWith("/")) out = out.slice(0, -1);
260
+ return out;
261
+ };
262
+
263
+ export async function fetchBlock(daemonUrl, height, fetchImpl = globalThis.fetch) {
264
+ const res = await fetchImpl(`${baseUrl(daemonUrl)}/json_rpc`, {
205
265
  method: "POST", headers: { "Content-Type": "application/json" },
206
266
  body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
207
267
  });
208
268
  const j = await res.json();
269
+ // A BUSY ANSWER IS NOT AN EMPTY BLOCK. The daemon answers BUSY while it is still indexing; a reader that treats
270
+ // the missing `json` as "no transactions" reports a complete range with a smaller balance.
271
+ if (j && String(j.status || "").toUpperCase() === "BUSY") throw new Error(`block ${height}: daemon is BUSY`);
209
272
  if (!j.result) throw new Error(`block ${height} not received: ${j.error ? j.error.message : "empty answer"}`);
273
+ if (String(j.result.status || "").toUpperCase() === "BUSY") throw new Error(`block ${height}: daemon is BUSY`);
274
+ // THE BLOCK MUST CARRY ITS DECODED BODY. `json` is what scanRange walks; without it the block is unreadable, not
275
+ // empty. A result with only a blob or a header is refused by the same rule.
276
+ if (j.result.json === undefined || j.result.json === null || j.result.json === "") {
277
+ throw new Error(`block ${height}: no decoded body`);
278
+ }
210
279
  return j.result;
211
280
  }
212
281
 
213
282
  // Transactions by hash list. We hit the PATH /get_transactions, not /json_rpc: over JSON-RPC this method
214
283
  // silently returns empty, which looks like "no transactions".
215
- export async function fetchTransactions(daemonUrl, hashes) {
284
+ export async function fetchTransactions(daemonUrl, hashes, fetchImpl = globalThis.fetch) {
216
285
  if (!hashes.length) return [];
217
- const res = await fetch(`${String(daemonUrl).replace(/\/+$/, "")}/get_transactions`, {
286
+ const res = await fetchImpl(`${baseUrl(daemonUrl)}/get_transactions`, {
218
287
  method: "POST", headers: { "Content-Type": "application/json" },
219
288
  body: JSON.stringify({ txs_hashes: hashes, decode_as_json: true }),
220
289
  });
221
290
  const j = await res.json();
222
- const wrappers = j.txs || [];
291
+ // A BUSY ANSWER IS NOT AN EMPTY BLOCK. The daemon says BUSY while it is still indexing; returning [] would turn
292
+ // money that is there into a complete scan with a zero balance.
293
+ if (j && String(j.status || "").toUpperCase() === "BUSY") throw new Error("transactions-busy");
294
+ const wrappers = Array.isArray(j && j.txs) ? j.txs : [];
295
+ const encoded = Array.isArray(j && j.txs_as_json) ? j.txs_as_json : [];
296
+ // EVERY HASH WE ASKED FOR MUST COME BACK. A partial answer is a missing block, not a quiet "no transactions".
297
+ const requested = [...new Set(hashes.map((h) => String(h).replace(/^0x/, "").toLowerCase()))];
298
+ const byHash = new Map();
299
+ for (const wrapper of wrappers) {
300
+ const hash = String((wrapper && wrapper.tx_hash) || "").replace(/^0x/, "").toLowerCase();
301
+ if (!hash) continue;
302
+ if (byHash.has(hash)) throw new Error("transactions-duplicate: " + hash);
303
+ byHash.set(hash, wrapper);
304
+ }
305
+ const missing = requested.filter((h) => !byHash.has(h));
306
+ if (missing.length) throw new Error("transactions-missing: " + missing.length);
307
+ // EXACTLY THE REQUESTED SET. An extra transaction is not money this range asked for, and it may belong to another
308
+ // block; counting it silently inflates the sum. A decoded body is required for every wrapper, one for one.
309
+ if (byHash.size !== requested.length) throw new Error("transactions-extra: " + (byHash.size - requested.length));
310
+ if (encoded.length !== wrappers.length) throw new Error("transactions-undecoded");
223
311
  // HASH AND HEIGHT LIVE IN THE WRAPPER, not in the parsed body: as_json omits them. Without this stitching a
224
312
  // "found transfer" would have neither hash nor height - useless to the caller, yet everything would look successful.
225
- return (j.txs_as_json || []).map((s, i) => {
313
+ const indexOf = new Map(wrappers.map((w, i) => [String((w && w.tx_hash) || "").replace(/^0x/, "").toLowerCase(), i]));
314
+ return requested.map((hash) => {
315
+ const w = byHash.get(hash) || {};
316
+ const s = encoded[indexOf.get(hash)];
226
317
  const body = typeof s === "string" ? JSON.parse(s) : s;
227
- const w = wrappers[i] || {};
228
318
  body.tx_hash = w.tx_hash || body.tx_hash || null;
229
319
  body.block_height = w.block_height ?? null;
230
320
  body.confirmations = w.confirmations ?? null;
@@ -235,22 +325,29 @@ export async function fetchTransactions(daemonUrl, hashes) {
235
325
 
236
326
  // Parsing one transaction against the watched addresses. watched: [{ address, viewSecret, spendPub }] - viewSecret
237
327
  // is taken as a number, LE-hex or BE-hex. The result carries viewKeyForm: the form name that matched.
238
- export function matchTransaction(tx, watched, { height = null, nowSec = null } = {}) {
328
+ export function matchTransaction(tx, watched, { height = null, tipHeight = null, nowSec = null } = {}) {
239
329
  const out = [];
240
330
  const txPublicKey = txPublicKeyFromExtra(tx.extra);
241
331
  if (!txPublicKey) return out; // no key in extra - nothing to look at
242
332
  // WHETHER THE OUTPUT IS LOCKED IS DECIDED HERE, ONCE PER TRANSACTION: all its outputs carry one unlock_time.
243
333
  // A normal transaction (unlock_time = 0) goes as before; the height comes from the block holding the transfer.
244
334
  const blockHeight = height === null || height === undefined ? (tx.block_height ?? null) : height;
245
- const unlock = unlockStateOf(tx.unlock_time, { height: blockHeight, nowSec });
335
+ // THE INCLUSION HEIGHT IS FOR CONFIRMATIONS, THE TIP FOR THE LOCK. A height lock at 101 is open once the chain
336
+ // has reached 101, not once the transaction was mined at 100; using the inclusion height kept a matured lock
337
+ // reporting `xmr-locked` forever.
338
+ const unlockHeight = tipHeight === null || tipHeight === undefined ? blockHeight : Number(tipHeight);
339
+ const unlock = unlockStateOf(tx.unlock_time, { height: unlockHeight, nowSec });
246
340
  const keyOf = (o) => ((o.target || {}).tagged_key || o.target || {}).key;
247
341
  const outputs = (tx.vout || []).map((o, i) => ({ index: i, key: keyOf(o) }));
248
- const ecdhAmounts = ((tx.rct_signatures || {}).ecdhInfo || []).map((e) => e.amount);
342
+ const ecdh = ((tx.rct_signatures || {}).ecdhInfo || []);
343
+ const ecdhAmounts = ecdh.map((e) => e.amount);
344
+ const ecdhMasks = ecdh.map((e) => e.mask);
345
+ const outPk = ((tx.rct_signatures || {}).outPk || []);
249
346
  const rctVersion = (tx.rct_signatures || {}).type || 2;
250
347
  for (const w of watched) {
251
348
  const r = findOurOutputs({
252
349
  txPublicKey, viewSecretHex: w.viewSecret, spendPubHex: w.spendPub,
253
- outputs, ecdhAmounts, rctVersion: Number(rctVersion) >= 2 ? 2 : 1,
350
+ outputs, ecdhAmounts, ecdhMasks, outPk, rctVersion: Number(rctVersion) || 1,
254
351
  });
255
352
  if (r.outputs.length) out.push({ address: w.address, index: w.index ?? null, outputs: r.outputs, derivation: r.derivation, viewKeyForm: r.viewKeyForm, unlockTime: Number(tx.unlock_time) || 0, unlock });
256
353
  }
@@ -259,13 +356,14 @@ export function matchTransaction(tx, watched, { height = null, nowSec = null } =
259
356
 
260
357
  // Range scan. Returns per address the sum (receivedAtomic - only the UNLOCKED), hashes, the locked sum
261
358
  // (lockedAtomic) with its boundary (lockedUntil) and the best (highest) height of a found output.
262
- export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log = () => {}, concurrency = 4, nowSec = null }) {
359
+ export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log = () => {}, concurrency = 4, nowSec = null, tipHeight = null, fetchImpl = globalThis.fetch }) {
263
360
  const from = Number(fromHeight), to = Number(toHeight);
264
361
  if (!(from >= 0) || !(to >= from)) throw new Error("bad height range: " + fromHeight + ".." + toHeight);
265
362
  const acc = new Map();
266
363
  for (const w of watched) acc.set(w.address, { address: w.address, receivedAtomic: 0n, lockedAtomic: 0n, lockedUntil: null, lockedTxids: [], txids: [], bestHeight: null, outputs: 0, viewKeyForm: null });
267
364
  const heights = [];
268
365
  for (let h = from; h <= to; h++) heights.push(h);
366
+ const missingHeights = [];
269
367
 
270
368
  // Bounded parallelism: the node is a shared resource and blocks are independent.
271
369
  let cursor = 0;
@@ -273,19 +371,21 @@ export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log
273
371
  while (cursor < heights.length) {
274
372
  const h = heights[cursor++];
275
373
  try {
276
- const block = await fetchBlock(daemonUrl, h);
374
+ const block = await fetchBlock(daemonUrl, h, fetchImpl);
277
375
  const hashes = block.json ? (typeof block.json === "string" ? JSON.parse(block.json) : block.json).tx_hashes || [] : [];
278
376
  if (!hashes.length) continue;
279
- const txs = await fetchTransactions(daemonUrl, hashes);
377
+ const txs = await fetchTransactions(daemonUrl, hashes, fetchImpl);
280
378
  for (const tx of txs) {
281
- for (const hit of matchTransaction(tx, watched, { height: h, nowSec })) {
379
+ for (const hit of matchTransaction(tx, watched, { height: h, tipHeight: tipHeight === null || tipHeight === undefined ? to : tipHeight, nowSec })) {
282
380
  const a = acc.get(hit.address);
283
381
  if (!a) continue;
284
382
  if (hit.viewKeyForm) a.viewKeyForm = hit.viewKeyForm; // what matched - this goes to the log
285
383
  const lockedHere = !!(hit.unlock && hit.unlock.locked);
286
384
  const hash = String(tx.tx_hash || "").replace(/^0x/, "");
287
385
  for (const o of hit.outputs) {
288
- const amount = o.amountAtomic || 0n;
386
+ // AN UNVERIFIED AMOUNT IS NOT MONEY. A node can alter ecdhInfo.amount; until the Pedersen
387
+ // commitment confirms it, the figure is not added to any sum.
388
+ const amount = o.amountVerified ? (o.amountAtomic || 0n) : 0n;
289
389
  // A LOCKED OUTPUT IS NOT AN ARRIVAL. Its transfer shows in txids (money is on the address) but the
290
390
  // sum does NOT go into receivedAtomic: else 'ready' would allow claiming ETH against locked XMR.
291
391
  if (lockedHere) {
@@ -302,11 +402,22 @@ export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log
302
402
  }
303
403
  }
304
404
  } catch (e) {
405
+ // A BLOCK THAT COULD NOT BE READ IS NOT A BLOCK WITH NO MONEY. Keep its height so the caller can say the
406
+ // range is partial instead of treating a smaller sum as complete.
407
+ missingHeights.push(h);
305
408
  log({ event: "indexer_block_error", height: h, error: String(e.message).slice(0, 160) });
306
409
  }
307
410
  }
308
411
  };
309
412
  await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, heights.length)) }, worker));
310
- return { fromHeight: from, toHeight: to, results: Array.from(acc.values()) };
413
+ const missing = [...new Set(missingHeights)].sort((a, b) => a - b);
414
+ return {
415
+ fromHeight: from,
416
+ toHeight: to,
417
+ results: Array.from(acc.values()),
418
+ complete: missing.length === 0,
419
+ missingHeights: missing,
420
+ ...(missing.length ? { incompleteReason: "blocks-unreadable" } : {}),
421
+ };
311
422
  }
312
423
 
package/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // 2) not a single DOM access: `document`, `window`, `localStorage` do not occur in the SDK. Storage and the wallet
10
10
  // come from outside: as an adapter and an EIP-1193 provider;
11
11
  // 3) the SDK opens no windows: the interface shows the wallet modal, the SDK accepts a ready provider;
12
- // 4) funds are not locked until the interface has confirmed the recovery file is saved (onRecoveryFile);
12
+ // 4) funds are not locked until the interface has confirmed the one recovery key is saved (onRecoveryKey);
13
13
  // 5) the guards cannot be bypassed: preflight is part of the same path, not a separate button.
14
14
  //
15
15
  // Naming language: the values are data. `step` and `code` are stable strings from the tables below; they are
@@ -288,7 +288,15 @@ export interface SdkConfig {
288
288
  export interface ChainConfig {
289
289
  readonly id: string;
290
290
  /** The network's virtual machine: "evm" today; "tron" and "solana" - when their leg drivers appear. */
291
- readonly vm: string;
291
+ readonly vm: string | null;
292
+ /** What this settlement side looks like. Metadata only: it does not claim that a driver exists. */
293
+ readonly settlementProfile?: {
294
+ readonly vm: string;
295
+ readonly addressFormat: string;
296
+ readonly settlementKind: "contract" | "program" | "utxo-script" | string;
297
+ readonly refKind: "address" | "pda" | "script" | string;
298
+ readonly txRef: "hash" | "signature" | "txid" | string;
299
+ } | null;
292
300
  readonly chainId: number;
293
301
  readonly name: string;
294
302
  readonly nativeSymbol: string;
@@ -742,14 +750,12 @@ export interface SwapsApi {
742
750
  /**
743
751
  * Start a swap. The SDK holds the step order, not the screen:
744
752
  * 1) guards (preflight); 2) the swap record (createSwap); 3) the order - it GENERATES the key halves;
745
- * 4) the recovery file / one key and the interface's confirmation that it is saved; 5) locking the funds (signature);
753
+ * 4) the one recovery key and the interface's confirmation that it is saved; 5) locking the funds (signature);
746
754
  * 6) saving on the server; 7) opening the watch on the Monero address.
747
- * HALVES DO NOT EXIST BEFORE THE ORDER: the recovery file carries MY half of the spend key and the counterparty's
748
- * open points, and they are taken from the signed order (www/js/atomic/order-worker.js, createOrderBuilder; in the
749
- * engine they are fetched by halvesWalletOf). So the file is assembled AFTER the order, but BEFORE locking the
750
- * funds.
751
- * FUNDS ARE NOT LOCKED UNTIL onRecoveryFile ALLOWS IT: the order is like this not for convenience, but because a
752
- * lock without a recovery file is a lock with nothing to open it with if the browser is lost.
755
+ * HALVES DO NOT EXIST BEFORE THE ORDER: the key's halves and the counterparty's open points are taken from the
756
+ * signed order. The key is shown BEFORE locking the funds.
757
+ * FUNDS ARE NOT LOCKED UNTIL onRecoveryKey ALLOWS IT: a lock without a saved key is a lock with nothing to open
758
+ * the swap with if the browser is lost.
753
759
  */
754
760
  start(request: StartRequest): Promise<SwapStart>;
755
761
  /**
@@ -1123,23 +1129,20 @@ export interface StartRequest {
1123
1129
  /**
1124
1130
  * The EVM wallet for LOCKING THE FUNDS: an adapter with `send` and `receipt` (the same role as WalletAdapter).
1125
1131
  * It comes from outside and is NOT substituted by default: without it the lock step answers with a code
1126
- * (`bad-input`), rather than going in blind. The recovery file has by now been confirmed by the person.
1132
+ * (`bad-input`), rather than going in blind. The recovery key has by now been confirmed by the person.
1127
1133
  */
1128
1134
  readonly wallet?: WalletAdapter;
1129
- /**
1130
- * Hand the OLD per-swap recovery file to the interface and wait for the person's decision.
1131
- * `true` - the person confirmed the file is saved; `false` - the swap is not started.
1132
- * Not used when `recoveryKey` is present: that path has one key, saved once.
1133
- */
1134
- onRecoveryFile?(file: RecoveryFile): Promise<boolean>;
1135
1135
  /**
1136
1136
  * THE ONE RECOVERY KEY (sdk#76). A BIP39 mnemonic, or a key object from `@ninsei-labs/sdk/recovery`.
1137
- * When present, the swap's spend and view halves are derived from it instead of being generated, and no
1138
- * per-swap recovery file is written. The key is kept through the SDK's storage adapter.
1137
+ * The swap's spend and view halves are derived from it; the per-swap recovery file path is gone, and the key is
1138
+ * kept through the SDK's storage adapter.
1139
1139
  */
1140
1140
  recoveryKey?: string | { readonly mnemonic: string };
1141
1141
  /** The swap number, when the caller already reserved one. Absent - the SDK reserves the next free number. */
1142
1142
  recoveryIndex?: number;
1143
+ /** The per-order salt the caller put into the signed quote. When present, the key uses the v2 salt binding and
1144
+ * no index is reserved; it must equal `order.salt`. */
1145
+ recoverySalt?: string | number | bigint;
1143
1146
  /** The first block for the on-chain number check. Absent - the chain registry's escrow deployment block, and
1144
1147
  * zero only when the registry has no height. */
1145
1148
  fromBlock?: string | number;
@@ -1272,6 +1275,7 @@ export interface SweepApi {
1272
1275
  */
1273
1276
  wallet(request: SweepWalletRequest): SweepWallet;
1274
1277
 
1278
+ /**
1275
1279
  * COMPARE A DESTINATION WITH THE FILE'S PROMISE. `ok` is "usable address on the file's network", `matches` is
1276
1280
  * "the promised one" and is null when the file named nobody. Sending XMR elsewhere is a different payment, so the
1277
1281
  * comparison is not presentation.
@@ -1344,6 +1348,10 @@ export interface SweepRestoreRequest {
1344
1348
  export interface SweepRestoreResult {
1345
1349
  readonly total: string;
1346
1350
  readonly unlocked: string;
1351
+ /** False when the wallet did not reach the node's height after its sync; a zero balance is then not evidence. */
1352
+ readonly scanComplete: boolean;
1353
+ /** `sync-failed`, `behind-node` or `no-heights` when `scanComplete` is false. */
1354
+ readonly scanIncompleteReason?: string;
1347
1355
  /** The height the scan actually started from. */
1348
1356
  readonly scanHeight: number;
1349
1357
  readonly walletHeight: number | null;
@@ -1431,7 +1439,10 @@ export interface SweepFromOrderRequest {
1431
1439
  readonly edPointLocker: string;
1432
1440
  readonly edPointClaimer: string;
1433
1441
  readonly edViewPointLocker: string;
1434
- readonly index: number;
1442
+ readonly index?: number | null;
1443
+ readonly derivation?: "index-v1" | "salt-v2";
1444
+ readonly factory?: string;
1445
+ readonly salt?: string;
1435
1446
  readonly side: "locker" | "claimer";
1436
1447
  readonly startHeight?: number | null;
1437
1448
  readonly blockTime?: number | null;
@@ -1549,8 +1560,9 @@ export interface SweepSignResult {
1549
1560
  /** The free remainder at the moment of signing, in atomic units (string). */
1550
1561
  readonly amount: string;
1551
1562
  /**
1552
- * THE FEE OF THE SIGNED TRANSFER, in atomic units, or null when the wallet could not report one. A withdrawal
1553
- * moves the whole remainder, so the fee comes out of it - `receive` is what the recipient gets.
1563
+ * THE FEE OF THE SIGNED TRANSFER, in atomic units, summed over every signed transaction, or null when any of
1564
+ * them could not report one. A withdrawal moves the whole remainder, so the fee comes out of it - `receive` is
1565
+ * what the recipient gets.
1554
1566
  */
1555
1567
  readonly fee: string | null;
1556
1568
  /** The remainder minus the fee. Null when the fee is unknown, or when a reported fee claims more than the remainder. */
@@ -1763,6 +1775,8 @@ export type ErrorCode =
1763
1775
  | "server-unavailable"
1764
1776
  /** The service answered and did not accept: the connection got through, the request did not (status and reason in params). */
1765
1777
  | "server-refused"
1778
+ /** The funding transaction was sent, but no status-1 receipt with the expected OrderCreated proves the escrow. */
1779
+ | "funding-pending"
1766
1780
  /** The wait for the XMR to arrive expired: a separate code, because this is a state of the money, not a breakdown. */
1767
1781
  | "xmr-timeout"
1768
1782
  /**