@ninsei-labs/sdk 0.39.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 +866 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/engine/atomic/dleq2.js +330 -0
- package/engine/atomic/halfenc.js +205 -0
- package/engine/atomic/halves.js +119 -0
- package/engine/atomic/order-client.js +57 -0
- package/engine/atomic/order.js +132 -0
- package/engine/atomic/orderContext.js +70 -0
- package/engine/atomic/orderHalfCommit.js +29 -0
- package/engine/core/chainSource.js +306 -0
- package/engine/core/clock.js +40 -0
- package/engine/core/config.js +885 -0
- package/engine/core/finality.js +93 -0
- package/engine/core/format.js +168 -0
- package/engine/core/preSignGuards.js +495 -0
- package/engine/core/rfqSource.js +193 -0
- package/engine/core/sellWindow.js +43 -0
- package/engine/core/store.js +100 -0
- package/engine/core/swap.js +1153 -0
- package/engine/evm/amounts.js +131 -0
- package/engine/evm/auth.js +156 -0
- package/engine/evm/claimGas.js +87 -0
- package/engine/evm/depositor.js +127 -0
- package/engine/evm/dex.js +836 -0
- package/engine/evm/escrow.js +208 -0
- package/engine/evm/factory.js +149 -0
- package/engine/evm/fees.js +90 -0
- package/engine/evm/funding.js +309 -0
- package/engine/evm/gasReserve.js +88 -0
- package/engine/evm/index.js +123 -0
- package/engine/evm/injected.js +175 -0
- package/engine/evm/permit.js +125 -0
- package/engine/evm/prices.js +179 -0
- package/engine/evm/session.js +777 -0
- package/engine/mock/chain.js +98 -0
- package/engine/monero/address.js +265 -0
- package/engine/monero/halvesSweep.js +68 -0
- package/engine/monero/node.js +110 -0
- package/engine/monero/scan.js +325 -0
- package/engine/monero/swapKeys.js +69 -0
- package/engine/monero/unlock.js +46 -0
- package/engine/monero/wallet.js +168 -0
- package/engine/recovery/recoveryFile.js +363 -0
- package/engine/recovery/restore.js +176 -0
- package/engine/sdk/bridge.js +609 -0
- package/engine/ui/dom.js +90 -0
- package/engine/ui/walletModal.js +132 -0
- package/index.d.ts +1417 -0
- package/package.json +74 -0
- package/src/actions.mjs +72 -0
- package/src/adapters/evm-wallet.mjs +126 -0
- package/src/adapters/index.mjs +21 -0
- package/src/adapters/local-storage.mjs +32 -0
- package/src/adapters/monero-wallet.mjs +185 -0
- package/src/adapters/monero.mjs +51 -0
- package/src/adapters/node-test-evm-wallet.mjs +345 -0
- package/src/codes.mjs +188 -0
- package/src/config.mjs +277 -0
- package/src/engine.mjs +53 -0
- package/src/errors.mjs +24 -0
- package/src/http.mjs +72 -0
- package/src/index.mjs +256 -0
- package/src/legs/evm.mjs +118 -0
- package/src/legs/index.mjs +50 -0
- package/src/levelsWalkSpec.mjs +183 -0
- package/src/lock.mjs +140 -0
- package/src/order.mjs +212 -0
- package/src/preflight.mjs +137 -0
- package/src/primitives.mjs +47 -0
- package/src/quoteEip712.mjs +35 -0
- package/src/quoteEip712Spec.mjs +214 -0
- package/src/quotes.mjs +465 -0
- package/src/record.mjs +65 -0
- package/src/recovery.mjs +40 -0
- package/src/sim.mjs +37 -0
- package/src/swap-flow.mjs +492 -0
- package/src/swaps.mjs +935 -0
- package/src/sweep.mjs +222 -0
- package/src/wallet.mjs +164 -0
- package/src/xmr-arrival.mjs +289 -0
package/src/quotes.mjs
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// LIVE INDICATIVE QUOTES: the SDK's own quote client.
|
|
2
|
+
//
|
|
3
|
+
// WHY OUR OWN AND NOT THE ENGINE'S. The package must work without `www/js`: the consumer does not have it. The
|
|
4
|
+
// rules for reading the quote book are a specification (docs 41 and 54), not foreign code, so they are embodied
|
|
5
|
+
// here anew and pinned by a run on fixtures next to the engine implementation. For now the demo screen computes
|
|
6
|
+
// with its own copy: it is a test bench, and it is not scary for it to diverge from the SDK - the bench exists
|
|
7
|
+
// precisely to notice that.
|
|
8
|
+
//
|
|
9
|
+
// WHAT MUST NOT BE LOST HERE (all three rules cost the project mistakes):
|
|
10
|
+
// 1) a row has TWO rates: rateQuote - as written in the pair, rate - how much non-XMR per 1 XMR. This is not
|
|
11
|
+
// duplication but different orientations; zero means "no direction", and it is NEVER divided and never
|
|
12
|
+
// becomes a price;
|
|
13
|
+
// 2) the size is requested in the units of the pair's ASSET (the first element): for a buy that is XMR, for a
|
|
14
|
+
// sell - the token; a size outside the provider's grid is not accepted, so the size is snapped to the grid
|
|
15
|
+
// DOWN, not to the nearest;
|
|
16
|
+
// 3) a quote older than its own ttlMs is not shown, and a row that contradicts itself (the named sides disagree
|
|
17
|
+
// with the pair's name) is not read at all.
|
|
18
|
+
import { fail } from "./errors.mjs";
|
|
19
|
+
import * as engine from "./engine.mjs";
|
|
20
|
+
import { orderQuoteMissingFields, recoverOrderQuoteKey } from "./quoteEip712.mjs";
|
|
21
|
+
// THE CHAIN READ NEEDED TO ASK THE REGISTRY WHO OWNS THE KEY. The selector is COMPUTED from the signature, not
|
|
22
|
+
// remembered: a single wrong hex digit would call another function and return a zero address - and a zero
|
|
23
|
+
// address is exactly the answer "nobody owns this key", which the caller would take as the truth.
|
|
24
|
+
import { keccak256 } from "./primitives.mjs";
|
|
25
|
+
|
|
26
|
+
const utf8 = (value) => new TextEncoder().encode(String(value));
|
|
27
|
+
const bytesToHexQ = (bytes) => { let out = "0x"; for (const b of bytes) out += b.toString(16).padStart(2, "0"); return out; };
|
|
28
|
+
const providerOfSelector = () => bytesToHexQ(keccak256(utf8("providerOf(address)")).slice(0, 4));
|
|
29
|
+
const providerOfData = (key) => providerOfSelector() + "0".repeat(24) + String(key).toLowerCase().replace(/^0x/, "");
|
|
30
|
+
// THE FACTORY IS READ FROM THE RECORD, NOT FROM SETTINGS: `factoryOf(provider)` answers the factory the
|
|
31
|
+
// provider named in its record (#103). Selected by a COMPUTED selector, like providerOf.
|
|
32
|
+
const factoryOfSelector = () => bytesToHexQ(keccak256(utf8("factoryOf(address)")).slice(0, 4));
|
|
33
|
+
const factoryOfData = (provider) => factoryOfSelector() + "0".repeat(24) + String(provider).toLowerCase().replace(/^0x/, "");
|
|
34
|
+
const hexToBytesQ = (hex) => {
|
|
35
|
+
const h = String(hex == null ? "" : hex).replace(/^0x/, "");
|
|
36
|
+
if (h.length % 2) return null;
|
|
37
|
+
const out = new Uint8Array(h.length / 2);
|
|
38
|
+
for (let i = 0; i < out.length; i++) { const b = parseInt(h.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(b)) return null; out[i] = b; }
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
41
|
+
const ZERO_ADDRESS = "0x" + "00".repeat(20);
|
|
42
|
+
const addressFromWord = (raw) => {
|
|
43
|
+
const h = String(raw == null ? "" : raw).replace(/^0x/, "");
|
|
44
|
+
return h.length >= 40 ? "0x" + h.slice(-40).toLowerCase() : null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const num = (v) => (v === undefined || v === null || v === "" ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
|
|
48
|
+
const pairAsset = (pair) => String(pair || "").split("/")[0].toUpperCase();
|
|
49
|
+
const pairCurrency = (pair) => String(pair || "").split("/")[1] ? String(pair).split("/")[1].toUpperCase() : "";
|
|
50
|
+
|
|
51
|
+
/** The pair the user sees: buying XMR - looking at XMR/<token>, selling - at the reverse. */
|
|
52
|
+
export const pairFacingUser = (direction, token) =>
|
|
53
|
+
(direction === "sell" ? String(token).toUpperCase() + "/XMR" : "XMR/" + String(token).toUpperCase());
|
|
54
|
+
|
|
55
|
+
/** Reading one quote row. The shape comes from the specification; networks are NOT filled in. */
|
|
56
|
+
export function readQuote(pair, raw, owner = {}, data = {}) {
|
|
57
|
+
const q = raw && typeof raw === "object" ? raw : {};
|
|
58
|
+
const text = String(pair || q.pair || "").trim().toUpperCase();
|
|
59
|
+
if (!text.includes("/")) return null;
|
|
60
|
+
const statedAsset = q.asset ? String(q.asset).toUpperCase() : null;
|
|
61
|
+
const statedCurrency = q.currency ? String(q.currency).toUpperCase() : null;
|
|
62
|
+
const asset = pairAsset(text);
|
|
63
|
+
const currency = pairCurrency(text);
|
|
64
|
+
const givenRateQuote = num(q.rateQuote);
|
|
65
|
+
const givenRate = num(q.rate);
|
|
66
|
+
const rateQuote = givenRateQuote !== null ? givenRateQuote : givenRate;
|
|
67
|
+
let rate = null;
|
|
68
|
+
if (givenRateQuote !== null) rate = givenRate;
|
|
69
|
+
else if (rateQuote !== null) rate = currency === "XMR" ? (rateQuote > 0 ? 1 / rateQuote : null) : rateQuote;
|
|
70
|
+
const contradicts = (statedAsset && statedAsset !== asset) || (statedCurrency && statedCurrency !== currency);
|
|
71
|
+
const at = num(q.at);
|
|
72
|
+
return {
|
|
73
|
+
providerId: String(owner.providerId || q.providerId || "").trim(),
|
|
74
|
+
displayName: owner.displayName || q.displayName || null,
|
|
75
|
+
// THE PROVIDER'S MATURITY LEVEL RIDES WITH THE OFFER: a person chooses not only by price but also by how long
|
|
76
|
+
// to wait. No field - no value either, not an invented default.
|
|
77
|
+
finality: owner.finality || q.finality || null,
|
|
78
|
+
pair: text,
|
|
79
|
+
asset: statedAsset || asset,
|
|
80
|
+
currency: statedCurrency || currency,
|
|
81
|
+
assetNetwork: q.assetNetwork ? String(q.assetNetwork) : null,
|
|
82
|
+
currencyNetwork: q.currencyNetwork ? String(q.currencyNetwork) : null,
|
|
83
|
+
rateQuote,
|
|
84
|
+
rate,
|
|
85
|
+
min: num(q.min),
|
|
86
|
+
max: num(q.max),
|
|
87
|
+
step: num(q.step),
|
|
88
|
+
ttlMs: num(q.ttlMs) && num(q.ttlMs) > 0 ? num(q.ttlMs) : null,
|
|
89
|
+
at,
|
|
90
|
+
why: q.why ? String(q.why) : null,
|
|
91
|
+
disabled: q.disabled === true || (givenRate !== null && !(givenRate > 0)),
|
|
92
|
+
// NETWORKS NOT NAMED / FILLED IN BY THE RECEIVER - this is a FACT ABOUT THE QUOTE, and it is visible apart
|
|
93
|
+
// from "named and matched": the screen must say "nothing to check against" rather than attribute our network
|
|
94
|
+
// to the quote.
|
|
95
|
+
networksUnnamed: q.networksUnnamed === true,
|
|
96
|
+
networksDerived: q.networksDerived === true,
|
|
97
|
+
seq: num(q.seq) !== null ? num(q.seq) : (num(owner.seq) !== null ? num(owner.seq) : num(data.seq)),
|
|
98
|
+
signature: q.signature || owner.signature || null,
|
|
99
|
+
keyId: q.keyId || null,
|
|
100
|
+
networkStated: Boolean(q.assetNetwork || q.currencyNetwork),
|
|
101
|
+
contradicts,
|
|
102
|
+
ageMs: at === null ? null : Math.max(0, Date.now() - at),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parsing the book in one place. The envelope is accepted in three forms (a map by pairs, a list of rows, a list
|
|
108
|
+
* of providers) and is brought to a single list: the screen must not know which form the answer came in.
|
|
109
|
+
* Refusals are named separately from offers: "no direction" and "the quote is stale" are not emptiness.
|
|
110
|
+
*/
|
|
111
|
+
export function readBook(data, now = Date.now()) {
|
|
112
|
+
const quotes = data && data.quotes;
|
|
113
|
+
const rows = [];
|
|
114
|
+
const add = (pair, raw, owner) => { const row = readQuote(pair, raw, owner, data || {}); if (row) rows.push(row); };
|
|
115
|
+
if (Array.isArray(quotes)) {
|
|
116
|
+
for (const row of quotes) {
|
|
117
|
+
if (!row || typeof row !== "object") continue;
|
|
118
|
+
const nested = row.quotes && !Array.isArray(row.quotes) && typeof row.quotes === "object" ? row.quotes : null;
|
|
119
|
+
if (nested) for (const [pair, q] of Object.entries(nested)) add(pair, q, row);
|
|
120
|
+
else add(row.pair, row, row);
|
|
121
|
+
}
|
|
122
|
+
} else if (quotes && typeof quotes === "object") {
|
|
123
|
+
for (const [pair, q] of Object.entries(quotes)) add(pair, q, q && typeof q === "object" ? q : {});
|
|
124
|
+
}
|
|
125
|
+
// ONE QUOTE PER PAIR PER PROVIDER: if several arrived, we keep the fresh one rather than choosing arbitrarily
|
|
126
|
+
// (these are two different offers under one name).
|
|
127
|
+
const byKey = new Map();
|
|
128
|
+
for (const row of rows) {
|
|
129
|
+
const key = (row.providerId || "?") + "|" + row.pair;
|
|
130
|
+
const prev = byKey.get(key);
|
|
131
|
+
if (!prev || Number(row.at) > Number(prev.at)) byKey.set(key, row);
|
|
132
|
+
}
|
|
133
|
+
const offers = [];
|
|
134
|
+
const refused = [];
|
|
135
|
+
for (const row of byKey.values()) {
|
|
136
|
+
if (row.contradicts) { refused.push({ ...row, code: "malformed" }); continue; }
|
|
137
|
+
if (row.rateQuote === null && row.rate === null) { refused.push({ ...row, code: "malformed" }); continue; }
|
|
138
|
+
if (row.disabled || !(row.rate > 0)) { refused.push({ ...row, code: "disabled" }); continue; }
|
|
139
|
+
if (row.ttlMs && row.ageMs !== null && row.ageMs > row.ttlMs) { refused.push({ ...row, code: "stale" }); continue; }
|
|
140
|
+
offers.push(row);
|
|
141
|
+
}
|
|
142
|
+
const best = offers.slice().sort((a, b) => b.rate - a.rate)[0] || null;
|
|
143
|
+
return { offers, refused, best, at: now };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The size is snapped to the provider's grid DOWN. A size refusal is not aligned: a size outside the range is a
|
|
148
|
+
* provider's refusal and must reach the interface as a code, not turn into permission.
|
|
149
|
+
*/
|
|
150
|
+
export function snapToGrid(size, { min = null, max = null, step = null } = {}) {
|
|
151
|
+
const s = Number(size);
|
|
152
|
+
if (!Number.isFinite(s) || s <= 0) return { size: null, asked: size, snapped: false, code: "bad-size" };
|
|
153
|
+
const st = Number(step);
|
|
154
|
+
const lo = Number.isFinite(Number(min)) && min !== null && min !== "" ? Number(min) : 0;
|
|
155
|
+
const hi = Number.isFinite(Number(max)) && max !== null && max !== "" ? Number(max) : null;
|
|
156
|
+
if (s < lo) return { size: s, asked: s, snapped: false, code: "below-min" };
|
|
157
|
+
if (hi !== null && s > hi) return { size: s, asked: s, snapped: false, code: "above-max" };
|
|
158
|
+
if (!Number.isFinite(st) || st <= 0) return { size: s, asked: s, snapped: false, code: null };
|
|
159
|
+
const steps = Math.floor((s - lo) / st + 1e-9);
|
|
160
|
+
let snapped = Number((lo + steps * st).toFixed(12));
|
|
161
|
+
if (snapped < lo) snapped = lo;
|
|
162
|
+
return { size: snapped, asked: s, snapped: Math.abs(snapped - s) > 1e-12, code: null };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What the user will receive and what they pay for it, by the best offer and its rate.
|
|
167
|
+
* buy: they pay with the token and receive XMR, so the size (the ASSET of the pair XMR/<token>) is XMR.
|
|
168
|
+
* sell: they give XMR and receive the token, the ASSET of the pair <token>/XMR is the token itself.
|
|
169
|
+
*/
|
|
170
|
+
export function derivedFor(offer, direction, amount, now = Date.now()) {
|
|
171
|
+
const rate = offer && Number.isFinite(Number(offer.rate)) && Number(offer.rate) > 0 ? Number(offer.rate) : null;
|
|
172
|
+
const size = direction === "sell" ? (rate !== null && amount > 0 ? amount * rate : null)
|
|
173
|
+
: (rate !== null && amount > 0 ? amount / rate : null);
|
|
174
|
+
if (size === null) return { rate, size: null, unit: null, code: "no-offer", xmr: null };
|
|
175
|
+
const settled = snapToGrid(size, offer || {});
|
|
176
|
+
return {
|
|
177
|
+
rate,
|
|
178
|
+
size: settled.size,
|
|
179
|
+
unit: direction === "sell" ? String(offer.currency || "").toUpperCase() || null : "XMR",
|
|
180
|
+
snapped: settled.snapped,
|
|
181
|
+
code: settled.code,
|
|
182
|
+
// HOW MUCH XMR FOR THE REQUESTED SIZE. For a buy this is the size, for a sell - the amount the person gives.
|
|
183
|
+
xmr: direction === "sell" ? amount : (Number.isFinite(Number(settled.size)) ? Number(settled.size) : null),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// THE ROW FIELDS THAT GO OUT. This is a translation of the SHAPE, not a recomputation: the values are taken from
|
|
188
|
+
// the read row and are not changed. The fields are listed explicitly (and not `...row`) so that internal parsing
|
|
189
|
+
// markers do not leak out. Selection and priority are done by readBook above: a row gets here only if it has
|
|
190
|
+
// already been recognised as an offer.
|
|
191
|
+
const compactOffer = (o) => ({
|
|
192
|
+
providerId: o.providerId, displayName: o.displayName, pair: o.pair, rate: o.rate, rateQuote: o.rateQuote,
|
|
193
|
+
min: o.min, max: o.max, step: o.step, ttlMs: o.ttlMs, at: o.at, seq: o.seq,
|
|
194
|
+
// THE MATURITY LEVEL IS PART OF THE CHOICE: one provider gives a better price but only on finalized, another is
|
|
195
|
+
// pricier but faster. No field - null, not an invented default.
|
|
196
|
+
finality: o.finality || null,
|
|
197
|
+
// THE PAIR'S SIDES AND BOTH NETWORKS: the network is part of the market's identity, and the display must
|
|
198
|
+
// distinguish arbitrum from arbitrum-sepolia. `networks` is kept as the assembled view of the same values.
|
|
199
|
+
asset: o.asset, currency: o.currency,
|
|
200
|
+
assetNetwork: o.assetNetwork, currencyNetwork: o.currencyNetwork, networkStated: o.networkStated,
|
|
201
|
+
networksDerived: o.networksDerived === true, networksUnnamed: o.networksUnnamed === true,
|
|
202
|
+
// THE REASON (in the provider's words), THE PACKAGE'S SIGNATURE AND KEY, THE AGE BY ITS OWN MARK.
|
|
203
|
+
why: o.why, signature: o.signature, keyId: o.keyId, ageMs: o.ageMs,
|
|
204
|
+
networks: { asset: o.assetNetwork, currency: o.currencyNetwork, stated: o.networkStated },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// A REFUSAL IS THE SAME ROW PLUS A REASON CODE. The fields are not trimmed: the interface needs the range and
|
|
208
|
+
// the price of the failed row to tell the person WHY there is no offer (and not just "none").
|
|
209
|
+
const compactRefusal = (r) => ({ ...compactOffer(r), code: r.code, why: r.why });
|
|
210
|
+
|
|
211
|
+
export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, providerAllowed = null, knownFactoryCodes = null }) {
|
|
212
|
+
// THE INTERFACE'S FACTORY POLICY (#103): the code hashes of factories it is willing to trade through
|
|
213
|
+
// (extcodehash semantics = keccak256 of the runtime code). An EMPTY list is "no policy" (like
|
|
214
|
+
// allowedProviders); a NON-EMPTY one refuses any factory whose code is not on it. Lower-cased once.
|
|
215
|
+
const factoryPolicy = Array.isArray(knownFactoryCodes) && knownFactoryCodes.length
|
|
216
|
+
? knownFactoryCodes.map((h) => String(h).toLowerCase()).filter(Boolean)
|
|
217
|
+
: null;
|
|
218
|
+
const listeners = new Set();
|
|
219
|
+
let timer = null;
|
|
220
|
+
let size = 1; // the request size in units of the pair's ASSET; we start from one, as the engine does
|
|
221
|
+
let state = null;
|
|
222
|
+
let request = { direction: "buy", token: null, amount: null };
|
|
223
|
+
let inFlight = false;
|
|
224
|
+
|
|
225
|
+
const emit = () => { for (const handler of listeners) handler(state); };
|
|
226
|
+
|
|
227
|
+
const tick = async () => {
|
|
228
|
+
if (inFlight) return;
|
|
229
|
+
inFlight = true;
|
|
230
|
+
try {
|
|
231
|
+
// THE REQUEST SIZE. Usually it is derived from the person's amount (amount) and refined on the next tick.
|
|
232
|
+
// But the caller can name it HIMSELF (size) - then it is taken AS IS rather than derived anew: it is the
|
|
233
|
+
// same size the interface has already aligned to the provider's grid. There is no second size rule here.
|
|
234
|
+
const explicit = Number.isFinite(Number(request.size)) && Number(request.size) > 0;
|
|
235
|
+
const asked = explicit ? Number(request.size) : size;
|
|
236
|
+
const data = await http.json("/rate?amount=" + encodeURIComponent(asked) + "&side=" + encodeURIComponent(request.direction));
|
|
237
|
+
const book = readBook(data, now());
|
|
238
|
+
const offer = book.best;
|
|
239
|
+
const amount = Number.isFinite(Number(request.amount)) ? Number(request.amount) : null;
|
|
240
|
+
// The size derived from the person's amount becomes the size of the NEXT request: the price does not depend
|
|
241
|
+
// on volume, and the volume is needed by the provider to say whether it takes such a size. With a named size
|
|
242
|
+
// there is nothing to derive - it stays what it was asked as.
|
|
243
|
+
const derived = offer && amount !== null && !explicit ? derivedFor(offer, request.direction, amount, now()) : null;
|
|
244
|
+
if (derived && derived.size !== null) size = derived.size;
|
|
245
|
+
// THE UNIT with a named size: a buy is measured in XMR (the ASSET of the pair XMR/<token>), a sell - in the token.
|
|
246
|
+
const unitFromOffer = offer ? (String(offer.currency || "").toUpperCase() || null) : null;
|
|
247
|
+
const settled = explicit ? (offer ? snapToGrid(asked, offer) : null) : null;
|
|
248
|
+
state = {
|
|
249
|
+
direction: request.direction,
|
|
250
|
+
token: request.token,
|
|
251
|
+
amount,
|
|
252
|
+
size: explicit ? asked : size,
|
|
253
|
+
unit: explicit ? (request.direction === "sell" ? unitFromOffer : "XMR") : (derived ? derived.unit : null),
|
|
254
|
+
rate: explicit ? (offer ? Number(offer.rate) : null) : (derived ? derived.rate : null),
|
|
255
|
+
xmr: derived ? derived.xmr : null,
|
|
256
|
+
at: now(),
|
|
257
|
+
providerId: offer ? offer.providerId : null,
|
|
258
|
+
sizeOk: explicit ? Boolean(settled && settled.code === null) : Boolean(derived && derived.code === null),
|
|
259
|
+
sizeCode: explicit ? (settled ? settled.code : "no-offer") : (derived ? derived.code : "no-offer"),
|
|
260
|
+
seq: book.offers.length ? (book.offers.find((o) => o.seq !== null) || {}).seq ?? null : null,
|
|
261
|
+
// THE BEST, AS THE SERVICE NAMED IT (for the requested volume and side). The core does NOT recompute it:
|
|
262
|
+
// these are response data the interface needs to confirm its own "best" mark by pair and provider.
|
|
263
|
+
best: data && data.best && typeof data.best === "object"
|
|
264
|
+
? { providerId: String(data.best.providerId || ""), pair: String(data.best.pair || "") } : null,
|
|
265
|
+
offers: book.offers.map(compactOffer),
|
|
266
|
+
refused: book.refused.map(compactRefusal),
|
|
267
|
+
references: Array.isArray(data && data.references) ? data.references.map((r) => ({ source: r.source, value: num(r.value), at: num(r.at) })) : [],
|
|
268
|
+
ok: book.offers.length > 0,
|
|
269
|
+
};
|
|
270
|
+
emit();
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// A REFUSAL IS NOT REPLACED BY PAST NUMBERS: showing a stale price as live is worse than saying the connection
|
|
273
|
+
// is gone. The data is kept, but ok is cleared, and the interface sees it.
|
|
274
|
+
state = { ...(state || { direction: request.direction, token: request.token, amount: null, size, unit: null, rate: null, xmr: null,
|
|
275
|
+
at: now(), providerId: null, sizeOk: false, sizeCode: null, seq: null, best: null, offers: [], refused: [], references: [] }),
|
|
276
|
+
at: now(), ok: false, error: { code: error && error.code === "server-unavailable" ? "quote-unavailable" : "unknown" } };
|
|
277
|
+
emit();
|
|
278
|
+
} finally {
|
|
279
|
+
inFlight = false;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const restart = () => {
|
|
284
|
+
if (timer) clearInterval(timer);
|
|
285
|
+
timer = null;
|
|
286
|
+
if (listeners.size > 0) {
|
|
287
|
+
void tick();
|
|
288
|
+
timer = setInterval(() => { void tick(); }, config.limits.quotePollMs);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
watch(next, onSnapshot) {
|
|
294
|
+
if (typeof onSnapshot !== "function") fail("bad-input", { field: "onSnapshot" });
|
|
295
|
+
const direction = next && next.direction === "sell" ? "sell" : "buy";
|
|
296
|
+
const token = next && typeof next.token === "string" && next.token ? next.token.toLowerCase() : config.settlement.chain.nativeSymbol.toLowerCase();
|
|
297
|
+
request = {
|
|
298
|
+
direction,
|
|
299
|
+
token,
|
|
300
|
+
amount: next && next.amount !== undefined ? next.amount : null,
|
|
301
|
+
// A NAMED SIZE (in units of `unit`): an interface that has already aligned the volume to the provider's
|
|
302
|
+
// grid hands it here, and the core asks for EXACTLY it rather than deriving it from the amount anew.
|
|
303
|
+
size: next && Number.isFinite(Number(next.size)) && Number(next.size) > 0 ? Number(next.size) : null,
|
|
304
|
+
};
|
|
305
|
+
size = 1;
|
|
306
|
+
state = null;
|
|
307
|
+
listeners.add(onSnapshot);
|
|
308
|
+
restart();
|
|
309
|
+
return () => {
|
|
310
|
+
listeners.delete(onSnapshot);
|
|
311
|
+
if (listeners.size === 0) {
|
|
312
|
+
if (timer) clearInterval(timer);
|
|
313
|
+
timer = null;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
// WHAT THE SDK KNOWS RIGHT NOW: a snapshot without waiting. Needed by an interface that subscribed later than
|
|
318
|
+
// the first poll happened.
|
|
319
|
+
snapshot: () => state,
|
|
320
|
+
// A FIRM QUOTE FOR AN ORDER. It carries a commitment under keys, so THE PROVIDER'S SIGNATURE IS CONFIRMED
|
|
321
|
+
// HERE, BY RECOVERING THE ADDRESS FROM THE EIP-712 SIGNATURE - not taken on trust from a flag the backend
|
|
322
|
+
// reports. A quote that cannot be verified is not "probably fine": a missing field, a missing signature or
|
|
323
|
+
// an unusable domain is REFUSED, never accepted (the rule the old flag carried is kept). The digest is
|
|
324
|
+
// built from the quote's own fields, so nothing signed can be swapped afterwards; the named quoteKey is
|
|
325
|
+
// matched against the recovered address. THEN THE CHAIN IS ASKED WHO OWNS THE KEY (providerOf): the
|
|
326
|
+
// signature alone certifies itself, because a key can sign anything and call itself any provider. The
|
|
327
|
+
// registry is read from the quote/order conditions - never from SDK settings: any registry can be deployed
|
|
328
|
+
// by anyone, and the order carries which one it is (the field is signed and bound into the terms). Finally
|
|
329
|
+
// the interface's allow-list, when it supplied one, is applied as policy (who is admitted at all).
|
|
330
|
+
//
|
|
331
|
+
// The path and the body are the same as the engine's (`requestOrderQuote` in www/js/core/rfqSource.js): POST to
|
|
332
|
+
// the named path, body { providerId, order }. The path is named by name, so it is overridable by options.
|
|
333
|
+
async firm(request) {
|
|
334
|
+
if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
|
|
335
|
+
if (typeof request.providerId !== "string" || !request.providerId) fail("bad-input", { field: "providerId" });
|
|
336
|
+
if (!request.order || typeof request.order !== "object") fail("bad-input", { field: "order" });
|
|
337
|
+
let body = null;
|
|
338
|
+
try {
|
|
339
|
+
body = await http.json(config.route("orderQuote"), {
|
|
340
|
+
method: "POST",
|
|
341
|
+
headers: { "content-type": "application/json" },
|
|
342
|
+
body: JSON.stringify({ providerId: request.providerId, order: request.order }),
|
|
343
|
+
});
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (error && error.code === "bad-input") throw error;
|
|
346
|
+
fail("server-unavailable", { step: "order-quote" });
|
|
347
|
+
}
|
|
348
|
+
if (!body || body.ok !== true || !body.quote) fail("quote-refused", { step: "order-quote" });
|
|
349
|
+
const quote = body.quote;
|
|
350
|
+
// A MISSING FIELD OF THE SIGNED SET IS NOT "PROBABLY ZERO": the digest cannot be built, so the quote is
|
|
351
|
+
// not verifiable and is refused (its own code, so the interface can say which field is unbound).
|
|
352
|
+
const missingFields = orderQuoteMissingFields(quote);
|
|
353
|
+
if (missingFields.length) fail("quote-field-unbound", { step: "signature", missing: missingFields });
|
|
354
|
+
// THE QUOTE'S OWN EXPIRY, checked against the clock the core already has. It is bound in the signature too,
|
|
355
|
+
// but a stale quote is refused by its own code rather than looking like a signature failure.
|
|
356
|
+
const validUntil = Number(quote.validUntil);
|
|
357
|
+
// THE SCALE IS SECONDS: the same as the order's deadlines and block.timestamp. The core's clock is in
|
|
358
|
+
// milliseconds (Date.now), so exactly ONE boundary is converted - "now", not the quote value itself: one
|
|
359
|
+
// unit for the value, one conversion at the input.
|
|
360
|
+
const nowSec = Math.floor(now() / 1000);
|
|
361
|
+
if (Number.isFinite(validUntil) && validUntil > 0 && validUntil <= nowSec) {
|
|
362
|
+
fail("quote-expired", { step: "signature", validUntil });
|
|
363
|
+
}
|
|
364
|
+
// RECOVER, THEN MATCH THE NAMED KEY. A signature that does not recover is one refusal; a valid signature
|
|
365
|
+
// that recovers to a DIFFERENT address is another (the quote names a key that did not sign it).
|
|
366
|
+
const recoveredKey = recoverOrderQuoteKey(quote);
|
|
367
|
+
if (recoveredKey === null) fail("quote-signature-invalid", { step: "signature" });
|
|
368
|
+
const declaredKey = String(quote.quoteKey).toLowerCase();
|
|
369
|
+
if (recoveredKey !== declaredKey) fail("quote-key-mismatch", { step: "signature", got: recoveredKey, want: declaredKey });
|
|
370
|
+
// WHO OWNS THE KEY - ASKED OF THE REGISTRY NAMED BY THE QUOTE. Both fields are part of the signed set, so
|
|
371
|
+
// they cannot be swapped after signing; this is what turns "a key signed it" into "this provider's key
|
|
372
|
+
// signed it". No chain-read seam - refuse rather than take the signature on trust.
|
|
373
|
+
const registry = String(quote.registry || "").toLowerCase();
|
|
374
|
+
if (!/^0x[0-9a-f]{40}$/.test(registry) || registry === ZERO_ADDRESS) {
|
|
375
|
+
fail("quote-field-unbound", { step: "registry", field: "registry" });
|
|
376
|
+
}
|
|
377
|
+
const declaredProvider = String(quote.provider || "").toLowerCase();
|
|
378
|
+
if (!/^0x[0-9a-f]{40}$/.test(declaredProvider) || declaredProvider === ZERO_ADDRESS) {
|
|
379
|
+
fail("quote-field-unbound", { step: "registry", field: "provider" });
|
|
380
|
+
}
|
|
381
|
+
if (typeof evmCall !== "function") fail("quote-registry-unchecked", { step: "registry", why: "no-chain-read" });
|
|
382
|
+
let mappedProvider = null;
|
|
383
|
+
try {
|
|
384
|
+
mappedProvider = addressFromWord(await evmCall({ to: registry, data: providerOfData(declaredKey) }));
|
|
385
|
+
} catch {
|
|
386
|
+
fail("quote-registry-unchecked", { step: "registry", why: "read-failed", registry });
|
|
387
|
+
}
|
|
388
|
+
if (mappedProvider === null || mappedProvider === ZERO_ADDRESS) {
|
|
389
|
+
fail("quote-key-unknown", { step: "registry", registry, quoteKey: declaredKey });
|
|
390
|
+
}
|
|
391
|
+
if (mappedProvider !== declaredProvider) {
|
|
392
|
+
fail("quote-provider-mismatch", { step: "registry", got: mappedProvider, want: declaredProvider });
|
|
393
|
+
}
|
|
394
|
+
// THE INTERFACE'S OWN ADMISSION LIST - POLICY, NOT PROOF. The chain says whose key it is; whether this
|
|
395
|
+
// interface trades with that provider at all is the interface's decision, and it rides as a hook.
|
|
396
|
+
if (typeof providerAllowed === "function" && providerAllowed(declaredProvider) !== true) {
|
|
397
|
+
fail("quote-provider-not-allowed", { step: "policy", provider: declaredProvider });
|
|
398
|
+
}
|
|
399
|
+
// THE FACTORY: NAMED BY THE PROVIDER'S RECORD, CHECKED BY ITS CODE (#103). The factory will verify the
|
|
400
|
+
// quote in EVM (it is the EIP-712 verifyingContract), so it must be the one the record names AND its
|
|
401
|
+
// code must be a build this interface knows. The record lookup always runs; the code check runs when a
|
|
402
|
+
// policy list was supplied (nothing to pin against otherwise).
|
|
403
|
+
const declaredFactory = String(quote.factory || "").toLowerCase();
|
|
404
|
+
if (!/^0x[0-9a-f]{40}$/.test(declaredFactory) || declaredFactory === ZERO_ADDRESS) {
|
|
405
|
+
fail("quote-field-unbound", { step: "factory", field: "factory" });
|
|
406
|
+
}
|
|
407
|
+
let recordedFactory = null;
|
|
408
|
+
try {
|
|
409
|
+
recordedFactory = addressFromWord(await evmCall({ to: registry, data: factoryOfData(declaredProvider) }));
|
|
410
|
+
} catch {
|
|
411
|
+
fail("quote-factory-unchecked", { step: "factory", why: "read-failed", registry });
|
|
412
|
+
}
|
|
413
|
+
if (recordedFactory === null || recordedFactory === ZERO_ADDRESS) {
|
|
414
|
+
fail("quote-factory-unknown", { step: "factory", registry, provider: declaredProvider });
|
|
415
|
+
}
|
|
416
|
+
if (recordedFactory !== declaredFactory) {
|
|
417
|
+
fail("quote-factory-mismatch", { step: "factory", got: declaredFactory, want: recordedFactory });
|
|
418
|
+
}
|
|
419
|
+
if (factoryPolicy) {
|
|
420
|
+
if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
|
|
421
|
+
let codeHex = null;
|
|
422
|
+
try { codeHex = await evmCode(declaredFactory); } catch { fail("quote-factory-unchecked", { step: "factory", why: "code-read-failed", factory: declaredFactory }); }
|
|
423
|
+
const codeBytes = hexToBytesQ(codeHex);
|
|
424
|
+
// EXTCODEHASH SEMANTICS: keccak256 of the runtime code. An address with no code (empty/0x) hashes to
|
|
425
|
+
// the keccak of empty, which is not on any build list - refused like any unknown build.
|
|
426
|
+
const codeHash = bytesToHexQ(keccak256(codeBytes || new Uint8Array(0)));
|
|
427
|
+
if (!factoryPolicy.includes(codeHash)) {
|
|
428
|
+
fail("quote-factory-code-unknown", { step: "factory", factory: declaredFactory, codeHash });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// BINDING THE QUOTE TO OUR TERMS. The signature confirms the provider signed THIS quote, but not that it is
|
|
432
|
+
// about our swap: we are responsible for the binding. The context string is computed by OUR engine module
|
|
433
|
+
// (format arrakis-order-v3; the field order is part of the protocol and must not be rearranged). There is
|
|
434
|
+
// deliberately no copy of the format here: a copy would silently drift from the protocol.
|
|
435
|
+
//
|
|
436
|
+
// THE ORDER OF THE GATES: signature first, then binding. So a refusal names the nearest reason, not the first
|
|
437
|
+
// one at hand.
|
|
438
|
+
// THE PROVIDER MAY NAME THE CLAIMER HIMSELF, AND THAT IS ALSO AN ASSEMBLED CONTEXT (a defect found). The
|
|
439
|
+
// provider may be the claimer: then it substitutes ITS OWN address into the order terms and signs the context
|
|
440
|
+
// with it already in place. Requiring the context from the caller in that case is impossible - they do not
|
|
441
|
+
// know the provider's address, and then the quote cannot be obtained at all (the live contour answered this
|
|
442
|
+
// with bad-input {field:"order"} BEFORE any network). So if the sent terms had no claimer, the context is
|
|
443
|
+
// computed from the terms WITH HIS value from the quote - exactly as the engine does it
|
|
444
|
+
// (sdk/src/swap-flow.mjs: `{ ...orderBase, claimer: claimerResolved }`). No OPTIONAL checks appeared here:
|
|
445
|
+
// a mismatched string is still a refusal (quote-refused { step: "context" }).
|
|
446
|
+
let expectedContext = null;
|
|
447
|
+
try { expectedContext = engine.orderContext.orderContextString(request.order); }
|
|
448
|
+
catch { expectedContext = null; }
|
|
449
|
+
if (expectedContext === null) {
|
|
450
|
+
const providerClaimer = quote && quote.claimer;
|
|
451
|
+
if (providerClaimer) {
|
|
452
|
+
try { expectedContext = engine.orderContext.orderContextString({ ...request.order, claimer: providerClaimer }); }
|
|
453
|
+
catch { fail("bad-input", { field: "order" }); }
|
|
454
|
+
} else {
|
|
455
|
+
fail("bad-input", { field: "order" });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const quoted = quote.context || "";
|
|
459
|
+
if (String(quoted) !== expectedContext) {
|
|
460
|
+
fail("quote-refused", { step: "context", why: quoted ? "other-terms" : "not-reported" });
|
|
461
|
+
}
|
|
462
|
+
return quote;
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
package/src/record.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// THE SWAP RECORD: CREATION AND OUR SIDE OF THE ORDER.
|
|
2
|
+
//
|
|
3
|
+
// What is here and what is not - and why exactly so.
|
|
4
|
+
//
|
|
5
|
+
// PRESENT: the swap record (the engine's `createSwap` - a pure state record) and OUR side of the order. When the
|
|
6
|
+
// order terms are given, the side is built by the ENGINE'S BUILDER (www/js/atomic/order.js): two halves, their
|
|
7
|
+
// public points, the DLEQ PROOF and the side's encryption key. The core does not invent its own halves: the
|
|
8
|
+
// proof is bound to the order terms (how much, to whom, the deadlines, the salt, the factory, the chain), so
|
|
9
|
+
// without them there is nothing to bind it to - and the record refuses with a code naming the field.
|
|
10
|
+
//
|
|
11
|
+
// ABSENT: the counterparty's side. Its half and point come from the order (or a sandbox stand-in is built), and
|
|
12
|
+
// that is exactly why the record returns nextStep: "order" and missing: ["counterparty-half"]: a recovery file
|
|
13
|
+
// cannot be assembled without the other half.
|
|
14
|
+
import { SdkError } from "./errors.mjs";
|
|
15
|
+
import { primitives } from "./primitives.mjs";
|
|
16
|
+
import * as engine from "./engine.mjs";
|
|
17
|
+
import { buildOwnSide, halfHex, norm } from "./order.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Our side. Without order terms - only the halves and their points (a shape of the record; there is no proof and
|
|
21
|
+
* there cannot be: there is nothing to prove). With terms - the whole side is built by the engine's builder.
|
|
22
|
+
*
|
|
23
|
+
* THE HALF NAMES ARE THE ENGINE'S: the half is returned in the engine's form (BigInt), and it goes into the
|
|
24
|
+
* record as a hex string, because a BigInt survives neither storage nor the recovery file's JSON.
|
|
25
|
+
*/
|
|
26
|
+
export function ownSide(order = null) {
|
|
27
|
+
if (order !== null && order !== undefined) {
|
|
28
|
+
const built = buildOwnSide(order);
|
|
29
|
+
return built.side;
|
|
30
|
+
}
|
|
31
|
+
const halves = engine.halves.createHalves(primitives);
|
|
32
|
+
const half = halves.newHalf();
|
|
33
|
+
const viewHalf = halves.newHalf();
|
|
34
|
+
return { half, viewHalf, pub: halves.publicHalves(half), viewPub: halves.publicHalves(viewHalf).ed };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create the swap record and put OUR side into it.
|
|
39
|
+
* The record's field names are the ones the engine reads them by (`halvesWalletOf`: half, viewHalf,
|
|
40
|
+
* edPointLocker, edViewPointLocker). The counterparty's half cannot and need not be put in: it will come from
|
|
41
|
+
* the order.
|
|
42
|
+
*/
|
|
43
|
+
export function createRecord(input, { order = null } = {}) {
|
|
44
|
+
if (!input || typeof input !== "object") throw new SdkError("bad-input", { field: "input" });
|
|
45
|
+
const side = ownSide(order);
|
|
46
|
+
let swap = null;
|
|
47
|
+
try { swap = engine.swap.createSwap(input); } catch { throw new SdkError("bad-input", { field: "input" }); }
|
|
48
|
+
const escrow = { ...(swap.escrow || {}),
|
|
49
|
+
// THE HALVES ARE A HEX STRING (a BigInt does not survive JSON), the points are in canonical form: that is how
|
|
50
|
+
// both the slot check against the chain and the address check read them.
|
|
51
|
+
half: halfHex(side.half), viewHalf: halfHex(side.viewHalf),
|
|
52
|
+
edPointLocker: norm(side.pub.ed), edViewPointLocker: norm(side.viewPub) };
|
|
53
|
+
// THE SIDE'S ENCRYPTION KEY - only the public one: the private one stays with the side and never enters the record.
|
|
54
|
+
if (side.enc && side.enc.pub) escrow.encPub = norm(side.enc.pub);
|
|
55
|
+
const record = { ...swap, escrow };
|
|
56
|
+
// THE ORDER-QUOTE ID (a defect found). The engine's createSwap builds the record BY A LIST OF FIELDS, and the
|
|
57
|
+
// `orderQuoteId` field is not in it: the value brought by the request was silently lost. And our service uses
|
|
58
|
+
// it to tie the created escrow to the provider node's record - without it the node will not take the ETH (the
|
|
59
|
+
// mark body carried quoteId: null). The screen used to set this field itself
|
|
60
|
+
// (www/js/ui/views/confirm.js: swap.orderQuoteId = flow.quoteId).
|
|
61
|
+
const quoteId = input && input.orderQuoteId;
|
|
62
|
+
if (quoteId !== undefined && quoteId !== null && quoteId !== "") record.orderQuoteId = quoteId;
|
|
63
|
+
try { engine.swap.saveSwap(record); } catch { throw new SdkError("storage-unavailable", { step: "save-swap" }); }
|
|
64
|
+
return { swap: record, side, missing: ["counterparty-half"], nextStep: "order" };
|
|
65
|
+
}
|
package/src/recovery.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// RECOVERY FROM FILE: parsing and integrity checks are done by the engine, the response shape by the SDK
|
|
2
|
+
// contract.
|
|
3
|
+
//
|
|
4
|
+
// The file arrives from the interface as a string or as `{ contents }`: the SDK knows neither about
|
|
5
|
+
// <input type=file> nor about buffers - that is the interface's business.
|
|
6
|
+
import * as engine from "./engine.mjs";
|
|
7
|
+
import { SdkError } from "./errors.mjs";
|
|
8
|
+
|
|
9
|
+
const contentsOf = (file) => {
|
|
10
|
+
if (typeof file === "string") return file;
|
|
11
|
+
if (file && typeof file.contents === "string") return file.contents;
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function createRecovery() {
|
|
16
|
+
return {
|
|
17
|
+
async open(file, passphrase) {
|
|
18
|
+
const contents = contentsOf(file);
|
|
19
|
+
if (contents === null) throw new SdkError("bad-input", { field: "file" });
|
|
20
|
+
let restored;
|
|
21
|
+
try {
|
|
22
|
+
restored = await engine.restore.restoreFromFile(contents, passphrase);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
// Parsing, decryption and the address check live in the engine; a code goes out, not the engine's wording.
|
|
25
|
+
throw new SdkError("recovery-failed", { stage: "restore" });
|
|
26
|
+
}
|
|
27
|
+
const payload = restored.payload || {};
|
|
28
|
+
const wallet = restored.wallet || {};
|
|
29
|
+
const halves = Number(payload.version) >= 3;
|
|
30
|
+
return {
|
|
31
|
+
address: typeof wallet.address === "string" ? wallet.address : null,
|
|
32
|
+
// THERE IS NO SPEND KEY FROM THE HALVES, AND THERE CANNOT BE: in the halves scheme the second half
|
|
33
|
+
// arrives from the chain once the counterparty takes the ETH. Observing - yes; disposing of funds -
|
|
34
|
+
// only after the half is revealed.
|
|
35
|
+
spendable: !halves,
|
|
36
|
+
swapId: typeof payload.swapId === "string" ? payload.swapId : null,
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/sim.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SANDBOX: failure scenarios and time. Added on requirement 8 of issue #32 - the interface lays out failure
|
|
2
|
+
// screens without waiting for a live testnet.
|
|
3
|
+
//
|
|
4
|
+
// What is deliberately absent, and why: scenarios are applied by the ENGINE (`www/js/mock`, the scenario fields
|
|
5
|
+
// on the swap), not by the SDK - the SDK merely names the scenarios and holds the interface's choice. Applying
|
|
6
|
+
// one to a swap will arrive together with `swaps.start` (stage 2), because a scenario is a property of the swap,
|
|
7
|
+
// not a global setting.
|
|
8
|
+
import * as engine from "./engine.mjs";
|
|
9
|
+
import { fail } from "./errors.mjs";
|
|
10
|
+
|
|
11
|
+
export const SCENARIOS = Object.keys(engine.config.SCENARIO_LABELS || {});
|
|
12
|
+
|
|
13
|
+
export function createSim() {
|
|
14
|
+
let scenario = SCENARIOS[0] || null;
|
|
15
|
+
let speedValue = 1;
|
|
16
|
+
let advancedMs = 0;
|
|
17
|
+
return {
|
|
18
|
+
scenarios: () => SCENARIOS.slice(),
|
|
19
|
+
apply(next) {
|
|
20
|
+
if (!SCENARIOS.includes(next)) fail("bad-input", { field: "scenario", known: SCENARIOS });
|
|
21
|
+
scenario = next;
|
|
22
|
+
},
|
|
23
|
+
speed(multiplier) {
|
|
24
|
+
const value = Number(multiplier);
|
|
25
|
+
if (!Number.isFinite(value) || value <= 0) fail("bad-input", { field: "speed" });
|
|
26
|
+
speedValue = value;
|
|
27
|
+
},
|
|
28
|
+
advance(ms) {
|
|
29
|
+
const value = Number(ms);
|
|
30
|
+
if (!Number.isFinite(value)) fail("bad-input", { field: "ms" });
|
|
31
|
+
advancedMs += value;
|
|
32
|
+
},
|
|
33
|
+
// The sandbox's internal state: needed by the interface's debug panel and by `swaps.start` (stage 2). Named
|
|
34
|
+
// `debug`, not `state`: this is not the swap state, and neither the interface nor a check should confuse them.
|
|
35
|
+
debug: () => ({ scenario, speed: speedValue, advancedMs }),
|
|
36
|
+
};
|
|
37
|
+
}
|