@ninsei-labs/sdk 0.51.1 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## 0.52.0 — 2026-10-26
2
+
3
+ THE NEXT CONTRACT VERSION IS WIRED IN.
4
+
5
+ - `arbitrum-sepolia` moves to factory `0x69b5a6878b542960af4d239ecc60ad1c9e3e6932`, cashier
6
+ `0x05b7433f66f2b75cc7ece9ec00af0fb810f9c9e0`, registry
7
+ `0x4707a6684b654fbd74372ec624efc0f825af0537` and implementation code hash
8
+ `0xc6cde36301b6551a6ca8bd2c47fbd5ffd01ffcdf22d2f6909be2b3b2dcf360b4`
9
+ (deployment block `323014007`).
10
+ - `OrderCreated` now carries `xmrAmount`. `findSwaps` reads it from the current event and reports
11
+ `xmrAmountReason: "event"`; orders from the previous event keep the clone word-18 fallback.
12
+ - The previous event topic is still accepted, so an old factory remains searchable.
13
+
14
+ ## 0.51.2 — 2026-10-22
15
+
16
+ - The withdrawal window falls back to the full scan when `scanTxs` or the balance read fails, and reports
17
+ `window.fallbackWhy: "window-scan"`.
18
+ - The SDK does not call a backend by default: the `/api/swaps` lookups, the `swept` report, the session calls and the
19
+ relative `apiBase` default are gone. A caller that needs a backend passes its base explicitly; a watch without
20
+ `verify` data is a named `no-backend` refusal.
21
+
1
22
  ## 0.51.1 — 2026-10-22
2
23
 
3
24
  A BUSY NODE NO LONGER ENDS A WITHDRAWAL, AND MISSING HEIGHT DATA HAS A NAME.
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
  });
