@ninsei-labs/sdk 0.39.2 → 0.40.0-develop.1
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 +63 -0
- package/README.md +4 -1
- package/engine/atomic/dleq2.js +86 -111
- package/engine/atomic/halfenc.js +58 -72
- package/engine/atomic/halves.js +31 -53
- package/engine/atomic/order-client.js +15 -16
- package/engine/atomic/order.js +44 -62
- package/engine/atomic/orderContext.js +26 -50
- package/engine/atomic/orderHalfCommit.js +17 -18
- package/engine/core/chainSource.js +54 -69
- package/engine/core/clock.js +11 -12
- package/engine/core/config.js +554 -449
- package/engine/core/finality.js +34 -53
- package/engine/core/format.js +35 -36
- package/engine/core/preSignGuards.js +109 -117
- package/engine/core/rfqSource.js +57 -75
- package/engine/core/sellWindow.js +23 -24
- package/engine/core/store.js +20 -21
- package/engine/core/swap.js +242 -243
- package/engine/evm/amounts.js +50 -51
- package/engine/evm/asyncExec.js +577 -0
- package/engine/evm/asyncLegs.js +308 -0
- package/engine/evm/asyncRoute.js +179 -0
- package/engine/evm/auth.js +41 -48
- package/engine/evm/claimGas.js +28 -47
- package/engine/evm/depositor.js +39 -43
- package/engine/evm/dex.js +266 -267
- package/engine/evm/escrow.js +113 -129
- package/engine/evm/factory.js +71 -77
- package/engine/evm/fees.js +41 -42
- package/engine/evm/funding.js +160 -162
- package/engine/evm/gasReserve.js +67 -36
- package/engine/evm/index.js +46 -53
- package/engine/evm/injected.js +34 -42
- package/engine/evm/permit.js +129 -56
- package/engine/evm/prices.js +30 -45
- package/engine/evm/session.js +188 -189
- package/engine/mock/chain.js +17 -18
- package/engine/monero/address.js +33 -48
- package/engine/monero/halvesSweep.js +25 -43
- package/engine/monero/node.js +14 -15
- package/engine/monero/scan.js +82 -95
- package/engine/monero/swapKeys.js +30 -33
- package/engine/monero/unlock.js +17 -20
- package/engine/monero/wallet.js +32 -42
- package/engine/recovery/recoveryFile.js +154 -161
- package/engine/recovery/restore.js +50 -61
- package/engine/sdk/bridge.js +244 -234
- package/engine/ui/dom.js +4 -5
- package/engine/ui/walletModal.js +20 -31
- package/index.d.ts +26 -0
- package/package.json +1 -1
- package/src/adapters/evm-wallet.mjs +16 -0
- package/src/adapters/monero-wallet.mjs +1 -1
- package/src/adapters/monero.mjs +1 -1
- package/src/adapters/node-test-evm-wallet.mjs +5 -5
- package/src/codes.mjs +5 -0
- package/src/config.mjs +1 -1
- package/src/engine.mjs +4 -0
- package/src/index.mjs +16 -2
- package/src/legs/cow-spec.mjs +254 -0
- package/src/legs/cow.mjs +315 -0
- package/src/legs/evm.mjs +86 -2
- package/src/legs/kyber-spec.mjs +265 -0
- package/src/legs/kyber.mjs +425 -0
- package/src/legs/shape.mjs +35 -0
- package/src/legs/uniswaprelay-spec.mjs +541 -0
- package/src/legs/uniswaprelay.mjs +422 -0
- package/src/legs/uniswapx-spec.mjs +329 -0
- package/src/legs/uniswapx.mjs +362 -0
- package/src/quotes.mjs +23 -5
- package/src/swaps.mjs +18 -1
|
@@ -1,50 +1,38 @@
|
|
|
1
1
|
// GENERATED FILE - a byte-for-byte copy of the engine module www/js/core/chainSource.js for the SDK package.
|
|
2
|
-
// Edit the source under www/js
|
|
3
|
-
// tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
|
|
2
|
+
// Edit the source under www/js and regenerate the mirror; a drift guard reddens on any difference.
|
|
4
3
|
// Everything below this header is identical to the source.
|
|
5
4
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// Monero-нога (пришёл ли XMR на адрес свопа и сколько у него подтверждений) - может быть
|
|
14
|
-
// НАСТОЯЩЕЙ: бэкенд app/ заводит watch-only кошелёк по адресу и view key и следит за
|
|
15
|
-
// поступлениями (см. app/README.md). Ключ траты при этом остаётся в браузере.
|
|
16
|
-
//
|
|
17
|
-
// Поэтому источник выбирается именно для Monero-ноги: 'mock' (как в демо: sim-время, без сети)
|
|
18
|
-
// либо 'app' (HTTP к бэкенду). За источником идёт и сеть: у бэкенда на сервере поднят
|
|
19
|
-
// stagenet-кошелёк, а мок-режим демки исторически mainnet (см. XMR_NETWORKS в config.js).
|
|
20
|
-
//
|
|
21
|
-
// ГРАНИЦА, ЗАКРЕПЛЁННАЯ КОДОМ: в бэкенд уходит только адрес и view key, и тело запроса
|
|
22
|
-
// собирается заново по белому списку полей - так в него физически не может уехать ни ключ траты,
|
|
23
|
-
// ни seed, даже если кто-то добавит их в share. Проверяется tools/check-chain-source.mjs.
|
|
5
|
+
// DATA SOURCE FOR THE MONERO LEG OF THE SWAP: the demo simulator or the real backend.
|
|
6
|
+
// TWO LEGS, DIFFERENT READINESS. The EVM leg (escrow, claim, refund) is a SIMULATION (no contract yet): every
|
|
7
|
+
// reply is marked mock: true and the UI must show it. The Monero leg (has XMR arrived, how many confirmations)
|
|
8
|
+
// can be REAL: the backend tracks a watch-only wallet by address and view key, while the spend key stays in the browser.
|
|
9
|
+
// So the source is chosen for the Monero leg: 'mock' (sim-time, no network) or 'app' (HTTP to the backend).
|
|
10
|
+
// A BOUNDARY PINNED BY CODE: only the address and view key go to the backend, and the request body is rebuilt from
|
|
11
|
+
// a field allowlist, so neither the spend key nor the seed can leave.
|
|
24
12
|
|
|
25
13
|
import { API, XMR_NETWORKS, MONERO } from "./config.js";
|
|
26
14
|
import { confirmations as mockConfirmations } from "../mock/chain.js";
|
|
27
15
|
|
|
28
|
-
//
|
|
16
|
+
// Backend statuses - the same strings the API returns.
|
|
29
17
|
export const XMR_STATUS = {
|
|
30
|
-
AWAITING: "awaiting_funding", //
|
|
31
|
-
FUNDED: "funded", //
|
|
32
|
-
//
|
|
33
|
-
//
|
|
18
|
+
AWAITING: "awaiting_funding", // nothing has arrived at the address yet (or less than expected)
|
|
19
|
+
FUNDED: "funded", // arrived, confirmations below target
|
|
20
|
+
// MONEY ON THE ADDRESS BUT LOCKED by unlock_time: NOT an arrival, and 'ready' does not turn on.
|
|
21
|
+
// A separate status, named by the backend itself.
|
|
34
22
|
LOCKED: "xmr_locked",
|
|
35
|
-
READY: "ready", //
|
|
36
|
-
SWEPT: "swept", // XMR
|
|
37
|
-
CLOSED: "closed", //
|
|
23
|
+
READY: "ready", // enough confirmations: the user can confirm readiness
|
|
24
|
+
SWEPT: "swept", // XMR withdrawn by the user
|
|
25
|
+
CLOSED: "closed", // swap closed (cancelled or refunded)
|
|
38
26
|
};
|
|
39
27
|
|
|
40
|
-
//
|
|
28
|
+
// The only fields allowed to be sent to the backend when a swap is created.
|
|
41
29
|
export const ALLOWED_SESSION_FIELDS = ["swapId", "network", "address", "viewKey", "expectedAmountXmr", "restoreHeight", "chain"];
|
|
42
30
|
|
|
43
31
|
export function isLiveSource(source = API.xmrSource) {
|
|
44
32
|
return source === "app";
|
|
45
33
|
}
|
|
46
34
|
|
|
47
|
-
//
|
|
35
|
+
// The Monero network the swap address must use for this source.
|
|
48
36
|
export function xmrNetworkForSource(source = API.xmrSource) {
|
|
49
37
|
return XMR_NETWORKS[isLiveSource(source) ? "app" : "mock"];
|
|
50
38
|
}
|
|
@@ -54,18 +42,17 @@ export function xmrSourceLabel(source = API.xmrSource) {
|
|
|
54
42
|
}
|
|
55
43
|
|
|
56
44
|
// ---------------------------------------------------------------------------
|
|
57
|
-
// HTTP
|
|
45
|
+
// HTTP to the backend
|
|
58
46
|
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// и для случая, когда фронт отдают не с боевого хоста (в обоих случаях относительный URL не парсится).
|
|
47
|
+
// The base can be overridden. In the browser this is not needed: "/api" is a relative path of the same origin
|
|
48
|
+
// and the CSP (connect-src self) covers it. The override exists for Node tests and for a non-production host.
|
|
62
49
|
let apiBaseOverride = null;
|
|
63
50
|
export function setApiBase(base) {
|
|
64
51
|
apiBaseOverride = base ? String(base).replace(/[/]$/, "") : null;
|
|
65
52
|
}
|
|
66
53
|
|
|
67
|
-
//
|
|
68
|
-
//
|
|
54
|
+
// Polling settings that can be overridden: needed by tests (interval in milliseconds, not seconds) and by the
|
|
55
|
+
// screen. Defaults come from config.
|
|
69
56
|
let pollMsOverride = null;
|
|
70
57
|
export function setApiOptions({ pollMs } = {}) {
|
|
71
58
|
if (Number.isFinite(Number(pollMs)) && Number(pollMs) > 0) pollMsOverride = Number(pollMs);
|
|
@@ -76,12 +63,12 @@ export function pollInterval() {
|
|
|
76
63
|
}
|
|
77
64
|
// ---------------------------------------------------------------------------
|
|
78
65
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
66
|
+
// One API request. It NEVER throws: a failed network is a screen state, not a demo crash, and the user must
|
|
67
|
+
// see the reason, not a blank page.
|
|
81
68
|
async function api(path, { method = "GET", body, base, timeoutMs = API.timeoutMs, fetchImpl } = {}) {
|
|
82
69
|
const target = base || apiBaseOverride || API.base;
|
|
83
70
|
const doFetch = fetchImpl || globalThis.fetch;
|
|
84
|
-
if (typeof doFetch !== "function") return { ok: false, status: 0, error: "
|
|
71
|
+
if (typeof doFetch !== "function") return { ok: false, status: 0, error: "no fetch: backend unavailable" };
|
|
85
72
|
const ctl = typeof AbortController === "function" ? new AbortController() : null;
|
|
86
73
|
const timer = ctl ? setTimeout(() => ctl.abort(), timeoutMs) : null;
|
|
87
74
|
try {
|
|
@@ -101,7 +88,7 @@ async function api(path, { method = "GET", body, base, timeoutMs = API.timeoutMs
|
|
|
101
88
|
try {
|
|
102
89
|
json = text ? JSON.parse(text) : null;
|
|
103
90
|
} catch {
|
|
104
|
-
/*
|
|
91
|
+
/* not JSON - the reason will be visible from the text */
|
|
105
92
|
}
|
|
106
93
|
if (!res.ok) {
|
|
107
94
|
const base_ = (json && (json.error || json.detail)) || text.slice(0, 200) || `HTTP ${res.status}`;
|
|
@@ -114,14 +101,14 @@ async function api(path, { method = "GET", body, base, timeoutMs = API.timeoutMs
|
|
|
114
101
|
return {
|
|
115
102
|
ok: false,
|
|
116
103
|
status: 0,
|
|
117
|
-
error: aborted ?
|
|
104
|
+
error: aborted ? `backend did not answer within ${timeoutMs} ms` : "backend unavailable: " + ((e && e.message) || e),
|
|
118
105
|
};
|
|
119
106
|
} finally {
|
|
120
107
|
if (timer) clearTimeout(timer);
|
|
121
108
|
}
|
|
122
109
|
}
|
|
123
110
|
|
|
124
|
-
//
|
|
111
|
+
// Backend response -> a snapshot for the screen and the state engine.
|
|
125
112
|
export function normalizeSession(json) {
|
|
126
113
|
const j = json || {};
|
|
127
114
|
return {
|
|
@@ -132,7 +119,7 @@ export function normalizeSession(json) {
|
|
|
132
119
|
expectedAmountXmr: j.expectedAmountXmr ?? null,
|
|
133
120
|
receivedXmr: j.receivedXmr ?? "0",
|
|
134
121
|
receivedAtomic: j.receivedAtomic ?? "0",
|
|
135
|
-
//
|
|
122
|
+
// LOCKED unlock_time: the sum and the boundary ({code,untilHeight,untilTime}) - separate from the arrival.
|
|
136
123
|
lockedXmr: j.lockedXmr ?? "0",
|
|
137
124
|
lockedAtomic: j.lockedAtomic ?? "0",
|
|
138
125
|
lockedUntil: j.lockedUntil || null,
|
|
@@ -145,10 +132,9 @@ export function normalizeSession(json) {
|
|
|
145
132
|
};
|
|
146
133
|
}
|
|
147
134
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// на тот же адрес, если человек ничего не менял. Чистая функция - проверяется тестом без сети.
|
|
135
|
+
// Creating a swap on the backend. share is what watchOnlyShareFromWallet() returns: address + private view key.
|
|
136
|
+
// The spend key stays in the browser. The same intent (sum, currency, network) must not create a new swap on
|
|
137
|
+
// the same address. A pure function - checked without a network.
|
|
152
138
|
export function sameSwapTarget(a, b) {
|
|
153
139
|
if (!a || !b) return false;
|
|
154
140
|
return String(a.payAmount) === String(b.payAmount) &&
|
|
@@ -161,17 +147,16 @@ export async function openSession({ swapId, share, expectedAmountXmr, chain, res
|
|
|
161
147
|
const address = src.address || null;
|
|
162
148
|
const viewKey = src.viewKey || src.privateViewKey || null;
|
|
163
149
|
if (!address || !viewKey) {
|
|
164
|
-
return { ok: false, status: 0, error: "
|
|
150
|
+
return { ok: false, status: 0, error: "the watch-only share has no address/viewKey - nothing to send to the backend" };
|
|
165
151
|
}
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// объяснением: не завести наблюдение лучше, чем завести его без возможности проверить деньги.
|
|
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.
|
|
170
155
|
const amountXmr = Number(expectedAmountXmr);
|
|
171
156
|
if (!Number.isFinite(amountXmr) || amountXmr <= 0) {
|
|
172
157
|
const where = (new Error("caller").stack || "").split("\n").slice(1, 4).map((x) => x.trim().split("/").pop()).join(" <- ");
|
|
173
|
-
console.warn("[chainSource]
|
|
174
|
-
return { ok: false, status: 0, error: "
|
|
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" };
|
|
175
160
|
}
|
|
176
161
|
const wanted = {
|
|
177
162
|
swapId: swapId || src.swapId || null,
|
|
@@ -182,7 +167,7 @@ export async function openSession({ swapId, share, expectedAmountXmr, chain, res
|
|
|
182
167
|
restoreHeight: Number.isFinite(Number(restoreHeight)) && Number(restoreHeight) > 0 ? Number(restoreHeight) : Number(src.restoreHeight) || undefined,
|
|
183
168
|
chain: chain || undefined,
|
|
184
169
|
};
|
|
185
|
-
//
|
|
170
|
+
// The body is rebuilt from the allowlist: what is not in the list never reaches the request.
|
|
186
171
|
const body = {};
|
|
187
172
|
for (const field of ALLOWED_SESSION_FIELDS) {
|
|
188
173
|
if (wanted[field] !== undefined && wanted[field] !== null) body[field] = wanted[field];
|
|
@@ -198,31 +183,31 @@ export async function fetchSession(swapId, opts = {}) {
|
|
|
198
183
|
return { ok: true, status: res.status, session: normalizeSession(res.json) };
|
|
199
184
|
}
|
|
200
185
|
|
|
201
|
-
//
|
|
202
|
-
//
|
|
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.
|
|
203
188
|
export async function reportSwept(swapId, txid, opts = {}) {
|
|
204
189
|
const res = await api(`/swaps/${encodeURIComponent(swapId)}/swept`, { method: "POST", body: { txid }, ...opts });
|
|
205
190
|
return res.ok ? { ok: true, status: res.status, session: normalizeSession(res.json) } : res;
|
|
206
191
|
}
|
|
207
192
|
|
|
208
|
-
//
|
|
193
|
+
// Close the swap (cancel or refund on the backend): the watch stops.
|
|
209
194
|
export async function closeSession(swapId, reason = "cancelled", opts = {}) {
|
|
210
195
|
const res = await api(`/swaps/${encodeURIComponent(swapId)}/closed`, { method: "POST", body: { reason }, ...opts });
|
|
211
196
|
return res.ok ? { ok: true, status: res.status, session: normalizeSession(res.json) } : res;
|
|
212
197
|
}
|
|
213
198
|
|
|
214
|
-
//
|
|
215
|
-
//
|
|
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.
|
|
216
201
|
export async function health(opts = {}) {
|
|
217
202
|
const res = await api("/health", opts);
|
|
218
203
|
return res.ok ? { ok: true, ...res.json } : res;
|
|
219
204
|
}
|
|
220
205
|
|
|
221
206
|
// ---------------------------------------------------------------------------
|
|
222
|
-
//
|
|
207
|
+
// The Monero-leg snapshot for the state engine
|
|
223
208
|
// ---------------------------------------------------------------------------
|
|
224
209
|
|
|
225
|
-
//
|
|
210
|
+
// Mock mode: confirmations by sim-time, as the demo always did.
|
|
226
211
|
export function mockView(swap, simElapsedMs) {
|
|
227
212
|
const confTarget = (swap.timeline && swap.timeline.confTarget) || MONERO.confirmTarget;
|
|
228
213
|
const lockAt = swap.timeline ? swap.timeline.lockAtSim : 0;
|
|
@@ -238,7 +223,7 @@ export function mockView(swap, simElapsedMs) {
|
|
|
238
223
|
live: false,
|
|
239
224
|
status,
|
|
240
225
|
received,
|
|
241
|
-
//
|
|
226
|
+
// The demo simulator has no unlock_time lock: the arrival and the unlocked are the same.
|
|
242
227
|
unlocked: received,
|
|
243
228
|
locked: 0,
|
|
244
229
|
lockedUntil: null,
|
|
@@ -252,8 +237,8 @@ export function mockView(swap, simElapsedMs) {
|
|
|
252
237
|
};
|
|
253
238
|
}
|
|
254
239
|
|
|
255
|
-
//
|
|
256
|
-
//
|
|
240
|
+
// Live mode: take the latest backend poll, put there by the ticker; derive() stays a pure function - it reads
|
|
241
|
+
// state, not the network.
|
|
257
242
|
export function liveView(swap) {
|
|
258
243
|
const confTarget = (swap.timeline && swap.timeline.confTarget) || MONERO.confirmTarget;
|
|
259
244
|
const s = swap.xmrSession;
|
|
@@ -277,8 +262,8 @@ export function liveView(swap) {
|
|
|
277
262
|
return {
|
|
278
263
|
live: true,
|
|
279
264
|
status: swap.xmrSessionError ? "error" : s.status,
|
|
280
|
-
//
|
|
281
|
-
// locked -
|
|
265
|
+
// ARRIVAL AND UNLOCKED ARE DIFFERENT FACTS. received - what can be spent (receivedXmr); locked - unlock_time-
|
|
266
|
+
// locked; arrived - everything visible at the address. The backend names the state.
|
|
282
267
|
received: Number(s.receivedXmr) || 0,
|
|
283
268
|
unlocked: Number(s.receivedXmr) || 0,
|
|
284
269
|
locked: Number(s.lockedXmr) || 0,
|
|
@@ -297,8 +282,8 @@ export function xmrView(swap, simElapsedMs) {
|
|
|
297
282
|
return isLiveSource(swap && swap.xmrSource) ? liveView(swap) : mockView(swap, simElapsedMs);
|
|
298
283
|
}
|
|
299
284
|
|
|
300
|
-
//
|
|
301
|
-
//
|
|
285
|
+
// A link to the transfer in an explorer. In live mode this is stagenet: it must not be signed as real money, so
|
|
286
|
+
// the network comes from the session.
|
|
302
287
|
export function explorerXmrTx(txid, network = "stagenet") {
|
|
303
288
|
if (!txid) return null;
|
|
304
289
|
const prefix = network === "mainnet" ? "" : network + ".";
|
package/engine/core/clock.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// GENERATED FILE - a byte-for-byte copy of the engine module www/js/core/clock.js for the SDK package.
|
|
2
|
-
// Edit the source under www/js
|
|
3
|
-
// tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
|
|
2
|
+
// Edit the source under www/js and regenerate the mirror; a drift guard reddens on any difference.
|
|
4
3
|
// Everything below this header is identical to the source.
|
|
5
4
|
|
|
6
|
-
//
|
|
5
|
+
// Demo sim-clock.
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// (20-40
|
|
7
|
+
// Idea: real time is compressed by the speed multiplier. speed=60 means one
|
|
8
|
+
// real second equals one sim-minute. All swap timings live in sim-time,
|
|
9
|
+
// so the demo can be shown fast, while at speed=1 it runs at real pace
|
|
10
|
+
// (20-40 minutes per swap, as in the litepaper).
|
|
12
11
|
//
|
|
13
|
-
// swap.realStart
|
|
14
|
-
// swap.simStartWall -
|
|
12
|
+
// swap.realStart - Date.now() at the moment the swap was created
|
|
13
|
+
// swap.simStartWall - the demo's wall time at that same moment (for UTC display)
|
|
15
14
|
// simElapsed(swap) = (Date.now() - realStart) * speed
|
|
16
15
|
|
|
17
16
|
export function nowReal() {
|
|
@@ -22,15 +21,15 @@ export function simElapsed(swap, now = nowReal()) {
|
|
|
22
21
|
return Math.max(0, (now - swap.realStart) * swap.speed);
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
//
|
|
24
|
+
// The demo's wall time at the moment when simMs of sim-time has passed since the swap started.
|
|
26
25
|
export function simWall(swap, simMs) {
|
|
27
26
|
return swap.simStartWall + simMs;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
|
|
31
30
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
31
|
+
// Rescale the base point when the speed changes: sim-progress is preserved,
|
|
32
|
+
// after that the swap runs at the new speed.
|
|
34
33
|
export function rescaleStart(swap, newSpeed, now = nowReal()) {
|
|
35
34
|
const elapsed = simElapsed(swap, now);
|
|
36
35
|
swap.speed = newSpeed;
|