@ninsei-labs/sdk 0.51.0 → 0.51.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## 0.51.2 — 2026-10-22
2
+
3
+ - The withdrawal window falls back to the full scan when `scanTxs` or the balance read fails, and reports
4
+ `window.fallbackWhy: "window-scan"`.
5
+ - The SDK does not call a backend by default: the `/api/swaps` lookups, the `swept` report, the session calls and the
6
+ relative `apiBase` default are gone. A caller that needs a backend passes its base explicitly; a watch without
7
+ `verify` data is a named `no-backend` refusal.
8
+
9
+ ## 0.51.1 — 2026-10-22
10
+
11
+ A BUSY NODE NO LONGER ENDS A WITHDRAWAL, AND MISSING HEIGHT DATA HAS A NAME.
12
+
13
+ - `windowTxHashes` retries a `429`, a timeout and a `5xx` with `Retry-After` or exponential backoff; only a read that
14
+ still fails reaches the fallback.
15
+ - `restoreWallet` runs the full scan when the window cannot be read and reports `window.fallback: true`,
16
+ `fallbackWhy: "window-read"` and the node's status. A malformed window is still a caller error, not a fallback.
17
+ - A null settlement block time is `no-block-time`, not timestamp 0. An unreachable Monero node
18
+ (`monero-nodes-unreachable`) and a reachable node on another network (`monero-network-mismatch`) no longer share
19
+ `no-height-readers`.
20
+ - `recoveryIndexTaken` is exported from `@ninsei-labs/sdk/recovery`.
21
+ - The on-chain recovery-number check starts at the registry's escrow deployment block, not block 0; an explicit
22
+ `fromBlock` still wins.
23
+
1
24
  ## 0.51.0 — 2026-10-22
2
25
 
3
26
  THE WITHDRAWAL AND THE ON-CHAIN SEARCH NOW CARRY EVERYTHING THE KEY CAN KNOW.
package/README.md CHANGED
@@ -44,7 +44,7 @@ import { createNinsei } from "@ninsei-labs/sdk";
44
44
  const sdk = createNinsei({
45
45
  settlement: { chain: "arbitrum-sepolia" }, // from sdk.config.chains; vm is taken from the registry
46
46
  xmrNetwork: "stagenet",
47
- apiBase: "/api",
47
+ // apiBase: "https://your-service", // only when you bring a backend; there is no default
48
48
  assetsBase: "/sdk/",
49
49
  mode: "live", // or "sim"
50
50
  });
@@ -173,6 +173,16 @@ When a token is needed, the order can be joined with an off-chain quote record b
173
173
  would have to put the token into the signed quote and the clone (and therefore a new order format and factory); only
174
174
  then can `findSwaps` return it from the chain and set `payTokenReason` to something other than `not-on-chain`.
175
175
 
176
+ The found order also carries the Monero start height and the reason it is absent. `no-block-time`,
177
+ `monero-nodes-unreachable` and `monero-network-mismatch` are different answers: a missing settlement timestamp is not
178
+ a dead node, and a node on another network is not an absent reader. A page that starts swaps without the SDK's start
179
+ path can call `recoveryIndexTaken` from `./recovery` before using a number; it checks the factory's `OrderCreated`
180
+ logs from the registry's deployment block.
181
+
182
+ The withdrawal window retries a `429`, a timeout or a `5xx` before it gives up. If the window still cannot be read,
183
+ `restoreWallet` falls back to the full scan and reports `window.fallback: true` with `fallbackWhy: "window-read"` and
184
+ the node's status; a withdrawal is not refused because the node was busy.
185
+
176
186
  ## Extending to other networks (Tron, Solana and beyond)
177
187
 
178
188
  The package name is deliberately common: the shared part — the quote client, the Monero leg, the state machine,