@@ -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
@@ -363,35 +363,22 @@ export const CHAINS = [
363
363
  explorerName: "arbiscan (sepolia)",
364
364
  gasUsd: 0.01,
365
365
  routeType: "atomic",
366
- // THE DEMO'S MAIN NETWORK: the Ninsei escrow factory and the interface's fee cashier live here; the
367
- // ArrakisFeeRouter is gone. The factory is a FACT, not an intention:
368
- // 0x3eE25f12758ec28e5b3045d16132D89Cd0bf341F (height 321430239, transaction
369
- // 0x61da6d34c77dee1e1ada04dbbe9cbb1c86e5297acbc5a5b08320df20a2cf3ef6, code 2049 bytes, keccak256
370
- // 0xf490764213ae9569f9ef19a174e237f4a22c3ff6613756852571672ae7dea2a8); every order is an EIP-1167 clone and
371
- // the factory carries the pair (implementation(), implementationCodeHash()). The fee cashier is the second CREATE
372
- // of the same pass - 0x1123A3875775Da25FA181Af0d91EB278E94a5dfb (height 321430248, code 582 bytes, keccak256
373
- // 0xf5cb00c7974e6fab3f04dbd2cdc24976249dc4d55de2fa31524bce60205a1877). This release did NOT deploy a maker
374
- // registry (DEPLOY_REGISTRY=false): the registry is a separate contract, and the node must name an existing one
375
- // in RFQ_REGISTRY_ADDRESS until a fresh one is deployed and its keys are registered.
376
- // `npm run check:deployed -- --address=0x3eE25f12758ec28e5b3045d16132D89Cd0bf341F`).
377
- // PREVIOUS (the factory before claimTip): 0x1029D6bC53f6b9919F8ec6Ac922327F7e5A3DdF2 (height 318033203,
378
- // transaction 0x0f82c556f056f2dcaba5c12da75a749c257dfb1ccbf0eb6962a4e7f0119d166d, code 4226 bytes, keccak256
379
- // 0x4b515f600c641bffde054b3a98f4e26bd43d2dd2b2d8e60e8cfe36ee03686dd9). PREVIOUS (the ArrakisFeeRouter of
380
- // the previous rules, now removed): 0xaEB1c0455C76551dcEb2E6B1245B65F0458b0aE8 (height 314323871, code 8223
381
- // bytes, keccak256 0x90f26f641b48a2a630463195d4429b0818d29187563e09711a40811fcf08a0ba).
382
- // PREVIOUS (ceiling 3%, no early refund): 0xEa619bDC78989627093B7830990367B2ECE52CE5 (height 314309038,
383
- // code 8143 bytes). PREVIOUS live (router with a 10% ceiling): 0x9f856cd9c14fd9493961473b804039b09a4012ef
384
- // (height 313597131, code 8723 bytes). PREVIOUS before it (factory without a fee):
385
- // 0xcb54af9ec11344d448c7900ccb08530a23783259 (height 312222006, code 6035 bytes), and earlier ones still.
386
- // mode: "live" - the form sends REAL transactions: order creation TOGETHER WITH THE MONEY in one transaction
387
- // (createOrderAndFund; the escrow has no separate funding function - an order is born funded, see FACTORY_METHODS
388
- // in www/js/evm/factory.js and ESCROW_METHODS in escrow.js). The old lock(sealedHalf) and hashlock are gone: the
389
- // half is not sent but revealed by presentation. Switching back to "simulated" is one edit, to show a demo with
390
- // no money movement. cashier: the INTERFACE's fee cashier, named in quotes and now deployed (the second CREATE of
391
- // the same pass). implementationCodeHash: the escrow implementation the factory carries, checked against the chain.
392
- // deploymentBlock: the height the factory was created at; a log search for its orders starts here, not at zero,
393
- // because public RPCs cap a log query at about 10M blocks and would refuse a scan from genesis.
394
- escrow: { address: "0x3eE25f12758ec28e5b3045d16132D89Cd0bf341F", mode: "live", deploymentBlock: 321430239, cashier: "0x1123A3875775Da25FA181Af0d91EB278E94a5dfb", implementationCodeHash: "0xf97af2b4eed8c646d82bce001e52ff03c065d43127073f8ee52271b65c35d659" },
366
+ // THE DEMO'S MAIN NETWORK: the Ninsei escrow factory and the interface's fee cashier live here. The next
367
+ // contract version (contracts-testnet-2026.10.26) is a FACT, not an intention:
368
+ // factory 0x69b5a6878b542960af4d239ecc60ad1c9e3e6932 (height 323014007,
369
+ // transaction 0xbe8244da0cb1429edc92d18c7c8b5ba1c6b17c42a5806609d930a6496e918408);
370
+ // cashier 0x05b7433f66f2b75cc7ece9ec00af0fb810f9c9e0;
371
+ // registry 0x4707a6684b654fbd74372ec624efc0f825af0537 (this release deploys it);
372
+ // implementation 0x2E0214c6242fB794a4cC861D609Ecee544735170, codehash
373
+ // 0xc6cde36301b6551a6ca8bd2c47fbd5ffd01ffcdf22d2f6909be2b3b2dcf360b4.
374
+ // `OrderCreated` now carries xmrAmount; the SDK reads it from the event and keeps the clone fallback for the
375
+ // previous event. PREVIOUS factory before this version: 0x3eE25f12758ec28e5b3045d16132D89Cd0bf341F
376
+ // (height 321430239, cashier 0x1123A3875775Da25FA181Af0d91EB278E94a5dfb).
377
+ // mode: "live" - order creation TOGETHER WITH THE MONEY in one transaction (createOrderAndFund). cashier: the
378
+ // INTERFACE's fee cashier, named in quotes. implementationCodeHash: the escrow implementation the factory
379
+ // carries, checked against the chain. deploymentBlock: the height the factory was created at; a log search for
380
+ // its orders starts here, not at zero, because public RPCs cap a log query at about 10M blocks.
381
+ escrow: { address: "0x69b5a6878b542960af4d239ecc60ad1c9e3e6932", mode: "live", deploymentBlock: 323014007, cashier: "0x05b7433f66f2b75cc7ece9ec00af0fb810f9c9e0", implementationCodeHash: "0xc6cde36301b6551a6ca8bd2c47fbd5ffd01ffcdf22d2f6909be2b3b2dcf360b4" },
395
382
  tokens: [
396
383
  // Test USDC (Circle): decimals 6 and symbol "USDC" verified on chain by the same node.
397
384
  { symbol: "USDC", address: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", decimals: 6, usd: 1, cls: "ar-token-usdc" },
@@ -908,14 +895,17 @@ export const MONERO = {
908
895
  };
909
896
 
910
897
  // ---------------------------------------------------------------------------
911
- // The backend (app/): swap sessions and watching the Monero leg.
898
+ // A backend is OPTIONAL (app/): the SDK does not point requests at the page's own origin.
912
899
  //
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.
900
+ // A caller that needs one passes `apiBase` explicitly; without it the swap engine watches the chain and the maker,
901
+ // not a service. The legacy session calls were removed for that reason.
915
902
  // ---------------------------------------------------------------------------
916
903
  export const API = {
917
- base: "/api",
918
- // The Monero-leg source: 'app' - the real backend over HTTP, 'mock' - the demo simulator with no network.
904
+ // NO BACKEND BY DEFAULT. A page that needs one passes the base explicitly; nothing in the SDK points requests at
905
+ // the page's own origin. The legacy session calls were removed for the same reason: a host could link the visitor
906
+ // to the escrow from a request the widget never needed.
907
+ base: null,
908
+ // The Monero-leg source: 'app' - a backend the caller explicitly configured, 'mock' - the demo simulator.
919
909
  xmrSource: "app",
920
910
  pollMs: 5_000, // how often the front end polls a swap session
921
911
  // 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") {
@@ -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.
@@ -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
@@ -1303,14 +1303,8 @@ export interface SweepApi {
1303
1303
  * the person what will leave, BEFORE sending.
1304
1304
  */
1305
1305
  sign(request: SweepRunRequest): Promise<SweepSignResult>;
1306
- /** 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. */
1307
1307
  relay(request: SweepRelayRequest): Promise<SweepRunResult>;
1308
- /**
1309
- * TELL THE SERVICE ABOUT THE WITHDRAWAL. The mark does not affect the transfer - it is already on the network - so
1310
- * it can be repeated: the interface calls this even after `relay` if the report did not go through then. A
1311
- * connection refusal gives `reported: false`.
1312
- */
1313
- report(request: SweepReportRequest): Promise<SweepReportResult>;
1314
1308
  /** Perform the withdrawal as a whole (`sign` + `relay`). */
1315
1309
  run(request: SweepRunRequest): Promise<SweepRunResult>;
1316
1310
  }
@@ -1421,16 +1415,6 @@ export interface SweepWallet {
1421
1415
  driver(): unknown;
1422
1416
  }
1423
1417
 
1424
- /** The withdrawal mark: the swap identifier and the transfer hash. */
1425
- export interface SweepReportRequest {
1426
- readonly swapId: string;
1427
- readonly txid: string;
1428
- }
1429
-
1430
- export interface SweepReportResult {
1431
- readonly reported: boolean;
1432
- }
1433
-
1434
1418
  export interface SweepFromOrderRequest {
1435
1419
  /** The 12 words of the recovery key. */
1436
1420
  readonly mnemonic: string;
@@ -1585,12 +1569,6 @@ export interface SweepRelayRequest {
1585
1569
  export interface SweepRunResult {
1586
1570
  readonly txids: readonly string[];
1587
1571
  readonly swept: string;
1588
- /**
1589
- * Whether the service was told about the withdrawal. THE REPORT DOES NOT AFFECT THE TRANSFER - it is already on
1590
- * the network - so a failed report does not make the withdrawal unsuccessful: it gives `reported: false`, so the
1591
- * interface offers to report again.
1592
- */
1593
- readonly reported: boolean;
1594
1572
  }
1595
1573
 
1596
1574
  // --- Recovery// --- Recovery ------------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.51.1",
3
+ "version": "0.52.0",
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,
@@ -333,10 +333,11 @@ export interface FoundSwap {
333
333
  readonly payToken: string | null;
334
334
  /** Why `payToken` is absent. `not-on-chain` means the order event and clone carry no token; do not infer one. */
335
335
  readonly payTokenReason: "not-on-chain";
336
- /** The XMR side of the order in atomic units, read from the clone's condition word 18; null when no code reader
337
- * was passed or the contract is not the expected clone. */
336
+ /** The XMR side of the order in atomic units. The current factory emits it in `OrderCreated`; for an older event
337
+ * it is read from the clone's condition word 18, and null when no code reader was passed or the contract is not
338
+ * the expected clone. */
338
339
  readonly xmrAmount: string | null;
339
- readonly xmrAmountReason: "no-code-reader" | "code" | "not-clone" | null;
340
+ readonly xmrAmountReason: "event" | "no-code-reader" | "code" | "not-clone" | null;
340
341
  readonly index: number;
341
342
  readonly side: "locker" | "claimer";
342
343
  /** The Monero height a withdrawal may start from, derived from the order's block time. Null when the caller did
@@ -17,7 +17,7 @@ import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/b
17
17
  import { wordlist } from "@scure/bip39/wordlists/english.js";
18
18
  import { createHalves } from "../engine/atomic/halves.js";
19
19
  import { halfCommitmentHex } from "../engine/atomic/orderHalfCommit.js";
20
- import { ORDER_CREATED_TOPIC } from "../engine/evm/factory.js";
20
+ import { ORDER_CREATED_TOPIC, ORDER_CREATED_TOPICS } from "../engine/evm/factory.js";
21
21
  import { SdkError } from "./errors.mjs";
22
22
  import { probeMoneroNode } from "./probes.mjs";
23
23
  import { ed25519, keccak256, randomBytes, secp256k1 } from "./primitives.mjs";
@@ -359,10 +359,15 @@ function topicAddress(address) {
359
359
 
360
360
  function decodeOrderLog(log) {
361
361
  const topics = Array.isArray(log && log.topics) ? log.topics : [];
362
- if (topics.length < 4 || String(topics[0]).toLowerCase() !== ORDER_CREATED_TOPIC) return null;
362
+ const topic0 = String(topics[0] || "").toLowerCase();
363
+ if (topics.length < 4 || !ORDER_CREATED_TOPICS.includes(topic0)) return null;
363
364
  const data = String(log.data || "").replace(/^0x/, "");
364
- if (data.length < 64 * 2) return null;
365
+ const modern = topic0 === ORDER_CREATED_TOPIC;
366
+ if (data.length < 64 * (modern ? 13 : 12)) return null;
365
367
  const word = (i) => "0x" + data.slice(i * 64, (i + 1) * 64).toLowerCase();
368
+ // THE FIELD OFFSETS MOVED WHEN `xmrAmount` JOINED THE EVENT. The old topic has no XMR amount; its orders fall
369
+ // back to the clone read, and a caller can still tell why from `xmrAmountReason`.
370
+ const shift = modern ? 1 : 0;
366
371
  return {
367
372
  escrow: "0x" + String(topics[1]).slice(26).toLowerCase(),
368
373
  locker: "0x" + String(topics[2]).slice(26).toLowerCase(),
@@ -373,12 +378,13 @@ function decodeOrderLog(log) {
373
378
  edPointClaimer: word(3),
374
379
  edViewPointLocker: word(4),
375
380
  amount: BigInt(word(5)).toString(),
376
- readyBy: Number(BigInt(word(6))),
377
- t1: Number(BigInt(word(7))),
378
- salt: word(8),
379
- registry: "0x" + data.slice(9 * 64 + 24, 10 * 64),
380
- provider: "0x" + data.slice(10 * 64 + 24, 11 * 64),
381
- quoteKey: "0x" + data.slice(11 * 64 + 24, 12 * 64),
381
+ xmrAmount: modern ? BigInt(word(6)).toString() : null,
382
+ readyBy: Number(BigInt(word(6 + shift))),
383
+ t1: Number(BigInt(word(7 + shift))),
384
+ salt: word(8 + shift),
385
+ registry: "0x" + data.slice((9 + shift) * 64 + 24, (10 + shift) * 64),
386
+ provider: "0x" + data.slice((10 + shift) * 64 + 24, (11 + shift) * 64),
387
+ quoteKey: "0x" + data.slice((11 + shift) * 64 + 24, (12 + shift) * 64),
382
388
  transactionHash: log.transactionHash || null,
383
389
  blockNumber: log.blockNumber != null ? Number(log.blockNumber) : null,
384
390
  logIndex: log.logIndex != null ? Number(log.logIndex) : null,
@@ -431,7 +437,7 @@ async function readLogs(evmLogs, request) {
431
437
  export async function recoveryIndexTaken({ mnemonic, chainId, index, factory, evmLogs, fromBlock = 0, toBlock = "latest" } = {}) {
432
438
  if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
433
439
  const own = takerHalves({ mnemonic, chainId, index });
434
- const logs = await readLogs(evmLogs, { address: factory, topics: [ORDER_CREATED_TOPIC], fromBlock, toBlock });
440
+ const logs = await readLogs(evmLogs, { address: factory, topics: [ORDER_CREATED_TOPICS], fromBlock, toBlock });
435
441
  return logs.map(decodeOrderLog).filter(Boolean).some((order) =>
436
442
  order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ||
437
443
  order.commitHalfClaimer.toLowerCase() === own.spendCommitment.toLowerCase());
@@ -581,8 +587,8 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
581
587
  if (address) {
582
588
  const padded = topicAddress(address);
583
589
  const [asLocker, asClaimer] = await Promise.all([
584
- readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPIC, null, padded, null] }),
585
- readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPIC, null, null, padded] }),
590
+ readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS, null, padded, null] }),
591
+ readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS, null, null, padded] }),
586
592
  ]);
587
593
  const seen = new Set();
588
594
  for (const log of asLocker.concat(asClaimer)) {
@@ -592,7 +598,7 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
592
598
  logs.push(log);
593
599
  }
594
600
  } else {
595
- logs = await readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPIC] });
601
+ logs = await readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS] });
596
602
  }
597
603
  const orders = logs.map(decodeOrderLog).filter(Boolean);
598
604
  const found = [];
@@ -637,9 +643,14 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
637
643
  for (const order of found) {
638
644
  Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache, readersReason }));
639
645
  }
640
- // THE XMR AMOUNT IS IN THE CLONE, NOT IN THE LOG. A caller without a code reader gets null and a named reason;
641
- // an unreadable or differently-shaped contract does the same. This is data, not a withdrawal verdict.
646
+ // THE XMR AMOUNT COMES FROM THE EVENT IN THE CURRENT FACTORY. The previous event has no field; those orders fall
647
+ // back to the clone read, where word 18 is read only from a clone of the exact shape. This is data, not a
648
+ // withdrawal verdict.
642
649
  for (const order of found) {
650
+ if (order.xmrAmount !== null && order.xmrAmount !== undefined) {
651
+ order.xmrAmountReason = "event";
652
+ continue;
653
+ }
643
654
  if (typeof evmCode !== "function") {
644
655
  order.xmrAmount = null;
645
656
  order.xmrAmountReason = "no-code-reader";
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;
@@ -989,20 +989,21 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
989
989
  }
990
990
  }
991
991
 
992
- // 5. THE SERVER: RECORDING THE SWAP WITH US, THEN THE FACTORY WATCH RECORD FOR THE MONERO ADDRESS.
993
- // THE ORDER IS EXACTLY THIS: first the swap becomes known to the service (by it, the service checks the escrow
994
- // address against the CHAIN), and only then is the watch opened. The reverse order would leave a watch on a swap
995
- // the service does not know.
996
- // IF ANYTHING OF THIS FAILED - THE REFUSAL GOES OUT AS A CODE (server-unavailable / server-refused) with the step
997
- // and the service's reason: the funds are already locked, and this must not be hidden in silence or in "success".
998
- const server = await markOnServer(record.swap, escrowAddress, request.order);
999
- 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
+ }
1000
1001
  // THE STATE IS NAMED, NOT PASSED OFF AS COMPLETION: the swap reached exactly "waiting for XMR at the joint address",
1001
- // 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.
1002
1003
  return {
1003
1004
  id: record.swap.id || null,
1004
1005
  escrow: escrowAddress,
1005
- address: watch.address,
1006
+ address: (watch && watch.address) || (record.swapWallet && record.swapWallet.address) || null,
1006
1007
  step: "xmr-incoming",
1007
1008
  server,
1008
1009
  watch,
@@ -1013,8 +1014,9 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1013
1014
  /**
1014
1015
  * WAIT FOR THE XMR TO ARRIVE AT THE JOINT ADDRESS - the whole point of opening the watch.
1015
1016
  *
1016
- * The polling uses THE SAME request as the page's (GET /api/swaps/{id}), the rate and the timeout - from the
1017
- * 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.
1018
1020
  * IT ARRIVED - we return the FACT (the amount, the confirmations, the txid); WE DID NOT WAIT - a refusal with the
1019
1021
  * code `xmr-timeout`, separate from the others, so "the wait expired" is confused with neither a breakdown nor an
1020
1022
  * empty response. XMR ON THE ADDRESS BUT BARRED BY unlock_time is a THIRD answer with its own code `xmr-locked`
@@ -1056,6 +1058,11 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1056
1058
  : (request.deadline && request.deadline.readyBy !== undefined ? Number(request.deadline.readyBy) : null);
1057
1059
  if (readyBy) xmrArrival.assertTimeForConfirmations({ readyBySec: readyBy, nowSec: Math.floor(Date.now() / 1000), minConfirmations, blockTimeSec: limitOf("xmrBlockTimeSec", 120) });
1058
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" });
1059
1066
  const started = Date.now();
1060
1067
  // --- THE MAKER'S DECLINE: THE WAIT IS NOT STARTED AT ALL (issue #40). -------------------------------------
1061
1068
  // A decline leaves the escrow `refunded`, so nothing in the state says "do not wait any longer": without
@@ -1123,8 +1130,12 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
1123
1130
  let last = null;
1124
1131
  for (;;) {
1125
1132
  await readDeclineLogs();
1126
- const body = await readWatch(id);
1127
- 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);
1128
1139
  // THE DECLINE IS DECIDED BEFORE THE ARRIVAL IS LOOKED AT, and before the "foreign response" refusal below:
1129
1140
  // a declined swap's answer carries no `receivedXmr` at all, and refusing it as a bad response would name
1130
1141
  // the wrong thing (the state is known - it is a decline, not a broken answer).
@@ -202,15 +202,33 @@ export async function restoreWallet(request = {}) {
202
202
  }
203
203
  windowReport.txs = hashes.length;
204
204
  if (hashes.length && typeof driver.scanTxs === "function") {
205
- await step("setRestoreHeight", () => driver.setRestoreHeight(tipHeight));
206
- await step("scanTxs", () => driver.scanTxs(hashes));
207
- try { await wallet.sync(); } catch { /* the balance below decides */ }
208
- windowTotal = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
209
- windowUnlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
210
- 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
+ }
211
229
  // THE KEY IMAGES OF THE WALLET, CHECKED AT THE NODE. `scanTxs` finds an output; whether the chain already
212
- // spent it is a separate fact, and the caller must not sign on a guess.
213
- 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") {
214
232
  try {
215
233
  const exported = await driver.getKeyImages();
216
234
  const images = Array.isArray(exported) ? exported
@@ -232,13 +250,13 @@ export async function restoreWallet(request = {}) {
232
250
  } else windowReport.spent = null;
233
251
  } else { windowReport.spent = null; windowReport.spentWhy = "no-key-images"; }
234
252
  } catch { windowReport.spent = null; windowReport.spentWhy = "check-failed"; }
235
- } else if (requestWindow.nodeUrl) {
253
+ } else if (windowReport.used && requestWindow.nodeUrl) {
236
254
  windowReport.spent = null;
237
255
  windowReport.spentWhy = "no-key-images";
238
256
  }
239
257
  // A WINDOW WITH COINS IS THE ANSWER. A window with none falls through to the full scan unless the caller
240
258
  // explicitly turned the fallback off.
241
- if (windowTotal > 0n || windowUnlocked > 0n || requestWindow.fallback === false) {
259
+ if (windowReport.used && (windowTotal > 0n || windowUnlocked > 0n || requestWindow.fallback === false)) {
242
260
  return {
243
261
  total: windowTotal.toString(), unlocked: windowUnlocked.toString(), scanHeight, walletHeight: await heightOf(driver, "getHeight"),
244
262
  nodeHeight: await heightOf(driver, "getDaemonHeight"), passes: 1, lockedPass: false, window: windowReport,
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.1";
9
+ export const SDK_VERSION = "0.52.0";