@xchainjs/xchain-monero 0.2.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/README.md +66 -0
- package/lib/client.d.ts +76 -0
- package/lib/const.d.ts +15 -0
- package/lib/crypto/address.d.ts +15 -0
- package/lib/crypto/base58monero.d.ts +13 -0
- package/lib/crypto/bulletproofsPlus.d.ts +23 -0
- package/lib/crypto/clsag.d.ts +31 -0
- package/lib/crypto/ecdh.d.ts +33 -0
- package/lib/crypto/hashToPoint.d.ts +15 -0
- package/lib/crypto/keyImage.d.ts +12 -0
- package/lib/crypto/keys.d.ts +19 -0
- package/lib/crypto/pedersen.d.ts +22 -0
- package/lib/crypto/stealth.d.ts +49 -0
- package/lib/daemon.d.ts +96 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.esm.js +2525 -0
- package/lib/index.js +2538 -0
- package/lib/lws.d.ts +88 -0
- package/lib/scanner.d.ts +51 -0
- package/lib/tx/builder.d.ts +45 -0
- package/lib/tx/decoySelection.d.ts +29 -0
- package/lib/tx/serialize.d.ts +43 -0
- package/lib/tx/types.d.ts +56 -0
- package/lib/types.d.ts +24 -0
- package/lib/utils.d.ts +52 -0
- package/package.json +47 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2538 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var xchainClient = require('@xchainjs/xchain-client');
|
|
4
|
+
var xchainCrypto = require('@xchainjs/xchain-crypto');
|
|
5
|
+
var xchainUtil = require('@xchainjs/xchain-util');
|
|
6
|
+
var sha3 = require('@noble/hashes/sha3');
|
|
7
|
+
var slip10 = require('micro-key-producer/slip10.js');
|
|
8
|
+
var ed25519 = require('@noble/curves/ed25519');
|
|
9
|
+
var utils = require('@noble/curves/abstract/utils');
|
|
10
|
+
|
|
11
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
12
|
+
|
|
13
|
+
var slip10__default = /*#__PURE__*/_interopDefault(slip10);
|
|
14
|
+
|
|
15
|
+
/******************************************************************************
|
|
16
|
+
Copyright (c) Microsoft Corporation.
|
|
17
|
+
|
|
18
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
19
|
+
purpose with or without fee is hereby granted.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
22
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
23
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
24
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
25
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
26
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
27
|
+
PERFORMANCE OF THIS SOFTWARE.
|
|
28
|
+
***************************************************************************** */
|
|
29
|
+
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
function __awaiter(thisArg, _arguments, P, generator) {
|
|
33
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
34
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
35
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
36
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
37
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
38
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
43
|
+
var e = new Error(message);
|
|
44
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Monero chain symbol
|
|
49
|
+
*/
|
|
50
|
+
const XMRChain = 'XMR';
|
|
51
|
+
/**
|
|
52
|
+
* Monero native asset decimals (piconero = 10^-12 XMR)
|
|
53
|
+
*/
|
|
54
|
+
const XMR_DECIMALS = 12;
|
|
55
|
+
/**
|
|
56
|
+
* Monero native asset
|
|
57
|
+
*/
|
|
58
|
+
const AssetXMR = {
|
|
59
|
+
chain: 'XMR',
|
|
60
|
+
ticker: 'XMR',
|
|
61
|
+
symbol: 'XMR',
|
|
62
|
+
type: xchainUtil.AssetType.NATIVE,
|
|
63
|
+
};
|
|
64
|
+
const mainnetExplorer = new xchainClient.ExplorerProvider('https://xmrchain.net/', 'https://xmrchain.net/search?value=%%ADDRESS%%', 'https://xmrchain.net/tx/%%TX_ID%%');
|
|
65
|
+
const defaultXMRParams = {
|
|
66
|
+
network: xchainClient.Network.Mainnet,
|
|
67
|
+
rootDerivationPaths: {
|
|
68
|
+
[xchainClient.Network.Mainnet]: "m/44'/128'/",
|
|
69
|
+
[xchainClient.Network.Testnet]: "m/44'/128'/",
|
|
70
|
+
[xchainClient.Network.Stagenet]: "m/44'/128'/",
|
|
71
|
+
},
|
|
72
|
+
explorerProviders: {
|
|
73
|
+
[xchainClient.Network.Mainnet]: mainnetExplorer,
|
|
74
|
+
[xchainClient.Network.Testnet]: new xchainClient.ExplorerProvider('https://stagenet.xmrchain.net/', 'https://stagenet.xmrchain.net/search?value=%%ADDRESS%%', 'https://stagenet.xmrchain.net/tx/%%TX_ID%%'),
|
|
75
|
+
[xchainClient.Network.Stagenet]: mainnetExplorer,
|
|
76
|
+
},
|
|
77
|
+
daemonUrls: {
|
|
78
|
+
[xchainClient.Network.Mainnet]: ['https://xmr-node.cakewallet.com:18081', 'https://node.sethforprivacy.com'],
|
|
79
|
+
[xchainClient.Network.Testnet]: ['http://stagenet.xmr-tw.org:38081'],
|
|
80
|
+
[xchainClient.Network.Stagenet]: ['https://xmr-node.cakewallet.com:18081', 'https://node.sethforprivacy.com'],
|
|
81
|
+
},
|
|
82
|
+
lwsUrls: {
|
|
83
|
+
[xchainClient.Network.Mainnet]: [],
|
|
84
|
+
[xchainClient.Network.Testnet]: [],
|
|
85
|
+
[xchainClient.Network.Stagenet]: [],
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Monero's custom base58 encoding.
|
|
91
|
+
* Unlike Bitcoin's base58, Monero encodes in fixed 8-byte blocks → 11-char blocks.
|
|
92
|
+
* The last block may be shorter depending on remaining bytes.
|
|
93
|
+
*/
|
|
94
|
+
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
95
|
+
/** Number of base58 chars needed to encode N bytes (for N = 0..8) */
|
|
96
|
+
const ENCODED_BLOCK_SIZES = [0, 2, 3, 5, 6, 7, 9, 10, 11];
|
|
97
|
+
const FULL_BLOCK_SIZE = 8;
|
|
98
|
+
const FULL_ENCODED_BLOCK_SIZE = 11;
|
|
99
|
+
/**
|
|
100
|
+
* Encodes a block of bytes (up to 8) into base58 characters.
|
|
101
|
+
*/
|
|
102
|
+
function encodeBlock(data) {
|
|
103
|
+
if (data.length === 0)
|
|
104
|
+
return '';
|
|
105
|
+
const size = ENCODED_BLOCK_SIZES[data.length];
|
|
106
|
+
// Convert bytes to a big number (big-endian)
|
|
107
|
+
let num = BigInt(0);
|
|
108
|
+
for (let i = 0; i < data.length; i++) {
|
|
109
|
+
num = (num << BigInt(8)) | BigInt(data[i]);
|
|
110
|
+
}
|
|
111
|
+
// Convert to base58 with fixed-width output
|
|
112
|
+
const chars = new Array(size);
|
|
113
|
+
for (let i = size - 1; i >= 0; i--) {
|
|
114
|
+
chars[i] = ALPHABET[Number(num % BigInt(58))];
|
|
115
|
+
num = num / BigInt(58);
|
|
116
|
+
}
|
|
117
|
+
return chars.join('');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Decodes a base58-encoded block back to bytes.
|
|
121
|
+
*/
|
|
122
|
+
function decodeBlock(encoded, targetLen) {
|
|
123
|
+
if (encoded.length === 0)
|
|
124
|
+
return new Uint8Array(0);
|
|
125
|
+
// Convert base58 string to number
|
|
126
|
+
let num = BigInt(0);
|
|
127
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
128
|
+
const idx = ALPHABET.indexOf(encoded[i]);
|
|
129
|
+
if (idx === -1)
|
|
130
|
+
throw new Error(`Invalid base58 character: ${encoded[i]}`);
|
|
131
|
+
num = num * BigInt(58) + BigInt(idx);
|
|
132
|
+
}
|
|
133
|
+
// Convert number to bytes (big-endian, fixed width)
|
|
134
|
+
const result = new Uint8Array(targetLen);
|
|
135
|
+
for (let i = targetLen - 1; i >= 0; i--) {
|
|
136
|
+
result[i] = Number(num & BigInt(0xff));
|
|
137
|
+
num = num >> BigInt(8);
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Encodes a Uint8Array using Monero's base58 scheme.
|
|
143
|
+
*/
|
|
144
|
+
const cnBase58Encode = (data) => {
|
|
145
|
+
const fullBlocks = Math.floor(data.length / FULL_BLOCK_SIZE);
|
|
146
|
+
const lastBlockSize = data.length % FULL_BLOCK_SIZE;
|
|
147
|
+
let result = '';
|
|
148
|
+
for (let i = 0; i < fullBlocks; i++) {
|
|
149
|
+
const block = data.slice(i * FULL_BLOCK_SIZE, (i + 1) * FULL_BLOCK_SIZE);
|
|
150
|
+
result += encodeBlock(block);
|
|
151
|
+
}
|
|
152
|
+
if (lastBlockSize > 0) {
|
|
153
|
+
const lastBlock = data.slice(fullBlocks * FULL_BLOCK_SIZE);
|
|
154
|
+
result += encodeBlock(lastBlock);
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Decodes a Monero base58-encoded string to Uint8Array.
|
|
160
|
+
*/
|
|
161
|
+
const cnBase58Decode = (encoded) => {
|
|
162
|
+
// Figure out how many full encoded blocks and the remainder
|
|
163
|
+
const fullBlocks = Math.floor(encoded.length / FULL_ENCODED_BLOCK_SIZE);
|
|
164
|
+
const lastEncodedSize = encoded.length % FULL_ENCODED_BLOCK_SIZE;
|
|
165
|
+
// Find the byte length of the last block
|
|
166
|
+
let lastBlockSize = 0;
|
|
167
|
+
if (lastEncodedSize > 0) {
|
|
168
|
+
lastBlockSize = ENCODED_BLOCK_SIZES.indexOf(lastEncodedSize);
|
|
169
|
+
if (lastBlockSize === -1)
|
|
170
|
+
throw new Error(`Invalid encoded length: ${encoded.length}`);
|
|
171
|
+
}
|
|
172
|
+
const totalBytes = fullBlocks * FULL_BLOCK_SIZE + lastBlockSize;
|
|
173
|
+
const result = new Uint8Array(totalBytes);
|
|
174
|
+
let offset = 0;
|
|
175
|
+
let encodedOffset = 0;
|
|
176
|
+
for (let i = 0; i < fullBlocks; i++) {
|
|
177
|
+
const block = encoded.slice(encodedOffset, encodedOffset + FULL_ENCODED_BLOCK_SIZE);
|
|
178
|
+
const decoded = decodeBlock(block, FULL_BLOCK_SIZE);
|
|
179
|
+
result.set(decoded, offset);
|
|
180
|
+
offset += FULL_BLOCK_SIZE;
|
|
181
|
+
encodedOffset += FULL_ENCODED_BLOCK_SIZE;
|
|
182
|
+
}
|
|
183
|
+
if (lastEncodedSize > 0) {
|
|
184
|
+
const lastEncoded = encoded.slice(encodedOffset);
|
|
185
|
+
const decoded = decodeBlock(lastEncoded, lastBlockSize);
|
|
186
|
+
result.set(decoded, offset);
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Network address prefixes (byte value) */
|
|
192
|
+
const NETWORK_PREFIXES = {
|
|
193
|
+
0: 0x12, // mainnet (18)
|
|
194
|
+
1: 0x35, // testnet (53)
|
|
195
|
+
2: 0x18, // stagenet (24)
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Encodes a standard Monero address from public spend/view keys.
|
|
199
|
+
* Format: [1-byte prefix][32-byte pubSpend][32-byte pubView][4-byte checksum]
|
|
200
|
+
* Result is 69 bytes → 95 base58 characters.
|
|
201
|
+
*/
|
|
202
|
+
const encodeAddress = (publicSpendKey, publicViewKey, networkType) => {
|
|
203
|
+
const prefix = NETWORK_PREFIXES[networkType];
|
|
204
|
+
if (prefix === undefined)
|
|
205
|
+
throw new Error(`Unknown network type: ${networkType}`);
|
|
206
|
+
// Build the 65-byte payload: prefix + pubSpend + pubView
|
|
207
|
+
const payload = new Uint8Array(1 + 32 + 32);
|
|
208
|
+
payload[0] = prefix;
|
|
209
|
+
payload.set(publicSpendKey, 1);
|
|
210
|
+
payload.set(publicViewKey, 33);
|
|
211
|
+
// Checksum: first 4 bytes of keccak256(payload)
|
|
212
|
+
const checksum = sha3.keccak_256(payload).slice(0, 4);
|
|
213
|
+
// Full data: payload + checksum = 69 bytes
|
|
214
|
+
const full = new Uint8Array(69);
|
|
215
|
+
full.set(payload, 0);
|
|
216
|
+
full.set(checksum, 65);
|
|
217
|
+
return cnBase58Encode(full);
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* Decodes a standard Monero address into its public spend/view keys.
|
|
221
|
+
* Validates the checksum.
|
|
222
|
+
*/
|
|
223
|
+
const decodeAddress = (address) => {
|
|
224
|
+
const data = cnBase58Decode(address);
|
|
225
|
+
if (data.length !== 69)
|
|
226
|
+
throw new Error(`Invalid address length: ${data.length}`);
|
|
227
|
+
const payload = data.slice(0, 65);
|
|
228
|
+
const checksum = data.slice(65, 69);
|
|
229
|
+
// Verify checksum
|
|
230
|
+
const expected = sha3.keccak_256(payload).slice(0, 4);
|
|
231
|
+
for (let i = 0; i < 4; i++) {
|
|
232
|
+
if (checksum[i] !== expected[i])
|
|
233
|
+
throw new Error('Invalid address checksum');
|
|
234
|
+
}
|
|
235
|
+
const prefix = data[0];
|
|
236
|
+
let networkType;
|
|
237
|
+
if (prefix === 0x12)
|
|
238
|
+
networkType = 0; // mainnet
|
|
239
|
+
else if (prefix === 0x35)
|
|
240
|
+
networkType = 1; // testnet
|
|
241
|
+
else if (prefix === 0x18)
|
|
242
|
+
networkType = 2; // stagenet
|
|
243
|
+
else
|
|
244
|
+
throw new Error(`Unknown address prefix: ${prefix}`);
|
|
245
|
+
return {
|
|
246
|
+
publicSpendKey: data.slice(1, 33),
|
|
247
|
+
publicViewKey: data.slice(33, 65),
|
|
248
|
+
networkType,
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Monero network type values matching monero-ts MoneroNetworkType enum
|
|
254
|
+
*/
|
|
255
|
+
const MoneroNetworkType = {
|
|
256
|
+
MAINNET: 0,
|
|
257
|
+
STAGENET: 2,
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Maps XChainJS Network to Monero network type
|
|
261
|
+
*/
|
|
262
|
+
const getMoneroNetworkType = (network) => {
|
|
263
|
+
const networkMap = {
|
|
264
|
+
[xchainClient.Network.Mainnet]: MoneroNetworkType.MAINNET,
|
|
265
|
+
[xchainClient.Network.Stagenet]: MoneroNetworkType.STAGENET,
|
|
266
|
+
[xchainClient.Network.Testnet]: MoneroNetworkType.STAGENET, // Monero testnet is deprecated; stagenet is used for testing
|
|
267
|
+
};
|
|
268
|
+
return networkMap[network];
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* Validates a Monero address format.
|
|
272
|
+
* Standard addresses are 95 chars, integrated addresses are 106 chars.
|
|
273
|
+
* Uses base58 character set and prefix validation.
|
|
274
|
+
*/
|
|
275
|
+
const validateMoneroAddress = (address) => {
|
|
276
|
+
const base58Chars = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/;
|
|
277
|
+
if (!base58Chars.test(address))
|
|
278
|
+
return false;
|
|
279
|
+
// Standard address (95 chars): primary starts with 4/5/9, subaddress starts with 8/7/B
|
|
280
|
+
if (address.length === 95)
|
|
281
|
+
return /^[45789B]/.test(address);
|
|
282
|
+
// Integrated address (106 chars): starts with 4/5/A
|
|
283
|
+
if (address.length === 106)
|
|
284
|
+
return /^[45A]/.test(address);
|
|
285
|
+
return false;
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Ed25519 group order (l) for sc_reduce32
|
|
289
|
+
*/
|
|
290
|
+
const ED25519_ORDER = BigInt('7237005577332262213973186563042994240857116359379907606001950938285454250989');
|
|
291
|
+
/**
|
|
292
|
+
* Reduces a 32-byte value modulo the ed25519 group order.
|
|
293
|
+
* Required to produce valid Monero private keys from arbitrary 32-byte seeds.
|
|
294
|
+
*/
|
|
295
|
+
const scReduce32 = (bytes) => {
|
|
296
|
+
if (bytes.length !== 32) {
|
|
297
|
+
throw new Error(`scReduce32 expects 32 bytes, got ${bytes.length}`);
|
|
298
|
+
}
|
|
299
|
+
// Interpret as little-endian integer
|
|
300
|
+
let value = BigInt(0);
|
|
301
|
+
for (let i = 31; i >= 0; i--) {
|
|
302
|
+
value = (value << BigInt(8)) | BigInt(bytes[i]);
|
|
303
|
+
}
|
|
304
|
+
// Reduce mod l
|
|
305
|
+
value = value % ED25519_ORDER;
|
|
306
|
+
// Convert back to little-endian bytes
|
|
307
|
+
const result = new Uint8Array(32);
|
|
308
|
+
let tmp = value;
|
|
309
|
+
for (let i = 0; i < 32; i++) {
|
|
310
|
+
result[i] = Number(tmp & BigInt(0xff));
|
|
311
|
+
tmp >>= BigInt(8);
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
};
|
|
315
|
+
/**
|
|
316
|
+
* Converts a Uint8Array to hex string
|
|
317
|
+
*/
|
|
318
|
+
const bytesToHex = (bytes) => {
|
|
319
|
+
return Array.from(bytes)
|
|
320
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
321
|
+
.join('');
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* Converts a hex string to Uint8Array
|
|
325
|
+
*/
|
|
326
|
+
const hexToBytes = (hex) => {
|
|
327
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
328
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
329
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
330
|
+
}
|
|
331
|
+
return bytes;
|
|
332
|
+
};
|
|
333
|
+
function bytesToBigIntLE(bytes) {
|
|
334
|
+
let value = BigInt(0);
|
|
335
|
+
for (let i = bytes.length - 1; i >= 0; i--) {
|
|
336
|
+
value = (value << BigInt(8)) | BigInt(bytes[i]);
|
|
337
|
+
}
|
|
338
|
+
return value;
|
|
339
|
+
}
|
|
340
|
+
function bigIntToBytes32LE(value) {
|
|
341
|
+
const result = new Uint8Array(32);
|
|
342
|
+
let tmp = value;
|
|
343
|
+
for (let i = 0; i < 32; i++) {
|
|
344
|
+
result[i] = Number(tmp & BigInt(0xff));
|
|
345
|
+
tmp >>= BigInt(8);
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Scalar multiplication: (a * b) mod l
|
|
351
|
+
*/
|
|
352
|
+
function scMul(a, b) {
|
|
353
|
+
const aVal = bytesToBigIntLE(a);
|
|
354
|
+
const bVal = bytesToBigIntLE(b);
|
|
355
|
+
return bigIntToBytes32LE((aVal * bVal) % ED25519_ORDER);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Scalar multiply-add: (a * b + c) mod l
|
|
359
|
+
*/
|
|
360
|
+
function scMulAdd(a, b, c) {
|
|
361
|
+
const aVal = bytesToBigIntLE(a);
|
|
362
|
+
const bVal = bytesToBigIntLE(b);
|
|
363
|
+
const cVal = bytesToBigIntLE(c);
|
|
364
|
+
return bigIntToBytes32LE((aVal * bVal + cVal) % ED25519_ORDER);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Scalar multiply-subtract: (c - a * b) mod l
|
|
368
|
+
*/
|
|
369
|
+
function scMulSub(a, b, c) {
|
|
370
|
+
const aVal = bytesToBigIntLE(a);
|
|
371
|
+
const bVal = bytesToBigIntLE(b);
|
|
372
|
+
const cVal = bytesToBigIntLE(c);
|
|
373
|
+
let result = (cVal - aVal * bVal) % ED25519_ORDER;
|
|
374
|
+
if (result < BigInt(0))
|
|
375
|
+
result += ED25519_ORDER;
|
|
376
|
+
return bigIntToBytes32LE(result);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Hash to scalar: keccak256(data) reduced mod l
|
|
380
|
+
*/
|
|
381
|
+
function hashToScalar$1(data) {
|
|
382
|
+
return scReduce32(sha3.keccak_256(data));
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Concatenate multiple Uint8Arrays
|
|
386
|
+
*/
|
|
387
|
+
function concatBytes(...arrays) {
|
|
388
|
+
const totalLen = arrays.reduce((sum, a) => sum + a.length, 0);
|
|
389
|
+
const result = new Uint8Array(totalLen);
|
|
390
|
+
let offset = 0;
|
|
391
|
+
for (const arr of arrays) {
|
|
392
|
+
result.set(arr, offset);
|
|
393
|
+
offset += arr.length;
|
|
394
|
+
}
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Derives an ed25519 public key from a raw private key scalar.
|
|
400
|
+
* Unlike standard EdDSA (which applies SHA-512 + clamping), Monero uses the raw scalar directly.
|
|
401
|
+
*/
|
|
402
|
+
const secretKeyToPublicKey = (privateKey) => {
|
|
403
|
+
const scalar = utils.bytesToNumberLE(privateKey);
|
|
404
|
+
return ed25519.ed25519.ExtendedPoint.BASE.multiply(scalar).toRawBytes();
|
|
405
|
+
};
|
|
406
|
+
/**
|
|
407
|
+
* Derives the private view key from a private spend key.
|
|
408
|
+
* view_key = sc_reduce32(keccak256(spend_key))
|
|
409
|
+
*/
|
|
410
|
+
const derivePrivateViewKey = (privateSpendKey) => {
|
|
411
|
+
const hash = sha3.keccak_256(privateSpendKey);
|
|
412
|
+
return scReduce32(hash);
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* Derives all four Monero keys from a private spend key.
|
|
416
|
+
*/
|
|
417
|
+
const deriveKeyPairs = (privateSpendKey) => {
|
|
418
|
+
const publicSpendKey = secretKeyToPublicKey(privateSpendKey);
|
|
419
|
+
const privateViewKey = derivePrivateViewKey(privateSpendKey);
|
|
420
|
+
const publicViewKey = secretKeyToPublicKey(privateViewKey);
|
|
421
|
+
return {
|
|
422
|
+
privateSpendKey,
|
|
423
|
+
publicSpendKey,
|
|
424
|
+
privateViewKey,
|
|
425
|
+
publicViewKey,
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Monero daemon HTTP JSON-RPC client.
|
|
431
|
+
* Provides raw RPC calls without depending on monero-ts.
|
|
432
|
+
*/
|
|
433
|
+
function jsonRpc(url, method, params) {
|
|
434
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
435
|
+
const response = yield fetch(`${url}/json_rpc`, {
|
|
436
|
+
method: 'POST',
|
|
437
|
+
headers: { 'Content-Type': 'application/json' },
|
|
438
|
+
body: JSON.stringify({
|
|
439
|
+
jsonrpc: '2.0',
|
|
440
|
+
id: '0',
|
|
441
|
+
method,
|
|
442
|
+
params: params !== null && params !== void 0 ? params : {},
|
|
443
|
+
}),
|
|
444
|
+
});
|
|
445
|
+
if (!response.ok) {
|
|
446
|
+
throw new Error(`Daemon RPC error: ${response.status} ${response.statusText}`);
|
|
447
|
+
}
|
|
448
|
+
const data = (yield response.json());
|
|
449
|
+
if (data.error) {
|
|
450
|
+
throw new Error(`Daemon RPC error: ${data.error.message} (code ${data.error.code})`);
|
|
451
|
+
}
|
|
452
|
+
return data.result;
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
function httpPost(url, path, body) {
|
|
456
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
457
|
+
const response = yield fetch(`${url}${path}`, {
|
|
458
|
+
method: 'POST',
|
|
459
|
+
headers: { 'Content-Type': 'application/json' },
|
|
460
|
+
body: JSON.stringify(body),
|
|
461
|
+
});
|
|
462
|
+
if (!response.ok) {
|
|
463
|
+
throw new Error(`Daemon HTTP error: ${response.status} ${response.statusText}`);
|
|
464
|
+
}
|
|
465
|
+
return (yield response.json());
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Get fee estimate from daemon.
|
|
470
|
+
* Returns fee per byte in piconero.
|
|
471
|
+
*/
|
|
472
|
+
const getFeeEstimate = (url) => __awaiter(void 0, void 0, void 0, function* () {
|
|
473
|
+
const result = yield jsonRpc(url, 'get_fee_estimate');
|
|
474
|
+
return result.fee;
|
|
475
|
+
});
|
|
476
|
+
/**
|
|
477
|
+
* Get transaction details by hash(es).
|
|
478
|
+
*/
|
|
479
|
+
const getTransactions = (url, txHashes) => __awaiter(void 0, void 0, void 0, function* () {
|
|
480
|
+
var _a;
|
|
481
|
+
const result = yield httpPost(url, '/get_transactions', {
|
|
482
|
+
txs_hashes: txHashes,
|
|
483
|
+
decode_as_json: false,
|
|
484
|
+
});
|
|
485
|
+
return (_a = result.txs) !== null && _a !== void 0 ? _a : [];
|
|
486
|
+
});
|
|
487
|
+
const getOuts = (url, outputs) => __awaiter(void 0, void 0, void 0, function* () {
|
|
488
|
+
const result = yield httpPost(url, '/get_outs', {
|
|
489
|
+
outputs,
|
|
490
|
+
get_txid: true,
|
|
491
|
+
});
|
|
492
|
+
if (result.status !== 'OK')
|
|
493
|
+
throw new Error(`get_outs failed: ${result.status}`);
|
|
494
|
+
return result.outs;
|
|
495
|
+
});
|
|
496
|
+
/**
|
|
497
|
+
* Get output distribution for decoy selection.
|
|
498
|
+
*/
|
|
499
|
+
const getOutputDistribution = (url_1, ...args_1) => __awaiter(void 0, [url_1, ...args_1], void 0, function* (url, fromHeight = 0, toHeight = 0) {
|
|
500
|
+
const result = yield jsonRpc(url, 'get_output_distribution', {
|
|
501
|
+
amounts: [0],
|
|
502
|
+
cumulative: true,
|
|
503
|
+
from_height: fromHeight,
|
|
504
|
+
to_height: toHeight,
|
|
505
|
+
});
|
|
506
|
+
if (!result.distributions || result.distributions.length === 0) {
|
|
507
|
+
throw new Error('No output distribution data returned from daemon');
|
|
508
|
+
}
|
|
509
|
+
const dist = result.distributions[0].distribution;
|
|
510
|
+
return { distribution: dist.data, startHeight: dist.start_height, base: dist.base };
|
|
511
|
+
});
|
|
512
|
+
/**
|
|
513
|
+
* Get current blockchain height.
|
|
514
|
+
*/
|
|
515
|
+
const getHeight = (url) => __awaiter(void 0, void 0, void 0, function* () {
|
|
516
|
+
const result = yield jsonRpc(url, 'get_block_count');
|
|
517
|
+
return result.count;
|
|
518
|
+
});
|
|
519
|
+
const getTransactionsDecoded = (url, txHashes) => __awaiter(void 0, void 0, void 0, function* () {
|
|
520
|
+
var _a;
|
|
521
|
+
if (txHashes.length === 0)
|
|
522
|
+
return [];
|
|
523
|
+
const result = yield httpPost(url, '/get_transactions', {
|
|
524
|
+
txs_hashes: txHashes,
|
|
525
|
+
decode_as_json: true,
|
|
526
|
+
});
|
|
527
|
+
return (_a = result.txs) !== null && _a !== void 0 ? _a : [];
|
|
528
|
+
});
|
|
529
|
+
const getBlockHeadersRange = (url, startHeight, endHeight) => __awaiter(void 0, void 0, void 0, function* () {
|
|
530
|
+
var _a;
|
|
531
|
+
const result = yield jsonRpc(url, 'get_block_headers_range', {
|
|
532
|
+
start_height: startHeight,
|
|
533
|
+
end_height: endHeight,
|
|
534
|
+
});
|
|
535
|
+
return (_a = result.headers) !== null && _a !== void 0 ? _a : [];
|
|
536
|
+
});
|
|
537
|
+
/**
|
|
538
|
+
* Get block tx hashes for a specific height.
|
|
539
|
+
* Returns just the tx_hashes array (excludes miner tx).
|
|
540
|
+
*/
|
|
541
|
+
const getBlockTxHashes = (url, height) => __awaiter(void 0, void 0, void 0, function* () {
|
|
542
|
+
var _a;
|
|
543
|
+
const result = yield jsonRpc(url, 'get_block', { height });
|
|
544
|
+
return { txHashes: (_a = result.tx_hashes) !== null && _a !== void 0 ? _a : [], timestamp: result.block_header.timestamp };
|
|
545
|
+
});
|
|
546
|
+
/**
|
|
547
|
+
* Broadcast a raw transaction hex to the network.
|
|
548
|
+
*/
|
|
549
|
+
const sendRawTransaction = (url, txHex) => __awaiter(void 0, void 0, void 0, function* () {
|
|
550
|
+
var _a;
|
|
551
|
+
const result = yield httpPost(url, '/send_raw_transaction', {
|
|
552
|
+
tx_as_hex: txHex,
|
|
553
|
+
do_not_relay: false,
|
|
554
|
+
});
|
|
555
|
+
if (result.status !== 'OK') {
|
|
556
|
+
throw new Error(`Broadcast failed: ${(_a = result.reason) !== null && _a !== void 0 ? _a : result.status}`);
|
|
557
|
+
}
|
|
558
|
+
return result;
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* MyMonero-compatible Light Wallet Server (LWS) REST API client.
|
|
563
|
+
* LWS accepts private view keys and returns pre-decoded balances/transactions,
|
|
564
|
+
* avoiding the need for client-side blockchain scanning.
|
|
565
|
+
*/
|
|
566
|
+
function lwsPost(url, path, body) {
|
|
567
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
568
|
+
const response = yield fetch(`${url}${path}`, {
|
|
569
|
+
method: 'POST',
|
|
570
|
+
headers: { 'Content-Type': 'application/json' },
|
|
571
|
+
body: JSON.stringify(body),
|
|
572
|
+
});
|
|
573
|
+
if (!response.ok) {
|
|
574
|
+
throw new Error(`LWS error: ${response.status} ${response.statusText}`);
|
|
575
|
+
}
|
|
576
|
+
return (yield response.json());
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Register wallet address with LWS for scanning.
|
|
581
|
+
* Must be called before other endpoints will return data.
|
|
582
|
+
*/
|
|
583
|
+
const login = (url, address, viewKeyHex) => __awaiter(void 0, void 0, void 0, function* () {
|
|
584
|
+
return lwsPost(url, '/login', {
|
|
585
|
+
address,
|
|
586
|
+
view_key: viewKeyHex,
|
|
587
|
+
create_account: true,
|
|
588
|
+
generated_locally: false,
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
/**
|
|
592
|
+
* Get balance summary for an address.
|
|
593
|
+
* Returns total received/sent in piconero strings.
|
|
594
|
+
*/
|
|
595
|
+
const getAddressInfo = (url, address, viewKeyHex) => __awaiter(void 0, void 0, void 0, function* () {
|
|
596
|
+
return lwsPost(url, '/get_address_info', {
|
|
597
|
+
address,
|
|
598
|
+
view_key: viewKeyHex,
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
/**
|
|
602
|
+
* Get transaction history for an address.
|
|
603
|
+
* Returns transactions with per-tx received/sent amounts.
|
|
604
|
+
*/
|
|
605
|
+
const getAddressTxs = (url, address, viewKeyHex) => __awaiter(void 0, void 0, void 0, function* () {
|
|
606
|
+
return lwsPost(url, '/get_address_txs', {
|
|
607
|
+
address,
|
|
608
|
+
view_key: viewKeyHex,
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
/**
|
|
612
|
+
* Get unspent outputs for transaction building.
|
|
613
|
+
*/
|
|
614
|
+
const getUnspentOuts = (url_1, address_1, viewKeyHex_1, ...args_1) => __awaiter(void 0, [url_1, address_1, viewKeyHex_1, ...args_1], void 0, function* (url, address, viewKeyHex, amount = '0') {
|
|
615
|
+
return lwsPost(url, '/get_unspent_outs', {
|
|
616
|
+
address,
|
|
617
|
+
view_key: viewKeyHex,
|
|
618
|
+
amount,
|
|
619
|
+
mixin: 15,
|
|
620
|
+
use_dust: false,
|
|
621
|
+
dust_threshold: '2000000000',
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Monero's hash_to_ec / ge_fromfe_frombytes_vartime implementation.
|
|
627
|
+
* Ported from CoinSpace/monerolib (pure JS Monero library).
|
|
628
|
+
*
|
|
629
|
+
* Maps arbitrary data to an ed25519 point. This is NOT standard hash-to-curve
|
|
630
|
+
* (RFC 9380) — it matches Monero's specific algorithm from crypto-ops.c.
|
|
631
|
+
*/
|
|
632
|
+
const ExtPoint$6 = ed25519.ed25519.ExtendedPoint;
|
|
633
|
+
// ed25519 field prime: 2^255 - 19
|
|
634
|
+
const P = BigInt('57896044618658097711785492504343953926634992332820282019728792003956564819949');
|
|
635
|
+
function mod(a) {
|
|
636
|
+
const r = a % P;
|
|
637
|
+
return r >= BigInt(0) ? r : r + P;
|
|
638
|
+
}
|
|
639
|
+
const A = BigInt(486662);
|
|
640
|
+
const A_SQUARED = mod(A * A);
|
|
641
|
+
// Precomputed constants (big-endian hex from monerolib/Monero C code)
|
|
642
|
+
const SQRTM1 = BigInt('0x547cdb7fb03e20f4d4b2ff66c2042858d0bce7f952d01b873b11e4d8b5f15f3d');
|
|
643
|
+
const FFFB1 = BigInt('0x7e71fbefdad61b1720a9c53741fb19e3d19404a8b92a738d22a76975321c41ee');
|
|
644
|
+
const FFFB2 = BigInt('0x32f9e1f5fba5d3096e2bae483fe9a041ae21fcb9fba908202d219b7c9f83650d');
|
|
645
|
+
const FFFB3 = BigInt('0x1a43f3031067dbf926c0f4887ef7432eee46fc08a13f4a49853d1903b6b39186');
|
|
646
|
+
const FFFB4 = BigInt('0x674a110d14c208efb89546403f0da2ed4024ff4ea5964229581b7d8717302c66');
|
|
647
|
+
function modPow(base, exp) {
|
|
648
|
+
let result = BigInt(1);
|
|
649
|
+
base = mod(base);
|
|
650
|
+
while (exp > BigInt(0)) {
|
|
651
|
+
if (exp & BigInt(1)) {
|
|
652
|
+
result = mod(result * base);
|
|
653
|
+
}
|
|
654
|
+
exp >>= BigInt(1);
|
|
655
|
+
base = mod(base * base);
|
|
656
|
+
}
|
|
657
|
+
return result;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* fe_divpowm1: computes (u/v)^((p+3)/8)
|
|
661
|
+
* Formula: u * v^3 * (u * v^7)^((p-5)/8)
|
|
662
|
+
*/
|
|
663
|
+
function divPowM1(u, v) {
|
|
664
|
+
const v3 = mod(v * mod(v * v));
|
|
665
|
+
const v7 = mod(v3 * mod(v3 * v));
|
|
666
|
+
const uv7 = mod(u * v7);
|
|
667
|
+
const exp = (P - BigInt(5)) >> BigInt(3); // (p-5)/8
|
|
668
|
+
const t = modPow(uv7, exp);
|
|
669
|
+
return mod(u * mod(v3 * t));
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Hash arbitrary data to an ed25519 point (Monero's hash_to_ec).
|
|
673
|
+
* Algorithm: ge_fromfe_frombytes_vartime
|
|
674
|
+
*/
|
|
675
|
+
function hashToPoint(data) {
|
|
676
|
+
const hash = sha3.keccak_256(data);
|
|
677
|
+
const u = mod(utils.bytesToNumberLE(hash));
|
|
678
|
+
// v = 2*u^2
|
|
679
|
+
const v = mod(BigInt(2) * mod(u * u));
|
|
680
|
+
// w = 1 + 2*u^2
|
|
681
|
+
const w = mod(v + BigInt(1));
|
|
682
|
+
// t = w^2 - A^2*v
|
|
683
|
+
const t = mod(mod(w * w) - mod(A_SQUARED * v));
|
|
684
|
+
// x = (w/t)^((p+3)/8) via divPowM1
|
|
685
|
+
let x = divPowM1(w, t);
|
|
686
|
+
let negative = false;
|
|
687
|
+
// Check: w - x^2*t == 0?
|
|
688
|
+
let check = mod(w - mod(mod(x * x) * t));
|
|
689
|
+
if (check !== BigInt(0)) {
|
|
690
|
+
// Check: w + x^2*t == 0?
|
|
691
|
+
check = mod(w + mod(mod(x * x) * t));
|
|
692
|
+
if (check !== BigInt(0)) {
|
|
693
|
+
negative = true;
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
x = mod(x * FFFB1);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
x = mod(x * FFFB2);
|
|
701
|
+
}
|
|
702
|
+
let odd;
|
|
703
|
+
let r;
|
|
704
|
+
if (!negative) {
|
|
705
|
+
odd = false;
|
|
706
|
+
r = mod(P - mod(A * v)); // r = -A*v
|
|
707
|
+
x = mod(x * u); // x *= u
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
odd = true;
|
|
711
|
+
r = mod(P - A); // r = -A
|
|
712
|
+
// Check with sqrtm1
|
|
713
|
+
check = mod(w - mod(mod(mod(x * x) * t) * SQRTM1));
|
|
714
|
+
if (check !== BigInt(0)) {
|
|
715
|
+
check = mod(w + mod(mod(mod(x * x) * t) * SQRTM1));
|
|
716
|
+
if (check !== BigInt(0)) {
|
|
717
|
+
throw new Error('hashToPoint: invalid point (should never happen)');
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
x = mod(x * FFFB3);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
x = mod(x * FFFB4);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Adjust sign
|
|
728
|
+
const xIsOdd = (x & BigInt(1)) === BigInt(1);
|
|
729
|
+
if (xIsOdd !== odd) {
|
|
730
|
+
x = mod(P - x);
|
|
731
|
+
}
|
|
732
|
+
// Projective coordinates: (X, Y, Z) where affine = (X/Z, Y/Z)
|
|
733
|
+
const z = mod(r + w);
|
|
734
|
+
const y = mod(r - w);
|
|
735
|
+
const X = mod(x * z);
|
|
736
|
+
// Convert projective (X, Y, Z) to affine and encode
|
|
737
|
+
const zInv = modPow(z, P - BigInt(2));
|
|
738
|
+
const xAff = mod(X * zInv);
|
|
739
|
+
const yAff = mod(y * zInv);
|
|
740
|
+
// Encode as ed25519 point bytes
|
|
741
|
+
const yBytes = utils.numberToBytesLE(yAff, 32);
|
|
742
|
+
if (xAff & BigInt(1)) {
|
|
743
|
+
yBytes[31] |= 0x80;
|
|
744
|
+
}
|
|
745
|
+
const point = ExtPoint$6.fromHex(yBytes);
|
|
746
|
+
// Cofactor clearing: multiply by 8 (Monero's hash_to_ec = ge_fromfe + mul8)
|
|
747
|
+
return point.multiply(BigInt(8));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* CLSAG (Concise Linkable Spontaneous Anonymous Group) signature.
|
|
752
|
+
* Used in Monero RingCT for input authorization + commitment balance proof.
|
|
753
|
+
*
|
|
754
|
+
* Reference: monero/src/ringct/rctSigs.cpp (CLSAG_Gen, verRctCLSAGSimple)
|
|
755
|
+
*/
|
|
756
|
+
const ExtPoint$5 = ed25519.ed25519.ExtendedPoint;
|
|
757
|
+
// Domain separators (ASCII zero-padded to 32 bytes)
|
|
758
|
+
function domainTag(s) {
|
|
759
|
+
const tag = new Uint8Array(32);
|
|
760
|
+
const encoded = new TextEncoder().encode(s);
|
|
761
|
+
tag.set(encoded);
|
|
762
|
+
return tag;
|
|
763
|
+
}
|
|
764
|
+
const CLSAG_AGG_0 = domainTag('CLSAG_agg_0');
|
|
765
|
+
const CLSAG_AGG_1 = domainTag('CLSAG_agg_1');
|
|
766
|
+
const CLSAG_ROUND = domainTag('CLSAG_round');
|
|
767
|
+
// INV_EIGHT: modular inverse of 8 mod l (ed25519 group order)
|
|
768
|
+
const INV_EIGHT_HEX = '792fdce229e50661d0da1c7db39dd30700000000000000000000000000000006';
|
|
769
|
+
const INV_EIGHT$1 = hexToBytes(INV_EIGHT_HEX);
|
|
770
|
+
/**
|
|
771
|
+
* Generate a random 32-byte scalar reduced mod l.
|
|
772
|
+
*/
|
|
773
|
+
function randomScalar$2() {
|
|
774
|
+
const bytes = new Uint8Array(32);
|
|
775
|
+
crypto.getRandomValues(bytes);
|
|
776
|
+
return scReduce32(bytes);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* CLSAG signing.
|
|
780
|
+
*
|
|
781
|
+
* @param message - 32-byte transaction prefix hash
|
|
782
|
+
* @param ring - Ring members (dest + mask for each)
|
|
783
|
+
* @param Cout - Pseudo-output commitment (C_offset)
|
|
784
|
+
* @param secretKey - Signer's one-time private key (p)
|
|
785
|
+
* @param secretMask - Commitment mask difference: z = input_mask - pseudo_out_mask
|
|
786
|
+
* @param realIndex - Index of real signer in ring
|
|
787
|
+
* @param keyImage - Pre-computed key image (p * Hp(P[l]))
|
|
788
|
+
* @returns CLSAG signature
|
|
789
|
+
*/
|
|
790
|
+
function clsagSign(message, ring, Cout, secretKey, secretMask, realIndex, keyImage) {
|
|
791
|
+
const n = ring.length;
|
|
792
|
+
if (n === 0)
|
|
793
|
+
throw new Error('CLSAG: ring must not be empty');
|
|
794
|
+
if (realIndex < 0 || realIndex >= n)
|
|
795
|
+
throw new Error(`CLSAG: realIndex ${realIndex} out of range [0, ${n})`);
|
|
796
|
+
const I = ExtPoint$5.fromHex(keyImage);
|
|
797
|
+
// Hp(P[l]) — hash of real signer's public key
|
|
798
|
+
const H = hashToPoint(ring[realIndex].dest);
|
|
799
|
+
// D_full = z * Hp(P[l])
|
|
800
|
+
const zScalar = utils.bytesToNumberLE(secretMask);
|
|
801
|
+
const D_full = H.multiply(zScalar);
|
|
802
|
+
// sig.D = (1/8) * D_full
|
|
803
|
+
const invEightScalar = utils.bytesToNumberLE(INV_EIGHT$1);
|
|
804
|
+
const D = D_full.multiply(invEightScalar);
|
|
805
|
+
const DBytes = D.toRawBytes();
|
|
806
|
+
// Extract ring keys as byte arrays
|
|
807
|
+
const P = ring.map((m) => m.dest);
|
|
808
|
+
const C_nonzero = ring.map((m) => m.mask);
|
|
809
|
+
// Compute aggregation coefficients mu_P, mu_C
|
|
810
|
+
const aggParts = [...P.map((p) => p), ...C_nonzero.map((c) => c), keyImage, DBytes, Cout];
|
|
811
|
+
const muP = hashToScalar$1(concatBytes(CLSAG_AGG_0, ...aggParts));
|
|
812
|
+
const muC = hashToScalar$1(concatBytes(CLSAG_AGG_1, ...aggParts));
|
|
813
|
+
// Round hash prefix (constant across all rounds)
|
|
814
|
+
const roundPrefix = concatBytes(CLSAG_ROUND, ...P, ...C_nonzero, Cout, message);
|
|
815
|
+
// Step 3: Generate nonce
|
|
816
|
+
const alpha = randomScalar$2();
|
|
817
|
+
const alphaScalar = utils.bytesToNumberLE(alpha);
|
|
818
|
+
const aG = ExtPoint$5.BASE.multiply(alphaScalar);
|
|
819
|
+
const aH = H.multiply(alphaScalar);
|
|
820
|
+
// Initial challenge at signer's position
|
|
821
|
+
let c = hashToScalar$1(concatBytes(roundPrefix, aG.toRawBytes(), aH.toRawBytes()));
|
|
822
|
+
// Precompute C_offset point
|
|
823
|
+
const CoutPoint = ExtPoint$5.fromHex(Cout);
|
|
824
|
+
// Ring loop
|
|
825
|
+
const s = new Array(n);
|
|
826
|
+
let c1;
|
|
827
|
+
let i = (realIndex + 1) % n;
|
|
828
|
+
if (i === 0)
|
|
829
|
+
c1 = c;
|
|
830
|
+
while (i !== realIndex) {
|
|
831
|
+
s[i] = randomScalar$2();
|
|
832
|
+
const sScalar = utils.bytesToNumberLE(s[i]);
|
|
833
|
+
const cP = scMul(c, muP);
|
|
834
|
+
const cC = scMul(c, muC);
|
|
835
|
+
const cPScalar = utils.bytesToNumberLE(cP);
|
|
836
|
+
const cCScalar = utils.bytesToNumberLE(cC);
|
|
837
|
+
const Pi = ExtPoint$5.fromHex(P[i]);
|
|
838
|
+
const Ci = ExtPoint$5.fromHex(C_nonzero[i]).subtract(CoutPoint);
|
|
839
|
+
// L = s[i]*G + c_p*P[i] + c_c*C[i]
|
|
840
|
+
const L = ExtPoint$5.BASE.multiply(sScalar).add(Pi.multiply(cPScalar)).add(Ci.multiply(cCScalar));
|
|
841
|
+
// R = s[i]*Hp(P[i]) + c_p*I + c_c*D_full
|
|
842
|
+
const HpPi = hashToPoint(P[i]);
|
|
843
|
+
const R = HpPi.multiply(sScalar).add(I.multiply(cPScalar)).add(D_full.multiply(cCScalar));
|
|
844
|
+
c = hashToScalar$1(concatBytes(roundPrefix, L.toRawBytes(), R.toRawBytes()));
|
|
845
|
+
i = (i + 1) % n;
|
|
846
|
+
if (i === 0)
|
|
847
|
+
c1 = c;
|
|
848
|
+
}
|
|
849
|
+
// Final response: s[l] = alpha - c * (mu_P * p + mu_C * z)
|
|
850
|
+
const muPp = scMul(muP, secretKey);
|
|
851
|
+
const combined = scMulAdd(muC, secretMask, muPp);
|
|
852
|
+
s[realIndex] = scMulSub(c, combined, alpha);
|
|
853
|
+
if (c1 === undefined)
|
|
854
|
+
c1 = c;
|
|
855
|
+
return { s, c1, D: DBytes };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Pedersen commitment scheme for Monero.
|
|
860
|
+
* C = mask*G + amount*H
|
|
861
|
+
*/
|
|
862
|
+
const ExtPoint$4 = ed25519.ed25519.ExtendedPoint;
|
|
863
|
+
// Monero's second generator H, hardcoded from rctTypes.h.
|
|
864
|
+
// Historically derived via toPoint(cn_fast_hash(G)), now a consensus constant.
|
|
865
|
+
const H_HEX = '8b655970153799af2aeadc9ff1add0ea6c7251d54154cfa92c173a0dd39c1f94';
|
|
866
|
+
let _H = null;
|
|
867
|
+
/**
|
|
868
|
+
* Get the Pedersen generator H.
|
|
869
|
+
*/
|
|
870
|
+
function getH() {
|
|
871
|
+
if (_H === null) {
|
|
872
|
+
_H = ExtPoint$4.fromHex(H_HEX);
|
|
873
|
+
}
|
|
874
|
+
return _H;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Create a Pedersen commitment: C = mask*G + amount*H
|
|
878
|
+
* @param mask - Blinding factor (32-byte scalar LE)
|
|
879
|
+
* @param amount - Amount to commit to (bigint)
|
|
880
|
+
*/
|
|
881
|
+
function commit(mask, amount) {
|
|
882
|
+
const maskScalar = utils.bytesToNumberLE(mask);
|
|
883
|
+
const H = getH();
|
|
884
|
+
const maskG = ExtPoint$4.BASE.multiply(maskScalar);
|
|
885
|
+
const amountH = H.multiply(amount);
|
|
886
|
+
return maskG.add(amountH);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Bulletproofs+ range proof implementation for Monero.
|
|
891
|
+
* Proves amounts are in [0, 2^64) without revealing them.
|
|
892
|
+
*
|
|
893
|
+
* Reference: monero/src/ringct/bulletproofs_plus.cc
|
|
894
|
+
*/
|
|
895
|
+
const ExtPoint$3 = ed25519.ed25519.ExtendedPoint;
|
|
896
|
+
// Ed25519 group order
|
|
897
|
+
const L$1 = BigInt('7237005577332262213973186563042994240857116359379907606001950938285454250989');
|
|
898
|
+
const ZERO = BigInt(0);
|
|
899
|
+
const ONE = BigInt(1);
|
|
900
|
+
const TWO = BigInt(2);
|
|
901
|
+
BigInt(8);
|
|
902
|
+
const maxN = 64; // bits per value
|
|
903
|
+
const maxM = 16; // max aggregated outputs
|
|
904
|
+
function modL(x) {
|
|
905
|
+
const r = x % L$1;
|
|
906
|
+
return r < ZERO ? r + L$1 : r;
|
|
907
|
+
}
|
|
908
|
+
// INV_EIGHT = modular inverse of 8 mod l
|
|
909
|
+
const INV_EIGHT = utils.bytesToNumberLE(hexToBytes('792fdce229e50661d0da1c7db39dd30700000000000000000000000000000006'));
|
|
910
|
+
const MINUS_INV_EIGHT = modL(L$1 - INV_EIGHT);
|
|
911
|
+
// Domain separator for generator derivation
|
|
912
|
+
const EXPONENT_TAG = new TextEncoder().encode('bulletproof_plus');
|
|
913
|
+
// Domain separator for transcript
|
|
914
|
+
const TRANSCRIPT_TAG = new TextEncoder().encode('bulletproof_plus_transcript');
|
|
915
|
+
// Varint encoding (same as tx/serialize)
|
|
916
|
+
function encodeVarint$2(n) {
|
|
917
|
+
const bytes = [];
|
|
918
|
+
let val = n;
|
|
919
|
+
while (val >= 0x80) {
|
|
920
|
+
bytes.push((val & 0x7f) | 0x80);
|
|
921
|
+
val >>>= 7;
|
|
922
|
+
}
|
|
923
|
+
bytes.push(val & 0x7f);
|
|
924
|
+
return new Uint8Array(bytes);
|
|
925
|
+
}
|
|
926
|
+
// --- Generator cache ---
|
|
927
|
+
let _generators = null;
|
|
928
|
+
function getExponent(base, idx) {
|
|
929
|
+
const data = concatBytes(base, EXPONENT_TAG, encodeVarint$2(idx));
|
|
930
|
+
const hash = sha3.keccak_256(data);
|
|
931
|
+
return hashToPoint(hash);
|
|
932
|
+
}
|
|
933
|
+
function getGenerators(count) {
|
|
934
|
+
if (_generators !== null && _generators.Gi.length >= count)
|
|
935
|
+
return _generators;
|
|
936
|
+
const H = getH();
|
|
937
|
+
const hBytes = H.toRawBytes();
|
|
938
|
+
const Gi = new Array(count);
|
|
939
|
+
const Hi = new Array(count);
|
|
940
|
+
for (let i = 0; i < count; i++) {
|
|
941
|
+
Hi[i] = getExponent(hBytes, i * 2);
|
|
942
|
+
Gi[i] = getExponent(hBytes, i * 2 + 1);
|
|
943
|
+
}
|
|
944
|
+
_generators = { Gi, Hi };
|
|
945
|
+
return _generators;
|
|
946
|
+
}
|
|
947
|
+
// --- Initial transcript (cached) ---
|
|
948
|
+
let _initTranscript = null;
|
|
949
|
+
function getInitialTranscript() {
|
|
950
|
+
if (_initTranscript !== null)
|
|
951
|
+
return _initTranscript;
|
|
952
|
+
const hash = sha3.keccak_256(TRANSCRIPT_TAG);
|
|
953
|
+
const point = hashToPoint(hash);
|
|
954
|
+
_initTranscript = point.toRawBytes();
|
|
955
|
+
return _initTranscript;
|
|
956
|
+
}
|
|
957
|
+
// --- Transcript updates ---
|
|
958
|
+
function transcriptUpdate1(transcript, update0) {
|
|
959
|
+
return hashToScalar$1(concatBytes(transcript, update0));
|
|
960
|
+
}
|
|
961
|
+
function transcriptUpdate2(transcript, update0, update1) {
|
|
962
|
+
return hashToScalar$1(concatBytes(transcript, update0, update1));
|
|
963
|
+
}
|
|
964
|
+
// --- Scalar helpers ---
|
|
965
|
+
function scalarToBytes(s) {
|
|
966
|
+
return new Uint8Array(utils.numberToBytesLE(s, 32));
|
|
967
|
+
}
|
|
968
|
+
function randomScalar$1() {
|
|
969
|
+
const bytes = new Uint8Array(32);
|
|
970
|
+
crypto.getRandomValues(bytes);
|
|
971
|
+
return utils.bytesToNumberLE(scReduce32(bytes));
|
|
972
|
+
}
|
|
973
|
+
function scalarInvert(x) {
|
|
974
|
+
// Fermat's little theorem: x^(l-2) mod l
|
|
975
|
+
let result = ONE;
|
|
976
|
+
let base = modL(x);
|
|
977
|
+
let exp = L$1 - TWO;
|
|
978
|
+
while (exp > ZERO) {
|
|
979
|
+
if (exp & ONE)
|
|
980
|
+
result = modL(result * base);
|
|
981
|
+
exp >>= ONE;
|
|
982
|
+
base = modL(base * base);
|
|
983
|
+
}
|
|
984
|
+
return result;
|
|
985
|
+
}
|
|
986
|
+
function nextPow2(n) {
|
|
987
|
+
let p = 1;
|
|
988
|
+
while (p < n)
|
|
989
|
+
p <<= 1;
|
|
990
|
+
return p;
|
|
991
|
+
}
|
|
992
|
+
function log2(n) {
|
|
993
|
+
let r = 0;
|
|
994
|
+
let v = n;
|
|
995
|
+
while (v > 1) {
|
|
996
|
+
v >>= 1;
|
|
997
|
+
r++;
|
|
998
|
+
}
|
|
999
|
+
return r;
|
|
1000
|
+
}
|
|
1001
|
+
// Weighted inner product: WIP(a, b, y) = sum(a[i] * b[i] * y^(i+1))
|
|
1002
|
+
function weightedInnerProduct(a, b, y) {
|
|
1003
|
+
let result = ZERO;
|
|
1004
|
+
let yPow = ONE;
|
|
1005
|
+
for (let i = 0; i < a.length; i++) {
|
|
1006
|
+
yPow = modL(yPow * y);
|
|
1007
|
+
result = modL(result + modL(modL(a[i] * b[i]) * yPow));
|
|
1008
|
+
}
|
|
1009
|
+
return result;
|
|
1010
|
+
}
|
|
1011
|
+
// Compute L or R in the inner product argument
|
|
1012
|
+
function computeLR(size, yFactor, G, gOff, H, hOff, a, aOff, b, bOff, c, d) {
|
|
1013
|
+
// Multi-scalar multiplication
|
|
1014
|
+
let result = ExtPoint$3.BASE.multiply(modL(d * INV_EIGHT)).add(getH().multiply(modL(c * INV_EIGHT)));
|
|
1015
|
+
for (let i = 0; i < size; i++) {
|
|
1016
|
+
const aScaled = modL(a[aOff + i] * modL(yFactor * INV_EIGHT));
|
|
1017
|
+
const bScaled = modL(b[bOff + i] * INV_EIGHT);
|
|
1018
|
+
result = result.add(G[gOff + i].multiply(aScaled));
|
|
1019
|
+
result = result.add(H[hOff + i].multiply(bScaled));
|
|
1020
|
+
}
|
|
1021
|
+
return result;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Generate a Bulletproofs+ range proof.
|
|
1025
|
+
*
|
|
1026
|
+
* @param amounts - Array of amounts (bigint, each < 2^64)
|
|
1027
|
+
* @param masks - Array of blinding factors (32-byte scalars)
|
|
1028
|
+
* @returns BP+ proof
|
|
1029
|
+
*/
|
|
1030
|
+
function bulletproofPlusProve(amounts, masks) {
|
|
1031
|
+
const m = amounts.length;
|
|
1032
|
+
if (m === 0 || m > maxM)
|
|
1033
|
+
throw new Error(`BP+: invalid output count ${m}`);
|
|
1034
|
+
const M = nextPow2(m);
|
|
1035
|
+
const logM = log2(M);
|
|
1036
|
+
const MN = M * maxN;
|
|
1037
|
+
const logMN = logM + log2(maxN);
|
|
1038
|
+
// Get generators
|
|
1039
|
+
const gens = getGenerators(MN);
|
|
1040
|
+
const { Gi, Hi } = gens;
|
|
1041
|
+
// Compute V[i] = (gamma*INV_EIGHT)*G + (sv*INV_EIGHT)*H
|
|
1042
|
+
const V = [];
|
|
1043
|
+
const sv = [];
|
|
1044
|
+
const gamma = [];
|
|
1045
|
+
for (let i = 0; i < m; i++) {
|
|
1046
|
+
sv.push(amounts[i]);
|
|
1047
|
+
gamma.push(utils.bytesToNumberLE(masks[i]));
|
|
1048
|
+
const gScalar = modL(gamma[i] * INV_EIGHT);
|
|
1049
|
+
const hScalar = modL(sv[i] * INV_EIGHT);
|
|
1050
|
+
const point = ExtPoint$3.BASE.multiply(gScalar).add(getH().multiply(hScalar));
|
|
1051
|
+
V.push(point.toRawBytes());
|
|
1052
|
+
}
|
|
1053
|
+
// Bit decomposition
|
|
1054
|
+
const aL = new Array(MN);
|
|
1055
|
+
const aR = new Array(MN);
|
|
1056
|
+
const aL8 = new Array(MN);
|
|
1057
|
+
const aR8 = new Array(MN);
|
|
1058
|
+
for (let j = 0; j < M; j++) {
|
|
1059
|
+
for (let i = 0; i < maxN; i++) {
|
|
1060
|
+
const idx = j * maxN + i;
|
|
1061
|
+
if (j < m && ((sv[j] >> BigInt(i)) & ONE) === ONE) {
|
|
1062
|
+
aL[idx] = ONE;
|
|
1063
|
+
aR[idx] = ZERO;
|
|
1064
|
+
aL8[idx] = INV_EIGHT;
|
|
1065
|
+
aR8[idx] = ZERO;
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
aL[idx] = ZERO;
|
|
1069
|
+
aR[idx] = modL(L$1 - ONE); // -1 mod l
|
|
1070
|
+
aL8[idx] = ZERO;
|
|
1071
|
+
aR8[idx] = MINUS_INV_EIGHT;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
// Transcript initialization
|
|
1076
|
+
let transcript = getInitialTranscript();
|
|
1077
|
+
const vConcat = concatBytes(...V);
|
|
1078
|
+
transcript = scalarToBytes(utils.bytesToNumberLE(transcriptUpdate1(transcript, hashToScalar$1(vConcat))));
|
|
1079
|
+
// Commitment A
|
|
1080
|
+
const alpha = randomScalar$1();
|
|
1081
|
+
// pre_A = sum(aL8[i]*Gi[i] + aR8[i]*Hi[i])
|
|
1082
|
+
let preA = ExtPoint$3.ZERO;
|
|
1083
|
+
for (let i = 0; i < MN; i++) {
|
|
1084
|
+
if (aL8[i] !== ZERO)
|
|
1085
|
+
preA = preA.add(Gi[i].multiply(aL8[i]));
|
|
1086
|
+
if (aR8[i] !== ZERO)
|
|
1087
|
+
preA = preA.add(Hi[i].multiply(aR8[i]));
|
|
1088
|
+
}
|
|
1089
|
+
const A = preA.add(ExtPoint$3.BASE.multiply(modL(alpha * INV_EIGHT)));
|
|
1090
|
+
const ABytes = A.toRawBytes();
|
|
1091
|
+
// Challenge y
|
|
1092
|
+
const yBytes = transcriptUpdate1(transcript, ABytes);
|
|
1093
|
+
const y = utils.bytesToNumberLE(yBytes);
|
|
1094
|
+
if (y === ZERO)
|
|
1095
|
+
throw new Error('BP+: y is zero');
|
|
1096
|
+
// Challenge z
|
|
1097
|
+
const zBytes = hashToScalar$1(yBytes);
|
|
1098
|
+
const z = utils.bytesToNumberLE(zBytes);
|
|
1099
|
+
transcript = zBytes;
|
|
1100
|
+
if (z === ZERO)
|
|
1101
|
+
throw new Error('BP+: z is zero');
|
|
1102
|
+
const zSq = modL(z * z);
|
|
1103
|
+
// d vector: d[j*N+i] = z^(2*(j+1)) * 2^i
|
|
1104
|
+
const d = new Array(MN);
|
|
1105
|
+
d[0] = zSq;
|
|
1106
|
+
for (let i = 1; i < maxN; i++) {
|
|
1107
|
+
d[i] = modL(d[i - 1] * TWO);
|
|
1108
|
+
}
|
|
1109
|
+
for (let j = 1; j < M; j++) {
|
|
1110
|
+
for (let i = 0; i < maxN; i++) {
|
|
1111
|
+
d[j * maxN + i] = modL(d[(j - 1) * maxN + i] * zSq);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
// y powers: y^0 to y^(MN+1)
|
|
1115
|
+
const yPowers = new Array(MN + 2);
|
|
1116
|
+
yPowers[0] = ONE;
|
|
1117
|
+
for (let i = 1; i <= MN + 1; i++) {
|
|
1118
|
+
yPowers[i] = modL(yPowers[i - 1] * y);
|
|
1119
|
+
}
|
|
1120
|
+
// aL1 = aL - z, aR1 = aR + z + d[i]*y^(MN-i)
|
|
1121
|
+
const aL1 = new Array(MN);
|
|
1122
|
+
const aR1 = new Array(MN);
|
|
1123
|
+
for (let i = 0; i < MN; i++) {
|
|
1124
|
+
aL1[i] = modL(aL[i] - z);
|
|
1125
|
+
aR1[i] = modL(modL(aR[i] + z) + modL(d[i] * yPowers[MN - i]));
|
|
1126
|
+
}
|
|
1127
|
+
// alpha1 = alpha + sum(y^(MN+1) * z^(2*(j+1)) * gamma[j])
|
|
1128
|
+
let alpha1 = alpha;
|
|
1129
|
+
let temp = ONE;
|
|
1130
|
+
for (let j = 0; j < m; j++) {
|
|
1131
|
+
temp = modL(temp * zSq);
|
|
1132
|
+
alpha1 = modL(alpha1 + modL(modL(yPowers[MN + 1] * temp) * gamma[j]));
|
|
1133
|
+
}
|
|
1134
|
+
// Inner-product argument
|
|
1135
|
+
let nprime = MN;
|
|
1136
|
+
const Gprime = Gi.slice(0, MN);
|
|
1137
|
+
const Hprime = Hi.slice(0, MN);
|
|
1138
|
+
let aprime = aL1.slice();
|
|
1139
|
+
let bprime = aR1.slice();
|
|
1140
|
+
const yInv = scalarInvert(y);
|
|
1141
|
+
const yInvPow = new Array(MN);
|
|
1142
|
+
yInvPow[0] = ONE;
|
|
1143
|
+
for (let i = 1; i < MN; i++) {
|
|
1144
|
+
yInvPow[i] = modL(yInvPow[i - 1] * yInv);
|
|
1145
|
+
}
|
|
1146
|
+
const Ls = [];
|
|
1147
|
+
const Rs = [];
|
|
1148
|
+
for (let round = 0; round < logMN; round++) {
|
|
1149
|
+
nprime = nprime >> 1;
|
|
1150
|
+
const cL = weightedInnerProduct(aprime.slice(0, nprime), bprime.slice(nprime, nprime * 2), y);
|
|
1151
|
+
// For cR: aprime[nprime..] scaled by y^nprime
|
|
1152
|
+
const aScaled = new Array(nprime);
|
|
1153
|
+
for (let i = 0; i < nprime; i++) {
|
|
1154
|
+
aScaled[i] = modL(aprime[nprime + i] * yPowers[nprime]);
|
|
1155
|
+
}
|
|
1156
|
+
const cR = weightedInnerProduct(aScaled, bprime.slice(0, nprime), y);
|
|
1157
|
+
const dL = randomScalar$1();
|
|
1158
|
+
const dR = randomScalar$1();
|
|
1159
|
+
const LPoint = computeLR(nprime, yInvPow[nprime], Gprime, nprime, Hprime, 0, aprime, 0, bprime, nprime, cL, dL);
|
|
1160
|
+
const RPoint = computeLR(nprime, yPowers[nprime], Gprime, 0, Hprime, nprime, aprime, nprime, bprime, 0, cR, dR);
|
|
1161
|
+
const LBytes = LPoint.toRawBytes();
|
|
1162
|
+
const RBytes = RPoint.toRawBytes();
|
|
1163
|
+
Ls.push(LBytes);
|
|
1164
|
+
Rs.push(RBytes);
|
|
1165
|
+
const eBytes = transcriptUpdate2(transcript, LBytes, RBytes);
|
|
1166
|
+
const e = utils.bytesToNumberLE(eBytes);
|
|
1167
|
+
if (e === ZERO)
|
|
1168
|
+
throw new Error('BP+: challenge is zero');
|
|
1169
|
+
transcript = eBytes;
|
|
1170
|
+
const eInv = scalarInvert(e);
|
|
1171
|
+
// Fold generators
|
|
1172
|
+
const tempG = modL(yInvPow[nprime] * e);
|
|
1173
|
+
const newGprime = new Array(nprime);
|
|
1174
|
+
const newHprime = new Array(nprime);
|
|
1175
|
+
for (let i = 0; i < nprime; i++) {
|
|
1176
|
+
newGprime[i] = Gprime[i].multiply(eInv).add(Gprime[nprime + i].multiply(tempG));
|
|
1177
|
+
newHprime[i] = Hprime[i].multiply(e).add(Hprime[nprime + i].multiply(eInv));
|
|
1178
|
+
}
|
|
1179
|
+
// Fold scalar vectors
|
|
1180
|
+
const tempA = modL(eInv * yPowers[nprime]);
|
|
1181
|
+
const newAprime = new Array(nprime);
|
|
1182
|
+
const newBprime = new Array(nprime);
|
|
1183
|
+
for (let i = 0; i < nprime; i++) {
|
|
1184
|
+
newAprime[i] = modL(modL(e * aprime[i]) + modL(tempA * aprime[nprime + i]));
|
|
1185
|
+
newBprime[i] = modL(modL(eInv * bprime[i]) + modL(e * bprime[nprime + i]));
|
|
1186
|
+
}
|
|
1187
|
+
// Update alpha1
|
|
1188
|
+
alpha1 = modL(alpha1 + modL(dL * modL(e * e)) + modL(dR * modL(eInv * eInv)));
|
|
1189
|
+
// Replace arrays
|
|
1190
|
+
Gprime.length = nprime;
|
|
1191
|
+
Hprime.length = nprime;
|
|
1192
|
+
for (let i = 0; i < nprime; i++) {
|
|
1193
|
+
Gprime[i] = newGprime[i];
|
|
1194
|
+
Hprime[i] = newHprime[i];
|
|
1195
|
+
}
|
|
1196
|
+
aprime = newAprime;
|
|
1197
|
+
bprime = newBprime;
|
|
1198
|
+
}
|
|
1199
|
+
// Final round
|
|
1200
|
+
const r = randomScalar$1();
|
|
1201
|
+
const s = randomScalar$1();
|
|
1202
|
+
const d_ = randomScalar$1();
|
|
1203
|
+
const eta = randomScalar$1();
|
|
1204
|
+
const rysb = modL(modL(r * modL(y * bprime[0])) + modL(s * modL(y * aprime[0])));
|
|
1205
|
+
const A1 = Gprime[0]
|
|
1206
|
+
.multiply(modL(r * INV_EIGHT))
|
|
1207
|
+
.add(Hprime[0].multiply(modL(s * INV_EIGHT)))
|
|
1208
|
+
.add(ExtPoint$3.BASE.multiply(modL(d_ * INV_EIGHT)))
|
|
1209
|
+
.add(getH().multiply(modL(rysb * INV_EIGHT)));
|
|
1210
|
+
const A1Bytes = A1.toRawBytes();
|
|
1211
|
+
const B = ExtPoint$3.BASE.multiply(modL(eta * INV_EIGHT)).add(getH().multiply(modL(modL(r * modL(y * s)) * INV_EIGHT)));
|
|
1212
|
+
const BBytes = B.toRawBytes();
|
|
1213
|
+
// Final challenge
|
|
1214
|
+
const eFinalBytes = transcriptUpdate2(transcript, A1Bytes, BBytes);
|
|
1215
|
+
const eFinal = utils.bytesToNumberLE(eFinalBytes);
|
|
1216
|
+
if (eFinal === ZERO)
|
|
1217
|
+
throw new Error('BP+: final challenge is zero');
|
|
1218
|
+
const eFinalSq = modL(eFinal * eFinal);
|
|
1219
|
+
const r1 = scalarToBytes(modL(modL(aprime[0] * eFinal) + r));
|
|
1220
|
+
const s1 = scalarToBytes(modL(modL(bprime[0] * eFinal) + s));
|
|
1221
|
+
const d1 = scalarToBytes(modL(eta + modL(d_ * eFinal) + modL(alpha1 * eFinalSq)));
|
|
1222
|
+
return { A: ABytes, A1: A1Bytes, B: BBytes, r1, s1, d1, L: Ls, R: Rs };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Key image generation for Monero ring signatures.
|
|
1227
|
+
* KI = x * Hp(P) where x is the private key and P is the public key.
|
|
1228
|
+
*/
|
|
1229
|
+
/**
|
|
1230
|
+
* Generate a key image from a private/public key pair.
|
|
1231
|
+
* KI = x * Hp(P)
|
|
1232
|
+
* @param privKey - Private key (32-byte scalar LE)
|
|
1233
|
+
* @param pubKey - Corresponding public key (32-byte compressed point)
|
|
1234
|
+
* @returns Key image as 32-byte compressed point
|
|
1235
|
+
*/
|
|
1236
|
+
function generateKeyImage(privKey, pubKey) {
|
|
1237
|
+
const hp = hashToPoint(pubKey);
|
|
1238
|
+
const scalar = utils.bytesToNumberLE(privKey);
|
|
1239
|
+
return hp.multiply(scalar).toRawBytes();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Monero stealth address (one-time address) derivation.
|
|
1244
|
+
*
|
|
1245
|
+
* Output key: P = Hs(r*A || i)*G + B
|
|
1246
|
+
* - r: tx private key
|
|
1247
|
+
* - A: recipient's public view key
|
|
1248
|
+
* - B: recipient's public spend key
|
|
1249
|
+
* - i: output index
|
|
1250
|
+
* - Hs: keccak256 -> sc_reduce32 (hash to scalar)
|
|
1251
|
+
*
|
|
1252
|
+
* Input key recovery: x = Hs(a*R || i) + b
|
|
1253
|
+
* - a: recipient's private view key
|
|
1254
|
+
* - R: tx public key (r*G)
|
|
1255
|
+
* - b: recipient's private spend key
|
|
1256
|
+
*/
|
|
1257
|
+
const ExtPoint$2 = ed25519.ed25519.ExtendedPoint;
|
|
1258
|
+
/**
|
|
1259
|
+
* Hash to scalar: Hs(data) = sc_reduce32(keccak256(data))
|
|
1260
|
+
*/
|
|
1261
|
+
function hashToScalar(data) {
|
|
1262
|
+
return scReduce32(sha3.keccak_256(data));
|
|
1263
|
+
}
|
|
1264
|
+
function encodeVarint$1(n) {
|
|
1265
|
+
const bytes = [];
|
|
1266
|
+
while (n >= 0x80) {
|
|
1267
|
+
bytes.push((n & 0x7f) | 0x80);
|
|
1268
|
+
n >>>= 7;
|
|
1269
|
+
}
|
|
1270
|
+
bytes.push(n & 0x7f);
|
|
1271
|
+
return new Uint8Array(bytes);
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Concatenate buffers with a varint output index.
|
|
1275
|
+
*/
|
|
1276
|
+
function deriveKeyData(sharedSecret, outputIndex) {
|
|
1277
|
+
const secretBytes = sharedSecret.toRawBytes();
|
|
1278
|
+
// Encode output index as varint (simple case: single byte for index < 128)
|
|
1279
|
+
const indexBytes = encodeVarint$1(outputIndex);
|
|
1280
|
+
const combined = new Uint8Array(secretBytes.length + indexBytes.length);
|
|
1281
|
+
combined.set(secretBytes, 0);
|
|
1282
|
+
combined.set(indexBytes, secretBytes.length);
|
|
1283
|
+
return combined;
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Derive the one-time output public key for a transaction output.
|
|
1287
|
+
* P = Hs(r*A || i)*G + B
|
|
1288
|
+
*
|
|
1289
|
+
* @param txPrivKey - Transaction private key r (32 bytes)
|
|
1290
|
+
* @param recipPubViewKey - Recipient's public view key A (32 bytes)
|
|
1291
|
+
* @param recipPubSpendKey - Recipient's public spend key B (32 bytes)
|
|
1292
|
+
* @param outputIndex - Output index i
|
|
1293
|
+
* @returns One-time public key P (32 bytes)
|
|
1294
|
+
*/
|
|
1295
|
+
function deriveOutputKey(txPrivKey, recipPubViewKey, recipPubSpendKey, outputIndex) {
|
|
1296
|
+
const rScalar = utils.bytesToNumberLE(txPrivKey);
|
|
1297
|
+
const A = ExtPoint$2.fromHex(recipPubViewKey);
|
|
1298
|
+
const B = ExtPoint$2.fromHex(recipPubSpendKey);
|
|
1299
|
+
// Monero derivation: 8*r*A (cofactor multiplication)
|
|
1300
|
+
const rA = A.multiply(rScalar).multiply(BigInt(8));
|
|
1301
|
+
// Hs(r*A || i)
|
|
1302
|
+
const keyData = deriveKeyData(rA, outputIndex);
|
|
1303
|
+
const hsBytes = hashToScalar(keyData);
|
|
1304
|
+
const hs = utils.bytesToNumberLE(hsBytes);
|
|
1305
|
+
// P = Hs(r*A || i)*G + B
|
|
1306
|
+
const hsG = ExtPoint$2.BASE.multiply(hs);
|
|
1307
|
+
return hsG.add(B).toRawBytes();
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Derive the one-time private key for spending a received output.
|
|
1311
|
+
* x = Hs(a*R || i) + b
|
|
1312
|
+
*
|
|
1313
|
+
* @param txPubKey - Transaction public key R = r*G (32 bytes)
|
|
1314
|
+
* @param privViewKey - Recipient's private view key a (32 bytes)
|
|
1315
|
+
* @param privSpendKey - Recipient's private spend key b (32 bytes)
|
|
1316
|
+
* @param outputIndex - Output index i
|
|
1317
|
+
* @returns One-time private key x (32 bytes)
|
|
1318
|
+
*/
|
|
1319
|
+
function deriveInputKey(txPubKey, privViewKey, privSpendKey, outputIndex) {
|
|
1320
|
+
const aScalar = utils.bytesToNumberLE(privViewKey);
|
|
1321
|
+
const R = ExtPoint$2.fromHex(txPubKey);
|
|
1322
|
+
// Monero derivation: 8*a*R (cofactor multiplication)
|
|
1323
|
+
const aR = R.multiply(aScalar).multiply(BigInt(8));
|
|
1324
|
+
// Hs(8*a*R || i)
|
|
1325
|
+
const keyData = deriveKeyData(aR, outputIndex);
|
|
1326
|
+
const hsBytes = hashToScalar(keyData);
|
|
1327
|
+
const hs = utils.bytesToNumberLE(hsBytes);
|
|
1328
|
+
// x = Hs(8*a*R || i) + b
|
|
1329
|
+
const b = utils.bytesToNumberLE(privSpendKey);
|
|
1330
|
+
const L = BigInt('7237005577332262213973186563042994240857116359379907606001950938285454250989');
|
|
1331
|
+
const x = (hs + b) % L;
|
|
1332
|
+
return utils.numberToBytesLE(x, 32);
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Check if an output belongs to us.
|
|
1336
|
+
* Computes P' = Hs(a*R || i)*G + B and checks if P' == P.
|
|
1337
|
+
*
|
|
1338
|
+
* @param txPubKey - Transaction public key R (32 bytes)
|
|
1339
|
+
* @param privViewKey - Our private view key a (32 bytes)
|
|
1340
|
+
* @param pubSpendKey - Our public spend key B (32 bytes)
|
|
1341
|
+
* @param outputKey - The output's public key P (32 bytes)
|
|
1342
|
+
* @param outputIndex - Output index i
|
|
1343
|
+
* @returns true if the output belongs to us
|
|
1344
|
+
*/
|
|
1345
|
+
function isOutputForUs(txPubKey, privViewKey, pubSpendKey, outputKey, outputIndex) {
|
|
1346
|
+
const aScalar = utils.bytesToNumberLE(privViewKey);
|
|
1347
|
+
const R = ExtPoint$2.fromHex(txPubKey);
|
|
1348
|
+
const B = ExtPoint$2.fromHex(pubSpendKey);
|
|
1349
|
+
// Monero derivation: 8*a*R (cofactor multiplication)
|
|
1350
|
+
const aR = R.multiply(aScalar).multiply(BigInt(8));
|
|
1351
|
+
const keyData = deriveKeyData(aR, outputIndex);
|
|
1352
|
+
const hsBytes = hashToScalar(keyData);
|
|
1353
|
+
const hs = utils.bytesToNumberLE(hsBytes);
|
|
1354
|
+
const expectedP = ExtPoint$2.BASE.multiply(hs).add(B);
|
|
1355
|
+
const actualP = ExtPoint$2.fromHex(outputKey);
|
|
1356
|
+
return expectedP.equals(actualP);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* ECDH-based amount encryption/decryption for Monero RingCT.
|
|
1361
|
+
*
|
|
1362
|
+
* Shared secret: ss = 8 * r * A (or 8 * a * R)
|
|
1363
|
+
* Amount mask: first 8 bytes of keccak256("amount" || Hs(ss || index))
|
|
1364
|
+
* Encrypted amount: XOR of 8-byte LE amount with mask
|
|
1365
|
+
*/
|
|
1366
|
+
const ExtPoint$1 = ed25519.ed25519.ExtendedPoint;
|
|
1367
|
+
function encodeVarint(n) {
|
|
1368
|
+
const bytes = [];
|
|
1369
|
+
while (n >= 0x80) {
|
|
1370
|
+
bytes.push((n & 0x7f) | 0x80);
|
|
1371
|
+
n >>>= 7;
|
|
1372
|
+
}
|
|
1373
|
+
bytes.push(n & 0x7f);
|
|
1374
|
+
return new Uint8Array(bytes);
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Derive the shared secret scalar for amount encryption.
|
|
1378
|
+
* Hs(r*A || i) — same derivation as stealth address.
|
|
1379
|
+
*/
|
|
1380
|
+
function derivationToScalar(sharedSecret, outputIndex) {
|
|
1381
|
+
const indexBytes = encodeVarint(outputIndex);
|
|
1382
|
+
const combined = new Uint8Array(sharedSecret.length + indexBytes.length);
|
|
1383
|
+
combined.set(sharedSecret, 0);
|
|
1384
|
+
combined.set(indexBytes, sharedSecret.length);
|
|
1385
|
+
return scReduce32(sha3.keccak_256(combined));
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Generate the amount mask for XOR encryption.
|
|
1389
|
+
* mask = keccak256("amount" || scalar)[0..8]
|
|
1390
|
+
*/
|
|
1391
|
+
function amountMask(scalar) {
|
|
1392
|
+
const prefix = new TextEncoder().encode('amount');
|
|
1393
|
+
const data = new Uint8Array(prefix.length + scalar.length);
|
|
1394
|
+
data.set(prefix, 0);
|
|
1395
|
+
data.set(scalar, prefix.length);
|
|
1396
|
+
return sha3.keccak_256(data).slice(0, 8);
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Derive ECDH shared secret point from tx private key and recipient's public view key.
|
|
1400
|
+
* Returns the compressed point bytes of r*A.
|
|
1401
|
+
*/
|
|
1402
|
+
function deriveSharedSecret(txPrivKey, pubViewKey) {
|
|
1403
|
+
const rScalar = utils.bytesToNumberLE(txPrivKey);
|
|
1404
|
+
const A = ExtPoint$1.fromHex(pubViewKey);
|
|
1405
|
+
// Monero derivation: 8*r*A (cofactor multiplication)
|
|
1406
|
+
return A.multiply(rScalar).multiply(BigInt(8)).toRawBytes();
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Compute the 1-byte view tag for an output.
|
|
1410
|
+
* view_tag = keccak256("view_tag" || derivation || varint(outputIndex))[0]
|
|
1411
|
+
*/
|
|
1412
|
+
function computeViewTag(sharedSecret, outputIndex) {
|
|
1413
|
+
const prefix = new TextEncoder().encode('view_tag');
|
|
1414
|
+
const idxBuf = encodeVarint(outputIndex);
|
|
1415
|
+
const data = new Uint8Array(prefix.length + sharedSecret.length + idxBuf.length);
|
|
1416
|
+
data.set(prefix, 0);
|
|
1417
|
+
data.set(sharedSecret, prefix.length);
|
|
1418
|
+
data.set(idxBuf, prefix.length + sharedSecret.length);
|
|
1419
|
+
return sha3.keccak_256(data)[0];
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Encrypt an amount using ECDH-derived mask.
|
|
1423
|
+
* @param amount - Amount in piconero (bigint)
|
|
1424
|
+
* @param sharedSecret - ECDH shared secret (r*A compressed bytes)
|
|
1425
|
+
* @param outputIndex - Output index for key derivation
|
|
1426
|
+
* @returns 8-byte encrypted amount
|
|
1427
|
+
*/
|
|
1428
|
+
function encryptAmount(amount, sharedSecret, outputIndex) {
|
|
1429
|
+
const scalar = derivationToScalar(sharedSecret, outputIndex);
|
|
1430
|
+
const mask = amountMask(scalar);
|
|
1431
|
+
const amountBytes = utils.numberToBytesLE(amount, 8);
|
|
1432
|
+
const encrypted = new Uint8Array(8);
|
|
1433
|
+
for (let i = 0; i < 8; i++) {
|
|
1434
|
+
encrypted[i] = amountBytes[i] ^ mask[i];
|
|
1435
|
+
}
|
|
1436
|
+
return encrypted;
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Decrypt an amount using ECDH-derived mask.
|
|
1440
|
+
* @param encrypted - 8-byte encrypted amount
|
|
1441
|
+
* @param sharedSecret - ECDH shared secret (a*R compressed bytes)
|
|
1442
|
+
* @param outputIndex - Output index for key derivation
|
|
1443
|
+
* @returns Decrypted amount in piconero
|
|
1444
|
+
*/
|
|
1445
|
+
function decryptAmount(encrypted, sharedSecret, outputIndex) {
|
|
1446
|
+
if (encrypted.length < 8)
|
|
1447
|
+
throw new Error(`decryptAmount: expected 8 bytes, got ${encrypted.length}`);
|
|
1448
|
+
const scalar = derivationToScalar(sharedSecret, outputIndex);
|
|
1449
|
+
const mask = amountMask(scalar);
|
|
1450
|
+
const decrypted = new Uint8Array(8);
|
|
1451
|
+
for (let i = 0; i < 8; i++) {
|
|
1452
|
+
decrypted[i] = encrypted[i] ^ mask[i];
|
|
1453
|
+
}
|
|
1454
|
+
return utils.bytesToNumberLE(decrypted);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Decoy output selection for Monero ring signatures.
|
|
1459
|
+
* Uses gamma distribution (shape=19.28, scale=1/1.61) matching Monero's wallet2.
|
|
1460
|
+
*
|
|
1461
|
+
* Reference: monero/src/wallet/wallet2.cpp (get_outs)
|
|
1462
|
+
*/
|
|
1463
|
+
const RING_SIZE = 16;
|
|
1464
|
+
const GAMMA_SHAPE = 19.28;
|
|
1465
|
+
const GAMMA_RATE = 1.61; // scale = 1/rate
|
|
1466
|
+
/**
|
|
1467
|
+
* Generate a cryptographically secure random float in [0, 1).
|
|
1468
|
+
*/
|
|
1469
|
+
function secureRandom() {
|
|
1470
|
+
const buf = new Uint32Array(1);
|
|
1471
|
+
crypto.getRandomValues(buf);
|
|
1472
|
+
return buf[0] / 0x100000000;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Sample from gamma distribution using Marsaglia & Tsang's method.
|
|
1476
|
+
* Uses crypto.getRandomValues() for privacy-safe randomness.
|
|
1477
|
+
*/
|
|
1478
|
+
function sampleGamma(shape, rate) {
|
|
1479
|
+
const d = shape - 1 / 3;
|
|
1480
|
+
const c = 1 / Math.sqrt(9 * d);
|
|
1481
|
+
while (true) {
|
|
1482
|
+
let x;
|
|
1483
|
+
let v;
|
|
1484
|
+
do {
|
|
1485
|
+
// Box-Muller for standard normal
|
|
1486
|
+
const u1 = secureRandom();
|
|
1487
|
+
const u2 = secureRandom();
|
|
1488
|
+
x = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
|
1489
|
+
v = Math.pow(1 + c * x, 3);
|
|
1490
|
+
} while (v <= 0);
|
|
1491
|
+
const u = secureRandom();
|
|
1492
|
+
if (u < 1 - 0.0331 * Math.pow(x, 4) || Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) {
|
|
1493
|
+
return (d * v) / rate;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Select decoy global output indices for ring signature construction.
|
|
1499
|
+
*
|
|
1500
|
+
* @param realGlobalIndex - Global output index of the real input
|
|
1501
|
+
* @param numOutputs - Total number of outputs on chain
|
|
1502
|
+
* @param newestHeight - Current blockchain height
|
|
1503
|
+
* @param outputDistribution - Cumulative output distribution per block
|
|
1504
|
+
* @param distStartHeight - Start height of distribution data
|
|
1505
|
+
* @returns Array of RING_SIZE - 1 decoy global indices (excluding the real one)
|
|
1506
|
+
*/
|
|
1507
|
+
function selectDecoys(realGlobalIndex, numOutputs, newestHeight, outputDistribution, distStartHeight) {
|
|
1508
|
+
const decoys = new Set();
|
|
1509
|
+
const maxIndex = numOutputs - 1;
|
|
1510
|
+
let attempts = 0;
|
|
1511
|
+
while (decoys.size < RING_SIZE - 1 && attempts < 1000) {
|
|
1512
|
+
attempts++;
|
|
1513
|
+
// Sample from gamma distribution
|
|
1514
|
+
let blockOffset = Math.floor(sampleGamma(GAMMA_SHAPE, GAMMA_RATE));
|
|
1515
|
+
// Map to a block height (counting from the newest block backwards)
|
|
1516
|
+
let blockHeight = newestHeight - blockOffset;
|
|
1517
|
+
// Ensure within valid range
|
|
1518
|
+
if (blockHeight < distStartHeight)
|
|
1519
|
+
blockHeight = distStartHeight;
|
|
1520
|
+
if (blockHeight >= distStartHeight + outputDistribution.length) {
|
|
1521
|
+
blockHeight = distStartHeight + outputDistribution.length - 1;
|
|
1522
|
+
}
|
|
1523
|
+
// Get output range for this block
|
|
1524
|
+
const distIdx = blockHeight - distStartHeight;
|
|
1525
|
+
const blockStart = distIdx > 0 ? outputDistribution[distIdx - 1] : 0;
|
|
1526
|
+
const blockEnd = outputDistribution[distIdx];
|
|
1527
|
+
if (blockEnd <= blockStart)
|
|
1528
|
+
continue; // empty block
|
|
1529
|
+
// Pick random output within this block
|
|
1530
|
+
const outputIdx = blockStart + Math.floor(secureRandom() * (blockEnd - blockStart));
|
|
1531
|
+
if (outputIdx > maxIndex)
|
|
1532
|
+
continue;
|
|
1533
|
+
if (outputIdx === realGlobalIndex)
|
|
1534
|
+
continue;
|
|
1535
|
+
if (decoys.has(outputIdx))
|
|
1536
|
+
continue;
|
|
1537
|
+
decoys.add(outputIdx);
|
|
1538
|
+
}
|
|
1539
|
+
// If we couldn't get enough decoys from gamma sampling, fill randomly
|
|
1540
|
+
let fallbackAttempts = 0;
|
|
1541
|
+
while (decoys.size < RING_SIZE - 1 && fallbackAttempts < 10000) {
|
|
1542
|
+
fallbackAttempts++;
|
|
1543
|
+
const idx = Math.floor(secureRandom() * (maxIndex + 1));
|
|
1544
|
+
if (idx !== realGlobalIndex && !decoys.has(idx)) {
|
|
1545
|
+
decoys.add(idx);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (decoys.size < RING_SIZE - 1) {
|
|
1549
|
+
throw new Error(`Insufficient decoys: need ${RING_SIZE - 1}, got ${decoys.size} from ${maxIndex + 1} outputs`);
|
|
1550
|
+
}
|
|
1551
|
+
return Array.from(decoys).sort((a, b) => a - b);
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Build sorted ring with real index.
|
|
1555
|
+
* Returns the ring indices (sorted) and the position of the real input.
|
|
1556
|
+
*/
|
|
1557
|
+
function buildRingIndices(realGlobalIndex, decoys) {
|
|
1558
|
+
const all = [...decoys, realGlobalIndex].sort((a, b) => a - b);
|
|
1559
|
+
const realIndex = all.indexOf(realGlobalIndex);
|
|
1560
|
+
return { indices: all, realIndex };
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Convert absolute global indices to relative offsets (as stored in tx).
|
|
1564
|
+
*/
|
|
1565
|
+
function toRelativeOffsets(sortedIndices) {
|
|
1566
|
+
const offsets = [BigInt(sortedIndices[0])];
|
|
1567
|
+
for (let i = 1; i < sortedIndices.length; i++) {
|
|
1568
|
+
offsets.push(BigInt(sortedIndices[i] - sortedIndices[i - 1]));
|
|
1569
|
+
}
|
|
1570
|
+
return offsets;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* Monero transaction binary serialization.
|
|
1575
|
+
* Reference: monero/src/cryptonote_basic/cryptonote_format_utils.cpp
|
|
1576
|
+
*/
|
|
1577
|
+
/**
|
|
1578
|
+
* Encode a number as a Monero varint (7 bits per byte, MSB = continuation flag).
|
|
1579
|
+
*/
|
|
1580
|
+
function writeVarint(n) {
|
|
1581
|
+
let val = typeof n === 'number' ? BigInt(n) : n;
|
|
1582
|
+
const bytes = [];
|
|
1583
|
+
while (val >= BigInt(0x80)) {
|
|
1584
|
+
bytes.push(Number(val & BigInt(0x7f)) | 0x80);
|
|
1585
|
+
val >>= BigInt(7);
|
|
1586
|
+
}
|
|
1587
|
+
bytes.push(Number(val & BigInt(0x7f)));
|
|
1588
|
+
return new Uint8Array(bytes);
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Helper class for building binary buffers.
|
|
1592
|
+
*/
|
|
1593
|
+
class TxWriter {
|
|
1594
|
+
constructor() {
|
|
1595
|
+
this.parts = [];
|
|
1596
|
+
}
|
|
1597
|
+
writeBytes(data) {
|
|
1598
|
+
this.parts.push(data);
|
|
1599
|
+
}
|
|
1600
|
+
writeVarint(n) {
|
|
1601
|
+
this.parts.push(writeVarint(n));
|
|
1602
|
+
}
|
|
1603
|
+
writeKey(key) {
|
|
1604
|
+
if (key.length !== 32)
|
|
1605
|
+
throw new Error(`Expected 32-byte key, got ${key.length}`);
|
|
1606
|
+
this.parts.push(key);
|
|
1607
|
+
}
|
|
1608
|
+
writeByte(b) {
|
|
1609
|
+
this.parts.push(new Uint8Array([b]));
|
|
1610
|
+
}
|
|
1611
|
+
toBytes() {
|
|
1612
|
+
const totalLen = this.parts.reduce((sum, p) => sum + p.length, 0);
|
|
1613
|
+
const result = new Uint8Array(totalLen);
|
|
1614
|
+
let offset = 0;
|
|
1615
|
+
for (const part of this.parts) {
|
|
1616
|
+
result.set(part, offset);
|
|
1617
|
+
offset += part.length;
|
|
1618
|
+
}
|
|
1619
|
+
return result;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
function serializeInput(w, input) {
|
|
1623
|
+
w.writeByte(0x02); // txin_to_key tag
|
|
1624
|
+
w.writeVarint(input.amount);
|
|
1625
|
+
w.writeVarint(input.keyOffsets.length);
|
|
1626
|
+
for (const offset of input.keyOffsets) {
|
|
1627
|
+
w.writeVarint(offset);
|
|
1628
|
+
}
|
|
1629
|
+
w.writeKey(input.keyImage);
|
|
1630
|
+
}
|
|
1631
|
+
function serializeOutput(w, output) {
|
|
1632
|
+
w.writeVarint(output.amount);
|
|
1633
|
+
w.writeByte(0x03); // txout_to_tagged_key variant tag
|
|
1634
|
+
w.writeKey(output.key);
|
|
1635
|
+
w.writeByte(output.viewTag); // 1-byte view tag for fast scanning
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Serialize the transaction prefix (everything except RingCT signatures).
|
|
1639
|
+
*/
|
|
1640
|
+
function serializeTxPrefix(tx) {
|
|
1641
|
+
const w = new TxWriter();
|
|
1642
|
+
w.writeVarint(tx.version);
|
|
1643
|
+
w.writeVarint(tx.unlockTime);
|
|
1644
|
+
// Inputs
|
|
1645
|
+
w.writeVarint(tx.inputs.length);
|
|
1646
|
+
for (const input of tx.inputs) {
|
|
1647
|
+
serializeInput(w, input);
|
|
1648
|
+
}
|
|
1649
|
+
// Outputs
|
|
1650
|
+
w.writeVarint(tx.outputs.length);
|
|
1651
|
+
for (const output of tx.outputs) {
|
|
1652
|
+
serializeOutput(w, output);
|
|
1653
|
+
}
|
|
1654
|
+
// Extra
|
|
1655
|
+
w.writeVarint(tx.extra.length);
|
|
1656
|
+
w.writeBytes(tx.extra);
|
|
1657
|
+
return w.toBytes();
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Compute the transaction prefix hash (keccak256 of serialized prefix).
|
|
1661
|
+
* This is the message signed by CLSAG.
|
|
1662
|
+
*/
|
|
1663
|
+
function txPrefixHash(tx) {
|
|
1664
|
+
return sha3.keccak_256(serializeTxPrefix(tx));
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Serialize the RCT signature base (type, fee, encrypted amounts, output commitments).
|
|
1668
|
+
*/
|
|
1669
|
+
function serializeRctBase(rct) {
|
|
1670
|
+
const w = new TxWriter();
|
|
1671
|
+
w.writeByte(rct.type);
|
|
1672
|
+
w.writeVarint(rct.txnFee);
|
|
1673
|
+
// Encrypted amounts (8 bytes each for v2)
|
|
1674
|
+
for (const ecdh of rct.ecdhInfo) {
|
|
1675
|
+
w.writeBytes(ecdh);
|
|
1676
|
+
}
|
|
1677
|
+
// Output commitments
|
|
1678
|
+
for (const outPk of rct.outPk) {
|
|
1679
|
+
w.writeKey(outPk);
|
|
1680
|
+
}
|
|
1681
|
+
return w.toBytes();
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Serialize a CLSAG signature.
|
|
1685
|
+
*/
|
|
1686
|
+
function serializeClsag(sig) {
|
|
1687
|
+
const w = new TxWriter();
|
|
1688
|
+
// s scalars
|
|
1689
|
+
for (const s of sig.s) {
|
|
1690
|
+
w.writeKey(s);
|
|
1691
|
+
}
|
|
1692
|
+
// c1 challenge
|
|
1693
|
+
w.writeKey(sig.c1);
|
|
1694
|
+
// D (commitment key image, already scaled by 1/8)
|
|
1695
|
+
w.writeKey(sig.D);
|
|
1696
|
+
return w.toBytes();
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Serialize a Bulletproofs+ proof.
|
|
1700
|
+
*/
|
|
1701
|
+
function serializeBPPlus(proof) {
|
|
1702
|
+
const w = new TxWriter();
|
|
1703
|
+
w.writeKey(proof.A);
|
|
1704
|
+
w.writeKey(proof.A1);
|
|
1705
|
+
w.writeKey(proof.B);
|
|
1706
|
+
w.writeKey(proof.r1);
|
|
1707
|
+
w.writeKey(proof.s1);
|
|
1708
|
+
w.writeKey(proof.d1);
|
|
1709
|
+
// L and R vectors
|
|
1710
|
+
w.writeVarint(proof.L.length);
|
|
1711
|
+
for (const l of proof.L) {
|
|
1712
|
+
w.writeKey(l);
|
|
1713
|
+
}
|
|
1714
|
+
for (const r of proof.R) {
|
|
1715
|
+
w.writeKey(r);
|
|
1716
|
+
}
|
|
1717
|
+
return w.toBytes();
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Serialize the prunable RCT data (pseudo-outputs, CLSAGs, BP+ proofs).
|
|
1721
|
+
*/
|
|
1722
|
+
function serializeRctPrunable(rct) {
|
|
1723
|
+
const w = new TxWriter();
|
|
1724
|
+
// BP+ proofs count + data
|
|
1725
|
+
w.writeVarint(rct.bppProofs.length);
|
|
1726
|
+
for (const proof of rct.bppProofs) {
|
|
1727
|
+
w.writeBytes(serializeBPPlus(proof));
|
|
1728
|
+
}
|
|
1729
|
+
// CLSAGs
|
|
1730
|
+
for (const clsag of rct.clsags) {
|
|
1731
|
+
w.writeBytes(serializeClsag(clsag));
|
|
1732
|
+
}
|
|
1733
|
+
// Pseudo-outputs
|
|
1734
|
+
for (const po of rct.pseudoOuts) {
|
|
1735
|
+
w.writeKey(po);
|
|
1736
|
+
}
|
|
1737
|
+
return w.toBytes();
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Compute the RCT signature hash (message for CLSAG signing).
|
|
1741
|
+
* hash = keccak256(prefixHash || keccak256(rctBase) || keccak256(rctPrunable))
|
|
1742
|
+
*/
|
|
1743
|
+
function rctSigHash(prefixHash, rctBase, rctPrunable) {
|
|
1744
|
+
const combined = new Uint8Array(96);
|
|
1745
|
+
combined.set(prefixHash, 0);
|
|
1746
|
+
combined.set(sha3.keccak_256(rctBase), 32);
|
|
1747
|
+
combined.set(sha3.keccak_256(rctPrunable), 64);
|
|
1748
|
+
return sha3.keccak_256(combined);
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Serialize a complete transaction.
|
|
1752
|
+
*/
|
|
1753
|
+
function serializeTransaction(tx) {
|
|
1754
|
+
const w = new TxWriter();
|
|
1755
|
+
w.writeBytes(serializeTxPrefix(tx));
|
|
1756
|
+
w.writeBytes(serializeRctBase(tx.rctSignatures));
|
|
1757
|
+
w.writeBytes(serializeRctPrunable(tx.rctSignatures));
|
|
1758
|
+
return w.toBytes();
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Monero transaction builder.
|
|
1763
|
+
* Orchestrates: stealth addresses, Pedersen commitments, ECDH encryption,
|
|
1764
|
+
* CLSAG signatures, Bulletproofs+ range proofs, and serialization.
|
|
1765
|
+
*
|
|
1766
|
+
* Reference: monero/src/wallet/wallet2.cpp (create_transactions_2)
|
|
1767
|
+
*/
|
|
1768
|
+
const L = BigInt('7237005577332262213973186563042994240857116359379907606001950938285454250989');
|
|
1769
|
+
function randomScalar() {
|
|
1770
|
+
const bytes = new Uint8Array(32);
|
|
1771
|
+
crypto.getRandomValues(bytes);
|
|
1772
|
+
return scReduce32(bytes);
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Build a complete Monero RingCT transaction.
|
|
1776
|
+
*
|
|
1777
|
+
* @param inputs - Spendable outputs to use as inputs
|
|
1778
|
+
* @param destinations - Recipients + amounts
|
|
1779
|
+
* @param changeDestination - Change address keys (pubView, pubSpend)
|
|
1780
|
+
* @param fee - Transaction fee in piconero
|
|
1781
|
+
* @param privViewKey - Sender's private view key
|
|
1782
|
+
* @param privSpendKey - Sender's private spend key
|
|
1783
|
+
* @param daemonUrl - Daemon RPC URL for decoy selection
|
|
1784
|
+
* @returns Built transaction ready for broadcast
|
|
1785
|
+
*/
|
|
1786
|
+
function buildTransaction(inputs, destinations, changeDestination, fee, privViewKey, privSpendKey, daemonUrl) {
|
|
1787
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
1788
|
+
if (inputs.length === 0)
|
|
1789
|
+
throw new Error('No inputs');
|
|
1790
|
+
if (destinations.length === 0)
|
|
1791
|
+
throw new Error('No destinations');
|
|
1792
|
+
const totalIn = inputs.reduce((sum, inp) => sum + inp.amount, BigInt(0));
|
|
1793
|
+
const totalOut = destinations.reduce((sum, d) => sum + d.amount, BigInt(0));
|
|
1794
|
+
if (totalIn < totalOut + fee)
|
|
1795
|
+
throw new Error('Insufficient funds');
|
|
1796
|
+
// Add change output if needed
|
|
1797
|
+
const change = totalIn - totalOut - fee;
|
|
1798
|
+
const allDestinations = [...destinations];
|
|
1799
|
+
if (change > BigInt(0)) {
|
|
1800
|
+
allDestinations.push({
|
|
1801
|
+
pubViewKey: changeDestination.pubViewKey,
|
|
1802
|
+
pubSpendKey: changeDestination.pubSpendKey,
|
|
1803
|
+
amount: change,
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
// Generate tx private key
|
|
1807
|
+
const txPrivKey = randomScalar();
|
|
1808
|
+
const txPubKey = secretKeyToPublicKey(txPrivKey);
|
|
1809
|
+
// --- Build outputs ---
|
|
1810
|
+
const txOutputs = [];
|
|
1811
|
+
const outAmounts = [];
|
|
1812
|
+
const outMasks = [];
|
|
1813
|
+
const outCommitments = [];
|
|
1814
|
+
const ecdhInfo = [];
|
|
1815
|
+
for (let i = 0; i < allDestinations.length; i++) {
|
|
1816
|
+
const dest = allDestinations[i];
|
|
1817
|
+
// ECDH shared secret for this output
|
|
1818
|
+
const sharedSecret = deriveSharedSecret(txPrivKey, dest.pubViewKey);
|
|
1819
|
+
// One-time output key
|
|
1820
|
+
const outputKey = deriveOutputKey(txPrivKey, dest.pubViewKey, dest.pubSpendKey, i);
|
|
1821
|
+
const viewTag = computeViewTag(sharedSecret, i);
|
|
1822
|
+
txOutputs.push({ amount: BigInt(0), key: outputKey, viewTag });
|
|
1823
|
+
// Output commitment mask
|
|
1824
|
+
const outMask = randomScalar();
|
|
1825
|
+
outMasks.push(outMask);
|
|
1826
|
+
outAmounts.push(dest.amount);
|
|
1827
|
+
// Pedersen commitment: C = mask*G + amount*H
|
|
1828
|
+
const commitment = commit(outMask, dest.amount);
|
|
1829
|
+
outCommitments.push(commitment.toRawBytes());
|
|
1830
|
+
// ECDH encrypted amount
|
|
1831
|
+
const encrypted = encryptAmount(dest.amount, sharedSecret, i);
|
|
1832
|
+
ecdhInfo.push(encrypted);
|
|
1833
|
+
}
|
|
1834
|
+
// --- Build inputs with decoys ---
|
|
1835
|
+
const distData = yield getOutputDistribution(daemonUrl);
|
|
1836
|
+
const height = yield getHeight(daemonUrl);
|
|
1837
|
+
const numOutputs = distData.distribution[distData.distribution.length - 1];
|
|
1838
|
+
const txInputs = [];
|
|
1839
|
+
const rings = [];
|
|
1840
|
+
const realIndices = [];
|
|
1841
|
+
const inputPrivKeys = [];
|
|
1842
|
+
const inputMasks = [];
|
|
1843
|
+
const keyImages = [];
|
|
1844
|
+
const pseudoOuts = [];
|
|
1845
|
+
const pseudoMasks = [];
|
|
1846
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
1847
|
+
const inp = inputs[i];
|
|
1848
|
+
// Derive the one-time private key for this input
|
|
1849
|
+
const inputPrivKey = deriveInputKey(inp.txPubKey, privViewKey, privSpendKey, inp.outputIndex);
|
|
1850
|
+
inputPrivKeys.push(inputPrivKey);
|
|
1851
|
+
// Key image
|
|
1852
|
+
const ki = generateKeyImage(inputPrivKey, inp.publicKey);
|
|
1853
|
+
keyImages.push(ki);
|
|
1854
|
+
// Select decoys
|
|
1855
|
+
const decoys = selectDecoys(inp.globalIndex, numOutputs, height, distData.distribution, distData.startHeight);
|
|
1856
|
+
const { indices: ringIndices, realIndex } = buildRingIndices(inp.globalIndex, decoys);
|
|
1857
|
+
realIndices.push(realIndex);
|
|
1858
|
+
const offsets = toRelativeOffsets(ringIndices);
|
|
1859
|
+
// Fetch ring member public keys and commitments from daemon
|
|
1860
|
+
const outsReq = ringIndices.map((idx) => ({ amount: 0, index: idx }));
|
|
1861
|
+
const outs = yield getOuts(daemonUrl, outsReq);
|
|
1862
|
+
const ring = outs.map((out) => ({
|
|
1863
|
+
dest: hexToBytes(out.key),
|
|
1864
|
+
mask: hexToBytes(out.mask),
|
|
1865
|
+
}));
|
|
1866
|
+
rings.push(ring);
|
|
1867
|
+
txInputs.push({
|
|
1868
|
+
amount: BigInt(0),
|
|
1869
|
+
keyOffsets: offsets,
|
|
1870
|
+
keyImage: ki,
|
|
1871
|
+
});
|
|
1872
|
+
inputMasks.push(inp.mask);
|
|
1873
|
+
// Pseudo-output commitment (random mask, same amount as real input)
|
|
1874
|
+
const pseudoMask = randomScalar();
|
|
1875
|
+
pseudoMasks.push(pseudoMask);
|
|
1876
|
+
const pseudoCommitment = commit(pseudoMask, inp.amount);
|
|
1877
|
+
pseudoOuts.push(pseudoCommitment.toRawBytes());
|
|
1878
|
+
}
|
|
1879
|
+
// Adjust last pseudo-output mask so commitments balance:
|
|
1880
|
+
// sum(pseudoMasks) = sum(outMasks) + 0 (fee mask is 0)
|
|
1881
|
+
// So: pseudoMasks[last] = sum(outMasks) - sum(pseudoMasks[0..n-2])
|
|
1882
|
+
if (inputs.length > 0) {
|
|
1883
|
+
let sumOutMasks = BigInt(0);
|
|
1884
|
+
for (const m of outMasks)
|
|
1885
|
+
sumOutMasks = (sumOutMasks + utils.bytesToNumberLE(m)) % L;
|
|
1886
|
+
let sumPseudoMasks = BigInt(0);
|
|
1887
|
+
for (let i = 0; i < pseudoMasks.length - 1; i++) {
|
|
1888
|
+
sumPseudoMasks = (sumPseudoMasks + utils.bytesToNumberLE(pseudoMasks[i])) % L;
|
|
1889
|
+
}
|
|
1890
|
+
let lastMask = (sumOutMasks - sumPseudoMasks) % L;
|
|
1891
|
+
if (lastMask < BigInt(0))
|
|
1892
|
+
lastMask += L;
|
|
1893
|
+
const lastIdx = pseudoMasks.length - 1;
|
|
1894
|
+
pseudoMasks[lastIdx] = new Uint8Array(utils.numberToBytesLE(lastMask, 32));
|
|
1895
|
+
// Recompute last pseudo-output commitment
|
|
1896
|
+
pseudoOuts[lastIdx] = commit(pseudoMasks[lastIdx], inputs[lastIdx].amount).toRawBytes();
|
|
1897
|
+
}
|
|
1898
|
+
// --- Bulletproofs+ range proof ---
|
|
1899
|
+
const bppProof = bulletproofPlusProve(outAmounts, outMasks);
|
|
1900
|
+
// --- Build tx extra (tx public key) ---
|
|
1901
|
+
const extra = concatBytes(new Uint8Array([0x01]), txPubKey); // tag 0x01 = tx pubkey
|
|
1902
|
+
// --- Assemble transaction ---
|
|
1903
|
+
const rctSig = {
|
|
1904
|
+
type: 6, // RCTTypeBulletproofPlus
|
|
1905
|
+
txnFee: fee,
|
|
1906
|
+
ecdhInfo,
|
|
1907
|
+
outPk: outCommitments,
|
|
1908
|
+
pseudoOuts,
|
|
1909
|
+
clsags: [], // filled below
|
|
1910
|
+
bppProofs: [bppProof],
|
|
1911
|
+
};
|
|
1912
|
+
const tx = {
|
|
1913
|
+
version: 2,
|
|
1914
|
+
unlockTime: BigInt(0),
|
|
1915
|
+
inputs: txInputs,
|
|
1916
|
+
outputs: txOutputs,
|
|
1917
|
+
extra,
|
|
1918
|
+
rctSignatures: rctSig,
|
|
1919
|
+
};
|
|
1920
|
+
// --- Sign each input with CLSAG ---
|
|
1921
|
+
const prefixHash = txPrefixHash(tx);
|
|
1922
|
+
const rctBase = serializeRctBase(rctSig);
|
|
1923
|
+
const rctPrunable = serializeRctPrunable(rctSig);
|
|
1924
|
+
const sigMessage = rctSigHash(prefixHash, rctBase, rctPrunable);
|
|
1925
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
1926
|
+
// z = inputMask - pseudoMask
|
|
1927
|
+
let z = (utils.bytesToNumberLE(inputMasks[i]) - utils.bytesToNumberLE(pseudoMasks[i])) % L;
|
|
1928
|
+
if (z < BigInt(0))
|
|
1929
|
+
z += L;
|
|
1930
|
+
const zBytes = new Uint8Array(utils.numberToBytesLE(z, 32));
|
|
1931
|
+
const sig = clsagSign(sigMessage, rings[i], pseudoOuts[i], inputPrivKeys[i], zBytes, realIndices[i], keyImages[i]);
|
|
1932
|
+
tx.rctSignatures.clsags.push(sig);
|
|
1933
|
+
}
|
|
1934
|
+
// Serialize
|
|
1935
|
+
const txBytes = serializeTransaction(tx);
|
|
1936
|
+
const txHex = bytesToHex(txBytes);
|
|
1937
|
+
// Monero txid = keccak256(prefixHash || keccak256(rctBase) || keccak256(rctPrunable))
|
|
1938
|
+
// Must recompute rctPrunable after CLSAGs are filled in
|
|
1939
|
+
const finalRctPrunable = serializeRctPrunable(rctSig);
|
|
1940
|
+
const txHash = bytesToHex(rctSigHash(prefixHash, rctBase, finalRctPrunable));
|
|
1941
|
+
return { tx, txHex, txHash };
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/**
|
|
1946
|
+
* Daemon-based blockchain scanner for Monero.
|
|
1947
|
+
* Scans blocks using the view key to find owned outputs and detect spent outputs.
|
|
1948
|
+
* Used as fallback when no LWS (Light Wallet Server) is available.
|
|
1949
|
+
*
|
|
1950
|
+
* This is intentionally slow — scanning the full chain from genesis is impractical.
|
|
1951
|
+
* Use a restore height close to when the wallet was created.
|
|
1952
|
+
*/
|
|
1953
|
+
const ExtPoint = ed25519.ed25519.ExtendedPoint;
|
|
1954
|
+
/**
|
|
1955
|
+
* Parse tx public key from the extra field of a decoded Monero transaction.
|
|
1956
|
+
* The extra field is an array of bytes; tag 0x01 followed by 32 bytes is the tx pub key.
|
|
1957
|
+
*/
|
|
1958
|
+
function parseTxPubKey(extraBytes) {
|
|
1959
|
+
for (let i = 0; i < extraBytes.length; i++) {
|
|
1960
|
+
if (extraBytes[i] === 0x01 && i + 32 < extraBytes.length) {
|
|
1961
|
+
return new Uint8Array(extraBytes.slice(i + 1, i + 33));
|
|
1962
|
+
}
|
|
1963
|
+
// Tag 0x02 = nonce, skip its length
|
|
1964
|
+
if (extraBytes[i] === 0x02 && i + 1 < extraBytes.length) {
|
|
1965
|
+
i += extraBytes[i + 1] + 1;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
return null;
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Compute the ECDH shared secret for amount decryption: a*R (view key * tx pub key).
|
|
1972
|
+
* Returns compressed point bytes.
|
|
1973
|
+
*/
|
|
1974
|
+
function computeSharedSecret(privViewKey, txPubKey) {
|
|
1975
|
+
const a = utils.bytesToNumberLE(privViewKey);
|
|
1976
|
+
const R = ExtPoint.fromHex(txPubKey);
|
|
1977
|
+
// Monero's generate_key_derivation: derivation = 8 * a * R (cofactor multiplication)
|
|
1978
|
+
return R.multiply(a).multiply(BigInt(8)).toRawBytes();
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Process decoded transactions to find owned outputs and spent key images.
|
|
1982
|
+
*/
|
|
1983
|
+
function processTransactions(decodedTxs, privViewKey, pubSpendKey, privSpendKey, heightTimestampMap) {
|
|
1984
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
1985
|
+
const ownedOutputs = [];
|
|
1986
|
+
const spentKeyImages = new Set();
|
|
1987
|
+
for (const txEntry of decodedTxs) {
|
|
1988
|
+
let txJson;
|
|
1989
|
+
try {
|
|
1990
|
+
txJson = JSON.parse(txEntry.as_json);
|
|
1991
|
+
}
|
|
1992
|
+
catch (_j) {
|
|
1993
|
+
continue;
|
|
1994
|
+
}
|
|
1995
|
+
// Collect spent key images from inputs
|
|
1996
|
+
if (txJson.vin) {
|
|
1997
|
+
for (const input of txJson.vin) {
|
|
1998
|
+
const ki = ((_a = input.key) === null || _a === void 0 ? void 0 : _a.key_image) || ((_b = input.key) === null || _b === void 0 ? void 0 : _b.k_image);
|
|
1999
|
+
if (ki)
|
|
2000
|
+
spentKeyImages.add(ki);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
if (!txJson.extra || !txJson.vout)
|
|
2004
|
+
continue;
|
|
2005
|
+
const txPubKey = parseTxPubKey(txJson.extra);
|
|
2006
|
+
if (!txPubKey)
|
|
2007
|
+
continue;
|
|
2008
|
+
const sharedSecret = computeSharedSecret(privViewKey, txPubKey);
|
|
2009
|
+
const meta = (_c = heightTimestampMap.get(txEntry.tx_hash)) !== null && _c !== void 0 ? _c : {
|
|
2010
|
+
height: txEntry.block_height,
|
|
2011
|
+
timestamp: txEntry.block_timestamp,
|
|
2012
|
+
};
|
|
2013
|
+
for (let i = 0; i < txJson.vout.length; i++) {
|
|
2014
|
+
const out = txJson.vout[i];
|
|
2015
|
+
const outputKeyHex = (_e = (_d = out.target.tagged_key) === null || _d === void 0 ? void 0 : _d.key) !== null && _e !== void 0 ? _e : out.target.key;
|
|
2016
|
+
if (!outputKeyHex)
|
|
2017
|
+
continue;
|
|
2018
|
+
// View tag optimization: skip EC math if view tag doesn't match
|
|
2019
|
+
const viewTag = (_f = out.target.tagged_key) === null || _f === void 0 ? void 0 : _f.view_tag;
|
|
2020
|
+
if (viewTag !== undefined) {
|
|
2021
|
+
const expected = computeViewTag(sharedSecret, i);
|
|
2022
|
+
if (expected !== parseInt(viewTag, 16))
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
const outputKey = hexToBytes(outputKeyHex);
|
|
2026
|
+
try {
|
|
2027
|
+
if (!isOutputForUs(txPubKey, privViewKey, pubSpendKey, outputKey, i))
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
catch (_k) {
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
// Decrypt amount from RingCT ecdhInfo
|
|
2034
|
+
let amount = BigInt(0);
|
|
2035
|
+
if ((_h = (_g = txJson.rct_signatures) === null || _g === void 0 ? void 0 : _g.ecdhInfo) === null || _h === void 0 ? void 0 : _h[i]) {
|
|
2036
|
+
const encryptedHex = txJson.rct_signatures.ecdhInfo[i].amount;
|
|
2037
|
+
const encrypted = hexToBytes(encryptedHex);
|
|
2038
|
+
try {
|
|
2039
|
+
amount = decryptAmount(encrypted, sharedSecret, i);
|
|
2040
|
+
}
|
|
2041
|
+
catch (_l) {
|
|
2042
|
+
continue;
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
// Compute key image for spent detection
|
|
2046
|
+
const oneTimePrivKey = deriveInputKey(txPubKey, privViewKey, privSpendKey, i);
|
|
2047
|
+
const keyImage = bytesToHex(generateKeyImage(oneTimePrivKey, outputKey));
|
|
2048
|
+
ownedOutputs.push({
|
|
2049
|
+
txHash: txEntry.tx_hash,
|
|
2050
|
+
outputIndex: i,
|
|
2051
|
+
amount,
|
|
2052
|
+
globalIndex: -1, // Not available from get_transactions; outputs from daemon scan cannot be spent directly
|
|
2053
|
+
publicKey: outputKey,
|
|
2054
|
+
txPubKey,
|
|
2055
|
+
keyImage,
|
|
2056
|
+
height: meta.height,
|
|
2057
|
+
timestamp: meta.timestamp,
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
return { ownedOutputs, spentKeyImages };
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Scan a range of blocks for owned outputs.
|
|
2065
|
+
* Uses batched RPC calls: fetches block tx hashes in parallel, then
|
|
2066
|
+
* fetches all transactions in a single get_transactions call per batch.
|
|
2067
|
+
*/
|
|
2068
|
+
function scanBlocks(daemonUrl, privViewKey, pubSpendKey, privSpendKey, fromHeight, toHeight, onProgress) {
|
|
2069
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2070
|
+
const allOwnedOutputs = [];
|
|
2071
|
+
const allSpentKeyImages = new Set();
|
|
2072
|
+
// Step 1: Fetch all block headers in one RPC call to find blocks with txs
|
|
2073
|
+
const HEADER_BATCH = 500;
|
|
2074
|
+
const blocksWithTxs = [];
|
|
2075
|
+
for (let h = fromHeight; h <= toHeight; h += HEADER_BATCH) {
|
|
2076
|
+
const rangeEnd = Math.min(h + HEADER_BATCH - 1, toHeight);
|
|
2077
|
+
try {
|
|
2078
|
+
const headers = yield getBlockHeadersRange(daemonUrl, h, rangeEnd);
|
|
2079
|
+
for (const hdr of headers) {
|
|
2080
|
+
if (hdr.num_txes > 0) {
|
|
2081
|
+
blocksWithTxs.push({ height: hdr.height, timestamp: hdr.timestamp });
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
catch (err) {
|
|
2086
|
+
console.error(`[XMR scanner] getBlockHeadersRange failed for ${h}-${rangeEnd}:`, err.message);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
console.log(`[XMR scanner] ${blocksWithTxs.length} blocks with transactions out of ${toHeight - fromHeight + 1} total`);
|
|
2090
|
+
// Step 2: For each block with txs, fetch tx hashes sequentially
|
|
2091
|
+
const allTxHashes = [];
|
|
2092
|
+
const heightMap = new Map();
|
|
2093
|
+
for (const block of blocksWithTxs) {
|
|
2094
|
+
try {
|
|
2095
|
+
const result = yield getBlockTxHashes(daemonUrl, block.height);
|
|
2096
|
+
for (const txHash of result.txHashes) {
|
|
2097
|
+
allTxHashes.push(txHash);
|
|
2098
|
+
heightMap.set(txHash, { height: block.height, timestamp: block.timestamp });
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
catch (err) {
|
|
2102
|
+
console.warn(`[XMR scanner] getBlockTxHashes failed for height ${block.height}:`, err.message);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
console.log(`[XMR scanner] collected ${allTxHashes.length} tx hashes from ${blocksWithTxs.length} blocks`);
|
|
2106
|
+
// Step 3: Fetch and process transactions in small batches (process-as-you-go to limit memory)
|
|
2107
|
+
const TX_BATCH = 25;
|
|
2108
|
+
let processedTxs = 0;
|
|
2109
|
+
console.log(`[XMR scanner] processing ${allTxHashes.length} txs in batches of ${TX_BATCH}...`);
|
|
2110
|
+
for (let t = 0; t < allTxHashes.length; t += TX_BATCH) {
|
|
2111
|
+
const slice = allTxHashes.slice(t, t + TX_BATCH);
|
|
2112
|
+
try {
|
|
2113
|
+
const decodedTxs = yield getTransactionsDecoded(daemonUrl, slice);
|
|
2114
|
+
const result = processTransactions(decodedTxs, privViewKey, pubSpendKey, privSpendKey, heightMap);
|
|
2115
|
+
allOwnedOutputs.push(...result.ownedOutputs);
|
|
2116
|
+
for (const ki of result.spentKeyImages)
|
|
2117
|
+
allSpentKeyImages.add(ki);
|
|
2118
|
+
processedTxs += decodedTxs.length;
|
|
2119
|
+
}
|
|
2120
|
+
catch (err) {
|
|
2121
|
+
console.error(`[XMR scanner] getTransactionsDecoded failed (batch ${t}):`, err.message);
|
|
2122
|
+
}
|
|
2123
|
+
if (onProgress) {
|
|
2124
|
+
const progress = Math.min(t + TX_BATCH, allTxHashes.length) / allTxHashes.length;
|
|
2125
|
+
onProgress({
|
|
2126
|
+
currentHeight: fromHeight + Math.floor(progress * (toHeight - fromHeight)),
|
|
2127
|
+
totalHeight: toHeight,
|
|
2128
|
+
outputsFound: allOwnedOutputs.length,
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
console.log(`[XMR scanner] processed ${processedTxs} txs, found ${allOwnedOutputs.length} owned outputs`);
|
|
2133
|
+
return { ownedOutputs: allOwnedOutputs, spentKeyImages: allSpentKeyImages };
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* Compute balance from scan results.
|
|
2138
|
+
* Balance = sum of owned outputs whose key images are NOT in the spent set.
|
|
2139
|
+
*/
|
|
2140
|
+
function computeBalance(ownedOutputs, spentKeyImages) {
|
|
2141
|
+
let balance = BigInt(0);
|
|
2142
|
+
for (const out of ownedOutputs) {
|
|
2143
|
+
if (!spentKeyImages.has(out.keyImage)) {
|
|
2144
|
+
balance += out.amount;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
return balance;
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Get unspent outputs from scan results.
|
|
2151
|
+
*/
|
|
2152
|
+
function getUnspentOutputs(ownedOutputs, spentKeyImages) {
|
|
2153
|
+
return ownedOutputs.filter((out) => !spentKeyImages.has(out.keyImage));
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
class Client extends xchainClient.BaseXChainClient {
|
|
2157
|
+
constructor(params = defaultXMRParams) {
|
|
2158
|
+
var _a, _b, _c, _d;
|
|
2159
|
+
super(XMRChain, Object.assign(Object.assign({}, defaultXMRParams), params));
|
|
2160
|
+
this.lwsLoggedIn = false;
|
|
2161
|
+
/** Cached scan state for daemon fallback */
|
|
2162
|
+
this.scanCache = null;
|
|
2163
|
+
this.explorerProviders = (_a = params.explorerProviders) !== null && _a !== void 0 ? _a : defaultXMRParams.explorerProviders;
|
|
2164
|
+
this.daemonUrls = (_b = params.daemonUrls) !== null && _b !== void 0 ? _b : defaultXMRParams.daemonUrls;
|
|
2165
|
+
this.lwsUrls = (_c = params.lwsUrls) !== null && _c !== void 0 ? _c : defaultXMRParams.lwsUrls;
|
|
2166
|
+
this.restoreHeight = (_d = params.restoreHeight) !== null && _d !== void 0 ? _d : 0;
|
|
2167
|
+
}
|
|
2168
|
+
getAssetInfo() {
|
|
2169
|
+
return {
|
|
2170
|
+
asset: AssetXMR,
|
|
2171
|
+
decimal: XMR_DECIMALS,
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
getExplorerUrl() {
|
|
2175
|
+
return this.explorerProviders[this.getNetwork()].getExplorerUrl();
|
|
2176
|
+
}
|
|
2177
|
+
getExplorerAddressUrl(address) {
|
|
2178
|
+
return this.explorerProviders[this.getNetwork()].getExplorerAddressUrl(address);
|
|
2179
|
+
}
|
|
2180
|
+
getExplorerTxUrl(txID) {
|
|
2181
|
+
return this.explorerProviders[this.getNetwork()].getExplorerTxUrl(txID);
|
|
2182
|
+
}
|
|
2183
|
+
getFullDerivationPath(walletIndex) {
|
|
2184
|
+
if (!this.rootDerivationPaths) {
|
|
2185
|
+
throw Error('Can not generate derivation path due to root derivation path is undefined');
|
|
2186
|
+
}
|
|
2187
|
+
return `${this.rootDerivationPaths[this.getNetwork()]}${walletIndex}'`;
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* Get the current address asynchronously.
|
|
2191
|
+
* Derives keys from mnemonic and encodes as Monero address (pure JS, no WASM).
|
|
2192
|
+
*/
|
|
2193
|
+
getAddressAsync(index) {
|
|
2194
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2195
|
+
const spendKey = this.getPrivateSpendKey(index !== null && index !== void 0 ? index : 0);
|
|
2196
|
+
const keys = deriveKeyPairs(spendKey);
|
|
2197
|
+
const networkType = getMoneroNetworkType(this.getNetwork());
|
|
2198
|
+
return encodeAddress(keys.publicSpendKey, keys.publicViewKey, networkType);
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* @deprecated Use getAddressAsync instead
|
|
2203
|
+
*/
|
|
2204
|
+
getAddress() {
|
|
2205
|
+
throw Error('Sync method not supported');
|
|
2206
|
+
}
|
|
2207
|
+
validateAddress(address) {
|
|
2208
|
+
return validateMoneroAddress(address);
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Get balance via LWS, falling back to daemon scanning if LWS is unavailable.
|
|
2212
|
+
*/
|
|
2213
|
+
getBalance(address) {
|
|
2214
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2215
|
+
// Try LWS first
|
|
2216
|
+
const urls = this.lwsUrls[this.getNetwork()];
|
|
2217
|
+
if (urls && urls.length > 0) {
|
|
2218
|
+
const viewKeyHex = this.getViewKeyHex(0);
|
|
2219
|
+
for (const url of urls) {
|
|
2220
|
+
try {
|
|
2221
|
+
if (!this.lwsLoggedIn) {
|
|
2222
|
+
yield login(url, address, viewKeyHex);
|
|
2223
|
+
this.lwsLoggedIn = true;
|
|
2224
|
+
}
|
|
2225
|
+
const info = yield getAddressInfo(url, address, viewKeyHex);
|
|
2226
|
+
const received = BigInt(info.total_received);
|
|
2227
|
+
const sent = BigInt(info.total_sent);
|
|
2228
|
+
const balance = received - sent;
|
|
2229
|
+
return [{ asset: AssetXMR, amount: xchainUtil.baseAmount(balance.toString(), XMR_DECIMALS) }];
|
|
2230
|
+
}
|
|
2231
|
+
catch (error) {
|
|
2232
|
+
console.warn(`LWS ${url} failed for getBalance:`, error.message);
|
|
2233
|
+
this.lwsLoggedIn = false;
|
|
2234
|
+
continue;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
// Fallback: daemon scanning
|
|
2239
|
+
console.warn('LWS unavailable, falling back to daemon scanning (this may be slow)');
|
|
2240
|
+
const scanResult = yield this.daemonScan();
|
|
2241
|
+
const balance = computeBalance(scanResult.ownedOutputs, scanResult.spentKeyImages);
|
|
2242
|
+
return [{ asset: AssetXMR, amount: xchainUtil.baseAmount(balance.toString(), XMR_DECIMALS) }];
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Get transaction fees via daemon RPC.
|
|
2247
|
+
*/
|
|
2248
|
+
getFees() {
|
|
2249
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2250
|
+
const urls = this.daemonUrls[this.getNetwork()];
|
|
2251
|
+
for (const url of urls) {
|
|
2252
|
+
try {
|
|
2253
|
+
const feeEstimate = yield getFeeEstimate(url);
|
|
2254
|
+
const fee = xchainUtil.baseAmount(feeEstimate.toString(), XMR_DECIMALS);
|
|
2255
|
+
return {
|
|
2256
|
+
type: xchainClient.FeeType.FlatFee,
|
|
2257
|
+
[xchainClient.FeeOption.Average]: fee,
|
|
2258
|
+
[xchainClient.FeeOption.Fast]: fee,
|
|
2259
|
+
[xchainClient.FeeOption.Fastest]: fee,
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
catch (error) {
|
|
2263
|
+
console.warn(`Daemon ${url} failed for getFees:`, error.message);
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
throw Error('No daemon able to get fees');
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Get transaction details by hash via daemon RPC.
|
|
2272
|
+
*/
|
|
2273
|
+
getTransactionData(txId) {
|
|
2274
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2275
|
+
const urls = this.daemonUrls[this.getNetwork()];
|
|
2276
|
+
for (const url of urls) {
|
|
2277
|
+
try {
|
|
2278
|
+
const txs = yield getTransactions(url, [txId]);
|
|
2279
|
+
if (!txs.length)
|
|
2280
|
+
throw Error('Transaction not found');
|
|
2281
|
+
const tx = txs[0];
|
|
2282
|
+
return {
|
|
2283
|
+
asset: AssetXMR,
|
|
2284
|
+
date: new Date(tx.block_timestamp * 1000),
|
|
2285
|
+
type: xchainClient.TxType.Transfer,
|
|
2286
|
+
hash: tx.tx_hash,
|
|
2287
|
+
from: [],
|
|
2288
|
+
to: [],
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
catch (error) {
|
|
2292
|
+
console.warn(`Daemon ${url} failed for getTransactionData:`, error.message);
|
|
2293
|
+
continue;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
throw Error('No daemon able to get transaction data');
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Get transaction history via LWS, falling back to daemon scanning.
|
|
2301
|
+
* Due to Monero privacy, counterparty addresses are unavailable;
|
|
2302
|
+
* the own address is used with net amount for from/to.
|
|
2303
|
+
*/
|
|
2304
|
+
getTransactions(params) {
|
|
2305
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2306
|
+
var _a, _b, _c;
|
|
2307
|
+
const address = (params === null || params === void 0 ? void 0 : params.address) || (yield this.getAddressAsync(0));
|
|
2308
|
+
const offset = (_a = params === null || params === void 0 ? void 0 : params.offset) !== null && _a !== void 0 ? _a : 0;
|
|
2309
|
+
const limit = (_b = params === null || params === void 0 ? void 0 : params.limit) !== null && _b !== void 0 ? _b : 10;
|
|
2310
|
+
// Try LWS first
|
|
2311
|
+
const urls = this.lwsUrls[this.getNetwork()];
|
|
2312
|
+
if (urls && urls.length > 0) {
|
|
2313
|
+
const viewKeyHex = this.getViewKeyHex(0);
|
|
2314
|
+
for (const url of urls) {
|
|
2315
|
+
try {
|
|
2316
|
+
if (!this.lwsLoggedIn) {
|
|
2317
|
+
yield login(url, address, viewKeyHex);
|
|
2318
|
+
this.lwsLoggedIn = true;
|
|
2319
|
+
}
|
|
2320
|
+
const result = yield getAddressTxs(url, address, viewKeyHex);
|
|
2321
|
+
const confirmedTxs = ((_c = result.transactions) !== null && _c !== void 0 ? _c : [])
|
|
2322
|
+
.filter((tx) => !tx.mempool && tx.height > 0)
|
|
2323
|
+
.sort((a, b) => b.height - a.height);
|
|
2324
|
+
const paginated = confirmedTxs.slice(offset, offset + limit);
|
|
2325
|
+
const txs = paginated.map((tx) => {
|
|
2326
|
+
const received = BigInt(tx.total_received);
|
|
2327
|
+
const sent = BigInt(tx.total_sent);
|
|
2328
|
+
const netAmount = received - sent;
|
|
2329
|
+
const isIncoming = netAmount > BigInt(0);
|
|
2330
|
+
return {
|
|
2331
|
+
asset: AssetXMR,
|
|
2332
|
+
date: new Date(tx.timestamp),
|
|
2333
|
+
type: xchainClient.TxType.Transfer,
|
|
2334
|
+
hash: tx.hash,
|
|
2335
|
+
from: isIncoming
|
|
2336
|
+
? []
|
|
2337
|
+
: [{ from: address, amount: xchainUtil.baseAmount((BigInt(-1) * netAmount).toString(), XMR_DECIMALS) }],
|
|
2338
|
+
to: isIncoming ? [{ to: address, amount: xchainUtil.baseAmount(netAmount.toString(), XMR_DECIMALS) }] : [],
|
|
2339
|
+
};
|
|
2340
|
+
});
|
|
2341
|
+
return { total: confirmedTxs.length, txs };
|
|
2342
|
+
}
|
|
2343
|
+
catch (error) {
|
|
2344
|
+
console.warn(`LWS ${url} failed for getTransactions:`, error.message);
|
|
2345
|
+
this.lwsLoggedIn = false;
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
// Fallback: daemon scanning
|
|
2351
|
+
console.warn('LWS unavailable, falling back to daemon scanning for tx history');
|
|
2352
|
+
const scanResult = yield this.daemonScan();
|
|
2353
|
+
const allOutputs = scanResult.ownedOutputs.sort((a, b) => b.height - a.height);
|
|
2354
|
+
const paginated = allOutputs.slice(offset, offset + limit);
|
|
2355
|
+
const txs = paginated.map((out) => ({
|
|
2356
|
+
asset: AssetXMR,
|
|
2357
|
+
date: new Date(out.timestamp * 1000),
|
|
2358
|
+
type: xchainClient.TxType.Transfer,
|
|
2359
|
+
hash: out.txHash,
|
|
2360
|
+
from: [],
|
|
2361
|
+
to: [{ to: address, amount: xchainUtil.baseAmount(out.amount.toString(), XMR_DECIMALS) }],
|
|
2362
|
+
}));
|
|
2363
|
+
return { total: allOutputs.length, txs };
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
/**
|
|
2367
|
+
* Transfer XMR to a recipient address.
|
|
2368
|
+
* Builds a complete RingCT transaction with CLSAG signatures and Bulletproofs+ range proof.
|
|
2369
|
+
*/
|
|
2370
|
+
transfer(params) {
|
|
2371
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2372
|
+
var _a, _b, _c;
|
|
2373
|
+
const { recipient, amount } = params;
|
|
2374
|
+
if (!recipient)
|
|
2375
|
+
throw new Error('Recipient address is required');
|
|
2376
|
+
if (!amount)
|
|
2377
|
+
throw new Error('Amount is required');
|
|
2378
|
+
const recipientKeys = decodeAddress(recipient);
|
|
2379
|
+
const spendKey = this.getPrivateSpendKey((_a = params.walletIndex) !== null && _a !== void 0 ? _a : 0);
|
|
2380
|
+
const keys = deriveKeyPairs(spendKey);
|
|
2381
|
+
// Get daemon URL
|
|
2382
|
+
const daemonUrls = this.daemonUrls[this.getNetwork()];
|
|
2383
|
+
if (!daemonUrls || daemonUrls.length === 0)
|
|
2384
|
+
throw new Error('No daemon URLs configured');
|
|
2385
|
+
const daemonUrl = daemonUrls[0];
|
|
2386
|
+
// Get LWS URL for unspent outputs
|
|
2387
|
+
const lwsUrls = this.lwsUrls[this.getNetwork()];
|
|
2388
|
+
if (!lwsUrls || lwsUrls.length === 0)
|
|
2389
|
+
throw new Error('No LWS URLs configured');
|
|
2390
|
+
const address = yield this.getAddressAsync((_b = params.walletIndex) !== null && _b !== void 0 ? _b : 0);
|
|
2391
|
+
const viewKeyHex = this.getViewKeyHex((_c = params.walletIndex) !== null && _c !== void 0 ? _c : 0);
|
|
2392
|
+
if (!this.lwsLoggedIn) {
|
|
2393
|
+
yield login(lwsUrls[0], address, viewKeyHex);
|
|
2394
|
+
this.lwsLoggedIn = true;
|
|
2395
|
+
}
|
|
2396
|
+
// Get unspent outputs
|
|
2397
|
+
const unspent = yield getUnspentOuts(lwsUrls[0], address, viewKeyHex, amount.amount().toString());
|
|
2398
|
+
// Convert LWS outputs to SpendableOutput format
|
|
2399
|
+
const amountPiconero = BigInt(amount.amount().toString());
|
|
2400
|
+
const fee = BigInt(unspent.per_byte_fee) * BigInt(3000); // approximate tx size * per byte fee
|
|
2401
|
+
// Select inputs to cover amount + fee
|
|
2402
|
+
const spendableOutputs = [];
|
|
2403
|
+
let selectedTotal = BigInt(0);
|
|
2404
|
+
const needed = amountPiconero + fee;
|
|
2405
|
+
for (const out of unspent.outputs) {
|
|
2406
|
+
if (selectedTotal >= needed)
|
|
2407
|
+
break;
|
|
2408
|
+
const outAmount = BigInt(out.amount);
|
|
2409
|
+
// Parse rct field: first 64 chars = commitment, next 64 = mask
|
|
2410
|
+
if (out.rct.length < 128) {
|
|
2411
|
+
console.warn(`Skipping output ${out.global_index}: invalid rct data (length ${out.rct.length})`);
|
|
2412
|
+
continue;
|
|
2413
|
+
}
|
|
2414
|
+
const rctMask = hexToBytes(out.rct.substring(64, 128));
|
|
2415
|
+
spendableOutputs.push({
|
|
2416
|
+
globalIndex: out.global_index,
|
|
2417
|
+
amount: outAmount,
|
|
2418
|
+
mask: rctMask,
|
|
2419
|
+
txPubKey: hexToBytes(out.tx_pub_key),
|
|
2420
|
+
outputIndex: out.index,
|
|
2421
|
+
publicKey: hexToBytes(out.public_key),
|
|
2422
|
+
});
|
|
2423
|
+
selectedTotal += outAmount;
|
|
2424
|
+
}
|
|
2425
|
+
if (selectedTotal < needed) {
|
|
2426
|
+
throw new Error(`Insufficient funds: have ${selectedTotal}, need ${needed}`);
|
|
2427
|
+
}
|
|
2428
|
+
const destinations = [
|
|
2429
|
+
{
|
|
2430
|
+
pubViewKey: recipientKeys.publicViewKey,
|
|
2431
|
+
pubSpendKey: recipientKeys.publicSpendKey,
|
|
2432
|
+
amount: amountPiconero,
|
|
2433
|
+
},
|
|
2434
|
+
];
|
|
2435
|
+
const changeKeys = {
|
|
2436
|
+
pubViewKey: keys.publicViewKey,
|
|
2437
|
+
pubSpendKey: keys.publicSpendKey,
|
|
2438
|
+
};
|
|
2439
|
+
const built = yield buildTransaction(spendableOutputs, destinations, changeKeys, fee, keys.privateViewKey, spendKey, daemonUrl);
|
|
2440
|
+
// Broadcast
|
|
2441
|
+
yield this.broadcastTx(built.txHex);
|
|
2442
|
+
return built.txHash;
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* Broadcast a signed transaction hex to the network.
|
|
2447
|
+
* Note: The returned hash is keccak256 of the raw tx blob, which differs from
|
|
2448
|
+
* the canonical Monero txid (three-hash construction). Use transfer() for the correct txid.
|
|
2449
|
+
*/
|
|
2450
|
+
broadcastTx(txHex) {
|
|
2451
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2452
|
+
const urls = this.daemonUrls[this.getNetwork()];
|
|
2453
|
+
for (const url of urls) {
|
|
2454
|
+
try {
|
|
2455
|
+
yield sendRawTransaction(url, txHex);
|
|
2456
|
+
const txBytes = hexToBytes(txHex);
|
|
2457
|
+
return bytesToHex(sha3.keccak_256(txBytes));
|
|
2458
|
+
}
|
|
2459
|
+
catch (error) {
|
|
2460
|
+
console.warn(`Daemon ${url} failed for broadcastTx:`, error.message);
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
throw Error('No daemon able to broadcast transaction');
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* Prepare an unsigned transaction.
|
|
2469
|
+
* Monero transactions require private key context for ring signature
|
|
2470
|
+
* construction, so this method is not supported.
|
|
2471
|
+
*/
|
|
2472
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
2473
|
+
prepareTx(_params) {
|
|
2474
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2475
|
+
throw Error('prepareTx is not supported for Monero. Use transfer() instead.');
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Get the private view key hex for a wallet index.
|
|
2480
|
+
*/
|
|
2481
|
+
getViewKeyHex(index) {
|
|
2482
|
+
const spendKey = this.getPrivateSpendKey(index);
|
|
2483
|
+
const keys = deriveKeyPairs(spendKey);
|
|
2484
|
+
return bytesToHex(keys.privateViewKey);
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* Derives private spend key from BIP-39 mnemonic via SLIP-10 derivation.
|
|
2488
|
+
*/
|
|
2489
|
+
getPrivateSpendKey(index) {
|
|
2490
|
+
if (!this.phrase)
|
|
2491
|
+
throw new Error('Phrase must be provided');
|
|
2492
|
+
const seed = xchainCrypto.getSeed(this.phrase);
|
|
2493
|
+
const hd = slip10__default.default.fromMasterSeed(seed);
|
|
2494
|
+
const derivedKey = hd.derive(this.getFullDerivationPath(index)).privateKey;
|
|
2495
|
+
return scReduce32(derivedKey);
|
|
2496
|
+
}
|
|
2497
|
+
/**
|
|
2498
|
+
* Scan the blockchain via daemon RPC to find owned outputs.
|
|
2499
|
+
* Uses cached results and scans incrementally from the last scanned height.
|
|
2500
|
+
*/
|
|
2501
|
+
daemonScan() {
|
|
2502
|
+
return __awaiter(this, arguments, void 0, function* (walletIndex = 0) {
|
|
2503
|
+
const daemonUrls = this.daemonUrls[this.getNetwork()];
|
|
2504
|
+
if (!daemonUrls || daemonUrls.length === 0) {
|
|
2505
|
+
throw new Error('No daemon URLs configured');
|
|
2506
|
+
}
|
|
2507
|
+
const daemonUrl = daemonUrls[0];
|
|
2508
|
+
const currentHeight = yield getHeight(daemonUrl);
|
|
2509
|
+
const fromHeight = this.scanCache ? this.scanCache.lastHeight + 1 : this.restoreHeight;
|
|
2510
|
+
if (fromHeight >= currentHeight && this.scanCache) {
|
|
2511
|
+
return this.scanCache;
|
|
2512
|
+
}
|
|
2513
|
+
const spendKey = this.getPrivateSpendKey(walletIndex);
|
|
2514
|
+
const keys = deriveKeyPairs(spendKey);
|
|
2515
|
+
const result = yield scanBlocks(daemonUrl, keys.privateViewKey, keys.publicSpendKey, spendKey, fromHeight, currentHeight - 1);
|
|
2516
|
+
// Merge with cached results
|
|
2517
|
+
const ownedOutputs = this.scanCache ? [...this.scanCache.ownedOutputs, ...result.ownedOutputs] : result.ownedOutputs;
|
|
2518
|
+
const spentKeyImages = this.scanCache
|
|
2519
|
+
? new Set([...this.scanCache.spentKeyImages, ...result.spentKeyImages])
|
|
2520
|
+
: result.spentKeyImages;
|
|
2521
|
+
this.scanCache = {
|
|
2522
|
+
lastHeight: currentHeight - 1,
|
|
2523
|
+
ownedOutputs,
|
|
2524
|
+
spentKeyImages,
|
|
2525
|
+
};
|
|
2526
|
+
return this.scanCache;
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
exports.AssetXMR = AssetXMR;
|
|
2532
|
+
exports.Client = Client;
|
|
2533
|
+
exports.XMRChain = XMRChain;
|
|
2534
|
+
exports.XMR_DECIMALS = XMR_DECIMALS;
|
|
2535
|
+
exports.computeBalance = computeBalance;
|
|
2536
|
+
exports.defaultXMRParams = defaultXMRParams;
|
|
2537
|
+
exports.getUnspentOutputs = getUnspentOutputs;
|
|
2538
|
+
exports.scanBlocks = scanBlocks;
|