@ninsei-labs/sdk 0.42.1 → 0.43.3

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
@@ -25,9 +25,44 @@ import { primitives } from "./primitives.mjs";
25
25
  import { createActions } from "./actions.mjs";
26
26
  // THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
27
27
  import * as xmrArrival from "./xmr-arrival.mjs";
28
+ // THE MAKER'S DECLINE (issue #40): the escrow's `Declined` event and the node's `declined` + `compliance` status
29
+ // are TWO independent sources of the same state, and either one is enough. The detectors and the neutral
30
+ // vocabulary live in one module, so this file names the state instead of re-reading the two.
31
+ import { declineOf, logReaderOf, blockBoundaryOf, DECLINED_TOPIC0, DECLINED_TERMINAL, DECLINED_CODE } from "./decline.mjs";
28
32
 
29
- // The engine's terminal states -> the contract's codes. An unknown kind is not invented: it comes as "closed".
30
- const TERMINAL = { success: "success", refunded: "refunded_eth", xmr_returned: "xmr_returned", closed: "closed" };
33
+ // THE ENGINE'S SETTLEMENT KINDS -> THE CONTRACT'S CODES: THE KEY IS THE KIND THE ENGINE REALLY WRITES.
34
+ //
35
+ // The map used to be keyed by names no code path writes - `refunded` for a refund (the engine writes
36
+ // `kind: "refund_eth"`), and `xmr_returned` and `closed`, for which no settlement with that kind is written
37
+ // anywhere in the engine or in this package. A REFUND therefore never met its key and fell into the fallback:
38
+ // get/list/progress told the app `closed` about an order whose ETH was already back in the wallet - the very
39
+ // distinction that became load-bearing once the maker's decline was made a state of its own (issue #40).
40
+ //
41
+ // WHAT CAN BE A KIND, EXHAUSTIVELY. The record's settlement belongs to the engine (engine/core/swap.js, the only
42
+ // module that assigns swap.settlement), and `declined` is the one kind written by us (src/decline.mjs):
43
+ // `success` - the swap completed and the XMR is on the depositor's address (sweepNow, the auto-sweep in the
44
+ // ticker, and the backend-reported sweep on the live path);
45
+ // `refund_eth` - the ETH went back to the depositor. TWO WRITERS, ONE KIND: the person's own refund (refundEth)
46
+ // and the watchtower inside the ticker both write `kind: "refund_eth"` with the phase
47
+ // `refunded_eth`, so there is exactly ONE refund-shaped kind here and nothing is collapsed;
48
+ // `declined` - the maker refused a funded order. Not the engine's: the escrow reads `refunded` either way, so
49
+ // the engine cannot tell a decline from a refund, and the SDK writes this kind itself when it SEES
50
+ // the decline (src/decline.mjs). From then on every surface that reads the record reports it
51
+ // without asking the chain again.
52
+ //
53
+ // AND WHAT IS NOT A KEY: an unknown kind is named, not invented - it comes out as "closed", the fallback in
54
+ // toState below, which is why `closed` is a code here and not a key (no settlement carries that kind; the string
55
+ // `closed` in the engine is the XMR SESSION's status vocabulary, not a settlement). The maker taking the XMR back
56
+ // without a ready mark is NOT a kind either: it does not end a swap - the depositor's ETH is still in the escrow -
57
+ // so it is a PHASE of a live swap (`xmr_reclaimed`, engine/core/swap.js, told apart by that code from the maker who
58
+ // never locked), and the swap ends later as `refunded_eth`. `xmr_returned`, which stood in the engine's terminal
59
+ // table and in the `TerminalCode` type as a state nothing could produce, is gone with it. Every one of these facts
60
+ // is asserted by the `terminal` check rather than left to a reader.
61
+ const TERMINAL = {
62
+ success: "success",
63
+ refund_eth: "refunded_eth",
64
+ [DECLINED_TERMINAL]: DECLINED_TERMINAL,
65
+ };
31
66
 
32
67
  const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
33
68
 
@@ -159,6 +194,10 @@ export function toState(swap) {
159
194
  });
160
195
  }
161
196
  const active = steps.find((s) => s.active) || [...steps].reverse().find((s) => s.done) || steps[0];
197
+ // THE TERMINAL STATE IS TRANSLATED ONCE, AND THE DECLINE IS READ OFF IT. A declined order is NOT a refunded
198
+ // one, and the difference is drawn here and in the action flags below, not left to the screen.
199
+ const terminal = d.terminal ? (TERMINAL[d.terminal.kind] ?? "closed") : null;
200
+ const declined = terminal === DECLINED_TERMINAL;
162
201
  const chain = swap.network || swap.chain || null;
