@ninsei-labs/sdk 0.52.0 → 0.53.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 +239 -0
- package/README.md +76 -3
- package/engine/atomic/order-worker.js +29 -2
- package/engine/atomic/order.js +24 -0
- package/engine/core/store.js +60 -12
- package/engine/evm/funding.js +65 -18
- package/engine/monero/arrivalRead.js +3 -22
- package/engine/monero/halvesSweep.js +5 -1
- package/engine/monero/scan.js +144 -33
- package/index.d.ts +35 -21
- package/package.json +23 -3
- package/src/adapters/monero-wallet.mjs +13 -6
- package/src/adapters/monero.mjs +5 -7
- package/src/codes.mjs +4 -0
- package/src/config.mjs +6 -3
- package/src/decline.mjs +6 -1
- package/src/feeEstimate.mjs +20 -13
- package/src/index.mjs +5 -4
- package/src/legs/index.mjs +8 -0
- package/src/legs/profiles.mjs +51 -0
- package/src/lock.mjs +4 -6
- package/src/order.mjs +53 -7
- package/src/public/networks.d.ts +14 -1
- package/src/public/recovery.d.ts +42 -5
- package/src/public/recovery.mjs +5 -0
- package/src/public/xmr.d.ts +26 -8
- package/src/quotes.mjs +165 -2
- package/src/recovery-key.mjs +177 -39
- package/src/recovery.mjs +9 -1
- package/src/swap-flow.mjs +3 -2
- package/src/swaps.mjs +204 -79
- package/src/sweep-fees.mjs +32 -0
- package/src/sweep-restore.mjs +30 -9
- package/src/sweep.mjs +28 -5
- package/src/version.mjs +1 -1
- package/src/xmr-arrival.mjs +76 -32
package/engine/evm/funding.js
CHANGED
|
@@ -125,18 +125,45 @@ export const GAS_CREATE_ORDER_AND_FUND = 700_000n;
|
|
|
125
125
|
|
|
126
126
|
// The ACTUAL escrow address from the receipt. Returns null when the event is absent (the transaction is not in a
|
|
127
127
|
// block, or the factory is another) - then the caller must say so aloud rather than substitute a guess.
|
|
128
|
-
export function escrowFromReceipt(receipt) {
|
|
128
|
+
export function escrowFromReceipt(receipt, { factory = null, expectedEscrow = null } = {}) {
|
|
129
129
|
if (!receipt || !Array.isArray(receipt.logs)) return null;
|
|
130
|
+
const wantFactory = factory ? String(factory).toLowerCase() : null;
|
|
131
|
+
const wantEscrow = expectedEscrow ? String(expectedEscrow).toLowerCase() : null;
|
|
130
132
|
for (const log of receipt.logs) {
|
|
133
|
+
// THE EVENT MUST COME FROM THE FACTORY THAT WAS CALLED. Another contract can emit the same topic; accepting
|
|
134
|
+
// it would let a receipt from a different order name the escrow that gets saved and used for the withdrawal.
|
|
135
|
+
if (wantFactory && String(log && log.address || "").toLowerCase() !== wantFactory) continue;
|
|
131
136
|
const topics = log.topics || [];
|
|
132
137
|
if (String(topics[0]).toLowerCase() !== ORDER_CREATED_TOPIC) continue;
|
|
133
138
|
if (topics.length < 2) continue;
|
|
134
139
|
const addr = "0x" + String(topics[1]).slice(26).toLowerCase();
|
|
135
|
-
if (
|
|
140
|
+
if (!/^0x[0-9a-f]{40}$/.test(addr) || /^0x0{40}$/.test(addr)) continue;
|
|
141
|
+
// THE PREDICTED ADDRESS IS THE EXPECTED ONE. A different escrow in the same factory is not this order; the
|
|
142
|
+
// caller must not save it as funded.
|
|
143
|
+
if (wantEscrow && addr !== wantEscrow) continue;
|
|
144
|
+
return addr;
|
|
136
145
|
}
|
|
137
146
|
return null;
|
|
138
147
|
}
|
|
139
148
|
|
|
149
|
+
// A RECEIPT IS A VERDICT, NOT A PRESENCE CHECK. `status: 0` is a reverted transaction; a receipt with no
|
|
150
|
+
// OrderCreated from the expected factory is not a funded escrow; a missing receipt is pending. Only status 1 plus
|
|
151
|
+
// the expected event is funded, and the caller saves the escrow only on that answer.
|
|
152
|
+
export function fundingReceiptVerdict(receipt, { factory = null, expectedEscrow = null } = {}) {
|
|
153
|
+
if (!receipt || typeof receipt !== "object") return { status: "pending", reason: "no-receipt" };
|
|
154
|
+
if (receipt.status === undefined || receipt.status === null || receipt.status === "") {
|
|
155
|
+
return { status: "pending", reason: "no-status" };
|
|
156
|
+
}
|
|
157
|
+
const status = Number(receipt.status);
|
|
158
|
+
if (!Number.isFinite(status)) return { status: "pending", reason: "bad-status" };
|
|
159
|
+
if (status === 0) return { status: "reverted", reason: "status-0" };
|
|
160
|
+
if (status !== 1) return { status: "pending", reason: "bad-status" };
|
|
161
|
+
const escrow = escrowFromReceipt(receipt, { factory, expectedEscrow });
|
|
162
|
+
if (!escrow) return { status: "pending", reason: "no-order-created" };
|
|
163
|
+
const blockNumber = Number(receipt.blockNumber);
|
|
164
|
+
return { status: "funded", escrow, birthBlock: Number.isFinite(blockNumber) ? blockNumber : null };
|
|
165
|
+
}
|
|
166
|
+
|
|
140
167
|
// Wait for a receipt: right after signing there is none, which is normal. Waiting forever is not allowed - we say so honestly.
|
|
141
168
|
async function waitReceipt(receiptReader, hash, { tries = 12, delayMs = 1500 } = {}) {
|
|
142
169
|
for (let i = 0; i < tries; i += 1) {
|
|
@@ -184,8 +211,13 @@ export async function fundOrder({ factory, locker, claimer, commitHalfLocker, co
|
|
|
184
211
|
// THE DEPOSITOR'S FIELDS GO AS SEPARATE FACTORY ARGUMENTS (they are not in the signed set).
|
|
185
212
|
const quoteEnc = { ...quote, commitHalfLocker: terms.commitHalfLocker, edPointLocker: terms.edPointLocker, edViewPointLocker: terms.edViewPointLocker };
|
|
186
213
|
// QUOTE AND TERMS ARE ABOUT ONE THING: a divergence moves the escrow address and signs the wrong order.
|
|
187
|
-
|
|
188
|
-
|
|
214
|
+
// THE COUNTERPARTY'S SPEND HALF IS PART OF THAT ONE THING. The proof is checked against the counterparty side,
|
|
215
|
+
// while the factory encodes the signed quote; without comparing these two fields a quote that commits to one
|
|
216
|
+
// point can be funded beside a proof for another, and the claim reveals a half that does not open the address.
|
|
217
|
+
const sameValue = (a, b) => String(a === undefined || a === null ? "" : a).replace(/^0x/i, "").toLowerCase()
|
|
218
|
+
=== String(b === undefined || b === null ? "" : b).replace(/^0x/i, "").toLowerCase();
|
|
219
|
+
for (const name of ["locker", "claimer", "amount", "readyBy", "t1", "salt", "commitHalfClaimer", "edPointClaimer"]) {
|
|
220
|
+
if (!sameValue(quote[name], terms[name])) {
|
|
189
221
|
throw new Error("the quote and the order terms diverge on field " + name);
|
|
190
222
|
}
|
|
191
223
|
}
|
|
@@ -260,20 +292,29 @@ export async function fundOrder({ factory, locker, claimer, commitHalfLocker, co
|
|
|
260
292
|
// independent of the Monero node. Declared outside the branch on purpose: a variable declared inside a block is
|
|
261
293
|
// not visible outside, and it must always be returned.
|
|
262
294
|
let birthBlock = null;
|
|
295
|
+
let verdict = { status: "pending", reason: "no-receipt-reader" };
|
|
263
296
|
if (typeof receipt === "function") {
|
|
264
297
|
const rc = await waitReceipt(receipt, createHash);
|
|
265
|
-
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
298
|
+
verdict = fundingReceiptVerdict(rc, { factory, expectedEscrow: escrow });
|
|
299
|
+
if (verdict.status === "reverted") {
|
|
300
|
+
const error = new Error("the funding transaction reverted");
|
|
301
|
+
error.code = "contract-reverted";
|
|
302
|
+
error.receiptStatus = 0;
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
if (verdict.status === "funded") {
|
|
306
|
+
actualEscrow = verdict.escrow;
|
|
307
|
+
birthBlock = verdict.birthBlock;
|
|
308
|
+
if (actualEscrow !== String(escrow).toLowerCase()) {
|
|
309
|
+
console.warn("[escrow] receipt address " + actualEscrow + " != predicted " + escrow + " - using the receipt one");
|
|
310
|
+
onStep?.("receipt address differs from predicted - using the receipt one");
|
|
311
|
+
}
|
|
312
|
+
} else if (!rc) {
|
|
313
|
+
onStep?.("no receipt yet - funding is pending, not funded");
|
|
314
|
+
} else {
|
|
315
|
+
onStep?.("receipt has no matching OrderCreated - funding is pending, not funded");
|
|
274
316
|
}
|
|
275
317
|
}
|
|
276
|
-
const escrowToUse = actualEscrow || escrow;
|
|
277
318
|
|
|
278
319
|
// 3) THE SECOND TRANSACTION IS GONE: the money left together with order creation. The name lockHash is kept on
|
|
279
320
|
// purpose - callers look at it, and renaming would touch them with no benefit. THERE IS AND WILL BE NO TOP-UP:
|
|
@@ -282,10 +323,16 @@ export async function fundOrder({ factory, locker, claimer, commitHalfLocker, co
|
|
|
282
323
|
// created without money.
|
|
283
324
|
const lockHash = createHash;
|
|
284
325
|
|
|
285
|
-
//
|
|
286
|
-
//
|
|
326
|
+
// ONLY A STATUS-1 RECEIPT WITH THE EXPECTED ORDER CREATED IS FUNDED. A missing receipt, a receipt without the
|
|
327
|
+
// event, or an event from another factory keeps `escrow` null and is returned as pending; the caller must not
|
|
328
|
+
// save it, retire a recovery number for it, or call it xmr-incoming.
|
|
287
329
|
// THE FEE GOES OUTWARD: it went into the transaction and the deal record must keep it - otherwise the record
|
|
288
330
|
// cannot tell how much the person deposited, and it would diverge from what the chain shows.
|
|
289
|
-
|
|
290
|
-
|
|
331
|
+
const common = { feeWei: fee.feeWei, feeTotalWei: totalValueWei, feeBps: fee.bps, feeWasLegacyFactory: Boolean(fee.legacy),
|
|
332
|
+
predictedEscrow: escrow, salt, terms, createHash, lockHash };
|
|
333
|
+
if (verdict.status !== "funded") {
|
|
334
|
+
return { ...common, escrow: null, birthBlock: null, funded: false, pending: true, pendingReason: verdict.reason };
|
|
335
|
+
}
|
|
336
|
+
return { ...common, escrow: actualEscrow, escrowMismatch: actualEscrow !== String(escrow).toLowerCase(),
|
|
337
|
+
birthBlock, funded: true, pending: false };
|
|
291
338
|
}
|
|
@@ -71,25 +71,6 @@ export function viewKeyMatchesAddress(viewScalar, expectedViewPubHex, halves) {
|
|
|
71
71
|
return got === want;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* IS THE COIN SPENDABLE AT THIS MOMENT, AND IF NOT, WHAT IS THE BOUNDARY.
|
|
79
|
-
*
|
|
80
|
-
* `unlocked` / `locked_height` / `locked_time`, with the boundary named in `untilHeight` or `untilTime`. A height
|
|
81
|
-
* that is not known is NOT "unlocked": without a height the answer stays locked, because the caller has no fact
|
|
82
|
-
* that would open it. `nowSec` defaults to the wall clock, and is a parameter so a check can pin it.
|
|
83
|
-
*/
|
|
84
|
-
export function unlockStateOf(unlockTime, { height = null, nowSec = null } = {}) {
|
|
85
|
-
const u = Number(unlockTime) || 0;
|
|
86
|
-
if (u <= 0) return { code: "unlocked", locked: false, untilHeight: null, untilTime: null };
|
|
87
|
-
if (u >= UNLOCK_TIME_BLOCK_MAX) {
|
|
88
|
-
const now = nowSec === null || nowSec === undefined ? Math.floor(Date.now() / 1000) : Number(nowSec);
|
|
89
|
-
const open = Number.isFinite(now) && now >= u;
|
|
90
|
-
return { code: open ? "unlocked" : "locked_time", locked: !open, untilHeight: null, untilTime: u };
|
|
91
|
-
}
|
|
92
|
-
const h = height === null || height === undefined ? null : Number(height);
|
|
93
|
-
const open = h !== null && Number.isFinite(h) && h >= u;
|
|
94
|
-
return { code: open ? "unlocked" : "locked_height", locked: !open, untilHeight: u, untilTime: null };
|
|
95
|
-
}
|
|
74
|
+
// THE UNLOCK RULE HAS ONE HOME: ./unlock.js. This module re-exports it for the public xmr entry instead of keeping
|
|
75
|
+
// a second copy; a second copy is a second truth about whether money can be spent.
|
|
76
|
+
export { UNLOCK_TIME_BLOCK_MAX, unlockStateOf } from "./unlock.js";
|
|
@@ -15,7 +15,11 @@ export function spendKeysFromRevealedHalf({
|
|
|
15
15
|
for (const [name, v] of Object.entries({ ownSpendHalf, ownViewHalf, otherSpendPoint, otherViewHalf, revealedHalf })) {
|
|
16
16
|
if (v === undefined || v === null || v === "") throw new Error("not set: " + name);
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// THE CONTRACT ACCEPTS THE REAL HALF PLUS THE SECP256K1 ORDER. `claim` and `refund` do not reject `h + n` (the
|
|
19
|
+
// escrow only hashes the word), but adding n is not the same point on edwards25519. Reduce the public word by the
|
|
20
|
+
// secp order first: the small real half is unchanged, and the alias lands on the same scalar.
|
|
21
|
+
const revealed = BigInt(revealedHalf) % halves.SECP_ORDER;
|
|
22
|
+
const sum = halves.combineHalves(BigInt(ownSpendHalf), revealed);
|
|
19
23
|
const sumPub = halves.publicHalves(sum).ed;
|
|
20
24
|
// The expected spend key is built FROM OWN HALF AND THE COUNTERPARTY POINT (what was in the order).
|
|
21
25
|
// The half from the file arrives as a string, so it must be converted - forgetting this crashed the XMR
|
package/engine/monero/scan.js
CHANGED
|
@@ -33,6 +33,25 @@ export function keccak(bytes) { return keccak_256(bytes); }
|
|
|
33
33
|
// corrupt the scalar, the derivation and the sum - an "invisible arrival".
|
|
34
34
|
export const ED_ORDER = ed25519.Point.CURVE().n;
|
|
35
35
|
|
|
36
|
+
// THE SECOND PEDERSEN GENERATOR, FROM THE MONERO SOURCE. `rct::H` is a fixed point whose discrete logarithm to
|
|
37
|
+
// the base point is unknown: C = mask*G + amount*H. The bytes are the compressed point from
|
|
38
|
+
// monero/src/ringct/rctTypes.h (`static const key H`), not a value recomputed from a remembered string.
|
|
39
|
+
// It is the same point the DLEQ module calls Galt - one constant, two uses, no second derivation.
|
|
40
|
+
export const PEDERSEN_H = "8b655970153799af2aeadc9ff1add0ea6c7251d54154cfa92c173a0dd39c1f94";
|
|
41
|
+
const PEDERSEN_H_POINT = ed25519.Point.fromHex(PEDERSEN_H);
|
|
42
|
+
|
|
43
|
+
function scalarPoint(point, scalar) {
|
|
44
|
+
const mod = ((BigInt(scalar) % ED_ORDER) + ED_ORDER) % ED_ORDER;
|
|
45
|
+
return mod === 0n ? ed25519.Point.ZERO : point.multiply(mod);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The Pedersen commitment C = mask*G + amount*H, compressed. Used to check a decoded amount against `outPk`. */
|
|
49
|
+
export function amountMaskCommitment(amount, mask) {
|
|
50
|
+
const value = BigInt(amount);
|
|
51
|
+
if (value < 0n) throw new Error("the amount must not be negative");
|
|
52
|
+
return scalarPoint(ed25519.Point.BASE, mask).add(scalarPoint(PEDERSEN_H_POINT, value)).toHex();
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
export function hexToBytes(hex) {
|
|
37
56
|
const t = String(hex).trim().replace(/^0x/, "");
|
|
38
57
|
if (t.length % 2) throw new Error("odd hex length");
|
|
@@ -138,32 +157,52 @@ export function txPublicKeyFromExtra(extra) {
|
|
|
138
157
|
return null;
|
|
139
158
|
}
|
|
140
159
|
|
|
141
|
-
// Amount decryption.
|
|
160
|
+
// Amount and mask decryption. THE ENCODING IS CHOSEN BY THE RingCT TYPE, not by a boolean:
|
|
161
|
+
// types 0-3 (old): every ecdhInfo entry carries a 32-byte amount and a 32-byte mask;
|
|
162
|
+
// types 4-6 (Bulletproof2, CLSAG, BulletproofPlus): the entry carries only the 8-byte amount, and the
|
|
163
|
+
// receiver DERIVES the mask as Hs("commitment_mask" || sharedSec). A transaction from a current node has no
|
|
164
|
+
// `ecdhInfo.mask` at all.
|
|
142
165
|
//
|
|
143
166
|
// THE SECRET IS THE DERIVATION SCALAR, NOT THE DERIVATION. Easy to get wrong: both are 32 bytes, but what goes
|
|
144
167
|
// into genAmountEncodingFactor is the scalar (derivation_to_scalar), written LEAST-SIGNIFICANT first. With the
|
|
145
168
|
// derivation the result looks plausible and is WRONG (checked on live numbers).
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
169
|
+
//
|
|
170
|
+
// THE TWO OLD FACTORS ARE DIFFERENT: amount -= Hs(Hs(sharedSec)), mask -= Hs(sharedSec). Using the amount's
|
|
171
|
+
// double hash for the mask (the first version) makes every old-encoding commitment fail.
|
|
172
|
+
function compactEncoding(rctType) { return Number(rctType) >= 4; }
|
|
173
|
+
function oldFactor(scalarLE, label) {
|
|
174
|
+
const base = hashToScalar(scalarLE);
|
|
175
|
+
if (label === "mask") return base;
|
|
176
|
+
return hashToScalar(hexToBytes(base.toString(16).padStart(64, "0")));
|
|
177
|
+
}
|
|
178
|
+
function decodeField(ecdhHex, scalarLE, rctType, label) {
|
|
179
|
+
if (compactEncoding(rctType)) {
|
|
180
|
+
if (label === "mask") return hashToScalar(new TextEncoder().encode("commitment_mask"), scalarLE);
|
|
181
|
+
const enc = hexToBytes(ecdhHex);
|
|
182
|
+
const prefix = new TextEncoder().encode(label);
|
|
183
|
+
const buf = new Uint8Array(prefix.length + scalarLE.length);
|
|
184
|
+
buf.set(prefix, 0); buf.set(scalarLE, prefix.length);
|
|
153
185
|
const factor = keccak(buf);
|
|
154
186
|
const out = enc.slice();
|
|
155
|
-
for (let i = 0; i <
|
|
156
|
-
// And again LEAST-SIGNIFICANT first: Monero amounts are little-endian too.
|
|
187
|
+
for (let i = 0; i < out.length && i < factor.length; i++) out[i] ^= factor[i];
|
|
157
188
|
return scalarFromBytesLE(out);
|
|
158
189
|
}
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
return (
|
|
190
|
+
const enc = hexToBytes(ecdhHex);
|
|
191
|
+
const value = BigInt("0x" + bytesToHex(enc)) % ED_ORDER;
|
|
192
|
+
return (value - oldFactor(scalarLE, label) + ED_ORDER) % ED_ORDER;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function decodeAmount(ecdhAmountHex, scalarLE, rctType) {
|
|
196
|
+
return decodeField(ecdhAmountHex, scalarLE, rctType, "amount");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function decodeMask(ecdhMaskHex, scalarLE, rctType) {
|
|
200
|
+
return decodeField(ecdhMaskHex, scalarLE, rctType, "mask");
|
|
162
201
|
}
|
|
163
202
|
|
|
164
203
|
// MAIN: parse a transaction and return OUR outputs (their indices, keys and amounts).
|
|
165
|
-
// txView - { txPublicKey, vout: [{key, index}], ecdhAmounts: [...], rctVersion }
|
|
166
|
-
export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, outputs, ecdhAmounts = [], rctVersion = 2 }) {
|
|
204
|
+
// txView - { txPublicKey, vout: [{key, index}], ecdhAmounts: [...], ecdhMasks: [...], outPk: [...], rctVersion }
|
|
205
|
+
export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, outputs, ecdhAmounts = [], ecdhMasks = [], outPk = [], rctVersion = 2 }) {
|
|
167
206
|
if (!txPublicKey) throw new Error("the transaction has no public key (extra tag 0x01) - nothing to parse");
|
|
168
207
|
// The key form is tried EXPLICITLY; the winner's name goes up so the log shows what was used. If none matched,
|
|
169
208
|
// the first meaningful form is returned, not nothing.
|
|
@@ -178,7 +217,20 @@ export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, output
|
|
|
178
217
|
if (derived.toLowerCase() === String(o.key).toLowerCase()) {
|
|
179
218
|
// The amount decryption uses THIS output's SCALAR, least-significant first.
|
|
180
219
|
const amount = ecdhAmounts[o.index] ? decodeAmount(ecdhAmounts[o.index], scalarToBytesLE(scalar), rctVersion) : null;
|
|
181
|
-
|
|
220
|
+
// THE COMMITMENT CHECK: outPk[i] = mask*G + amount*H. A node can flip a bit of the encrypted amount
|
|
221
|
+
// without any key; the commitment is the only figure it cannot forge. A missing or mismatching
|
|
222
|
+
// commitment is named on the output instead of being accepted as an arrival.
|
|
223
|
+
// COMPACT TYPES HAVE NO mask FIELD: it is derived from the same scalar. Old types carry it.
|
|
224
|
+
const mask = compactEncoding(rctVersion)
|
|
225
|
+
? decodeMask(null, scalarToBytesLE(scalar), rctVersion)
|
|
226
|
+
: (ecdhMasks[o.index] ? decodeMask(ecdhMasks[o.index], scalarToBytesLE(scalar), rctVersion) : null);
|
|
227
|
+
let amountVerified = false;
|
|
228
|
+
if (amount !== null && mask !== null && outPk[o.index]) {
|
|
229
|
+
try {
|
|
230
|
+
amountVerified = amountMaskCommitment(amount, mask).toLowerCase() === String(outPk[o.index]).replace(/^0x/, "").toLowerCase();
|
|
231
|
+
} catch { amountVerified = false; }
|
|
232
|
+
}
|
|
233
|
+
found.push({ index: o.index, key: o.key, amountAtomic: amount, maskAtomic: mask, amountVerified });
|
|
182
234
|
}
|
|
183
235
|
}
|
|
184
236
|
if (found.length) return { derivation: bytesToHex(derivation), outputs: found, viewKeyForm: form };
|
|
@@ -200,31 +252,69 @@ export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, output
|
|
|
200
252
|
// ============================================================================================
|
|
201
253
|
|
|
202
254
|
|
|
203
|
-
|
|
204
|
-
|
|
255
|
+
// A trailing-slash trim without a backtracking regular expression: the daemon URL is caller input, and CodeQL
|
|
256
|
+
// flags `/+$` on a long run of slashes as a possible denial of service.
|
|
257
|
+
const baseUrl = (value) => {
|
|
258
|
+
let out = String(value);
|
|
259
|
+
while (out.endsWith("/")) out = out.slice(0, -1);
|
|
260
|
+
return out;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export async function fetchBlock(daemonUrl, height, fetchImpl = globalThis.fetch) {
|
|
264
|
+
const res = await fetchImpl(`${baseUrl(daemonUrl)}/json_rpc`, {
|
|
205
265
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
206
266
|
body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
|
|
207
267
|
});
|
|
208
268
|
const j = await res.json();
|
|
269
|
+
// A BUSY ANSWER IS NOT AN EMPTY BLOCK. The daemon answers BUSY while it is still indexing; a reader that treats
|
|
270
|
+
// the missing `json` as "no transactions" reports a complete range with a smaller balance.
|
|
271
|
+
if (j && String(j.status || "").toUpperCase() === "BUSY") throw new Error(`block ${height}: daemon is BUSY`);
|
|
209
272
|
if (!j.result) throw new Error(`block ${height} not received: ${j.error ? j.error.message : "empty answer"}`);
|
|
273
|
+
if (String(j.result.status || "").toUpperCase() === "BUSY") throw new Error(`block ${height}: daemon is BUSY`);
|
|
274
|
+
// THE BLOCK MUST CARRY ITS DECODED BODY. `json` is what scanRange walks; without it the block is unreadable, not
|
|
275
|
+
// empty. A result with only a blob or a header is refused by the same rule.
|
|
276
|
+
if (j.result.json === undefined || j.result.json === null || j.result.json === "") {
|
|
277
|
+
throw new Error(`block ${height}: no decoded body`);
|
|
278
|
+
}
|
|
210
279
|
return j.result;
|
|
211
280
|
}
|
|
212
281
|
|
|
213
282
|
// Transactions by hash list. We hit the PATH /get_transactions, not /json_rpc: over JSON-RPC this method
|
|
214
283
|
// silently returns empty, which looks like "no transactions".
|
|
215
|
-
export async function fetchTransactions(daemonUrl, hashes) {
|
|
284
|
+
export async function fetchTransactions(daemonUrl, hashes, fetchImpl = globalThis.fetch) {
|
|
216
285
|
if (!hashes.length) return [];
|
|
217
|
-
const res = await
|
|
286
|
+
const res = await fetchImpl(`${baseUrl(daemonUrl)}/get_transactions`, {
|
|
218
287
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
219
288
|
body: JSON.stringify({ txs_hashes: hashes, decode_as_json: true }),
|
|
220
289
|
});
|
|
221
290
|
const j = await res.json();
|
|
222
|
-
|
|
291
|
+
// A BUSY ANSWER IS NOT AN EMPTY BLOCK. The daemon says BUSY while it is still indexing; returning [] would turn
|
|
292
|
+
// money that is there into a complete scan with a zero balance.
|
|
293
|
+
if (j && String(j.status || "").toUpperCase() === "BUSY") throw new Error("transactions-busy");
|
|
294
|
+
const wrappers = Array.isArray(j && j.txs) ? j.txs : [];
|
|
295
|
+
const encoded = Array.isArray(j && j.txs_as_json) ? j.txs_as_json : [];
|
|
296
|
+
// EVERY HASH WE ASKED FOR MUST COME BACK. A partial answer is a missing block, not a quiet "no transactions".
|
|
297
|
+
const requested = [...new Set(hashes.map((h) => String(h).replace(/^0x/, "").toLowerCase()))];
|
|
298
|
+
const byHash = new Map();
|
|
299
|
+
for (const wrapper of wrappers) {
|
|
300
|
+
const hash = String((wrapper && wrapper.tx_hash) || "").replace(/^0x/, "").toLowerCase();
|
|
301
|
+
if (!hash) continue;
|
|
302
|
+
if (byHash.has(hash)) throw new Error("transactions-duplicate: " + hash);
|
|
303
|
+
byHash.set(hash, wrapper);
|
|
304
|
+
}
|
|
305
|
+
const missing = requested.filter((h) => !byHash.has(h));
|
|
306
|
+
if (missing.length) throw new Error("transactions-missing: " + missing.length);
|
|
307
|
+
// EXACTLY THE REQUESTED SET. An extra transaction is not money this range asked for, and it may belong to another
|
|
308
|
+
// block; counting it silently inflates the sum. A decoded body is required for every wrapper, one for one.
|
|
309
|
+
if (byHash.size !== requested.length) throw new Error("transactions-extra: " + (byHash.size - requested.length));
|
|
310
|
+
if (encoded.length !== wrappers.length) throw new Error("transactions-undecoded");
|
|
223
311
|
// HASH AND HEIGHT LIVE IN THE WRAPPER, not in the parsed body: as_json omits them. Without this stitching a
|
|
224
312
|
// "found transfer" would have neither hash nor height - useless to the caller, yet everything would look successful.
|
|
225
|
-
|
|
313
|
+
const indexOf = new Map(wrappers.map((w, i) => [String((w && w.tx_hash) || "").replace(/^0x/, "").toLowerCase(), i]));
|
|
314
|
+
return requested.map((hash) => {
|
|
315
|
+
const w = byHash.get(hash) || {};
|
|
316
|
+
const s = encoded[indexOf.get(hash)];
|
|
226
317
|
const body = typeof s === "string" ? JSON.parse(s) : s;
|
|
227
|
-
const w = wrappers[i] || {};
|
|
228
318
|
body.tx_hash = w.tx_hash || body.tx_hash || null;
|
|
229
319
|
body.block_height = w.block_height ?? null;
|
|
230
320
|
body.confirmations = w.confirmations ?? null;
|
|
@@ -235,22 +325,29 @@ export async function fetchTransactions(daemonUrl, hashes) {
|
|
|
235
325
|
|
|
236
326
|
// Parsing one transaction against the watched addresses. watched: [{ address, viewSecret, spendPub }] - viewSecret
|
|
237
327
|
// is taken as a number, LE-hex or BE-hex. The result carries viewKeyForm: the form name that matched.
|
|
238
|
-
export function matchTransaction(tx, watched, { height = null, nowSec = null } = {}) {
|
|
328
|
+
export function matchTransaction(tx, watched, { height = null, tipHeight = null, nowSec = null } = {}) {
|
|
239
329
|
const out = [];
|
|
240
330
|
const txPublicKey = txPublicKeyFromExtra(tx.extra);
|
|
241
331
|
if (!txPublicKey) return out; // no key in extra - nothing to look at
|
|
242
332
|
// WHETHER THE OUTPUT IS LOCKED IS DECIDED HERE, ONCE PER TRANSACTION: all its outputs carry one unlock_time.
|
|
243
333
|
// A normal transaction (unlock_time = 0) goes as before; the height comes from the block holding the transfer.
|
|
244
334
|
const blockHeight = height === null || height === undefined ? (tx.block_height ?? null) : height;
|
|
245
|
-
|
|
335
|
+
// THE INCLUSION HEIGHT IS FOR CONFIRMATIONS, THE TIP FOR THE LOCK. A height lock at 101 is open once the chain
|
|
336
|
+
// has reached 101, not once the transaction was mined at 100; using the inclusion height kept a matured lock
|
|
337
|
+
// reporting `xmr-locked` forever.
|
|
338
|
+
const unlockHeight = tipHeight === null || tipHeight === undefined ? blockHeight : Number(tipHeight);
|
|
339
|
+
const unlock = unlockStateOf(tx.unlock_time, { height: unlockHeight, nowSec });
|
|
246
340
|
const keyOf = (o) => ((o.target || {}).tagged_key || o.target || {}).key;
|
|
247
341
|
const outputs = (tx.vout || []).map((o, i) => ({ index: i, key: keyOf(o) }));
|
|
248
|
-
const
|
|
342
|
+
const ecdh = ((tx.rct_signatures || {}).ecdhInfo || []);
|
|
343
|
+
const ecdhAmounts = ecdh.map((e) => e.amount);
|
|
344
|
+
const ecdhMasks = ecdh.map((e) => e.mask);
|
|
345
|
+
const outPk = ((tx.rct_signatures || {}).outPk || []);
|
|
249
346
|
const rctVersion = (tx.rct_signatures || {}).type || 2;
|
|
250
347
|
for (const w of watched) {
|
|
251
348
|
const r = findOurOutputs({
|
|
252
349
|
txPublicKey, viewSecretHex: w.viewSecret, spendPubHex: w.spendPub,
|
|
253
|
-
outputs, ecdhAmounts, rctVersion: Number(rctVersion)
|
|
350
|
+
outputs, ecdhAmounts, ecdhMasks, outPk, rctVersion: Number(rctVersion) || 1,
|
|
254
351
|
});
|
|
255
352
|
if (r.outputs.length) out.push({ address: w.address, index: w.index ?? null, outputs: r.outputs, derivation: r.derivation, viewKeyForm: r.viewKeyForm, unlockTime: Number(tx.unlock_time) || 0, unlock });
|
|
256
353
|
}
|
|
@@ -259,13 +356,14 @@ export function matchTransaction(tx, watched, { height = null, nowSec = null } =
|
|
|
259
356
|
|
|
260
357
|
// Range scan. Returns per address the sum (receivedAtomic - only the UNLOCKED), hashes, the locked sum
|
|
261
358
|
// (lockedAtomic) with its boundary (lockedUntil) and the best (highest) height of a found output.
|
|
262
|
-
export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log = () => {}, concurrency = 4, nowSec = null }) {
|
|
359
|
+
export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log = () => {}, concurrency = 4, nowSec = null, tipHeight = null, fetchImpl = globalThis.fetch }) {
|
|
263
360
|
const from = Number(fromHeight), to = Number(toHeight);
|
|
264
361
|
if (!(from >= 0) || !(to >= from)) throw new Error("bad height range: " + fromHeight + ".." + toHeight);
|
|
265
362
|
const acc = new Map();
|
|
266
363
|
for (const w of watched) acc.set(w.address, { address: w.address, receivedAtomic: 0n, lockedAtomic: 0n, lockedUntil: null, lockedTxids: [], txids: [], bestHeight: null, outputs: 0, viewKeyForm: null });
|
|
267
364
|
const heights = [];
|
|
268
365
|
for (let h = from; h <= to; h++) heights.push(h);
|
|
366
|
+
const missingHeights = [];
|
|
269
367
|
|
|
270
368
|
// Bounded parallelism: the node is a shared resource and blocks are independent.
|
|
271
369
|
let cursor = 0;
|
|
@@ -273,19 +371,21 @@ export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log
|
|
|
273
371
|
while (cursor < heights.length) {
|
|
274
372
|
const h = heights[cursor++];
|
|
275
373
|
try {
|
|
276
|
-
const block = await fetchBlock(daemonUrl, h);
|
|
374
|
+
const block = await fetchBlock(daemonUrl, h, fetchImpl);
|
|
277
375
|
const hashes = block.json ? (typeof block.json === "string" ? JSON.parse(block.json) : block.json).tx_hashes || [] : [];
|
|
278
376
|
if (!hashes.length) continue;
|
|
279
|
-
const txs = await fetchTransactions(daemonUrl, hashes);
|
|
377
|
+
const txs = await fetchTransactions(daemonUrl, hashes, fetchImpl);
|
|
280
378
|
for (const tx of txs) {
|
|
281
|
-
for (const hit of matchTransaction(tx, watched, { height: h, nowSec })) {
|
|
379
|
+
for (const hit of matchTransaction(tx, watched, { height: h, tipHeight: tipHeight === null || tipHeight === undefined ? to : tipHeight, nowSec })) {
|
|
282
380
|
const a = acc.get(hit.address);
|
|
283
381
|
if (!a) continue;
|
|
284
382
|
if (hit.viewKeyForm) a.viewKeyForm = hit.viewKeyForm; // what matched - this goes to the log
|
|
285
383
|
const lockedHere = !!(hit.unlock && hit.unlock.locked);
|
|
286
384
|
const hash = String(tx.tx_hash || "").replace(/^0x/, "");
|
|
287
385
|
for (const o of hit.outputs) {
|
|
288
|
-
|
|
386
|
+
// AN UNVERIFIED AMOUNT IS NOT MONEY. A node can alter ecdhInfo.amount; until the Pedersen
|
|
387
|
+
// commitment confirms it, the figure is not added to any sum.
|
|
388
|
+
const amount = o.amountVerified ? (o.amountAtomic || 0n) : 0n;
|
|
289
389
|
// A LOCKED OUTPUT IS NOT AN ARRIVAL. Its transfer shows in txids (money is on the address) but the
|
|
290
390
|
// sum does NOT go into receivedAtomic: else 'ready' would allow claiming ETH against locked XMR.
|
|
291
391
|
if (lockedHere) {
|
|
@@ -302,11 +402,22 @@ export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log
|
|
|
302
402
|
}
|
|
303
403
|
}
|
|
304
404
|
} catch (e) {
|
|
405
|
+
// A BLOCK THAT COULD NOT BE READ IS NOT A BLOCK WITH NO MONEY. Keep its height so the caller can say the
|
|
406
|
+
// range is partial instead of treating a smaller sum as complete.
|
|
407
|
+
missingHeights.push(h);
|
|
305
408
|
log({ event: "indexer_block_error", height: h, error: String(e.message).slice(0, 160) });
|
|
306
409
|
}
|
|
307
410
|
}
|
|
308
411
|
};
|
|
309
412
|
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, heights.length)) }, worker));
|
|
310
|
-
|
|
413
|
+
const missing = [...new Set(missingHeights)].sort((a, b) => a - b);
|
|
414
|
+
return {
|
|
415
|
+
fromHeight: from,
|
|
416
|
+
toHeight: to,
|
|
417
|
+
results: Array.from(acc.values()),
|
|
418
|
+
complete: missing.length === 0,
|
|
419
|
+
missingHeights: missing,
|
|
420
|
+
...(missing.length ? { incompleteReason: "blocks-unreadable" } : {}),
|
|
421
|
+
};
|
|
311
422
|
}
|
|
312
423
|
|
package/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// 2) not a single DOM access: `document`, `window`, `localStorage` do not occur in the SDK. Storage and the wallet
|
|
10
10
|
// come from outside: as an adapter and an EIP-1193 provider;
|
|
11
11
|
// 3) the SDK opens no windows: the interface shows the wallet modal, the SDK accepts a ready provider;
|
|
12
|
-
// 4) funds are not locked until the interface has confirmed the recovery
|
|
12
|
+
// 4) funds are not locked until the interface has confirmed the one recovery key is saved (onRecoveryKey);
|
|
13
13
|
// 5) the guards cannot be bypassed: preflight is part of the same path, not a separate button.
|
|
14
14
|
//
|
|
15
15
|
// Naming language: the values are data. `step` and `code` are stable strings from the tables below; they are
|
|
@@ -288,7 +288,15 @@ export interface SdkConfig {
|
|
|
288
288
|
export interface ChainConfig {
|
|
289
289
|
readonly id: string;
|
|
290
290
|
/** The network's virtual machine: "evm" today; "tron" and "solana" - when their leg drivers appear. */
|
|
291
|
-
readonly vm: string;
|
|
291
|
+
readonly vm: string | null;
|
|
292
|
+
/** What this settlement side looks like. Metadata only: it does not claim that a driver exists. */
|
|
293
|
+
readonly settlementProfile?: {
|
|
294
|
+
readonly vm: string;
|
|
295
|
+
readonly addressFormat: string;
|
|
296
|
+
readonly settlementKind: "contract" | "program" | "utxo-script" | string;
|
|
297
|
+
readonly refKind: "address" | "pda" | "script" | string;
|
|
298
|
+
readonly txRef: "hash" | "signature" | "txid" | string;
|
|
299
|
+
} | null;
|
|
292
300
|
readonly chainId: number;
|
|
293
301
|
readonly name: string;
|
|
294
302
|
readonly nativeSymbol: string;
|
|
@@ -742,14 +750,12 @@ export interface SwapsApi {
|
|
|
742
750
|
/**
|
|
743
751
|
* Start a swap. The SDK holds the step order, not the screen:
|
|
744
752
|
* 1) guards (preflight); 2) the swap record (createSwap); 3) the order - it GENERATES the key halves;
|
|
745
|
-
* 4) the recovery
|
|
753
|
+
* 4) the one recovery key and the interface's confirmation that it is saved; 5) locking the funds (signature);
|
|
746
754
|
* 6) saving on the server; 7) opening the watch on the Monero address.
|
|
747
|
-
* HALVES DO NOT EXIST BEFORE THE ORDER: the
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
* FUNDS ARE NOT LOCKED UNTIL onRecoveryFile ALLOWS IT: the order is like this not for convenience, but because a
|
|
752
|
-
* lock without a recovery file is a lock with nothing to open it with if the browser is lost.
|
|
755
|
+
* HALVES DO NOT EXIST BEFORE THE ORDER: the key's halves and the counterparty's open points are taken from the
|
|
756
|
+
* signed order. The key is shown BEFORE locking the funds.
|
|
757
|
+
* FUNDS ARE NOT LOCKED UNTIL onRecoveryKey ALLOWS IT: a lock without a saved key is a lock with nothing to open
|
|
758
|
+
* the swap with if the browser is lost.
|
|
753
759
|
*/
|
|
754
760
|
start(request: StartRequest): Promise<SwapStart>;
|
|
755
761
|
/**
|
|
@@ -1123,23 +1129,20 @@ export interface StartRequest {
|
|
|
1123
1129
|
/**
|
|
1124
1130
|
* The EVM wallet for LOCKING THE FUNDS: an adapter with `send` and `receipt` (the same role as WalletAdapter).
|
|
1125
1131
|
* It comes from outside and is NOT substituted by default: without it the lock step answers with a code
|
|
1126
|
-
* (`bad-input`), rather than going in blind. The recovery
|
|
1132
|
+
* (`bad-input`), rather than going in blind. The recovery key has by now been confirmed by the person.
|
|
1127
1133
|
*/
|
|
1128
1134
|
readonly wallet?: WalletAdapter;
|
|
1129
|
-
/**
|
|
1130
|
-
* Hand the OLD per-swap recovery file to the interface and wait for the person's decision.
|
|
1131
|
-
* `true` - the person confirmed the file is saved; `false` - the swap is not started.
|
|
1132
|
-
* Not used when `recoveryKey` is present: that path has one key, saved once.
|
|
1133
|
-
*/
|
|
1134
|
-
onRecoveryFile?(file: RecoveryFile): Promise<boolean>;
|
|
1135
1135
|
/**
|
|
1136
1136
|
* THE ONE RECOVERY KEY (sdk#76). A BIP39 mnemonic, or a key object from `@ninsei-labs/sdk/recovery`.
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1137
|
+
* The swap's spend and view halves are derived from it; the per-swap recovery file path is gone, and the key is
|
|
1138
|
+
* kept through the SDK's storage adapter.
|
|
1139
1139
|
*/
|
|
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 per-order salt the caller put into the signed quote. When present, the key uses the v2 salt binding and
|
|
1144
|
+
* no index is reserved; it must equal `order.salt`. */
|
|
1145
|
+
recoverySalt?: string | number | bigint;
|
|
1143
1146
|
/** The first block for the on-chain number check. Absent - the chain registry's escrow deployment block, and
|
|
1144
1147
|
* zero only when the registry has no height. */
|
|
1145
1148
|
fromBlock?: string | number;
|
|
@@ -1272,6 +1275,7 @@ export interface SweepApi {
|
|
|
1272
1275
|
*/
|
|
1273
1276
|
wallet(request: SweepWalletRequest): SweepWallet;
|
|
1274
1277
|
|
|
1278
|
+
/**
|
|
1275
1279
|
* COMPARE A DESTINATION WITH THE FILE'S PROMISE. `ok` is "usable address on the file's network", `matches` is
|
|
1276
1280
|
* "the promised one" and is null when the file named nobody. Sending XMR elsewhere is a different payment, so the
|
|
1277
1281
|
* comparison is not presentation.
|
|
@@ -1344,6 +1348,10 @@ export interface SweepRestoreRequest {
|
|
|
1344
1348
|
export interface SweepRestoreResult {
|
|
1345
1349
|
readonly total: string;
|
|
1346
1350
|
readonly unlocked: string;
|
|
1351
|
+
/** False when the wallet did not reach the node's height after its sync; a zero balance is then not evidence. */
|
|
1352
|
+
readonly scanComplete: boolean;
|
|
1353
|
+
/** `sync-failed`, `behind-node` or `no-heights` when `scanComplete` is false. */
|
|
1354
|
+
readonly scanIncompleteReason?: string;
|
|
1347
1355
|
/** The height the scan actually started from. */
|
|
1348
1356
|
readonly scanHeight: number;
|
|
1349
1357
|
readonly walletHeight: number | null;
|
|
@@ -1431,7 +1439,10 @@ export interface SweepFromOrderRequest {
|
|
|
1431
1439
|
readonly edPointLocker: string;
|
|
1432
1440
|
readonly edPointClaimer: string;
|
|
1433
1441
|
readonly edViewPointLocker: string;
|
|
1434
|
-
readonly index
|
|
1442
|
+
readonly index?: number | null;
|
|
1443
|
+
readonly derivation?: "index-v1" | "salt-v2";
|
|
1444
|
+
readonly factory?: string;
|
|
1445
|
+
readonly salt?: string;
|
|
1435
1446
|
readonly side: "locker" | "claimer";
|
|
1436
1447
|
readonly startHeight?: number | null;
|
|
1437
1448
|
readonly blockTime?: number | null;
|
|
@@ -1549,8 +1560,9 @@ export interface SweepSignResult {
|
|
|
1549
1560
|
/** The free remainder at the moment of signing, in atomic units (string). */
|
|
1550
1561
|
readonly amount: string;
|
|
1551
1562
|
/**
|
|
1552
|
-
* THE FEE OF THE SIGNED TRANSFER, in atomic units,
|
|
1553
|
-
* moves the whole remainder, so the fee comes out of it - `receive` is
|
|
1563
|
+
* THE FEE OF THE SIGNED TRANSFER, in atomic units, summed over every signed transaction, or null when any of
|
|
1564
|
+
* them could not report one. A withdrawal moves the whole remainder, so the fee comes out of it - `receive` is
|
|
1565
|
+
* what the recipient gets.
|
|
1554
1566
|
*/
|
|
1555
1567
|
readonly fee: string | null;
|
|
1556
1568
|
/** The remainder minus the fee. Null when the fee is unknown, or when a reported fee claims more than the remainder. */
|
|
@@ -1763,6 +1775,8 @@ export type ErrorCode =
|
|
|
1763
1775
|
| "server-unavailable"
|
|
1764
1776
|
/** The service answered and did not accept: the connection got through, the request did not (status and reason in params). */
|
|
1765
1777
|
| "server-refused"
|
|
1778
|
+
/** The funding transaction was sent, but no status-1 receipt with the expected OrderCreated proves the escrow. */
|
|
1779
|
+
| "funding-pending"
|
|
1766
1780
|
/** The wait for the XMR to arrive expired: a separate code, because this is a state of the money, not a breakdown. */
|
|
1767
1781
|
| "xmr-timeout"
|
|
1768
1782
|
/**
|