@@ -67,6 +67,7 @@ export function pollInterval() {
67
67
  // see the reason, not a blank page.
68
68
  async function api(path, { method = "GET", body, base, timeoutMs = API.timeoutMs, fetchImpl } = {}) {
69
69
  const target = base || apiBaseOverride || API.base;
70
+ if (!target) return { ok: false, status: 0, error: "no backend base configured" };
70
71
  const doFetch = fetchImpl || globalThis.fetch;
71
72
  if (typeof doFetch !== "function") return { ok: false, status: 0, error: "no fetch: backend unavailable" };
72
73
  const ctl = typeof AbortController === "function" ? new AbortController() : null;
@@ -142,66 +143,9 @@ export function sameSwapTarget(a, b) {
142
143
  a.chain === b.chain;
143
144
  }
144
145
 
145
- export async function openSession({ swapId, share, expectedAmountXmr, chain, restoreHeight }, opts = {}) {
146
- const src = share || {};
147
- const address = src.address || null;
148
- const viewKey = src.viewKey || src.privateViewKey || null;
149
- if (!address || !viewKey) {
150
- return { ok: false, status: 0, error: "the watch-only share has no address/viewKey - nothing to send to the backend" };
151
- }
152
- // THE EXPECTED AMOUNT IS MANDATORY and must be a number. Before, a non-number meant no field, and the swap
153
- // was created WITHOUT an amount: any arrival was accepted as enough, so an underpayment looked like a done
154
- // deal. Now it is a refusal with an explanation.
155
- const amountXmr = Number(expectedAmountXmr);
156
- if (!Number.isFinite(amountXmr) || amountXmr <= 0) {
157
- const where = (new Error("caller").stack || "").split("\n").slice(1, 4).map((x) => x.trim().split("/").pop()).join(" <- ");
158
- console.warn("[chainSource] no expected amount, call from: " + where);
159
- return { ok: false, status: 0, error: "the expected XMR amount is unknown (" + String(expectedAmountXmr) + "): without it no watch is created" };
160
- }
161
- const wanted = {
162
- swapId: swapId || src.swapId || null,
163
- network: src.network || xmrNetworkForSource(),
164
- address,
165
- viewKey,
166
- expectedAmountXmr: amountXmr,
167
- restoreHeight: Number.isFinite(Number(restoreHeight)) && Number(restoreHeight) > 0 ? Number(restoreHeight) : Number(src.restoreHeight) || undefined,
168
- chain: chain || undefined,
169
- };
170
- // The body is rebuilt from the allowlist: what is not in the list never reaches the request.
171
- const body = {};
172
- for (const field of ALLOWED_SESSION_FIELDS) {
173
- if (wanted[field] !== undefined && wanted[field] !== null) body[field] = wanted[field];
174
- }
175
- const res = await api("/swaps", { method: "POST", body, ...opts });
176
- if (!res.ok) return res;
177
- return { ok: true, status: res.status, created: res.json?.created !== false, session: normalizeSession(res.json) };
178
- }
179
-
180
- export async function fetchSession(swapId, opts = {}) {
181
- const res = await api(`/swaps/${encodeURIComponent(swapId)}`, opts);
182
- if (!res.ok) return res;
183
- return { ok: true, status: res.status, session: normalizeSession(res.json) };
184
- }
185
-
186
- // Tell the backend the XMR was withdrawn (the hash of the real transfer). Needed because a watch-only wallet
187
- // cannot see outgoing transfers.
188
- export async function reportSwept(swapId, txid, opts = {}) {
189
- const res = await api(`/swaps/${encodeURIComponent(swapId)}/swept`, { method: "POST", body: { txid }, ...opts });
190
- return res.ok ? { ok: true, status: res.status, session: normalizeSession(res.json) } : res;
191
- }
192
-
193
- // Close the swap (cancel or refund on the backend): the watch stops.
194
- export async function closeSession(swapId, reason = "cancelled", opts = {}) {
195
- const res = await api(`/swaps/${encodeURIComponent(swapId)}/closed`, { method: "POST", body: { reason }, ...opts });
196
- return res.ok ? { ok: true, status: res.status, session: normalizeSession(res.json) } : res;
197
- }
198
-
199
- // Backend state: is it up and does it see the wallet. The screen needs this to show 'backend unavailable'
200
- // before the user sends money, not after.
201
- export async function health(opts = {}) {
202
- const res = await api("/health", opts);
203
- return res.ok ? { ok: true, ...res.json } : res;
204
- }
146
+ // NO SESSION CALLS. The swap engine no longer creates a watch on a backend: the address and the view key are not
147
+ // sent to the page's own host, and the SDK does not poll a session for the Monero leg. A caller that needs a
148
+ // backend later passes its base explicitly, outside these rules.
205
149
 
206
150
  // ---------------------------------------------------------------------------
207
151
  // The Monero-leg snapshot for the state engine
@@ -908,14 +908,17 @@ export const MONERO = {
908
908
  };
909
909
 
910
910
  // ---------------------------------------------------------------------------
911
- // The backend (app/): swap sessions and watching the Monero leg.
911
+ // A backend is OPTIONAL (app/): the SDK does not point requests at the page's own origin.
912
912
  //
913
- // The API lives on the same domain at /api/ (Angie proxies to 127.0.0.1:8787), so CSP needs no edit - connect-src
914
- // 'self' covers it. Locally the same path is served with an --api flag - also without touching the policy.
913
+ // A caller that needs one passes `apiBase` explicitly; without it the swap engine watches the chain and the maker,
914
+ // not a service. The legacy session calls were removed for that reason.
915
915
  // ---------------------------------------------------------------------------
916
916
  export const API = {
917
- base: "/api",
918
- // The Monero-leg source: 'app' - the real backend over HTTP, 'mock' - the demo simulator with no network.
917
+ // NO BACKEND BY DEFAULT. A page that needs one passes the base explicitly; nothing in the SDK points requests at
918
+ // the page's own origin. The legacy session calls were removed for the same reason: a host could link the visitor
919
+ // to the escrow from a request the widget never needed.
920
+ base: null,
921
+ // The Monero-leg source: 'app' - a backend the caller explicitly configured, 'mock' - the demo simulator.
919
922
  xmrSource: "app",
920
923
  pollMs: 5_000, // how often the front end polls a swap session
921
924
  // HOW MANY PROVIDERS TO SHOW in the price block. A list of dozens of rows is unreadable, and a person cannot
@@ -29,7 +29,7 @@ import * as node from "../monero/node.js";
29
29
  // The Monero-leg source: in "app" mode confirmations and arrivals come from the backend,
30
30
  // in "mock" mode - from the demo simulator. Exactly what goes outward (only the address and view key)
31
31
  // is checked mechanically.
32
- import { XMR_STATUS, explorerXmrTx, fetchSession, isLiveSource, openSession, pollInterval, xmrView } from "./chainSource.js";
32
+ import { XMR_STATUS, explorerXmrTx, isLiveSource, xmrView } from "./chainSource.js";
33
33
  import { watchOnlyShareFromWallet } from "../recovery/restore.js";
34
34
 
35
35
  export const STEP_LABELS = [
@@ -788,25 +788,6 @@ function emit() {
788
788
  }
789
789
  }
790
790
 
791
- // --- live mode: the swap session on the backend -----------------------------------------------
792
- //
793
- // The poll runs without waiting: the screen ticker must not wait for the network, else with an unavailable backend
794
- // the demo starts to lag. The result is put into the swap itself (xmrSession / xmrSessionError), and derive()
795
- // reads it as ordinary state - it stays a pure function.
796
- const liveInFlight = new Set();
797
-
798
- function applyLive(id, patch) {
799
- const s = loadState();
800
- if (!s.swaps[id]) return;
801
- Object.assign(s.swaps[id], patch);
802
- saveState(s);
803
- }
804
-
805
- // The time of the last ATTEMPT to reach the backend per swap. Attempts, not successes: rate limiting must work
806
- // even when the request fails.
807
- const liveLastTry = new Map();
808
-
809
-
810
791
  // THE DEAL KEY: THE PAGE AND THE APP NAME IT DIFFERENTLY. The page has its own id (lives in the browser), the
811
792
  // app its own (localtest-...): the id is set by the app when it creates the watch record. The page asked for the
812
793
  // XMR state by ITS OWN id, which the app does not know and answers with a refusal. From outside it looks like
@@ -830,188 +811,21 @@ export function dealSwapIds(swap) {
830
811
  return [...ids];
831
812
  }
832
813
 
833
- const serverIdByAddress = new Map();
834
-
835
- // WE LOOK FOR THE DEAL'S FACTS ACROSS ALL ITS RECORDS IN THE BROWSER. The page keeps TWO records per deal: one
836
- // created by the swap (Monero address, expected XMR amount), another by the escrow (halves, escrow address). A
837
- // screen opened on the escrow record did not know the expected amount, the session was not created at all and the
838
- // readiness button did not turn on - although the app was already reporting readiness. The records are tied by the SHARED Monero address: one per deal.
839
- function dealFactsFor(swap) {
840
- const w0 = (() => { try { return halvesWalletOf(swap) || {}; } catch { return {}; } })();
841
- let address = w0.address || (swap && swap.escrow && swap.escrow.moneroAddress) || (swap && swap.xmrSession && swap.xmrSession.address) || null;
842
- let amountXmr = Number(swap && swap.xmrAmount) > 0 ? Number(swap.xmrAmount) : null;
843
- let source = "own record";
844
- if (address && amountXmr === null) {
845
- for (const other of Object.values((loadState() || {}).swaps || {})) {
846
- if (!other || other === swap) continue;
847
- const w = (() => { try { return halvesWalletOf(other) || {}; } catch { return {}; } })();
848
- if (!w.address || w.address !== address) continue;
849
- if (Number(other.xmrAmount) > 0) { amountXmr = Number(other.xmrAmount); source = "another record of the same deal (" + String(other.id || "?") + ")"; break; }
850
- }
851
- }
852
- if (!address) {
853
- for (const other of Object.values((loadState() || {}).swaps || {})) {
854
- const w = (() => { try { return halvesWalletOf(other) || {}; } catch { return {}; } })();
855
- if (w.address && Number(other.xmrAmount) > 0) { address = w.address; amountXmr = amountXmr || Number(other.xmrAmount); source = "another record of the same deal"; break; }
856
- }
857
- }
858
- return { address, amountXmr, source };
859
- }
860
-
861
- // WE TAKE THE DEAL ADDRESS FROM EVERYWHERE IT MAY LIE. A browser deal and the app's watch record are DIFFERENT
862
- // records about one deal (the page creates its own for the escrow, the app one for watching the address), so the
863
- // page's record does not always have the address: on the escrow record it lies in the halves, not in the
864
- // escrow.moneroAddress field. A live run therefore polled a dead id, got 404 and left the readiness button off.
865
- // Extracted separately so the same address is used by the server height lookup too.
866
- function dealMoneroAddress(swap) {
867
- const fromHalves = (() => { try { return (halvesWalletOf(swap) || {}).address || null; } catch { return null; } })();
868
- return (swap && swap.escrow && swap.escrow.moneroAddress)
869
- || (swap && swap.xmrSession && swap.xmrSession.address)
870
- || (swap && swap.swapWallet && swap.swapWallet.address)
871
- || fromHalves
872
- || null;
873
- }
874
-
875
814
  // THE SCAN HEIGHT THE APP KNOWS. Needed by OLD deals' files: their record has no birthHeight (the field appeared
876
815
  // later), while the app resolved and stored the height when registering the watch. The source is ours, so it does
877
816
  // not depend on someone else's Monero node. The server is silent - we return null and the caller keeps the
878
817
  // previous behaviour: the height comes from other sources or the file honestly warns of its absence.
879
- export async function serverRestoreHeightFor(swap, { attempts = 3, pauseMs = 1200 } = {}) {
880
- const addr = dealMoneroAddress(swap);
881
- if (!addr) return null;
882
- // NOT ONE ATTEMPT. The app's watch row appears the same instant as the escrow, and the file may be issued a
883
- // hair earlier: then the fallback source is silent and null goes into the file - and there is nowhere else to
884
- // get the height, the file is already with the owner. A few short attempts close this gap instead of turning
885
- // it into an empty field; the price is fractions of a second in the rare case when there is no row at all.
886
- for (let i = 0; i < attempts; i++) {
887
- try {
888
- const r = await fetch("/api/swaps", { headers: { accept: "application/json" } });
889
- if (r.ok) {
890
- const j = await r.json();
891
- const rows = Array.isArray(j) ? j : (j.swaps || []);
892
- const row = rows.find((x) => String((x && x.address) || "") === String(addr));
893
- const h = Number(row && row.restoreHeight);
894
- if (Number.isFinite(h) && h > 0) return h;
895
- }
896
- } catch {
897
- /* try again: the service may have been busy with the first request */
898
- }
899
- if (i < attempts - 1) await new Promise((r) => setTimeout(r, pauseMs));
900
- }
818
+ export async function serverRestoreHeightFor() {
819
+ // NO BACKEND LOOKUP. The app's watch record is not a source for the SDK any more: asking the page's own host by
820
+ // the deal's Monero address is exactly the request the owner removed. A caller that still has a backend passes the
821
+ // height itself; otherwise the recovery file says it is missing.
901
822
  return null;
902
823
  }
903
824
 
904
- async function resolveServerSwapId(swap) {
905
- const addr = dealMoneroAddress(swap);
906
- if (!addr) return null;
907
- if (serverIdByAddress.has(addr)) return serverIdByAddress.get(addr);
908
- try {
909
- const r = await fetch("/api/swaps", { headers: { accept: "application/json" } });
910
- if (!r.ok) return null;
911
- const j = await r.json();
912
- const rows = Array.isArray(j) ? j : (j.swaps || []);
913
- const row = rows.find((x) => String((x && x.address) || "") === String(addr));
914
- if (!row || !row.id) return null;
915
- serverIdByAddress.set(addr, String(row.id));
916
- return String(row.id);
917
- } catch {
918
- return null; // the server is silent - we keep the previous behaviour, the screen does not wait for the network
919
- }
920
- }
921
-
922
- function refreshLiveSession(id) {
923
- if (liveInFlight.has(id)) return;
924
- // No more than once per pollMs per swap, REGARDLESS of whether the session survived. The limit used to stand
925
- // only in the "session exists" branch: if the request failed (429 from the proxy, 502 from a timeout), the
926
- // session was not saved, the limit did not apply - and the tick repeated the request every 5 seconds for EACH
927
- // swap. With eight accumulated swaps that produced a stream of 429s: the app fought itself.
928
- const now = Date.now();
929
- const lastTry = liveLastTry.get(id) || 0;
930
- if (now - lastTry < pollInterval()) return;
931
- liveLastTry.set(id, now);
932
- liveInFlight.add(id);
933
- (async () => {
934
- try {
935
- const fresh = loadState().swaps[id];
936
- if (!fresh || fresh.settlement) return;
937
- if (!fresh.xmrSession) {
938
- // The swap wallet is created in the browser; only the address and view key go outward (see chainSource).
939
- // THE EXPECTED AMOUNT IS MANDATORY. Without it we do not create a session at all: the wallet is the same
940
- // as on the confirmation screen, so the address is already watched. Otherwise a second truth about one
941
- // deal would appear at the same address, and a record without an amount would let an underpayment through.
942
- // As soon as the amount appears, the next tick creates the session itself.
943
- const facts = dealFactsFor(fresh);
944
- const amountXmr = facts.amountXmr;
945
- if (!Number.isFinite(amountXmr) || amountXmr <= 0) {
946
- pushLog(fresh, simElapsed(fresh), "Backend session not opened yet: amount of XMR is unknown", String((fresh && fresh.xmrAmount) ?? "no"));
947
- return;
948
- }
949
- if (facts.source !== "own record") {
950
- pushLog(fresh, simElapsed(fresh), "Amount of XMR taken from the deal's other record", facts.source + ", " + String(amountXmr));
951
- }
952
- const share = watchOnlyShareFromWallet({ swapId: fresh.id, wallet: fresh.swapWallet });
953
- const opened = await openSession({
954
- swapId: fresh.id,
955
- share,
956
- expectedAmountXmr: amountXmr,
957
- chain: fresh.network,
958
- });
959
- if (opened.ok) {
960
- applyLive(id, { xmrSession: opened.session, xmrSessionError: null });
961
- const s2 = loadState();
962
- if (s2.swaps[id]) {
963
- pushLog(s2.swaps[id], simElapsed(s2.swaps[id]), "Backend session opened (watch-only: address + view key)", opened.session.address || "");
964
- saveState(s2);
965
- }
966
- } else {
967
- applyLive(id, { xmrSessionError: opened.error });
968
- }
969
- } else if (Date.now() - (fresh.xmrSession.at || 0) >= pollInterval()) {
970
- // WE ASK BY THE APP'S ID, not our own: see resolveServerSwapId. If the record was not found, we do NOT
971
- // ask by our own id - the app will answer 404 and the screen will silently wait forever. Instead we
972
- // create the watch afresh: the app itself ties it by address to the existing record and returns the same
973
- // one, not a second truth about one deal.
974
- const serverId = await resolveServerSwapId(fresh);
975
- const polled = await fetchSession(serverId || id);
976
- if (polled.ok) {
977
- applyLive(id, { xmrSession: polled.session, xmrSessionError: null });
978
- } else if (!serverId) {
979
- const share = watchOnlyShareFromWallet({ swapId: fresh.id, wallet: fresh.swapWallet });
980
- const again = await openSession({
981
- swapId: fresh.id, share, expectedAmountXmr: Number(fresh.xmrAmount), chain: fresh.network,
982
- });
983
- if (again.ok) {
984
- applyLive(id, { xmrSession: again.session, xmrSessionError: null });
985
- const s3 = loadState();
986
- if (s3.swaps[id]) { pushLog(s3.swaps[id], simElapsed(s3.swaps[id]), "Backend record re-linked by the Monero address", again.session.address || ""); saveState(s3); }
987
- } else {
988
- applyLive(id, { xmrSessionError: again.error });
989
- }
990
- } else {
991
- applyLive(id, { xmrSessionError: polled.error });
992
- }
993
- }
994
- } catch (e) {
995
- applyLive(id, { xmrSessionError: "session did not open: " + e.message });
996
- } finally {
997
- liveInFlight.delete(id);
998
- }
999
- })();
1000
- }
1001
-
1002
- // The deals currently open on screen. The core polls the backend ONLY for them.
1003
- //
1004
- // Why: the tick used to poll all unfinished deals indiscriminately. After a few presses of the sign button they
1005
- // accumulated to eight, and the app itself flooded the proxy with requests - a 429 came back. And on the main
1006
- // page there is nothing to watch at all: there is no deal yet.
1007
- //
1008
- // An empty set (the default) means "poll nothing". Screens that need watching report their set themselves: the
1009
- // deal list - all unfinished, the deal screen - one.
1010
- let liveFocusIds = new Set();
1011
-
825
+ // Kept for callers that used to name the deals on screen. There is no backend poll any more; the function only
826
+ // returns how many ids were named, so an old caller does not break.
1012
827
  export function setLiveFocus(ids) {
1013
- liveFocusIds = new Set(Array.isArray(ids) ? ids.map(String) : []);
1014
- return liveFocusIds.size;
828
+ return new Set(Array.isArray(ids) ? ids.map(String) : []).size;
1015
829
  }
1016
830
 
1017
831
  export function tick() {
@@ -1024,7 +838,6 @@ export function tick() {
1024
838
  // Live mode: a session on the backend. We create it on the first tick after the swap is created and then
1025
839
  // poll no more often than API.pollMs. Not awaited: the screen must not wait for the network.
1026
840
  // We poll only what is open on screen.
1027
- if (isLiveSource(swap.xmrSource) && liveFocusIds.has(String(swap.id))) refreshLiveSession(swap.id);
1028
841
 
1029
842
  const view = derive(swap);
1030
843
 
@@ -1080,26 +893,7 @@ export function tick() {
1080
893
  changed = true;
1081
894
  }
1082
895
 
1083
- // Live mode: the withdrawal is confirmed by the sweep page (a watch-only wallet cannot see outgoing
1084
- // transfers), so the final state is taken from the session status, and the hash is not invented.
1085
- if (isLiveSource(swap.xmrSource) && swap.xmrSession && swap.xmrSession.status === XMR_STATUS.SWEPT && !swap.settlement) {
1086
- const atSim = view.sim;
1087
- swap.settlement = {
1088
- kind: "success",
1089
- phase: TERMINAL.success.phase,
1090
- atSim,
1091
- txid: swap.xmrSession.sweepTxid || null,
1092
- by: "your sweep wallet",
1093
- };
1094
- pushLog(swap, atSim, "XMR swept by you (backend reported)", swap.settlement.txid || "");
1095
- const key = swap.id + ":success";
1096
- if (!notified.has(key)) {
1097
- notified.add(key);
1098
- notify("Swap " + swap.id + " complete: XMR is on your address", "ok");
1099
- }
1100
- changed = true;
1101
- continue;
1102
- }
896
+ // Live mode: the XMR arrives from the chain; the SDK does not confirm a sweep from a backend session.
1103
897
 
1104
898
  // happy path: after claim the user's wallet sweeps the XMR itself (litepaper §4.2)
1105
899
  if (!isLiveSource(swap.xmrSource) && view.can.sweep && view.phase === "success") {
@@ -122,7 +122,7 @@ function optionsFor(chain) {
122
122
  return {
123
123
  settlement: { chain: chain.id },
124
124
  xmrNetwork: MONERO.networkType,
125
- apiBase: "/api",
125
+ // NO BACKEND BY DEFAULT: a page that needs one passes its base explicitly.
126
126
  assetsBase: "/",
127
127
  mode: modeOf(chain),
128
128
  // THE PAGE STORAGE BECOMES THE CORE STORAGE: deal records and the session live in one place.
package/index.d.ts CHANGED
@@ -1140,6 +1140,9 @@ export interface StartRequest {
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 first block for the on-chain number check. Absent - the chain registry's escrow deployment block, and
1144
+ * zero only when the registry has no height. */
1145
+ fromBlock?: string | number;
1143
1146
  /** The Monero height the swap starts at. Absent - the SDK asks the configured nodes (`get_info`) and refuses
1144
1147
  * the start if none answers on the swap's network. */
1145
1148
  restoreHeight?: number;
@@ -1300,14 +1303,8 @@ export interface SweepApi {
1300
1303
  * the person what will leave, BEFORE sending.
1301
1304
  */
1302
1305
  sign(request: SweepRunRequest): Promise<SweepSignResult>;
1303
- /** Send what `sign` signed, close the wallet and tell the service about the withdrawal. */
1306
+ /** Send what `sign` signed, close the wallet and return the transaction hashes. */
1304
1307
  relay(request: SweepRelayRequest): Promise<SweepRunResult>;
1305
- /**
1306
- * TELL THE SERVICE ABOUT THE WITHDRAWAL. The mark does not affect the transfer - it is already on the network - so
1307
- * it can be repeated: the interface calls this even after `relay` if the report did not go through then. A
1308
- * connection refusal gives `reported: false`.
1309
- */
1310
- report(request: SweepReportRequest): Promise<SweepReportResult>;
1311
1308
  /** Perform the withdrawal as a whole (`sign` + `relay`). */
1312
1309
  run(request: SweepRunRequest): Promise<SweepRunResult>;
1313
1310
  }
@@ -1328,8 +1325,9 @@ export interface SweepRestoreRequest {
1328
1325
  readonly rescanWhenLocked?: boolean;
1329
1326
  /**
1330
1327
  * THE BLOCK WINDOW AROUND THE LOCK. When present, the wallet is set at the node tip and only these blocks are
1331
- * scanned. An empty window falls back to the full scan unless `fallback` is false. `txHashes` may be supplied by
1332
- * the caller; otherwise the SDK reads `get_block` from `nodeUrl`.
1328
+ * scanned. An empty window falls back to the full scan unless `fallback` is false. A window read that fails
1329
+ * (429, timeout, 5xx) is retried; if it still fails, the full scan runs and `fallbackWhy` names the read.
1330
+ * `txHashes` may be supplied by the caller; otherwise the SDK reads `get_block` from `nodeUrl`.
1333
1331
  */
1334
1332
  readonly window?: {
1335
1333
  readonly nodeUrl?: string;
@@ -1357,8 +1355,18 @@ export interface SweepRestoreResult {
1357
1355
  /** Present when a window was requested: whether it was used, and why it was skipped. */
1358
1356
  readonly window?: {
1359
1357
  readonly used: boolean;
1360
- /** Whether the full scan ran because the window found nothing (or could not scan). */
1358
+ /** Whether the full scan ran because the window found nothing, could not scan, or could not be read. */
1361
1359
  readonly fallback: boolean;
1360
+ /** Why the fallback ran: `empty-window`, `window-read`, or a named skip. */
1361
+ readonly fallbackWhy?: string;
1362
+ /** The node's answer when the window read failed; absent when the window was read. */
1363
+ readonly error?: {
1364
+ readonly code: string;
1365
+ readonly surface: string;
1366
+ readonly status: number | null;
1367
+ readonly height: number | null;
1368
+ readonly why: string | null;
1369
+ };
1362
1370
  readonly skipped: string | null;
1363
1371
  readonly fromHeight: number;
1364
1372
  readonly toHeight: number;
@@ -1407,16 +1415,6 @@ export interface SweepWallet {
1407
1415
  driver(): unknown;
1408
1416
  }
1409
1417
 
1410
- /** The withdrawal mark: the swap identifier and the transfer hash. */
1411
- export interface SweepReportRequest {
1412
- readonly swapId: string;
1413
- readonly txid: string;
1414
- }
1415
-
1416
- export interface SweepReportResult {
1417
- readonly reported: boolean;
1418
- }
1419
-
1420
1418
  export interface SweepFromOrderRequest {
1421
1419
  /** The 12 words of the recovery key. */
1422
1420
  readonly mnemonic: string;
@@ -1571,12 +1569,6 @@ export interface SweepRelayRequest {
1571
1569
  export interface SweepRunResult {
1572
1570
  readonly txids: readonly string[];
1573
1571
  readonly swept: string;
1574
- /**
1575
- * Whether the service was told about the withdrawal. THE REPORT DOES NOT AFFECT THE TRANSFER - it is already on
1576
- * the network - so a failed report does not make the withdrawal unsuccessful: it gives `reported: false`, so the
1577
- * interface offers to report again.
1578
- */
1579
- readonly reported: boolean;
1580
1572
  }
1581
1573
 
1582
1574
  // --- Recovery// --- Recovery ------------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.51.0",
3
+ "version": "0.51.2",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
package/src/http.mjs CHANGED
@@ -16,6 +16,7 @@ export function createHttp({ apiBase, fetchImpl = null, timeoutMs = 20_000 }) {
16
16
  // a GET, and the service would answer with a refusal to a request it did not understand. The defaults are
17
17
  // the previous ones (GET without a body).
18
18
  async json(path, { signal = null, method = "GET", headers = null, body = null } = {}) {
19
+ if (!apiBase) throw new SdkError("server-unavailable", { why: "no-api-base" });
19
20
  if (typeof impl !== "function") throw new SdkError("bad-input", { field: "fetch" });
20
21
  const ctrl = new AbortController();
21
22
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
@@ -47,6 +48,7 @@ export function createHttp({ apiBase, fetchImpl = null, timeoutMs = 20_000 }) {
47
48
  * refused" are different things, and the caller must see them.
48
49
  */
49
50
  async tryJson(path, { signal = null, method = "GET", headers = null, body = null } = {}) {
51
+ if (!apiBase) throw new SdkError("server-unavailable", { why: "no-api-base" });
50
52
  if (typeof impl !== "function") throw new SdkError("bad-input", { field: "fetch" });
51
53
  const ctrl = new AbortController();
52
54
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
package/src/index.mjs CHANGED
@@ -142,7 +142,7 @@ const normalizeOptions = (options) => {
142
142
  return {
143
143
  settlement: { chain: settlement.chain, vm: settlement.vm },
144
144
  xmrNetwork: o.xmrNetwork,
145
- apiBase: typeof o.apiBase === "string" && o.apiBase ? o.apiBase : "/api",
145
+ apiBase: typeof o.apiBase === "string" && o.apiBase ? o.apiBase : null,
146
146
  assetsBase: typeof o.assetsBase === "string" && o.assetsBase ? o.assetsBase : "/",
147
147
  mode,
148
148
  storage: o.storage || null,
@@ -342,7 +342,7 @@ export interface FoundSwap {
342
342
  /** The Monero height a withdrawal may start from, derived from the order's block time. Null when the caller did
343
343
  * not pass the readers; `startHeightReason` then names why. */
344
344
  readonly startHeight: number | null;
345
- readonly startHeightReason: "no-block-number" | "no-height-readers" | "no-block-time" | "monero-header" | null;
345
+ readonly startHeightReason: "no-block-number" | "no-height-readers" | "monero-nodes-unreachable" | "monero-network-mismatch" | "no-block-time" | "monero-header" | null;
346
346
  }
347
347
 
348
348
  /** A storage adapter: the same shape the SDK store takes. */
@@ -376,6 +376,17 @@ export declare function saveRecoveryKey(storage: RecoveryKeyStorage, key: Recove
376
376
  export declare function loadRecoveryKey(storage: RecoveryKeyStorage): (RecoveryKey & { nextIndex: number; reserved: Record<string, unknown>; retired: Record<string, unknown> }) | null;
377
377
  /** Reserves a swap number; the caller releases it if the request is abandoned. */
378
378
  export declare function reserveRecoveryIndex(storage: RecoveryKeyStorage, request: { chainId: number; swapId?: string | null }): number;
379
+ /** True when an order already holds this number's commitment on chain. A page that starts swaps without the SDK's
380
+ * start path calls this before using a number; an unreadable log is a refusal, not "free". */
381
+ export declare function recoveryIndexTaken(request: {
382
+ mnemonic: string;
383
+ chainId: number;
384
+ index: number;
385
+ factory: string;
386
+ evmLogs: (request: { address: string; topics: (string | null)[]; fromBlock: number | string; toBlock: number | string }) => Promise<unknown[]>;
387
+ fromBlock?: number | string;
388
+ toBlock?: number | string;
389
+ }): Promise<boolean>;
379
390
  /** Releases a number that never reached the chain. */
380
391
  export declare function releaseRecoveryIndex(storage: RecoveryKeyStorage, index: number): void;
381
392
  /** Retires a number after its order exists on chain. */
@@ -41,6 +41,7 @@ export {
41
41
  loadRecoveryKey,
42
42
  openRecoveryKeyFile,
43
43
  recoveryBinding,
44
+ recoveryIndexTaken,
44
45
  recoveryKeyFileName,
45
46
  recoveryKeyText,
46
47
  releaseRecoveryIndex,
@@ -446,6 +446,9 @@ export async function recoveryIndexTaken({ mnemonic, chainId, index, factory, ev
446
446
  const MONERO_HEIGHT_SAFETY_BLOCKS = 60;
447
447
 
448
448
  function secondsOf(value) {
449
+ // A MISSING BLOCK TIME IS NOT 1970. `Number(null)` is 0, so a reader that answered null used to place an order
450
+ // at the epoch and derive a Monero height from it. Null means "not read": the caller names no-block-time.
451
+ if (value === null || value === undefined) return null;
449
452
  const n = Number(value);
450
453
  if (!Number.isFinite(n) || n < 0) return null;
451
454
  return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
@@ -477,13 +480,18 @@ async function heightAtOrBefore(timestamp, { tip, headerTimestamp }) {
477
480
  async function moneroReadersFromNodes({ nodes = [], network = null, fetchImpl = globalThis.fetch } = {}) {
478
481
  const urls = (Array.isArray(nodes) ? nodes : [])
479
482
  .map((entry) => typeof entry === "string" ? entry : entry && entry.url).filter(Boolean);
480
- if (!urls.length) return null;
483
+ if (!urls.length) return { readers: null, reason: "no-height-readers" };
481
484
  const answers = await Promise.all(urls.map(async (url) => {
482
485
  try { return { ...(await probeMoneroNode({ url, fetchImpl })), url }; }
483
486
  catch { return { ok: false, why: "unreachable", url }; }
484
487
  }));
485
488
  const node = answers.find((answer) => answer.ok === true && (!network || answer.network === network));
486
- if (!node) return null;
489
+ if (!node) {
490
+ // TWO DIFFERENT FAILURES, TWO DIFFERENT REASONS. A node that answered but runs another network is not the
491
+ // same as no node answering at all; a page must be able to tell the person which one to fix.
492
+ const answered = answers.some((answer) => answer.ok === true);
493
+ return { readers: null, reason: answered ? "monero-network-mismatch" : "monero-nodes-unreachable" };
494
+ }
487
495
  const call = async (method, params) => {
488
496
  let response;
489
497
  try {
@@ -500,27 +508,30 @@ async function moneroReadersFromNodes({ nodes = [], network = null, fetchImpl =
500
508
  return body.result;
501
509
  };
502
510
  return {
503
- // `get_info.height` is the block COUNT, one past the top block. Asking a real node for a header at that height
504
- // answers "greater than current top block"; the tip reader must name the top block itself.
505
- tip: async () => Math.max(0, Number(node.height) - 1),
506
- headerTimestamp: async (height) => {
507
- const result = await call("get_block_header_by_height", { height });
508
- const ts = Number(result.block_header && result.block_header.timestamp);
509
- // GENESIS HAS TIMESTAMP 0. The binary search reads height 0 to prove the requested moment is before it; a
510
- // `<= 0` refusal turned a legitimate genesis answer into "monero-header" and every old order lost its height.
511
- if (!Number.isFinite(ts) || ts < 0) throw new Error("monero-header");
512
- return ts;
511
+ reason: null,
512
+ readers: {
513
+ // `get_info.height` is the block COUNT, one past the top block. Asking a real node for a header at that height
514
+ // answers "greater than current top block"; the tip reader must name the top block itself.
515
+ tip: async () => Math.max(0, Number(node.height) - 1),
516
+ headerTimestamp: async (height) => {
517
+ const result = await call("get_block_header_by_height", { height });
518
+ const ts = Number(result.block_header && result.block_header.timestamp);
519
+ // GENESIS HAS TIMESTAMP 0. The binary search reads height 0 to prove the requested moment is before it; a
520
+ // `<= 0` refusal turned a legitimate genesis answer into "monero-header" and every old order lost its height.
521
+ if (!Number.isFinite(ts) || ts < 0) throw new Error("monero-header");
522
+ return ts;
523
+ },
513
524
  },
514
525
  };
515
526
  }
516
527
 
517
- async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }) {
528
+ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache, readersReason = "no-height-readers" }) {
518
529
  if (order.blockNumber === null || order.blockNumber === undefined) {
519
530
  return { startHeight: null, startHeightReason: "no-block-number", blockTime: null };
520
531
  }
521
532
  // NOTHING TO READ WITH: no settlement block time and no Monero readers. That is a missing seam, not a missing date.
522
533
  if (typeof evmBlockTime !== "function" && typeof moneroTip !== "function" && typeof moneroBlockTimestamp !== "function") {
523
- return { startHeight: null, startHeightReason: "no-height-readers", blockTime: null };
534
+ return { startHeight: null, startHeightReason: readersReason || "no-height-readers", blockTime: null };
524
535
  }
525
536
  // THE BLOCK TIME IS PART OF THE FOUND ORDER. The page used to read the block again just to show a date; the
526
537
  // timestamp is already needed here for the Monero height, so it is returned with it.
@@ -531,7 +542,7 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
531
542
  cache.set(order.blockNumber, timestamp);
532
543
  }
533
544
  if (typeof moneroTip !== "function" || typeof moneroBlockTimestamp !== "function") {
534
- return { startHeight: null, startHeightReason: "no-height-readers", blockTime: timestamp };
545
+ return { startHeight: null, startHeightReason: readersReason || "no-height-readers", blockTime: timestamp };
535
546
  }
536
547
  try {
537
548
  const height = await heightAtOrBefore(timestamp, { tip: moneroTip, headerTimestamp: moneroBlockTimestamp });
@@ -608,9 +619,11 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
608
619
  const cache = new Map();
609
620
  let tip = moneroTip;
610
621
  let header = moneroBlockTimestamp;
622
+ let readersReason = "no-height-readers";
611
623
  if ((typeof tip !== "function" || typeof header !== "function") && Array.isArray(xmrNodes) && xmrNodes.length) {
612
- const readers = await moneroReadersFromNodes({ nodes: xmrNodes, network: moneroNetwork, fetchImpl });
613
- if (readers) { tip = tip || readers.tip; header = header || readers.headerTimestamp; }
624
+ const probed = await moneroReadersFromNodes({ nodes: xmrNodes, network: moneroNetwork, fetchImpl });
625
+ if (probed.readers) { tip = tip || probed.readers.tip; header = header || probed.readers.headerTimestamp; }
626
+ else readersReason = probed.reason || "no-height-readers";
614
627
  }
615
628
  // THE ORDER IS NATIVE, AND THE PAYING TOKEN IS NOT ON THE CHAIN. The escrow receives the settlement network's
616
629
  // native coin (`amount` is wei); which token the person swapped into it (USDC, USDT, ...) belongs to the DEX/quote
@@ -622,7 +635,7 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
622
635
  order.payTokenReason = "not-on-chain";
623
636
  }
624
637
  for (const order of found) {
625
- Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache }));
638
+ Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache, readersReason }));
626
639
  }
627
640
  // THE XMR AMOUNT IS IN THE CLONE, NOT IN THE LOG. A caller without a code reader gets null and a named reason;
628
641
  // an unreadable or differently-shaped contract does the same. This is data, not a withdrawal verdict.
package/src/swaps.mjs CHANGED
@@ -46,8 +46,8 @@ import { declineOf, logReaderOf, blockBoundaryOf, DECLINED_TOPIC0, DECLINED_TERM
46
46
  //
47
47
  // WHAT CAN BE A KIND, EXHAUSTIVELY. The record's settlement belongs to the engine (engine/core/swap.js, the only
48
48
  // module that assigns swap.settlement), and `declined` is the one kind written by us (src/decline.mjs):
49
- // `success` - the swap completed and the XMR is on the depositor's address (sweepNow, the auto-sweep in the
50
- // ticker, and the backend-reported sweep on the live path);
49
+ // `success` - the swap completed and the XMR is on the depositor's address (sweepNow and the auto-sweep in the
50
+ // ticker; the SDK does not take it from a backend session);
51
51
  // `refund_eth` - the ETH went back to the depositor. TWO WRITERS, ONE KIND: the person's own refund (refundEth)
52
52
  // and the watchtower inside the ticker both write `kind: "refund_eth"` with the phase
53
53
  // `refunded_eth`, so there is exactly ONE refund-shaped kind here and nothing is collapsed;
@@ -811,9 +811,18 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
811
811
  // log is asked before the number is used; a taken number is retired locally and the next one is reserved.
812
812
  // Without a log seam the previous behaviour is kept - the caller cannot be asked to invent a chain read.
813
813
  if (typeof chainLogs === "function" && request.order.factory) {
814
+ // THE CHAIN'S BIRTH HEIGHT IS THE DEFAULT START, NOT ZERO. The factory's own log cannot hold an order
815
+ // before it existed; asking from zero is tens of log queries per number on a public RPC. An explicit
816
+ // `fromBlock` still wins, including the literal zero.
817
+ const chain = config && Array.isArray(config.chains)
818
+ ? config.chains.find((entry) => Number(entry.chainId) === Number(chainId)) : null;
819
+ const deploymentBlock = chain && chain.escrow ? chain.escrow.deploymentBlock : null;
820
+ const fromBlock = request.fromBlock !== undefined
821
+ ? request.fromBlock
822
+ : (Number.isSafeInteger(deploymentBlock) ? deploymentBlock : 0);
814
823
  let hops = 0;
815
824
  while (await recoveryIndexTaken({ mnemonic: key.mnemonic, chainId, index, factory: request.order.factory,
816
- evmLogs: chainLogs, fromBlock: request.fromBlock === undefined ? 0 : request.fromBlock })) {
825
+ evmLogs: chainLogs, fromBlock })) {
817
826
  if (explicit) throw new SdkError("bad-input", { field: "recoveryIndex", why: "on-chain" });
818
827
  commitRecoveryIndex(storage, index);
819
828
  index = reserveRecoveryIndex(storage, { chainId });
@@ -980,20 +989,21 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
980
989
  }
981
990
  }
982
991
 
983
- // 5. THE SERVER: RECORDING THE SWAP WITH US, THEN THE FACTORY WATCH RECORD FOR THE MONERO ADDRESS.
984
- // THE ORDER IS EXACTLY THIS: first the swap becomes known to the service (by it, the service checks the escrow
985
- // address against the CHAIN), and only then is the watch opened. The reverse order would leave a watch on a swap
986
- // the service does not know.
987
- // IF ANYTHING OF THIS FAILED - THE REFUSAL GOES OUT AS A CODE (server-unavailable / server-refused) with the step
988
- // and the service's reason: the funds are already locked, and this must not be hidden in silence or in "success".
989
- const server = await markOnServer(record.swap, escrowAddress, request.order);
990
- const watch = await openWatch(record.swap, request.order);
992
+ // 5. A BACKEND IS OPTIONAL AND NEVER A DEFAULT. When the caller configured an API base, the swap is recorded
993
+ // there and the watch row is opened. Without one, the SDK returns the local state and the joint address: the
994
+ // caller watches the chain itself. The owner's rule: no request to the page's own origin.
995
+ let server = null;
996
+ let watch = null;
997
+ if (config && config.apiBase) {
998
+ server = await markOnServer(record.swap, escrowAddress, request.order);
999
+ watch = await openWatch(record.swap, request.order);
1000
+ }
991
1001
  // THE STATE IS NAMED, NOT PASSED OFF AS COMPLETION: the swap reached exactly "waiting for XMR at the joint address",
992
- // and there is no arrival yet - `receivedXmr` is taken from the service's response, not implied by zero.
1002
+ // and there is no arrival yet - `receivedXmr` is never implied by zero.
993
1003
  return {
994
1004
  id: record.swap.id || null,
995
1005
  escrow: escrowAddress,
996
- address: watch.address,
1006
+ address: (watch && watch.address) || (record.swapWallet && record.swapWallet.address) || null,
997
1007
  step: "xmr-incoming",
998
1008
  server,
999
1009
  watch,
@@ -1004,8 +1014,9 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1004
1014
  /**
1005
1015
  * WAIT FOR THE XMR TO ARRIVE AT THE JOINT ADDRESS - the whole point of opening the watch.
1006
1016
  *
1007
- * The polling uses THE SAME request as the page's (GET /api/swaps/{id}), the rate and the timeout - from the
1008
- * configuration (config.limits.watchPollMs/watchTimeoutMs, the engine's defaults too) and are overridable here.
1017
+ * The CLIENT checks the arrival from the Monero nodes (`verify`); there is no backend poll by default. The rate
1018
+ * and the timeout come from the configuration (config.limits.watchPollMs/watchTimeoutMs) and are overridable here.
1019
+ * A caller that explicitly configured `apiBase` may use the old watch-row flow instead.
1009
1020
  * IT ARRIVED - we return the FACT (the amount, the confirmations, the txid); WE DID NOT WAIT - a refusal with the
1010
1021
  * code `xmr-timeout`, separate from the others, so "the wait expired" is confused with neither a breakdown nor an
1011
1022
  * empty response. XMR ON THE ADDRESS BUT BARRED BY unlock_time is a THIRD answer with its own code `xmr-locked`
@@ -1047,6 +1058,11 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1047
1058
  : (request.deadline && request.deadline.readyBy !== undefined ? Number(request.deadline.readyBy) : null);
1048
1059
  if (readyBy) xmrArrival.assertTimeForConfirmations({ readyBySec: readyBy, nowSec: Math.floor(Date.now() / 1000), minConfirmations, blockTimeSec: limitOf("xmrBlockTimeSec", 120) });
1049
1060
  }
1061
+ const hasBackend = Boolean(config && config.apiBase);
1062
+ // THE WATCH DOES NOT CALL A BACKEND BY DEFAULT. The client-side arrival check needs the joint address, the view
1063
+ // secret and the spend public key; without them, and without an explicitly configured backend, there is nothing
1064
+ // to wait on. That is a named refusal, not a request to the page's own origin.
1065
+ if (!verifyRequest && !hasBackend) throw new SdkError("bad-input", { field: "verify", why: "no-backend" });
1050
1066
  const started = Date.now();
1051
1067
  // --- THE MAKER'S DECLINE: THE WAIT IS NOT STARTED AT ALL (issue #40). -------------------------------------
1052
1068
  // A decline leaves the escrow `refunded`, so nothing in the state says "do not wait any longer": without
@@ -1114,8 +1130,12 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1114
1130
  let last = null;
1115
1131
  for (;;) {
1116
1132
  await readDeclineLogs();
1117
- const body = await readWatch(id);
1118
- last = incomingOf(body);
1133
+ // THE CLIENT-SIDE CHECK DOES NOT ASK THE SERVICE. Only a watch without `verify` and with an explicitly
1134
+ // configured backend reads the watch row.
1135
+ const body = verifyRequest ? {} : await readWatch(id);
1136
+ last = verifyRequest
1137
+ ? { address: request.address || escrowAddress, expectedXmr: request.expectedXmr ?? null, status: null }
1138
+ : incomingOf(body);
1119
1139
  // THE DECLINE IS DECIDED BEFORE THE ARRIVAL IS LOOKED AT, and before the "foreign response" refusal below:
1120
1140
  // a declined swap's answer carries no `receivedXmr` at all, and refusing it as a bad response would name
1121
1141
  // the wrong thing (the state is known - it is a decline, not a broken answer).
@@ -64,7 +64,7 @@ const heightOf = async (driver, name) => {
64
64
 
65
65
  // THE BLOCK WINDOW AROUND THE LOCK. A full rescan of mainnet takes hours; the lock can only be in the blocks from
66
66
  // the order's start to just after its t1. The caller may pass the hashes, or a node URL and the SDK asks for them.
67
- export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl = globalThis.fetch } = {}) {
67
+ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl = globalThis.fetch, sleep = defaultSleep, attempts = 3 } = {}) {
68
68
  let base = typeof nodeUrl === "string" ? nodeUrl.trim() : "";
69
69
  while (base.endsWith("/")) base = base.slice(0, -1);
70
70
  const from = Number(fromHeight);
@@ -73,17 +73,39 @@ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl
73
73
  if (!Number.isSafeInteger(from) || from < 0) throw new SdkError("bad-input", { field: "window.fromHeight" });
74
74
  if (!Number.isSafeInteger(to) || to < from) throw new SdkError("bad-input", { field: "window.toHeight" });
75
75
  if (typeof fetchImpl !== "function") throw new SdkError("bad-input", { field: "fetchImpl" });
76
+ const tries = Math.max(1, Math.min(5, Number(attempts) || 3));
76
77
  const hashes = [];
77
78
  for (let height = from; height <= to; height += 1) {
78
- let response;
79
- try {
80
- response = await fetchImpl(base + "/json_rpc", {
81
- method: "POST", headers: { "content-type": "application/json" },
82
- body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
83
- signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
84
- });
85
- } catch { throw new SdkError("wallet-rejected", { surface: "window-blocks", height }); }
86
- if (!response || response.ok !== true) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, status: response ? response.status : null });
79
+ let response = null;
80
+ let last = null;
81
+ for (let attempt = 1; attempt <= tries; attempt += 1) {
82
+ try {
83
+ response = await fetchImpl(base + "/json_rpc", {
84
+ method: "POST", headers: { "content-type": "application/json" },
85
+ body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
86
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
87
+ });
88
+ } catch {
89
+ // A TIMEOUT OR A BROKEN CONNECTION IS RETRIED, NOT THE END OF THE WINDOW. The node is rate-limited by the
90
+ // same bursts the page sends; a withdrawal that stops on the first 429 or timeout is a refund waiting to
91
+ // happen (the page's measured failure: 93 of 150 parallel reads answered 429).
92
+ response = null;
93
+ last = new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "transport" });
94
+ if (attempt < tries) { await sleep(backoffMs(attempt)); continue; }
95
+ throw last;
96
+ }
97
+ if (response && response.ok === true) { last = null; break; }
98
+ const status = response ? response.status : null;
99
+ last = new SdkError("wallet-rejected", { surface: "window-blocks", height, status });
100
+ // ONLY A RATE LIMIT AND A SERVER FAULT ARE WORTH ANOTHER READ. A 404 or a bad request will answer the same
101
+ // way every time, and retrying it only makes the fallback slower.
102
+ if ((status === 429 || (status !== null && status >= 500)) && attempt < tries) {
103
+ await sleep(retryAfterMs(response) ?? backoffMs(attempt));
104
+ continue;
105
+ }
106
+ throw last;
107
+ }
108
+ if (!response || response.ok !== true) throw last || new SdkError("wallet-rejected", { surface: "window-blocks", height });
87
109
  let body = null;
88
110
  try { body = await response.json(); } catch { throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "not-json" }); }
89
111
  if (!body || body.error || !body.result) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "rpc" });
@@ -101,6 +123,21 @@ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl
101
123
  return hashes;
102
124
  }
103
125
 
126
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
127
+ const backoffMs = (attempt) => Math.min(4000, 250 * (2 ** Math.max(0, attempt - 1)));
128
+ function retryAfterMs(response) {
129
+ try {
130
+ const value = response && response.headers && typeof response.headers.get === "function"
131
+ ? response.headers.get("retry-after") : null;
132
+ if (value === null || value === undefined || value === "") return null;
133
+ const seconds = Number(value);
134
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(5000, Math.floor(seconds * 1000));
135
+ const date = Date.parse(String(value));
136
+ if (Number.isFinite(date)) return Math.min(5000, Math.max(0, date - Date.now()));
137
+ } catch { /* a bad header is not a reason to stop */ }
138
+ return null;
139
+ }
140
+
104
141
  /**
105
142
  * Restore the wallet from a height and report what it holds.
106
143
  *
@@ -136,24 +173,62 @@ export async function restoreWallet(request = {}) {
136
173
  if (requestWindow) {
137
174
  const tipHeight = Number(requestWindow.tipHeight);
138
175
  if (!Number.isSafeInteger(tipHeight) || tipHeight <= 0) throw new SdkError("bad-input", { field: "window.tipHeight" });
176
+ windowReport = { used: false, skipped: null, fallback: false, fromHeight, toHeight: Number(requestWindow.toHeight),
177
+ blocks: Math.max(0, Number(requestWindow.toHeight) - fromHeight + 1), txs: 0 };
139
178
  let hashes = Array.isArray(requestWindow.txHashes) ? requestWindow.txHashes.map(String) : null;
140
179
  if (hashes === null) {
141
- hashes = await windowTxHashes({
142
- nodeUrl: requestWindow.nodeUrl, fromHeight, toHeight: requestWindow.toHeight, fetchImpl: request.fetchImpl,
143
- });
180
+ try {
181
+ hashes = await windowTxHashes({
182
+ nodeUrl: requestWindow.nodeUrl, fromHeight, toHeight: requestWindow.toHeight, fetchImpl: request.fetchImpl,
183
+ });
184
+ } catch (e) {
185
+ // A MALFORMED WINDOW IS NOT A BUSY NODE. A bad height or a missing URL is the caller's error and is
186
+ // rethrown; only a read the node refused reaches the fallback.
187
+ if (e && e.code === "bad-input") throw e;
188
+ // THE WINDOW READ FAILED: FALL BACK TO THE FULL SCAN, NOT TO A REFUSAL. A 429 from a busy node, a timeout
189
+ // or a 5xx is a fact about the node, not about the coins; the scan below can still find them. The reason is
190
+ // carried on the report so the caller does not present this as a normal empty window.
191
+ windowReport.fallback = true;
192
+ windowReport.fallbackWhy = "window-read";
193
+ windowReport.error = {
194
+ code: e && e.code ? e.code : "wallet-rejected",
195
+ surface: e && e.params && e.params.surface ? e.params.surface : "window-blocks",
196
+ status: e && e.params && e.params.status !== undefined ? e.params.status : null,
197
+ height: e && e.params && e.params.height !== undefined ? e.params.height : null,
198
+ why: e && e.params && e.params.why ? e.params.why : null,
199
+ };
200
+ hashes = [];
201
+ }
144
202
  }
145
- windowReport = { used: false, skipped: null, fallback: false, fromHeight, toHeight: Number(requestWindow.toHeight),
146
- blocks: Math.max(0, Number(requestWindow.toHeight) - fromHeight + 1), txs: hashes.length };
203
+ windowReport.txs = hashes.length;
147
204
  if (hashes.length && typeof driver.scanTxs === "function") {
148
- await step("setRestoreHeight", () => driver.setRestoreHeight(tipHeight));
149
- await step("scanTxs", () => driver.scanTxs(hashes));
150
- try { await wallet.sync(); } catch { /* the balance below decides */ }
151
- windowTotal = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
152
- windowUnlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
153
- windowReport.used = true;
205
+ try {
206
+ await step("setRestoreHeight", () => driver.setRestoreHeight(tipHeight));
207
+ await step("scanTxs", () => driver.scanTxs(hashes));
208
+ try { await wallet.sync(); } catch { /* the balance below decides */ }
209
+ windowTotal = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
210
+ windowUnlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
211
+ windowReport.used = true;
212
+ } catch (e) {
213
+ // A WINDOW THAT WAS READ BUT NOT APPLIED IS STILL A FAILED WINDOW. `scanTxs` and the balance read use the
214
+ // wallet's own transport; a node that misses one route (`/gettransactions`) or a wallet that refuses the
215
+ // scan must not end the withdrawal. The full scan below is the fallback, and the reason says which step.
216
+ if (e && e.code === "bad-input") throw e;
217
+ windowTotal = 0n;
218
+ windowUnlocked = 0n;
219
+ windowReport.fallback = true;
220
+ windowReport.fallbackWhy = "window-scan";
221
+ windowReport.error = {
222
+ code: e && e.code ? e.code : "wallet-rejected",
223
+ surface: e && e.params && e.params.surface ? e.params.surface : "scanTxs",
224
+ status: null,
225
+ height: null,
226
+ why: e && e.params && e.params.why ? e.params.why : null,
227
+ };
228
+ }
154
229
  // THE KEY IMAGES OF THE WALLET, CHECKED AT THE NODE. `scanTxs` finds an output; whether the chain already
155
- // spent it is a separate fact, and the caller must not sign on a guess.
156
- if (requestWindow.nodeUrl && typeof driver.getKeyImages === "function") {
230
+ // spent it is a separate fact, and the caller must not sign on a guess. A failed scan has no images to ask for.
231
+ if (windowReport.used && requestWindow.nodeUrl && typeof driver.getKeyImages === "function") {
157
232
  try {
158
233
  const exported = await driver.getKeyImages();
159
234
  const images = Array.isArray(exported) ? exported
@@ -175,13 +250,13 @@ export async function restoreWallet(request = {}) {
175
250
  } else windowReport.spent = null;
176
251
  } else { windowReport.spent = null; windowReport.spentWhy = "no-key-images"; }
177
252
  } catch { windowReport.spent = null; windowReport.spentWhy = "check-failed"; }
178
- } else if (requestWindow.nodeUrl) {
253
+ } else if (windowReport.used && requestWindow.nodeUrl) {
179
254
  windowReport.spent = null;
180
255
  windowReport.spentWhy = "no-key-images";
181
256
  }
182
257
  // A WINDOW WITH COINS IS THE ANSWER. A window with none falls through to the full scan unless the caller
183
258
  // explicitly turned the fallback off.
184
- if (windowTotal > 0n || windowUnlocked > 0n || requestWindow.fallback === false) {
259
+ if (windowReport.used && (windowTotal > 0n || windowUnlocked > 0n || requestWindow.fallback === false)) {
185
260
  return {
186
261
  total: windowTotal.toString(), unlocked: windowUnlocked.toString(), scanHeight, walletHeight: await heightOf(driver, "getHeight"),
187
262
  nodeHeight: await heightOf(driver, "getDaemonHeight"), passes: 1, lockedPass: false, window: windowReport,
@@ -194,7 +269,10 @@ export async function restoreWallet(request = {}) {
194
269
 
195
270
  // THE WINDOW DID NOT ANSWER: the full scan below is the fallback. Say so on the report, so the caller does not
196
271
  // have to infer it from `used: false` and a balance.
197
- if (windowReport) windowReport.fallback = true;
272
+ if (windowReport) {
273
+ windowReport.fallback = true;
274
+ if (!windowReport.fallbackWhy) windowReport.fallbackWhy = "empty-window";
275
+ }
198
276
  await step("setRestoreHeight", () => driver.setRestoreHeight(scanHeight));
199
277
  // A RESCAN IS REQUIRED, NOT OPTIONAL: without it the height only applies to a wallet that has not scanned yet,
200
278
  // and a wallet restored from a key has. A driver that cannot rescan is the caller's mistake, named here.
package/src/sweep.mjs CHANGED
@@ -209,30 +209,15 @@ export function createSweep({ http, config, call = null }) {
209
209
  const spendable = !!wallet.spendHalf;
210
210
  if (!spendable) missing.push("spendable");
211
211
 
212
- // THE RESTORE HEIGHT: first from the file, then from the app BY ADDRESS - exactly as the sweep page does it.
213
- // An unavailable app is a lack of data, not a refusal: the plan is returned anyway, and `missing` shows what
214
- // is lacking.
212
+ // THE RESTORE HEIGHT COMES FROM THE FILE OR THE CHAIN. The SDK does not ask a service by address: a static
213
+ // page and a widget must not tell their own host which deal they are recovering. A missing height is named in
214
+ // `missing`; the caller can pass one from `findSwaps` or the person can type it.
215
215
  let restoreHeight = Number(wallet.restoreHeight) || 0;
216
- let row = null;
217
- try {
218
- // THE PATH BY NAME, not by a string: the override comes through the `routes` option.
219
- const answer = await http.json(config.route("swaps"));
220
- const rows = Array.isArray(answer) ? answer : ((answer && answer.swaps) || []);
221
- row = rows.find((x) => String((x && x.address) || "") === String(address)) || null;
222
- } catch { row = null; }
223
- const fromApp = Number(row && row.restoreHeight) || 0;
224
- if (fromApp > 0) restoreHeight = fromApp;
225
216
  if (!(restoreHeight > 0)) missing.push("restore-height");
226
217
 
227
- // THE COUNTERPARTY'S REVEALED HALF - FROM THE CHAIN, VIA THE APP. Exactly as the sweep page does it: POST to
228
- // the named path, body { network, address }, where address is the ESCROW ADDRESS (not the Monero address).
229
- // The half is published by the settlement that moves the ETH: a claim or a refund. Before that no one has the
230
- // spend key, so "not revealed yet" is not an error but a state, and that is exactly where it lands in `missing`.
231
218
  // THE COUNTERPARTY'S REVEALED HALF - FROM THE CHAIN WHEN THE CALLER HAS A CHAIN READ. The half is public:
232
- // `claim` and `refund` store it in `revealedHalf()` and emit `HalfRevealed`. A recovery page served from a
233
- // static host has no `/escrow/revealed-half` route, so asking the app was the only path and a static page
234
- // could never withdraw. The app route stays as the fallback for a caller that has no chain read and did
235
- // configure an API base.
219
+ // `claim` and `refund` store it in `revealedHalf()` and emit `HalfRevealed`. There is no service fallback: a
220
+ // caller without a chain read gets `missing: ["revealed-half"]`, not a request to its own origin.
236
221
  let revealed = false;
237
222
  let revealedValue = null;
238
223
  const escrowAddress = wallet.escrowAddress || (payload.escrow && payload.escrow.address) || null;
@@ -246,17 +231,6 @@ export function createSweep({ http, config, call = null }) {
246
231
  }
247
232
  } catch { revealed = false; }
248
233
  }
249
- if (!revealed && escrowAddress && config && config.apiBase) {
250
- try {
251
- const half = await http.json(config.route("revealedHalf"), {
252
- method: "POST",
253
- headers: { "content-type": "application/json" },
254
- body: JSON.stringify({ network: payload.network, address: escrowAddress }),
255
- });
256
- revealed = !!(half && half.ok && half.revealed && half.half);
257
- if (revealed) revealedValue = String(half.half);
258
- } catch { revealed = false; }
259
- }
260
234
  if (!revealed) missing.push("revealed-half");
261
235
 
262
236
  return {
@@ -317,24 +291,6 @@ export function createSweep({ http, config, call = null }) {
317
291
  // SIGNING AND SENDING ARE DELIBERATELY SPLIT: between them the interface has time to show the person what will
318
292
  // leave, rather than telling them about it after sending. An empty remainder stops the withdrawal BEFORE
319
293
  // signing - otherwise the wallet would sign a transaction for zero and the refusal would look like success.
320
- // TELLING THE SERVICE ABOUT THE WITHDRAWAL - SEPARATELY AND REPEATABLY. The mark does not affect the transfer
321
- // (it is already on the network), so it can be repeated: the interface calls this even after `relay` if the
322
- // report did not go through then.
323
- async report(request) {
324
- if (!request || typeof request.swapId !== "string" || !request.swapId) throw new SdkError("bad-input", { field: "swapId" });
325
- if (typeof request.txid !== "string" || !request.txid) throw new SdkError("bad-input", { field: "txid" });
326
- try {
327
- const answer = await http.json(config.route("swept", { id: request.swapId }), {
328
- method: "POST",
329
- headers: { "content-type": "application/json" },
330
- body: JSON.stringify({ txid: request.txid }),
331
- });
332
- return { reported: !(answer && answer.ok === false) };
333
- } catch {
334
- // The service is unavailable - that is `reported: false`, not a withdrawal refusal: the money has already left.
335
- return { reported: false };
336
- }
337
- },
338
294
 
339
295
  // SIGNING WITHOUT SENDING - AS A SEPARATE STEP. The core HOLDS the signed transaction until the person's
340
296
  // decision: otherwise the interface could not show what exactly will leave, and would tell about it only after
@@ -410,14 +366,9 @@ export function createSweep({ http, config, call = null }) {
410
366
  try {
411
367
  const txids = await wallet.relay(signed);
412
368
  const list = Array.isArray(txids) ? txids : [];
413
- // MARKING THE WITHDRAWAL - AFTER SENDING AND NOT AFFECTING IT. The transfer is already on the network; the
414
- // report merely tells the service that the XMR left. So a failed report does NOT make the withdrawal
415
- // unsuccessful: it gives `reported: false`, so the interface offers to report again - by the same path as
416
- // here (`report`).
417
- const reported = plan.swapId && list.length
418
- ? (await api.report({ swapId: plan.swapId, txid: list[0] })).reported
419
- : false;
420
- return { txids: list, swept: plan.address, reported };
369
+ // NO SERVICE REPORT. The transfer is on the Monero network; the SDK does not ask the page's own host to
370
+ // record it, so the host cannot link the visitor's IP to the escrow and the withdrawal.
371
+ return { txids: list, swept: plan.address };
421
372
  } finally {
422
373
  await wallet.close();
423
374
  }
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.51.0";
9
+ export const SDK_VERSION = "0.51.2";