163
202
  // The virtual machine is taken from the network registry. An unknown network has none, and "unknown" is more honest
164
203
  // than the network's name: it is visible in the state and does not turn into "probably EVM".
@@ -214,9 +253,16 @@ export function toState(swap) {
214
253
  status: d.phase || null,
215
254
  txs: (swap.escrow && (swap.escrow.txids || swap.escrow.txs)) || [],
216
255
  },
217
- actions: { markReady: !!(d.can && d.can.ready), sweep: !!(d.can && d.can.sweep), refund: !!(d.can && d.can.refund) },
256
+ // A DECLINED ORDER OFFERS NOTHING, AND THAT IS THE POINT OF THE STATE (issue #40). The ETH - the fee
257
+ // included - is already back in the depositor's wallet and no half was revealed, so there is no ready mark to
258
+ // place, no XMR to sweep and NO REFUND TO SIGN: the flags are OFF rather than left to whatever the engine's
259
+ // `can` says about an order whose escrow merely reads `refunded`. The app needs no second source for "there
260
+ // is nothing to do": this state plus the terminal code says it.
261
+ actions: declined
262
+ ? { markReady: false, sweep: false, refund: false }
263
+ : { markReady: !!(d.can && d.can.ready), sweep: !!(d.can && d.can.sweep), refund: !!(d.can && d.can.refund) },
218
264
  simulated: { evm: !!(d.simulated && d.simulated.evm), xmr: !!(d.simulated && d.simulated.xmr) },
219
- terminal: d.terminal ? (TERMINAL[d.terminal.kind] ?? "closed") : null,
265
+ terminal,
220
266
  };
221
267
  }
222
268
 
@@ -268,11 +314,18 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
268
314
  // The check on the ACTUAL sender is done in start() below, ONE place, right before the send - the wallet adapter
269
315
  // only HANDS OUT the current account (`address()`), it does not carry the policy.
270
316
  denylist = null,
271
- watch: watchOptions = null } = {}) {
317
+ watch: watchOptions = null,
318
+ // THE CHAIN LOG READER FOR THE DECLINE CHECK (issue #40). A seam, as the chain read is: by default the logs are
319
+ // read through the page's wallet (`readDeclinedLogs`), and from outside a caller brings its own
320
+ // ({ address, topics, fromBlock, toBlock }) -> logs. It is a READ of the escrow's own event, not a node
321
+ // substitution - the address is the escrow of the swap being watched.
322
+ logs: chainLogs = null } = {}) {
272
323
  // WHAT READS THE CHAIN AT THE LOCK IS A SEAM, as in actions.mjs: by default the engine's read, from outside you can
273
324
  // substitute your own. Otherwise the lock step could not be checked beyond predicting the address - and "the address
274
325
  // from the receipt" would remain an argument on trust, not a verified property.
275
326
  const reader = typeof call === "function" ? call : engine.evm.readContract;
327
+ // THE SAME RULE FOR THE LOGS: one reader per facade, chosen once.
328
+ const logReader = logReaderOf(chainLogs);
276
329
  // MARKING READY GOES THROUGH THE SAME READ SEAM as the lock: checking the slots and signing must go one way,
277
330
  // otherwise part of the check goes past the wallet and part does not (see actions.mjs).
278
331
  const actions = createActions({ call: typeof call === "function" ? call : null });
@@ -880,10 +933,82 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
880
933
  if (readyBy) xmrArrival.assertTimeForConfirmations({ readyBySec: readyBy, nowSec: Math.floor(Date.now() / 1000), minConfirmations, blockTimeSec: limitOf("xmrBlockTimeSec", 120) });
881
934
  }
882
935
  const started = Date.now();
