@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 +23 -0
- package/README.md +11 -1
- package/engine/core/chainSource.js +4 -60
- package/engine/core/config.js +8 -5
- package/engine/core/swap.js +9 -215
- package/engine/sdk/bridge.js +1 -1
- package/index.d.ts +18 -26
- package/package.json +1 -1
- package/src/http.mjs +2 -0
- package/src/index.mjs +1 -1
- package/src/public/recovery.d.ts +12 -1
- package/src/public/recovery.mjs +1 -0
- package/src/recovery-key.mjs +31 -18
- package/src/swaps.mjs +37 -17
- package/src/sweep-restore.mjs +104 -26
- package/src/sweep.mjs +8 -57
- package/src/version.mjs +1 -1
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: "
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
package/engine/core/config.js
CHANGED
|
@@ -908,14 +908,17 @@ export const MONERO = {
|
|
|
908
908
|
};
|
|
909
909
|
|
|
910
910
|
// ---------------------------------------------------------------------------
|
|
911
|
-
//
|
|
911
|
+
// A backend is OPTIONAL (app/): the SDK does not point requests at the page's own origin.
|
|
912
912
|
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
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
|
|
918
|
-
//
|
|
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
|
package/engine/core/swap.js
CHANGED
|
@@ -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,
|
|
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(
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
//
|
|
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
|
-
|
|
905
|
-
|
|
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
|
-
|
|
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
|
|
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") {
|
package/engine/sdk/bridge.js
CHANGED
|
@@ -122,7 +122,7 @@ function optionsFor(chain) {
|
|
|
122
122
|
return {
|
|
123
123
|
settlement: { chain: chain.id },
|
|
124
124
|
xmrNetwork: MONERO.networkType,
|
|
125
|
-
|
|
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
|
|
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.
|
|
1332
|
-
*
|
|
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
|
|
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
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 :
|
|
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,
|
package/src/public/recovery.d.ts
CHANGED
|
@@ -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. */
|
package/src/public/recovery.mjs
CHANGED
package/src/recovery-key.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
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
|
|
50
|
-
// ticker
|
|
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
|
|
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.
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
|
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
|
|
1008
|
-
* configuration (config.limits.watchPollMs/watchTimeoutMs
|
|
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
|
-
|
|
1118
|
-
|
|
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).
|
package/src/sweep-restore.mjs
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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 =
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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)
|
|
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
|
|
213
|
-
//
|
|
214
|
-
//
|
|
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`.
|
|
233
|
-
//
|
|
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
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
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.
|
|
9
|
+
export const SDK_VERSION = "0.51.2";
|