936
+ // --- THE MAKER'S DECLINE: THE WAIT IS NOT STARTED AT ALL (issue #40). -------------------------------------
937
+ // A decline leaves the escrow `refunded`, so nothing in the state says "do not wait any longer": without
938
+ // this, the wait runs to its timeout and the app is told the generic `xmr-timeout` about an order that is
939
+ // CLOSED and whose ETH is already back - or, worse, the app offers the recovery file and a manual refund
940
+ // for money nobody can recover.
941
+ //
942
+ // THE DECLINE IS ASKED WITH TWO INDEPENDENT SOURCES AND EITHER ONE ENDS THE WAIT:
943
+ // * THE CHAIN - the escrow's own `Declined` event, through the same log reader the rest of the core uses;
944
+ // * THE WIRE - the status the service answers with (`declined` with the code `compliance`).
945
+ // A CHAIN READ THAT FAILS is neither a decline nor a refusal of the swap: the wait goes on and the wire may
946
+ // still answer. What the chain read did is carried out with the result (`chain.checked` / `chain.error`), so
947
+ // a broken reader is visible instead of silent.
948
+ // A STORAGE THAT CANNOT BE READ IS NOT A REASON TO REFUSE THE WAIT: the record is what makes the state
949
+ // durable, and the escrow may also arrive in the request. The watch is not a new user of the store - and it
950
+ // must not start failing where it did not before.
951
+ let record = null;
952
+ try { record = engine.swap.getSwap(id); } catch { record = null; }
953
+ const escrowAddress = request.escrow || (record && record.escrow && record.escrow.address) || null;
954
+ // FROM THE ESCROW'S BIRTH HEIGHT when the record knows it: the event cannot be older than the funding, and
955
+ // walking the whole chain every tick is a cost with no answer behind it.
956
+ const fromBlock = blockBoundaryOf(record && record.escrow ? record.escrow.birthHeight : null);
957
+ const chainDecline = { checked: false, error: null, logs: undefined };
958
+ const readDeclineLogs = async () => {
959
+ chainDecline.checked = false; chainDecline.error = null; chainDecline.logs = undefined;
960
+ // NO ESCROW KNOWN, NO CHAIN ANSWER: named, so `checked: false` is never a silent "nothing there".
961
+ if (!escrowAddress) { chainDecline.error = "no-escrow"; return; }
962
+ try {
963
+ chainDecline.logs = await logReader({ address: escrowAddress, topics: [DECLINED_TOPIC0], fromBlock, toBlock: "latest" });
964
+ chainDecline.checked = true;
965
+ } catch (error) {
966
+ chainDecline.error = (error && error.code) || "logs-unread";
967
+ }
968
+ };
969
+ // WRITTEN, NAMED, NOT INFERRED - the rule the record's destination follows too. The decline becomes the
970
+ // record's OWN settlement once, so later reads of the record report the same state without asking the chain
971
+ // again, and the state survives a page reload.
972
+ const settleDecline = (swap, at) => {
973
+ if (!swap || swap.settlement) return;
974
+ swap.settlement = { kind: DECLINED_TERMINAL, phase: DECLINED_TERMINAL, at };
975
+ try { engine.swap.saveSwap(swap); } catch { throw new SdkError("storage-unavailable", { step: "decline" }); }
976
+ };
977
+ // THE STATE THE WATCH REPORTS FOR A DECLINE: no arrival, the state's own code, the node's own token and
978
+ // WHERE it was seen ("event", "status", or "record" when it was already written down). NO reason, category
979
+ // or provider travels here - the node names none, and the sentence a person reads is the interface's.
980
+ const declineResult = ({ source, at, waitedMs, address, chainChecked, chainError }) => ({
981
+ id,
982
+ escrow: escrowAddress,
983
+ address: address || null,
984
+ seen: false,
985
+ declined: true,
986
+ terminal: DECLINED_TERMINAL,
987
+ code: DECLINED_CODE,
988
+ source,
989
+ at,
990
+ waitedMs,
991
+ chain: { checked: Boolean(chainChecked), error: chainError || null },
992
+ });
993
+ if (record && record.settlement && record.settlement.kind === DECLINED_TERMINAL) {
994
+ const at = Number(record.settlement.at);
995
+ return declineResult({ source: "record", at: Number.isFinite(at) ? at : Date.now(), waitedMs: 0,
996
+ address: null, chainChecked: false, chainError: null });
997
+ }
883
998
  let last = null;
884
999
  for (;;) {
1000
+ await readDeclineLogs();
885
1001
  const body = await readWatch(id);
886
1002
  last = incomingOf(body);
1003
+ // THE DECLINE IS DECIDED BEFORE THE ARRIVAL IS LOOKED AT, and before the "foreign response" refusal below:
1004
+ // a declined swap's answer carries no `receivedXmr` at all, and refusing it as a bad response would name
1005
+ // the wrong thing (the state is known - it is a decline, not a broken answer).
1006
+ const declineNow = declineOf({ logs: chainDecline.logs, status: body, escrow: escrowAddress, at: Date.now() });
1007
+ if (declineNow.declined) {
1008
+ settleDecline(record, declineNow.at);
1009
+ return declineResult({ source: declineNow.source, at: declineNow.at, waitedMs: Date.now() - started,
1010
+ address: body && body.address, chainChecked: chainDecline.checked, chainError: chainDecline.error });
1011
+ }
887
1012
  // AN EMPTY OR FOREIGN RESPONSE IS NOT AN ARRIVAL: counting it as incoming would mean declaring money that was
888
1013
  // never seen. This is a named refusal, not "let us wait some more".
889
1014
  if (!last) throw new SdkError("server-unavailable", { step: "watch", why: "bad-response", id });
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.42.1";
9
+ export const SDK_VERSION = "0.43.3";