Add transfer/sweep/stake API, integrate BP+ proofs, dual CN+CARROT wallet keys
- Wire up full transaction lifecycle: UTXO selection → input prep → build → serialize → broadcast
- Integrate bulletproofPlusProve() into buildTransaction() (replaces placeholder)
- Fix BP+ serialization to match Salvium binary format (varint-prefixed vectors)
- Fix serializeTransaction() ordering to match Salvium consensus (prefix → RCT base → prunable)
- Create src/wallet/transfer.js with transfer(), sweep(), stake() high-level functions
- Fix getOutputIndexes to use portable storage binary format
- Fix prepareInputs to use getOutputDistribution for decoy selection
- Handle coinbase outputs with null mask (identity scalar + zeroCommit)
- Refactor Wallet class to always derive BOTH legacy CN and CARROT keys from seed
(matching Salvium C++ carrot_and_legacy_account::generate behavior)
- Add getLegacyAddress(), getCarrotAddress(), height-based auto-format in getAddress()
- Bump wallet JSON to v3 with both addresses and full CARROT key set
- Backward-compatible: old wallet files with seed upgrade seamlessly
This commit is contained in:
+84
-38
@@ -223,49 +223,66 @@ export function parseProof(proofBytes) {
|
||||
|
||||
let offset = 0;
|
||||
|
||||
// Read number of commitments (first varint or fixed)
|
||||
// For simplicity, assume V count is derived from L/R length
|
||||
// Salvium binary format: varint(V.len), V[], A, A1, B, r1, s1, d1, varint(L.len), L[], varint(R.len), R[]
|
||||
|
||||
// A - initial commitment
|
||||
// V (commitments)
|
||||
const { value: vCount, bytesRead: vBytes } = _decodeVarint(proofBytes, offset);
|
||||
offset += vBytes;
|
||||
const V = [];
|
||||
for (let i = 0; i < vCount; i++) {
|
||||
V.push(bytesToPoint(proofBytes.slice(offset, offset + 32)));
|
||||
offset += 32;
|
||||
}
|
||||
|
||||
// A, A1, B (points)
|
||||
const A = bytesToPoint(proofBytes.slice(offset, offset + 32));
|
||||
offset += 32;
|
||||
|
||||
// A1 - final round commitment
|
||||
const A1 = bytesToPoint(proofBytes.slice(offset, offset + 32));
|
||||
offset += 32;
|
||||
|
||||
// B - final round element
|
||||
const B = bytesToPoint(proofBytes.slice(offset, offset + 32));
|
||||
offset += 32;
|
||||
|
||||
// r1, s1, d1 - final scalars
|
||||
// r1, s1, d1 (scalars)
|
||||
const r1 = bytesToScalar(proofBytes.slice(offset, offset + 32));
|
||||
offset += 32;
|
||||
|
||||
const s1 = bytesToScalar(proofBytes.slice(offset, offset + 32));
|
||||
offset += 32;
|
||||
|
||||
const d1 = bytesToScalar(proofBytes.slice(offset, offset + 32));
|
||||
offset += 32;
|
||||
|
||||
// Remaining bytes are L and R pairs
|
||||
const remaining = proofBytes.length - offset;
|
||||
if (remaining % 64 !== 0) {
|
||||
throw new Error('Invalid L/R length');
|
||||
}
|
||||
|
||||
const rounds = remaining / 64;
|
||||
// L
|
||||
const { value: lCount, bytesRead: lBytes } = _decodeVarint(proofBytes, offset);
|
||||
offset += lBytes;
|
||||
const L = [];
|
||||
const R = [];
|
||||
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
for (let i = 0; i < lCount; i++) {
|
||||
L.push(bytesToPoint(proofBytes.slice(offset, offset + 32)));
|
||||
offset += 32;
|
||||
}
|
||||
|
||||
// R
|
||||
const { value: rCount, bytesRead: rBytes } = _decodeVarint(proofBytes, offset);
|
||||
offset += rBytes;
|
||||
const R = [];
|
||||
for (let i = 0; i < rCount; i++) {
|
||||
R.push(bytesToPoint(proofBytes.slice(offset, offset + 32)));
|
||||
offset += 32;
|
||||
}
|
||||
|
||||
return { A, A1, B, r1, s1, d1, L, R };
|
||||
return { V, A, A1, B, r1, s1, d1, L, R };
|
||||
}
|
||||
|
||||
function _decodeVarint(bytes, offset) {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
let bytesRead = 0;
|
||||
while (offset + bytesRead < bytes.length) {
|
||||
const byte = bytes[offset + bytesRead];
|
||||
bytesRead++;
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if ((byte & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
return { value, bytesRead };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1016,33 +1033,62 @@ export function bulletproofPlusProve(amounts, masks) {
|
||||
* Serialize a Bulletproof+ proof to bytes
|
||||
*/
|
||||
export function serializeProof(proof) {
|
||||
const { A, A1, B, r1, s1, d1, L, R } = proof;
|
||||
const { V, A, A1, B, r1, s1, d1, L, R } = proof;
|
||||
|
||||
// Calculate total size: A + A1 + B + r1 + s1 + d1 + L + R
|
||||
// = 3*32 + 3*32 + 2*L.length*32
|
||||
const size = 32 * 6 + 64 * L.length;
|
||||
const bytes = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
// Monero/Salvium binary format for BulletproofPlus:
|
||||
// varint(V.length), V[0..n] (32 bytes each)
|
||||
// A (32), A1 (32), B (32)
|
||||
// r1 (32), s1 (32), d1 (32)
|
||||
// varint(L.length), L[0..n] (32 bytes each)
|
||||
// varint(R.length), R[0..n] (32 bytes each)
|
||||
|
||||
const chunks = [];
|
||||
|
||||
// V (commitments)
|
||||
chunks.push(_encodeVarint(V.length));
|
||||
for (const v of V) chunks.push(v.toBytes());
|
||||
|
||||
// A, A1, B (points)
|
||||
bytes.set(A.toBytes(), offset); offset += 32;
|
||||
bytes.set(A1.toBytes(), offset); offset += 32;
|
||||
bytes.set(B.toBytes(), offset); offset += 32;
|
||||
chunks.push(A.toBytes());
|
||||
chunks.push(A1.toBytes());
|
||||
chunks.push(B.toBytes());
|
||||
|
||||
// r1, s1, d1 (scalars)
|
||||
bytes.set(scalarToBytes(r1), offset); offset += 32;
|
||||
bytes.set(scalarToBytes(s1), offset); offset += 32;
|
||||
bytes.set(scalarToBytes(d1), offset); offset += 32;
|
||||
chunks.push(scalarToBytes(r1));
|
||||
chunks.push(scalarToBytes(s1));
|
||||
chunks.push(scalarToBytes(d1));
|
||||
|
||||
// L and R pairs
|
||||
for (let i = 0; i < L.length; i++) {
|
||||
bytes.set(L[i].toBytes(), offset); offset += 32;
|
||||
bytes.set(R[i].toBytes(), offset); offset += 32;
|
||||
// L
|
||||
chunks.push(_encodeVarint(L.length));
|
||||
for (const l of L) chunks.push(l.toBytes());
|
||||
|
||||
// R
|
||||
chunks.push(_encodeVarint(R.length));
|
||||
for (const r of R) chunks.push(r.toBytes());
|
||||
|
||||
// Concatenate
|
||||
let totalLen = 0;
|
||||
for (const c of chunks) totalLen += c.length;
|
||||
const bytes = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
bytes.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function _encodeVarint(value) {
|
||||
const bytes = [];
|
||||
let v = value;
|
||||
while (v >= 0x80) {
|
||||
bytes.push((v & 0x7f) | 0x80);
|
||||
v >>>= 7;
|
||||
}
|
||||
bytes.push(v);
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a range proof for a single amount
|
||||
*/
|
||||
|
||||
@@ -36,6 +36,7 @@ export * from './offline.js';
|
||||
export * from './multisig.js';
|
||||
export * from './wallet-store.js';
|
||||
export * from './wallet-sync.js';
|
||||
export { transfer, sweep, stake } from './wallet/transfer.js';
|
||||
export * from './persistent-wallet.js';
|
||||
export * from './consensus.js';
|
||||
|
||||
|
||||
+20
-2
@@ -483,7 +483,7 @@ export class DaemonRPC extends RPCClient {
|
||||
to_height: options.to_height,
|
||||
rct_asset_type: options.rct_asset_type || 'SAL',
|
||||
cumulative: options.cumulative || false,
|
||||
binary: options.binary !== false,
|
||||
binary: options.binary || false,
|
||||
compress: options.compress || false
|
||||
});
|
||||
}
|
||||
@@ -935,7 +935,25 @@ export class DaemonRPC extends RPCClient {
|
||||
* @returns {Promise<RPCResponse>} Output indexes
|
||||
*/
|
||||
async getOutputIndexes(txid) {
|
||||
return this.post('/get_o_indexes.bin', { txid });
|
||||
// Binary endpoint using portable storage (epee) format
|
||||
const txidBytes = typeof txid === 'string'
|
||||
? Uint8Array.from(txid.match(/.{2}/g).map(b => parseInt(b, 16)))
|
||||
: txid;
|
||||
const reqBody = psSerialize({ txid: txidBytes });
|
||||
const resp = await this.postBinary('/get_o_indexes.bin', reqBody);
|
||||
if (!resp.success) return resp;
|
||||
const rawBytes = resp.result;
|
||||
if (!rawBytes || rawBytes.length === 0) {
|
||||
return { success: false, error: { message: 'Empty response from get_o_indexes.bin' } };
|
||||
}
|
||||
const data = psDeserialize(rawBytes);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
o_indexes: data.o_indexes || [],
|
||||
status: data.status || 'OK'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+25
-25
@@ -18,6 +18,7 @@ import {
|
||||
hashToPoint, generateKeyImage,
|
||||
} from './crypto/index.js';
|
||||
import { bytesToHex, hexToBytes } from './address.js';
|
||||
import { bulletproofPlusProve, serializeProof as serializeBpPlus } from './bulletproofs_plus.js';
|
||||
|
||||
// =============================================================================
|
||||
// RE-EXPORTS FROM SUBMODULES
|
||||
@@ -1698,19 +1699,16 @@ export function buildTransaction(params, options = {}) {
|
||||
}
|
||||
|
||||
// Generate Bulletproofs+ range proofs for outputs
|
||||
// Note: This requires the proveRangeMultiple function from bulletproofs_plus.js
|
||||
let bulletproofPlus = null;
|
||||
try {
|
||||
// Import dynamically if needed, or assume caller handles proofs separately
|
||||
// For now, we note that range proofs should be generated
|
||||
bulletproofPlus = {
|
||||
// Range proof would be generated here
|
||||
// proof: proveRangeMultiple(outputs.map(o => o.amount), outputMasks)
|
||||
note: 'Range proof generation requires proveRangeMultiple'
|
||||
};
|
||||
} catch (e) {
|
||||
// Range proofs can be added after
|
||||
}
|
||||
const bpAmounts = outputs.map(o => o.amount);
|
||||
const bpMasks = outputMasks.map(m => {
|
||||
const bytes = typeof m === 'string' ? hexToBytes(m) : m;
|
||||
return _bytesToBigInt(bytes);
|
||||
});
|
||||
const bpProof = bulletproofPlusProve(bpAmounts, bpMasks);
|
||||
const bulletproofPlus = {
|
||||
...bpProof,
|
||||
serialized: serializeBpPlus(bpProof)
|
||||
};
|
||||
|
||||
// Assemble complete transaction
|
||||
const transaction = {
|
||||
@@ -2309,15 +2307,17 @@ export function signTransaction(unsignedTx, secrets) {
|
||||
export async function prepareInputs(ownedOutputs, rpcClient, options = {}) {
|
||||
const { ringSize = DEFAULT_RING_SIZE, rctOffsets } = options;
|
||||
|
||||
// Fetch output distribution once (not per-input)
|
||||
let offsets = rctOffsets;
|
||||
if (!offsets && rpcClient) {
|
||||
const distResp = await rpcClient.getOutputDistribution([0], { cumulative: true });
|
||||
const dist = distResp.result?.distributions?.[0] || distResp.distributions?.[0];
|
||||
offsets = dist?.distribution || [];
|
||||
}
|
||||
|
||||
const preparedInputs = [];
|
||||
|
||||
for (const output of ownedOutputs) {
|
||||
// Get global output distribution if not provided
|
||||
let offsets = rctOffsets;
|
||||
if (!offsets && rpcClient) {
|
||||
const histogram = await rpcClient.getOutputHistogram({ amounts: [0] });
|
||||
offsets = histogram.histogram[0]?.recent_outputs_offsets || [];
|
||||
}
|
||||
|
||||
// Select decoy indices
|
||||
const decoyIndices = selectDecoys(
|
||||
@@ -2330,13 +2330,13 @@ export async function prepareInputs(ownedOutputs, rpcClient, options = {}) {
|
||||
// Fetch ring member keys and commitments
|
||||
let ring, ringCommitments;
|
||||
if (rpcClient) {
|
||||
const outsResponse = await rpcClient.getOuts({
|
||||
outputs: decoyIndices.map(i => ({ amount: 0, index: i })),
|
||||
get_txid: false
|
||||
});
|
||||
const outsResponse = await rpcClient.getOuts(
|
||||
decoyIndices.map(i => ({ amount: 0, index: i }))
|
||||
);
|
||||
|
||||
ring = outsResponse.outs.map(o => hexToBytes(o.key));
|
||||
ringCommitments = outsResponse.outs.map(o => hexToBytes(o.mask));
|
||||
const outs = outsResponse.result?.outs || outsResponse.outs || [];
|
||||
ring = outs.map(o => hexToBytes(o.key));
|
||||
ringCommitments = outs.map(o => hexToBytes(o.mask));
|
||||
} else {
|
||||
// Placeholder for testing
|
||||
ring = decoyIndices.map(() => new Uint8Array(32));
|
||||
|
||||
@@ -744,61 +744,39 @@ export function serializeTransaction(tx) {
|
||||
extra: tx.prefix.extra
|
||||
};
|
||||
|
||||
// Serialize prefix
|
||||
const prefixBytes = serializeTxPrefix(prefixForSerialization);
|
||||
const chunks = [];
|
||||
|
||||
// Serialize RingCT base
|
||||
const rctBaseBytes = serializeRctBase(tx.rct);
|
||||
// 1. TX prefix
|
||||
chunks.push(serializeTxPrefix(prefixForSerialization));
|
||||
|
||||
// Serialize CLSAG signatures
|
||||
const clsagBytes = [];
|
||||
for (const sig of tx.rct.CLSAGs) {
|
||||
clsagBytes.push(serializeCLSAG(sig));
|
||||
}
|
||||
// 2. RCT base: type + fee + ecdhInfo + outPk
|
||||
// (matches Salvium serialize_rctsig_base)
|
||||
chunks.push(serializeRctBase(tx.rct));
|
||||
chunks.push(serializeEcdhInfo(tx.rct.ecdhInfo));
|
||||
chunks.push(serializeOutPk(tx.rct.outPk));
|
||||
|
||||
// Serialize output commitments
|
||||
const outPkBytes = serializeOutPk(tx.rct.outPk);
|
||||
// 3. RCT prunable: BP+ proofs, CLSAGs, pseudoOuts
|
||||
// (matches Salvium serialize_rctsig_prunable)
|
||||
|
||||
// Serialize ECDH info
|
||||
const ecdhBytes = serializeEcdhInfo(tx.rct.ecdhInfo);
|
||||
|
||||
// Combine all parts
|
||||
let totalLen = prefixBytes.length + rctBaseBytes.length;
|
||||
for (const cb of clsagBytes) {
|
||||
totalLen += cb.length;
|
||||
}
|
||||
totalLen += outPkBytes.length + ecdhBytes.length;
|
||||
|
||||
// Add Bulletproof+ proof if present
|
||||
let bpBytes = new Uint8Array(0);
|
||||
// BP+ proofs (varint count + proof data)
|
||||
if (tx.rct.bulletproofPlus && tx.rct.bulletproofPlus.serialized) {
|
||||
bpBytes = tx.rct.bulletproofPlus.serialized;
|
||||
totalLen += bpBytes.length;
|
||||
chunks.push(encodeVarint(1)); // number of BP+ proofs (always 1 aggregated)
|
||||
chunks.push(tx.rct.bulletproofPlus.serialized);
|
||||
} else {
|
||||
chunks.push(encodeVarint(0));
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
|
||||
result.set(prefixBytes, offset);
|
||||
offset += prefixBytes.length;
|
||||
|
||||
result.set(rctBaseBytes, offset);
|
||||
offset += rctBaseBytes.length;
|
||||
|
||||
for (const cb of clsagBytes) {
|
||||
result.set(cb, offset);
|
||||
offset += cb.length;
|
||||
// CLSAGs
|
||||
for (const sig of tx.rct.CLSAGs) {
|
||||
chunks.push(serializeCLSAG(sig));
|
||||
}
|
||||
|
||||
result.set(outPkBytes, offset);
|
||||
offset += outPkBytes.length;
|
||||
|
||||
result.set(ecdhBytes, offset);
|
||||
offset += ecdhBytes.length;
|
||||
|
||||
if (bpBytes.length > 0) {
|
||||
result.set(bpBytes, offset);
|
||||
// pseudoOuts (in prunable section for BP+ types)
|
||||
if (tx.rct.pseudoOuts) {
|
||||
for (const po of tx.rct.pseudoOuts) {
|
||||
chunks.push(typeof po === 'string' ? hexToBytes(po) : po);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return concatBytes(chunks);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,9 @@ export class WalletOutput {
|
||||
// Salvium-specific
|
||||
this.txType = data.txType || 3; // TX_TYPE (default: TRANSFER)
|
||||
|
||||
// TX public key (needed to derive output secret key for spending)
|
||||
this.txPubKey = data.txPubKey || null;
|
||||
|
||||
// Frozen (user-controlled)
|
||||
this.isFrozen = data.isFrozen || false;
|
||||
|
||||
@@ -195,6 +198,7 @@ export class WalletOutput {
|
||||
spentTxHash: this.spentTxHash,
|
||||
unlockTime: this.unlockTime.toString(),
|
||||
txType: this.txType,
|
||||
txPubKey: this.txPubKey,
|
||||
isCarrot: this.isCarrot,
|
||||
isFrozen: this.isFrozen,
|
||||
createdAt: this.createdAt,
|
||||
|
||||
@@ -93,6 +93,10 @@ export class WalletSync {
|
||||
this.keys = options.keys;
|
||||
this.carrotKeys = options.carrotKeys || null;
|
||||
this.subaddresses = options.subaddresses || new Map();
|
||||
// Always include the primary address in subaddress map
|
||||
if (this.keys?.spendPublicKey && !this.subaddresses.has(this.keys.spendPublicKey)) {
|
||||
this.subaddresses.set(this.keys.spendPublicKey, { major: 0, minor: 0 });
|
||||
}
|
||||
this.carrotSubaddresses = options.carrotSubaddresses || new Map();
|
||||
this.batchSize = options.batchSize || DEFAULT_BATCH_SIZE;
|
||||
|
||||
@@ -963,6 +967,7 @@ export class WalletSync {
|
||||
subaddressIndex: scanResult.subaddressIndex,
|
||||
unlockTime: tx.prefix?.unlockTime || 0n,
|
||||
txType,
|
||||
txPubKey: txPubKey ? bytesToHex(txPubKey) : null,
|
||||
isCarrot: scanResult.isCarrot || false
|
||||
});
|
||||
|
||||
|
||||
+159
-89
@@ -84,22 +84,20 @@ export class Wallet {
|
||||
* @param {Object} options - Wallet options
|
||||
* @param {string} options.type - Wallet type (full, view_only, watch)
|
||||
* @param {string} options.network - Network (mainnet, testnet, stagenet)
|
||||
* @param {string} options.format - Address format (legacy, carrot)
|
||||
* @private - Use static factory methods instead
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.type = options.type || WALLET_TYPE.FULL;
|
||||
this.network = options.network || NETWORK.MAINNET;
|
||||
this.format = options.format || ADDRESS_FORMAT.LEGACY;
|
||||
|
||||
// Key material (set by factory methods)
|
||||
// Legacy CryptoNote key material (primary keys, set by factory methods)
|
||||
this._seed = null;
|
||||
this._spendSecretKey = null;
|
||||
this._spendPublicKey = null;
|
||||
this._viewSecretKey = null;
|
||||
this._viewPublicKey = null;
|
||||
|
||||
// CARROT additional keys (if using CARROT format)
|
||||
// CARROT keys (always derived alongside legacy keys from same seed)
|
||||
this._carrotKeys = null;
|
||||
|
||||
// Account management
|
||||
@@ -143,35 +141,22 @@ export class Wallet {
|
||||
|
||||
/**
|
||||
* Create a new random wallet
|
||||
*
|
||||
* Always derives both legacy CryptoNote AND CARROT keys from the same seed,
|
||||
* matching Salvium C++ behavior (carrot_and_legacy_account::generate).
|
||||
*
|
||||
* @param {Object} options - Wallet options
|
||||
* @param {string} options.network - Network (default: mainnet)
|
||||
* @param {string} options.format - Address format (default: legacy)
|
||||
* @returns {Wallet} New wallet instance
|
||||
* @returns {Wallet} New wallet instance with both key sets
|
||||
*/
|
||||
static create(options = {}) {
|
||||
const wallet = new Wallet({
|
||||
type: WALLET_TYPE.FULL,
|
||||
network: options.network || NETWORK.MAINNET,
|
||||
format: options.format || ADDRESS_FORMAT.LEGACY
|
||||
network: options.network || NETWORK.MAINNET
|
||||
});
|
||||
|
||||
// Generate random seed
|
||||
wallet._seed = generateSeed();
|
||||
|
||||
// Derive keys based on format
|
||||
if (wallet.format === ADDRESS_FORMAT.CARROT) {
|
||||
wallet._carrotKeys = deriveCarrotKeys(wallet._seed);
|
||||
wallet._spendSecretKey = wallet._carrotKeys.spendSecretKey;
|
||||
wallet._spendPublicKey = wallet._carrotKeys.spendPublicKey;
|
||||
wallet._viewSecretKey = wallet._carrotKeys.viewSecretKey;
|
||||
wallet._viewPublicKey = wallet._carrotKeys.viewPublicKey;
|
||||
} else {
|
||||
const keys = deriveKeys(wallet._seed);
|
||||
wallet._spendSecretKey = keys.spendSecretKey;
|
||||
wallet._spendPublicKey = keys.spendPublicKey;
|
||||
wallet._viewSecretKey = keys.viewSecretKey;
|
||||
wallet._viewPublicKey = keys.viewPublicKey;
|
||||
}
|
||||
wallet._deriveAllKeys();
|
||||
|
||||
return wallet;
|
||||
}
|
||||
@@ -181,7 +166,6 @@ export class Wallet {
|
||||
* @param {string} mnemonic - 25-word mnemonic phrase
|
||||
* @param {Object} options - Wallet options
|
||||
* @param {string} options.network - Network (default: mainnet)
|
||||
* @param {string} options.format - Address format (default: legacy)
|
||||
* @param {string} options.language - Mnemonic language (default: english)
|
||||
* @returns {Wallet} Restored wallet instance
|
||||
*/
|
||||
@@ -207,32 +191,16 @@ export class Wallet {
|
||||
* Restore wallet from 32-byte seed
|
||||
* @param {Uint8Array|string} seed - 32-byte seed
|
||||
* @param {Object} options - Wallet options
|
||||
* @returns {Wallet} Restored wallet instance
|
||||
* @returns {Wallet} Restored wallet instance with both key sets
|
||||
*/
|
||||
static fromSeed(seed, options = {}) {
|
||||
const wallet = new Wallet({
|
||||
type: WALLET_TYPE.FULL,
|
||||
network: options.network || NETWORK.MAINNET,
|
||||
format: options.format || ADDRESS_FORMAT.LEGACY
|
||||
network: options.network || NETWORK.MAINNET
|
||||
});
|
||||
|
||||
// Store seed
|
||||
wallet._seed = typeof seed === 'string' ? hexToBytes(seed) : seed;
|
||||
|
||||
// Derive keys
|
||||
if (wallet.format === ADDRESS_FORMAT.CARROT) {
|
||||
wallet._carrotKeys = deriveCarrotKeys(wallet._seed);
|
||||
wallet._spendSecretKey = wallet._carrotKeys.spendSecretKey;
|
||||
wallet._spendPublicKey = wallet._carrotKeys.spendPublicKey;
|
||||
wallet._viewSecretKey = wallet._carrotKeys.viewSecretKey;
|
||||
wallet._viewPublicKey = wallet._carrotKeys.viewPublicKey;
|
||||
} else {
|
||||
const keys = deriveKeys(wallet._seed);
|
||||
wallet._spendSecretKey = keys.spendSecretKey;
|
||||
wallet._spendPublicKey = keys.spendPublicKey;
|
||||
wallet._viewSecretKey = keys.viewSecretKey;
|
||||
wallet._viewPublicKey = keys.viewPublicKey;
|
||||
}
|
||||
wallet._deriveAllKeys();
|
||||
|
||||
return wallet;
|
||||
}
|
||||
@@ -247,8 +215,7 @@ export class Wallet {
|
||||
static fromViewKey(viewSecretKey, spendPublicKey, options = {}) {
|
||||
const wallet = new Wallet({
|
||||
type: WALLET_TYPE.VIEW_ONLY,
|
||||
network: options.network || NETWORK.MAINNET,
|
||||
format: options.format || ADDRESS_FORMAT.LEGACY
|
||||
network: options.network || NETWORK.MAINNET
|
||||
});
|
||||
|
||||
wallet._viewSecretKey = typeof viewSecretKey === 'string'
|
||||
@@ -278,8 +245,7 @@ export class Wallet {
|
||||
|
||||
const wallet = new Wallet({
|
||||
type: WALLET_TYPE.WATCH,
|
||||
network: parsed.network,
|
||||
format: parsed.format
|
||||
network: parsed.network
|
||||
});
|
||||
|
||||
wallet._spendPublicKey = parsed.spendPublicKey;
|
||||
@@ -288,6 +254,36 @@ export class Wallet {
|
||||
return wallet;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// KEY DERIVATION
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Derive both legacy CryptoNote AND CARROT keys from seed.
|
||||
*
|
||||
* Matches Salvium C++ carrot_and_legacy_account::generate():
|
||||
* - Legacy CN keys: deriveKeys(seed)
|
||||
* - CARROT keys: deriveCarrotKeys(seed), where s_master = spend_secret_key
|
||||
*
|
||||
* Primary keys (_spendSecretKey, _viewSecretKey, etc.) are always legacy CN keys
|
||||
* for backward compatibility with scanning, signing, and transfer code.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_deriveAllKeys() {
|
||||
if (!this._seed) throw new Error('Seed required to derive keys');
|
||||
|
||||
// Legacy CryptoNote keys (primary)
|
||||
const legacyKeys = deriveKeys(this._seed);
|
||||
this._spendSecretKey = legacyKeys.spendSecretKey;
|
||||
this._spendPublicKey = legacyKeys.spendPublicKey;
|
||||
this._viewSecretKey = legacyKeys.viewSecretKey;
|
||||
this._viewPublicKey = legacyKeys.viewPublicKey;
|
||||
|
||||
// CARROT keys (s_master = spend_secret_key in C++)
|
||||
this._carrotKeys = deriveCarrotKeys(this._seed);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// EVENT LISTENER MANAGEMENT
|
||||
// ===========================================================================
|
||||
@@ -435,33 +431,90 @@ export class Wallet {
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get the main wallet address
|
||||
* @param {boolean} carrot - Use CARROT format (default: use wallet default)
|
||||
* Get the main wallet address.
|
||||
*
|
||||
* Without arguments, returns the legacy address (pre-CARROT HF) or CARROT
|
||||
* address (post-CARROT HF) based on the wallet's sync height.
|
||||
*
|
||||
* @param {string|null} format - Explicit format ('legacy' or 'carrot'), or null for auto
|
||||
* @returns {string} Main address
|
||||
*/
|
||||
getAddress(carrot = null) {
|
||||
const format = carrot === null ? this.format :
|
||||
(carrot ? ADDRESS_FORMAT.CARROT : ADDRESS_FORMAT.LEGACY);
|
||||
|
||||
if (format === this.format && this._mainAddress) {
|
||||
return this._mainAddress;
|
||||
getAddress(format = null) {
|
||||
// Resolve format: explicit > height-based > legacy fallback
|
||||
if (format === null) {
|
||||
format = isCarrotActive(this._syncHeight || 0, this._resolveNetworkId())
|
||||
? ADDRESS_FORMAT.CARROT : ADDRESS_FORMAT.LEGACY;
|
||||
}
|
||||
// Backward compat: getAddress(true) / getAddress(false)
|
||||
if (format === true) format = ADDRESS_FORMAT.CARROT;
|
||||
if (format === false) format = ADDRESS_FORMAT.LEGACY;
|
||||
|
||||
// Use CARROT keys for CARROT address, legacy keys for legacy address
|
||||
const { spendPub, viewPub } = this._keysForFormat(format);
|
||||
|
||||
const cacheKey = `main_${format}`;
|
||||
if (this._subaddresses.has(cacheKey)) return this._subaddresses.get(cacheKey);
|
||||
|
||||
const address = createAddress({
|
||||
spendPublicKey: this._spendPublicKey,
|
||||
viewPublicKey: this._viewPublicKey,
|
||||
spendPublicKey: spendPub,
|
||||
viewPublicKey: viewPub,
|
||||
network: this.network,
|
||||
format: format,
|
||||
format,
|
||||
type: 'standard'
|
||||
});
|
||||
|
||||
if (format === this.format) {
|
||||
this._mainAddress = address;
|
||||
}
|
||||
|
||||
this._subaddresses.set(cacheKey, address);
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get legacy CryptoNote address (SaLv...)
|
||||
* @returns {string} Legacy address
|
||||
*/
|
||||
getLegacyAddress() {
|
||||
return this.getAddress(ADDRESS_FORMAT.LEGACY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CARROT address (SC1...)
|
||||
* @returns {string|null} CARROT address, or null if CARROT keys unavailable
|
||||
*/
|
||||
getCarrotAddress() {
|
||||
if (!this._carrotKeys) return null;
|
||||
return this.getAddress(ADDRESS_FORMAT.CARROT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the appropriate public keys for the given address format.
|
||||
* @param {string} format - 'legacy' or 'carrot'
|
||||
* @returns {{spendPub: Uint8Array, viewPub: Uint8Array}}
|
||||
* @private
|
||||
*/
|
||||
_keysForFormat(format) {
|
||||
if (format === ADDRESS_FORMAT.CARROT && this._carrotKeys) {
|
||||
return {
|
||||
spendPub: typeof this._carrotKeys.accountSpendPubkey === 'string'
|
||||
? hexToBytes(this._carrotKeys.accountSpendPubkey) : this._carrotKeys.accountSpendPubkey,
|
||||
viewPub: typeof this._carrotKeys.primaryAddressViewPubkey === 'string'
|
||||
? hexToBytes(this._carrotKeys.primaryAddressViewPubkey) : this._carrotKeys.primaryAddressViewPubkey
|
||||
};
|
||||
}
|
||||
return {
|
||||
spendPub: this._spendPublicKey,
|
||||
viewPub: this._viewPublicKey
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve network string to NETWORK_ID number for consensus functions.
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_resolveNetworkId() {
|
||||
const map = { mainnet: NETWORK_ID.MAINNET, testnet: NETWORK_ID.TESTNET, stagenet: NETWORK_ID.STAGENET };
|
||||
return map[this.network] ?? NETWORK_ID.MAINNET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a subaddress
|
||||
* @param {number} major - Account index (default: 0)
|
||||
@@ -495,7 +548,7 @@ export class Wallet {
|
||||
spendPublicKey: keys.spendPublicKey,
|
||||
viewPublicKey: keys.viewPublicKey,
|
||||
network: this.network,
|
||||
format: this.format,
|
||||
format: ADDRESS_FORMAT.LEGACY, // Subaddresses use legacy CN keys
|
||||
type: 'subaddress'
|
||||
});
|
||||
|
||||
@@ -513,18 +566,25 @@ export class Wallet {
|
||||
/**
|
||||
* Generate an integrated address with payment ID
|
||||
* @param {string} paymentId - 8-byte payment ID (hex)
|
||||
* @param {boolean} carrot - Use CARROT format
|
||||
* @param {string|null} format - Address format (null = auto based on height)
|
||||
* @returns {string} Integrated address
|
||||
*/
|
||||
getIntegratedAddress(paymentId, carrot = null) {
|
||||
const format = carrot === null ? this.format :
|
||||
(carrot ? ADDRESS_FORMAT.CARROT : ADDRESS_FORMAT.LEGACY);
|
||||
getIntegratedAddress(paymentId, format = null) {
|
||||
if (format === null) {
|
||||
format = isCarrotActive(this._syncHeight || 0, this._resolveNetworkId())
|
||||
? ADDRESS_FORMAT.CARROT : ADDRESS_FORMAT.LEGACY;
|
||||
}
|
||||
// Backward compat: getIntegratedAddress(id, true/false)
|
||||
if (format === true) format = ADDRESS_FORMAT.CARROT;
|
||||
if (format === false) format = ADDRESS_FORMAT.LEGACY;
|
||||
|
||||
const { spendPub, viewPub } = this._keysForFormat(format);
|
||||
|
||||
return createAddress({
|
||||
spendPublicKey: this._spendPublicKey,
|
||||
viewPublicKey: this._viewPublicKey,
|
||||
spendPublicKey: spendPub,
|
||||
viewPublicKey: viewPub,
|
||||
network: this.network,
|
||||
format: format,
|
||||
format,
|
||||
type: 'integrated',
|
||||
paymentId
|
||||
});
|
||||
@@ -1841,14 +1901,22 @@ export class Wallet {
|
||||
*/
|
||||
toJSON(includeSecrets = true) {
|
||||
const data = {
|
||||
version: 2,
|
||||
version: 3,
|
||||
type: this.type,
|
||||
network: this.network,
|
||||
format: this.format,
|
||||
// Legacy CN public keys
|
||||
spendPublicKey: bytesToHex(this._spendPublicKey),
|
||||
viewPublicKey: bytesToHex(this._viewPublicKey),
|
||||
// Both addresses
|
||||
address: this.getLegacyAddress(),
|
||||
carrotAddress: this._carrotKeys ? this.getCarrotAddress() : null,
|
||||
// CARROT public keys
|
||||
carrotKeys: this._carrotKeys ? {
|
||||
accountSpendPubkey: this._carrotKeys.accountSpendPubkey,
|
||||
primaryAddressViewPubkey: this._carrotKeys.primaryAddressViewPubkey,
|
||||
accountViewPubkey: this._carrotKeys.accountViewPubkey
|
||||
} : null,
|
||||
syncHeight: this._syncHeight,
|
||||
address: this.getAddress(),
|
||||
accounts: this._accounts.map(a => ({
|
||||
index: a.index,
|
||||
label: a.label
|
||||
@@ -1859,6 +1927,7 @@ export class Wallet {
|
||||
if (includeSecrets) {
|
||||
if (this._seed) {
|
||||
data.seed = bytesToHex(this._seed);
|
||||
data.mnemonic = seedToMnemonic(this._seed);
|
||||
}
|
||||
if (this._spendSecretKey) {
|
||||
data.spendSecretKey = bytesToHex(this._spendSecretKey);
|
||||
@@ -1866,6 +1935,10 @@ export class Wallet {
|
||||
if (this._viewSecretKey) {
|
||||
data.viewSecretKey = bytesToHex(this._viewSecretKey);
|
||||
}
|
||||
// Full CARROT secrets (for completeness; can be re-derived from seed)
|
||||
if (this._carrotKeys) {
|
||||
data.carrotKeys = { ...this._carrotKeys };
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
@@ -1879,32 +1952,29 @@ export class Wallet {
|
||||
static fromJSON(data) {
|
||||
let wallet;
|
||||
|
||||
// Determine how to restore based on available data
|
||||
if (data.seed) {
|
||||
wallet = Wallet.fromSeed(data.seed, {
|
||||
network: data.network,
|
||||
format: data.format
|
||||
});
|
||||
// Best case: re-derive all keys from seed (seamless v2→v3 upgrade)
|
||||
wallet = Wallet.fromSeed(data.seed, { network: data.network });
|
||||
} else if (data.spendSecretKey) {
|
||||
wallet = new Wallet({
|
||||
type: WALLET_TYPE.FULL,
|
||||
network: data.network,
|
||||
format: data.format
|
||||
network: data.network
|
||||
});
|
||||
wallet._spendSecretKey = hexToBytes(data.spendSecretKey);
|
||||
wallet._spendPublicKey = hexToBytes(data.spendPublicKey);
|
||||
wallet._viewSecretKey = data.viewSecretKey ? hexToBytes(data.viewSecretKey) : null;
|
||||
wallet._viewPublicKey = hexToBytes(data.viewPublicKey);
|
||||
// Restore CARROT keys if stored (no seed to re-derive)
|
||||
if (data.carrotKeys && data.carrotKeys.proveSpendKey) {
|
||||
wallet._carrotKeys = data.carrotKeys;
|
||||
}
|
||||
} else if (data.viewSecretKey) {
|
||||
wallet = Wallet.fromViewKey(
|
||||
data.viewSecretKey,
|
||||
data.spendPublicKey,
|
||||
{ network: data.network, format: data.format }
|
||||
);
|
||||
wallet = Wallet.fromViewKey(data.viewSecretKey, data.spendPublicKey, {
|
||||
network: data.network
|
||||
});
|
||||
} else {
|
||||
wallet = Wallet.fromAddress(data.address, {
|
||||
network: data.network,
|
||||
format: data.format
|
||||
wallet = Wallet.fromAddress(data.address || data.carrotAddress, {
|
||||
network: data.network
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,3 +25,6 @@ export {
|
||||
|
||||
// Account Class
|
||||
export { Account } from './account.js';
|
||||
|
||||
// Transfer, Sweep & Stake
|
||||
export { transfer, sweep, stake } from './transfer.js';
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* High-Level Transfer, Sweep & Stake Functions
|
||||
*
|
||||
* Orchestrates the full transaction lifecycle:
|
||||
* UTXO selection → input preparation → TX build → serialize → broadcast
|
||||
*
|
||||
* Reference: Salvium src/wallet/wallet2.cpp create_transactions_2()
|
||||
*/
|
||||
|
||||
import { parseAddress, hexToBytes, bytesToHex } from '../address.js';
|
||||
import {
|
||||
buildTransaction, buildStakeTransaction, prepareInputs, estimateTransactionFee,
|
||||
selectUTXOs, serializeTransaction, TX_TYPE, DEFAULT_RING_SIZE
|
||||
} from '../transaction.js';
|
||||
import { getNetworkConfig, NETWORK_ID } from '../consensus.js';
|
||||
import {
|
||||
generateKeyDerivation, deriveSecretKey, scalarAdd
|
||||
} from '../crypto/index.js';
|
||||
import { cnSubaddressSecretKey } from '../subaddress.js';
|
||||
|
||||
/**
|
||||
* Derive the output secret key needed for spending.
|
||||
*
|
||||
* @param {Object} output - WalletOutput with txPubKey, outputIndex
|
||||
* @param {Object} keys - Wallet keys { viewSecretKey, spendSecretKey }
|
||||
* @param {Object} [subaddressIndex] - { major, minor }
|
||||
* @returns {Uint8Array} Output secret key
|
||||
*/
|
||||
function deriveOutputSecretKey(output, keys) {
|
||||
const txPubKey = typeof output.txPubKey === 'string'
|
||||
? hexToBytes(output.txPubKey) : output.txPubKey;
|
||||
const viewSecretKey = typeof keys.viewSecretKey === 'string'
|
||||
? hexToBytes(keys.viewSecretKey) : keys.viewSecretKey;
|
||||
let spendSecretKey = typeof keys.spendSecretKey === 'string'
|
||||
? hexToBytes(keys.spendSecretKey) : keys.spendSecretKey;
|
||||
|
||||
// For subaddresses, compute subaddress secret key
|
||||
const sub = output.subaddressIndex;
|
||||
if (sub && (sub.major !== 0 || sub.minor !== 0)) {
|
||||
const subaddrScalar = cnSubaddressSecretKey(viewSecretKey, sub.major, sub.minor);
|
||||
spendSecretKey = scalarAdd(spendSecretKey, subaddrScalar);
|
||||
}
|
||||
|
||||
const derivation = generateKeyDerivation(txPubKey, viewSecretKey);
|
||||
return deriveSecretKey(derivation, output.outputIndex, spendSecretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve global output indices for a set of outputs.
|
||||
*
|
||||
* @param {Array<Object>} outputs - WalletOutput objects
|
||||
* @param {Object} daemon - DaemonRPC instance
|
||||
* @returns {Promise<Map<string, number>>} Map of keyImage → globalIndex
|
||||
*/
|
||||
async function resolveGlobalIndices(outputs, daemon) {
|
||||
const indices = new Map();
|
||||
|
||||
// Group outputs by txHash to minimize RPC calls
|
||||
const byTx = new Map();
|
||||
for (const output of outputs) {
|
||||
if (output.globalIndex != null) {
|
||||
indices.set(output.keyImage, output.globalIndex);
|
||||
continue;
|
||||
}
|
||||
if (!byTx.has(output.txHash)) byTx.set(output.txHash, []);
|
||||
byTx.get(output.txHash).push(output);
|
||||
}
|
||||
|
||||
for (const [txHash, outs] of byTx) {
|
||||
const resp = await daemon.getOutputIndexes(txHash);
|
||||
if (!resp.success || !resp.data?.o_indexes) {
|
||||
throw new Error(`Failed to get output indexes for tx ${txHash}`);
|
||||
}
|
||||
const oIndexes = resp.data.o_indexes;
|
||||
for (const out of outs) {
|
||||
if (out.outputIndex < oIndexes.length) {
|
||||
const gi = Number(oIndexes[out.outputIndex]);
|
||||
indices.set(out.keyImage, gi);
|
||||
out.globalIndex = gi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer SAL to one or more destinations.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Object} params.wallet - WalletSync instance (has storage + keys)
|
||||
* @param {Object} params.daemon - DaemonRPC instance
|
||||
* @param {Array<{address: string, amount: bigint}>} params.destinations - Where to send
|
||||
* @param {Object} [params.options]
|
||||
* @param {string} [params.options.priority='default'] - Fee priority
|
||||
* @param {boolean} [params.options.subtractFeeFromAmount=false] - Deduct fee from first destination
|
||||
* @param {string} [params.options.assetType='SAL'] - Asset type to send
|
||||
* @param {boolean} [params.options.dryRun=false] - Build TX but don't broadcast
|
||||
* @returns {Promise<{txHash: string, fee: bigint, tx: Object}>}
|
||||
*/
|
||||
export async function transfer({ wallet, daemon, destinations, options = {} }) {
|
||||
const {
|
||||
priority = 'default',
|
||||
subtractFeeFromAmount = false,
|
||||
assetType = 'SAL',
|
||||
dryRun = false
|
||||
} = options;
|
||||
|
||||
if (!destinations || destinations.length === 0) {
|
||||
throw new Error('At least one destination is required');
|
||||
}
|
||||
|
||||
// 1. Parse destination addresses
|
||||
const parsedDests = destinations.map(d => {
|
||||
const parsed = parseAddress(d.address);
|
||||
if (!parsed.valid) {
|
||||
throw new Error(`Invalid address: ${d.address} — ${parsed.error}`);
|
||||
}
|
||||
return {
|
||||
viewPublicKey: parsed.viewPublicKey,
|
||||
spendPublicKey: parsed.spendPublicKey,
|
||||
isSubaddress: parsed.type === 'subaddress',
|
||||
amount: typeof d.amount === 'bigint' ? d.amount : BigInt(d.amount)
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Calculate total send amount
|
||||
let totalSend = 0n;
|
||||
for (const d of parsedDests) totalSend += d.amount;
|
||||
|
||||
// 3. Get current height for spendability check
|
||||
const infoResp = await daemon.getInfo();
|
||||
if (!infoResp.success) throw new Error('Failed to get daemon info');
|
||||
const currentHeight = infoResp.result?.height || infoResp.data?.height;
|
||||
|
||||
// 4. Get spendable outputs
|
||||
const allOutputs = await wallet.storage.getOutputs({
|
||||
isSpent: false,
|
||||
isFrozen: false,
|
||||
assetType
|
||||
});
|
||||
const spendable = allOutputs.filter(o => o.isSpendable(currentHeight));
|
||||
|
||||
if (spendable.length === 0) {
|
||||
throw new Error('No spendable outputs available');
|
||||
}
|
||||
|
||||
// 5. Initial fee estimate (2 outputs: dest + change)
|
||||
let estimatedFee = estimateTransactionFee(
|
||||
2, // guess 2 inputs initially
|
||||
parsedDests.length + 1, // destinations + change
|
||||
{ priority }
|
||||
);
|
||||
|
||||
// 6. Select UTXOs
|
||||
const target = subtractFeeFromAmount ? totalSend : totalSend + estimatedFee;
|
||||
const selection = selectUTXOs(
|
||||
spendable.map(o => ({
|
||||
amount: o.amount,
|
||||
globalIndex: o.globalIndex,
|
||||
keyImage: o.keyImage,
|
||||
_output: o
|
||||
})),
|
||||
target,
|
||||
estimatedFee / 2n, // rough per-input fee
|
||||
{ currentHeight }
|
||||
);
|
||||
|
||||
if (!selection.selected || selection.selected.length === 0) {
|
||||
throw new Error(`Insufficient balance. Need ${target}, have ${spendable.reduce((s, o) => s + o.amount, 0n)}`);
|
||||
}
|
||||
|
||||
// 7. Recalculate fee with actual input count
|
||||
estimatedFee = estimateTransactionFee(
|
||||
selection.selected.length,
|
||||
parsedDests.length + (selection.changeAmount > 0n ? 1 : 0),
|
||||
{ priority }
|
||||
);
|
||||
|
||||
// Adjust amounts if subtracting fee
|
||||
if (subtractFeeFromAmount) {
|
||||
parsedDests[0].amount -= estimatedFee;
|
||||
if (parsedDests[0].amount <= 0n) {
|
||||
throw new Error('Amount too small to cover fee');
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Resolve global indices for selected outputs
|
||||
const selectedOutputs = selection.selected.map(s => s._output);
|
||||
await resolveGlobalIndices(selectedOutputs, daemon);
|
||||
|
||||
// 9. Derive output secret keys for each input
|
||||
// For coinbase outputs (no RCT), mask = identity scalar (1), commitment = zeroCommit(amount)
|
||||
const IDENTITY_MASK = '0100000000000000000000000000000000000000000000000000000000000000';
|
||||
const { commit: pedersenCommit } = await import('../transaction/serialization.js');
|
||||
|
||||
const ownedForPrep = selectedOutputs.map(o => {
|
||||
let mask = o.mask;
|
||||
let commitment = o.commitment;
|
||||
|
||||
// Coinbase outputs have no RCT mask — use identity
|
||||
if (!mask) {
|
||||
mask = IDENTITY_MASK;
|
||||
commitment = bytesToHex(pedersenCommit(o.amount, hexToBytes(IDENTITY_MASK)));
|
||||
}
|
||||
|
||||
return {
|
||||
secretKey: deriveOutputSecretKey(o, wallet.keys),
|
||||
publicKey: o.publicKey,
|
||||
amount: o.amount,
|
||||
mask,
|
||||
globalIndex: o.globalIndex,
|
||||
commitment
|
||||
};
|
||||
});
|
||||
|
||||
// 10. Prepare inputs (fetch decoys from daemon)
|
||||
const preparedInputs = await prepareInputs(ownedForPrep, daemon, {
|
||||
ringSize: DEFAULT_RING_SIZE
|
||||
});
|
||||
|
||||
// 11. Build change address from wallet's primary address
|
||||
const changeAddress = {
|
||||
viewPublicKey: wallet.keys.viewPublicKey,
|
||||
spendPublicKey: wallet.keys.spendPublicKey,
|
||||
isSubaddress: false
|
||||
};
|
||||
|
||||
// 12. Build the transaction
|
||||
const tx = buildTransaction(
|
||||
{
|
||||
inputs: preparedInputs,
|
||||
destinations: parsedDests,
|
||||
changeAddress,
|
||||
fee: estimatedFee
|
||||
},
|
||||
{
|
||||
txType: TX_TYPE.TRANSFER,
|
||||
sourceAssetType: assetType,
|
||||
destinationAssetType: assetType
|
||||
}
|
||||
);
|
||||
|
||||
// 13. Serialize
|
||||
const serialized = serializeTransaction(tx);
|
||||
const txHex = bytesToHex(serialized);
|
||||
|
||||
// 14. Broadcast
|
||||
if (!dryRun) {
|
||||
const sendResp = await daemon.sendRawTransaction(txHex);
|
||||
if (!sendResp.success) {
|
||||
throw new Error(`Failed to broadcast: ${JSON.stringify(sendResp.error || sendResp.data)}`);
|
||||
}
|
||||
if (sendResp.data?.status !== 'OK' && sendResp.data?.result?.status !== 'OK') {
|
||||
const reason = sendResp.data?.reason || sendResp.data?.result?.reason || 'unknown';
|
||||
throw new Error(`Transaction rejected: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 15. Compute TX hash
|
||||
const { keccak256 } = await import('../crypto/index.js');
|
||||
const txHash = bytesToHex(keccak256(serialized));
|
||||
|
||||
return {
|
||||
txHash,
|
||||
fee: estimatedFee,
|
||||
tx,
|
||||
serializedHex: txHex,
|
||||
inputCount: preparedInputs.length,
|
||||
outputCount: tx.prefix.vout.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep all spendable outputs to a single destination.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Object} params.wallet - WalletSync instance
|
||||
* @param {Object} params.daemon - DaemonRPC instance
|
||||
* @param {string} params.address - Destination address
|
||||
* @param {Object} [params.options]
|
||||
* @param {string} [params.options.priority='default'] - Fee priority
|
||||
* @param {string} [params.options.assetType='SAL'] - Asset type
|
||||
* @param {boolean} [params.options.dryRun=false] - Build TX but don't broadcast
|
||||
* @returns {Promise<{txHash: string, fee: bigint, amount: bigint, tx: Object}>}
|
||||
*/
|
||||
export async function sweep({ wallet, daemon, address, options = {} }) {
|
||||
const {
|
||||
priority = 'default',
|
||||
assetType = 'SAL',
|
||||
dryRun = false
|
||||
} = options;
|
||||
|
||||
// Parse destination
|
||||
const parsed = parseAddress(address);
|
||||
if (!parsed.valid) {
|
||||
throw new Error(`Invalid address: ${address} — ${parsed.error}`);
|
||||
}
|
||||
|
||||
// Get current height
|
||||
const infoResp = await daemon.getInfo();
|
||||
if (!infoResp.success) throw new Error('Failed to get daemon info');
|
||||
const currentHeight = infoResp.result?.height || infoResp.data?.height;
|
||||
|
||||
// Get all spendable outputs
|
||||
const allOutputs = await wallet.storage.getOutputs({
|
||||
isSpent: false,
|
||||
isFrozen: false,
|
||||
assetType
|
||||
});
|
||||
const spendable = allOutputs.filter(o => o.isSpendable(currentHeight));
|
||||
|
||||
if (spendable.length === 0) {
|
||||
throw new Error('No spendable outputs available');
|
||||
}
|
||||
|
||||
let totalAmount = 0n;
|
||||
for (const o of spendable) totalAmount += o.amount;
|
||||
|
||||
// Estimate fee with all inputs, 1 output (no change for sweep)
|
||||
const estimatedFee = estimateTransactionFee(
|
||||
spendable.length,
|
||||
1, // single output, no change
|
||||
{ priority }
|
||||
);
|
||||
|
||||
const sendAmount = totalAmount - estimatedFee;
|
||||
if (sendAmount <= 0n) {
|
||||
throw new Error(`Total balance ${totalAmount} is too small to cover fee ${estimatedFee}`);
|
||||
}
|
||||
|
||||
// Resolve global indices
|
||||
await resolveGlobalIndices(spendable, daemon);
|
||||
|
||||
// Derive secret keys
|
||||
const ownedForPrep = spendable.map(o => ({
|
||||
secretKey: deriveOutputSecretKey(o, wallet.keys),
|
||||
publicKey: o.publicKey,
|
||||
amount: o.amount,
|
||||
mask: o.mask,
|
||||
globalIndex: o.globalIndex,
|
||||
commitment: o.commitment
|
||||
}));
|
||||
|
||||
// Prepare inputs
|
||||
const preparedInputs = await prepareInputs(ownedForPrep, daemon, {
|
||||
ringSize: DEFAULT_RING_SIZE
|
||||
});
|
||||
|
||||
// Build TX (no change output)
|
||||
const tx = buildTransaction(
|
||||
{
|
||||
inputs: preparedInputs,
|
||||
destinations: [{
|
||||
viewPublicKey: parsed.viewPublicKey,
|
||||
spendPublicKey: parsed.spendPublicKey,
|
||||
isSubaddress: parsed.type === 'subaddress',
|
||||
amount: sendAmount
|
||||
}],
|
||||
changeAddress: null,
|
||||
fee: estimatedFee
|
||||
},
|
||||
{
|
||||
txType: TX_TYPE.TRANSFER,
|
||||
sourceAssetType: assetType,
|
||||
destinationAssetType: assetType
|
||||
}
|
||||
);
|
||||
|
||||
// Serialize and broadcast
|
||||
const serialized = serializeTransaction(tx);
|
||||
const txHex = bytesToHex(serialized);
|
||||
|
||||
if (!dryRun) {
|
||||
const sendResp = await daemon.sendRawTransaction(txHex);
|
||||
if (!sendResp.success) {
|
||||
throw new Error(`Failed to broadcast: ${JSON.stringify(sendResp.error || sendResp.data)}`);
|
||||
}
|
||||
if (sendResp.data?.status !== 'OK' && sendResp.data?.result?.status !== 'OK') {
|
||||
const reason = sendResp.data?.reason || sendResp.data?.result?.reason || 'unknown';
|
||||
throw new Error(`Transaction rejected: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { keccak256 } = await import('../crypto/index.js');
|
||||
const txHash = bytesToHex(keccak256(serialized));
|
||||
|
||||
return {
|
||||
txHash,
|
||||
fee: estimatedFee,
|
||||
amount: sendAmount,
|
||||
tx,
|
||||
serializedHex: txHex,
|
||||
inputCount: preparedInputs.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stake SAL to earn yield from conversion slippage.
|
||||
*
|
||||
* Locks funds for STAKE_LOCK_PERIOD blocks. After maturity, a protocol_tx
|
||||
* returns the original stake + accumulated yield to the return address.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Object} params.wallet - WalletSync instance (has storage + keys)
|
||||
* @param {Object} params.daemon - DaemonRPC instance
|
||||
* @param {bigint} params.amount - Amount to stake (atomic units)
|
||||
* @param {Object} [params.options]
|
||||
* @param {string} [params.options.priority='default'] - Fee priority
|
||||
* @param {string} [params.options.assetType='SAL'] - Asset type to stake
|
||||
* @param {string} [params.options.network='mainnet'] - Network for lock period
|
||||
* @param {boolean} [params.options.dryRun=false] - Build TX but don't broadcast
|
||||
* @returns {Promise<{txHash: string, fee: bigint, stakeAmount: bigint, lockPeriod: number, tx: Object}>}
|
||||
*/
|
||||
export async function stake({ wallet, daemon, amount, options = {} }) {
|
||||
const {
|
||||
priority = 'default',
|
||||
assetType = 'SAL',
|
||||
network = 'mainnet',
|
||||
dryRun = false
|
||||
} = options;
|
||||
|
||||
const stakeAmount = typeof amount === 'bigint' ? amount : BigInt(amount);
|
||||
if (stakeAmount <= 0n) {
|
||||
throw new Error('Stake amount must be positive');
|
||||
}
|
||||
|
||||
// Resolve string network names to numeric NETWORK_ID
|
||||
const NETWORK_NAME_MAP = { mainnet: NETWORK_ID.MAINNET, testnet: NETWORK_ID.TESTNET, stagenet: NETWORK_ID.STAGENET };
|
||||
const networkId = typeof network === 'string' ? (NETWORK_NAME_MAP[network] ?? network) : network;
|
||||
const networkConfig = getNetworkConfig(networkId);
|
||||
const stakeLockPeriod = networkConfig.STAKE_LOCK_PERIOD;
|
||||
|
||||
// 1. Get current height
|
||||
const infoResp = await daemon.getInfo();
|
||||
if (!infoResp.success) throw new Error('Failed to get daemon info');
|
||||
const currentHeight = infoResp.result?.height || infoResp.data?.height;
|
||||
|
||||
// 2. Get spendable outputs
|
||||
const allOutputs = await wallet.storage.getOutputs({
|
||||
isSpent: false,
|
||||
isFrozen: false,
|
||||
assetType
|
||||
});
|
||||
const spendable = allOutputs.filter(o => o.isSpendable(currentHeight));
|
||||
|
||||
if (spendable.length === 0) {
|
||||
throw new Error('No spendable outputs available');
|
||||
}
|
||||
|
||||
// 3. Estimate fee (inputs + 1 change output, no payment outputs)
|
||||
let estimatedFee = estimateTransactionFee(
|
||||
2, // guess 2 inputs
|
||||
1, // change only
|
||||
{ priority }
|
||||
);
|
||||
|
||||
// 4. Select UTXOs
|
||||
const target = stakeAmount + estimatedFee;
|
||||
const selection = selectUTXOs(
|
||||
spendable.map(o => ({
|
||||
amount: o.amount,
|
||||
globalIndex: o.globalIndex,
|
||||
keyImage: o.keyImage,
|
||||
_output: o
|
||||
})),
|
||||
target,
|
||||
estimatedFee / 2n,
|
||||
{ currentHeight }
|
||||
);
|
||||
|
||||
if (!selection.selected || selection.selected.length === 0) {
|
||||
throw new Error(`Insufficient balance. Need ${target}, have ${spendable.reduce((s, o) => s + o.amount, 0n)}`);
|
||||
}
|
||||
|
||||
// 5. Recalculate fee with actual input count
|
||||
estimatedFee = estimateTransactionFee(
|
||||
selection.selected.length,
|
||||
selection.changeAmount > 0n ? 1 : 0,
|
||||
{ priority }
|
||||
);
|
||||
|
||||
// 6. Resolve global indices
|
||||
const selectedOutputs = selection.selected.map(s => s._output);
|
||||
await resolveGlobalIndices(selectedOutputs, daemon);
|
||||
|
||||
// 7. Derive output secret keys
|
||||
const IDENTITY_MASK = '0100000000000000000000000000000000000000000000000000000000000000';
|
||||
const { commit: pedersenCommit } = await import('../transaction/serialization.js');
|
||||
|
||||
const ownedForPrep = selectedOutputs.map(o => {
|
||||
let mask = o.mask;
|
||||
let commitment = o.commitment;
|
||||
|
||||
if (!mask) {
|
||||
mask = IDENTITY_MASK;
|
||||
commitment = bytesToHex(pedersenCommit(o.amount, hexToBytes(IDENTITY_MASK)));
|
||||
}
|
||||
|
||||
return {
|
||||
secretKey: deriveOutputSecretKey(o, wallet.keys),
|
||||
publicKey: o.publicKey,
|
||||
amount: o.amount,
|
||||
mask,
|
||||
globalIndex: o.globalIndex,
|
||||
commitment
|
||||
};
|
||||
});
|
||||
|
||||
// 8. Prepare inputs (fetch decoys)
|
||||
const preparedInputs = await prepareInputs(ownedForPrep, daemon, {
|
||||
ringSize: DEFAULT_RING_SIZE
|
||||
});
|
||||
|
||||
// 9. Return address = wallet's own address (stake returns here)
|
||||
const returnAddress = {
|
||||
viewPublicKey: wallet.keys.viewPublicKey,
|
||||
spendPublicKey: wallet.keys.spendPublicKey,
|
||||
isSubaddress: false
|
||||
};
|
||||
|
||||
// 10. Build stake transaction
|
||||
const tx = buildStakeTransaction(
|
||||
{
|
||||
inputs: preparedInputs,
|
||||
stakeAmount,
|
||||
returnAddress,
|
||||
fee: estimatedFee
|
||||
},
|
||||
{
|
||||
stakeLockPeriod,
|
||||
assetType
|
||||
}
|
||||
);
|
||||
|
||||
// 11. Serialize
|
||||
const serialized = serializeTransaction(tx);
|
||||
const txHex = bytesToHex(serialized);
|
||||
|
||||
// 12. Broadcast
|
||||
if (!dryRun) {
|
||||
const sendResp = await daemon.sendRawTransaction(txHex);
|
||||
if (!sendResp.success) {
|
||||
throw new Error(`Failed to broadcast: ${JSON.stringify(sendResp.error || sendResp.data)}`);
|
||||
}
|
||||
if (sendResp.data?.status !== 'OK' && sendResp.data?.result?.status !== 'OK') {
|
||||
const reason = sendResp.data?.reason || sendResp.data?.result?.reason || 'unknown';
|
||||
throw new Error(`Transaction rejected: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 13. Compute TX hash
|
||||
const { keccak256 } = await import('../crypto/index.js');
|
||||
const txHash = bytesToHex(keccak256(serialized));
|
||||
|
||||
return {
|
||||
txHash,
|
||||
fee: estimatedFee,
|
||||
stakeAmount,
|
||||
lockPeriod: stakeLockPeriod,
|
||||
tx,
|
||||
serializedHex: txHex,
|
||||
inputCount: preparedInputs.length,
|
||||
outputCount: tx.prefix.vout.length
|
||||
};
|
||||
}
|
||||
@@ -245,31 +245,48 @@ test('multiScalarMul with empty arrays', () => {
|
||||
console.log('\n--- Proof Parsing Tests ---');
|
||||
|
||||
test('parseProof extracts correct structure', () => {
|
||||
// Create a minimal mock proof (6 rounds = 384 bytes for L/R + 192 bytes header)
|
||||
const proofBytes = new Uint8Array(32 * 6 + 64 * 6);
|
||||
|
||||
// Fill with valid point encodings (use base point bytes)
|
||||
// Build a proof in Salvium binary format:
|
||||
// varint(V.len), V[], A, A1, B, r1, s1, d1, varint(L.len), L[], varint(R.len), R[]
|
||||
const baseBytes = Point.BASE.toBytes();
|
||||
const chunks = [];
|
||||
|
||||
// V: 1 commitment
|
||||
chunks.push(new Uint8Array([1])); // varint(1)
|
||||
chunks.push(baseBytes);
|
||||
|
||||
// A, A1, B
|
||||
for (let i = 0; i < 3; i++) {
|
||||
proofBytes.set(baseBytes, i * 32);
|
||||
}
|
||||
chunks.push(baseBytes);
|
||||
chunks.push(baseBytes);
|
||||
chunks.push(baseBytes);
|
||||
|
||||
// r1, s1, d1 (scalars - just use small values)
|
||||
proofBytes[96] = 1; // r1
|
||||
proofBytes[128] = 2; // s1
|
||||
proofBytes[160] = 3; // d1
|
||||
// r1, s1, d1 (scalars)
|
||||
const s1b = new Uint8Array(32); s1b[0] = 1;
|
||||
const s2b = new Uint8Array(32); s2b[0] = 2;
|
||||
const s3b = new Uint8Array(32); s3b[0] = 3;
|
||||
chunks.push(s1b);
|
||||
chunks.push(s2b);
|
||||
chunks.push(s3b);
|
||||
|
||||
// L and R pairs (6 rounds)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
proofBytes.set(baseBytes, 192 + i * 32);
|
||||
}
|
||||
// L: 6 entries
|
||||
chunks.push(new Uint8Array([6])); // varint(6)
|
||||
for (let i = 0; i < 6; i++) chunks.push(baseBytes);
|
||||
|
||||
// R: 6 entries
|
||||
chunks.push(new Uint8Array([6])); // varint(6)
|
||||
for (let i = 0; i < 6; i++) chunks.push(baseBytes);
|
||||
|
||||
// Concatenate
|
||||
let totalLen = 0;
|
||||
for (const c of chunks) totalLen += c.length;
|
||||
const proofBytes = new Uint8Array(totalLen);
|
||||
let off = 0;
|
||||
for (const c of chunks) { proofBytes.set(c, off); off += c.length; }
|
||||
|
||||
const proof = parseProof(proofBytes);
|
||||
assertExists(proof.A);
|
||||
assertExists(proof.A1);
|
||||
assertExists(proof.B);
|
||||
assertEqual(proof.V.length, 1);
|
||||
assertEqual(proof.r1, 1n);
|
||||
assertEqual(proof.s1, 2n);
|
||||
assertEqual(proof.d1, 3n);
|
||||
@@ -450,8 +467,9 @@ test('serializeProof produces correct size', () => {
|
||||
const proof = proveRange(amount, mask);
|
||||
const bytes = serializeProof(proof);
|
||||
|
||||
// Size = 3 points + 3 scalars + 6 L/R pairs = 6*32 + 6*64 = 576 bytes
|
||||
assertEqual(bytes.length, 576, 'Serialized proof should be 576 bytes');
|
||||
// Salvium binary format: varint(V.len) + V + A + A1 + B + r1 + s1 + d1 + varint(L.len) + L + varint(R.len) + R
|
||||
// For single amount: 1 + 32 + 3*32 + 3*32 + 1 + 6*32 + 1 + 6*32 = 611 bytes
|
||||
assertEqual(bytes.length, 611, 'Serialized proof should be 611 bytes');
|
||||
});
|
||||
|
||||
test('serialized proof can be parsed and verified', () => {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Integration Test: Transfer & Sweep
|
||||
*
|
||||
* Tests the full transaction lifecycle between two wallets.
|
||||
*
|
||||
* Env vars:
|
||||
* DAEMON_URL - RPC endpoint (default: http://web.whiskymine.io:29081)
|
||||
* WALLET_FILE - Path to wallet JSON (default: ~/testnet-wallet/wallet.json)
|
||||
* NETWORK - mainnet|testnet|stagenet (default: testnet)
|
||||
* DRY_RUN - 1 = build but don't broadcast (default: 1)
|
||||
*
|
||||
* Usage: bun test/integration-transfer.test.js
|
||||
* DRY_RUN=0 bun test/integration-transfer.test.js
|
||||
*/
|
||||
|
||||
import { DaemonRPC } from '../src/rpc/daemon.js';
|
||||
import { Wallet } from '../src/wallet.js';
|
||||
import { createWalletSync } from '../src/wallet-sync.js';
|
||||
import { MemoryStorage } from '../src/wallet-store.js';
|
||||
import { transfer, sweep, stake } from '../src/wallet/transfer.js';
|
||||
|
||||
const DAEMON_URL = process.env.DAEMON_URL || 'http://web.whiskymine.io:29081';
|
||||
const WALLET_FILE = process.env.WALLET_FILE || `${process.env.HOME}/testnet-wallet/wallet.json`;
|
||||
const NETWORK = process.env.NETWORK || 'testnet';
|
||||
const DRY_RUN = process.env.DRY_RUN !== '0';
|
||||
|
||||
let daemon;
|
||||
|
||||
async function syncWallet(label, keys) {
|
||||
console.log(`Syncing ${label}...`);
|
||||
const storage = new MemoryStorage();
|
||||
const sync = createWalletSync({
|
||||
daemon,
|
||||
keys,
|
||||
storage,
|
||||
network: NETWORK
|
||||
});
|
||||
|
||||
await sync.start();
|
||||
|
||||
const infoResp = await daemon.getInfo();
|
||||
const currentHeight = infoResp.result?.height || infoResp.data?.height || 0;
|
||||
|
||||
const allOutputs = await storage.getOutputs({ isSpent: false });
|
||||
const spendable = allOutputs.filter(o => o.isSpendable(currentHeight));
|
||||
let balance = 0n;
|
||||
for (const o of allOutputs) balance += o.amount;
|
||||
let spendableBalance = 0n;
|
||||
for (const o of spendable) spendableBalance += o.amount;
|
||||
|
||||
console.log(` ${allOutputs.length} unspent (${spendable.length} spendable)`);
|
||||
console.log(` Balance: ${Number(balance) / 1e8} SAL (${Number(spendableBalance) / 1e8} SAL spendable)`);
|
||||
|
||||
return { sync, storage, balance, spendableBalance, outputs: allOutputs, spendable };
|
||||
}
|
||||
|
||||
async function testTransfer(keys, storage, toAddress, amount, label) {
|
||||
console.log(`\n--- ${label} ---`);
|
||||
console.log(` Amount: ${Number(amount) / 1e8} SAL`);
|
||||
console.log(` To: ${toAddress.slice(0, 30)}...`);
|
||||
|
||||
try {
|
||||
const result = await transfer({
|
||||
wallet: { keys, storage },
|
||||
daemon,
|
||||
destinations: [{ address: toAddress, amount }],
|
||||
options: { priority: 'default', dryRun: DRY_RUN }
|
||||
});
|
||||
|
||||
console.log(` TX Hash: ${result.txHash}`);
|
||||
console.log(` Fee: ${Number(result.fee) / 1e8} SAL`);
|
||||
console.log(` Inputs: ${result.inputCount}, Outputs: ${result.outputCount}`);
|
||||
console.log(` Serialized: ${result.serializedHex.length / 2} bytes`);
|
||||
console.log(` ${DRY_RUN ? '(dry run — not broadcast)' : 'BROADCAST OK'}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(` FAILED: ${e.message}`);
|
||||
if (e.stack) console.error(e.stack.split('\n').slice(1, 4).join('\n'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Transfer Integration Test ===\n');
|
||||
console.log(`Daemon: ${DAEMON_URL}`);
|
||||
console.log(`Network: ${NETWORK}`);
|
||||
console.log(`Dry run: ${DRY_RUN}\n`);
|
||||
|
||||
daemon = new DaemonRPC({ url: DAEMON_URL });
|
||||
|
||||
const info = await daemon.getInfo();
|
||||
if (!info.success) throw new Error('Cannot reach daemon');
|
||||
const height = info.result?.height || info.data?.height;
|
||||
console.log(`Daemon height: ${height}\n`);
|
||||
|
||||
// Load wallet A from file
|
||||
console.log(`Loading wallet A from ${WALLET_FILE}`);
|
||||
const walletAJson = JSON.parse(await Bun.file(WALLET_FILE).text());
|
||||
const keysA = {
|
||||
viewSecretKey: walletAJson.viewSecretKey,
|
||||
spendSecretKey: walletAJson.spendSecretKey,
|
||||
viewPublicKey: walletAJson.viewPublicKey,
|
||||
spendPublicKey: walletAJson.spendPublicKey,
|
||||
};
|
||||
console.log(` Address: ${walletAJson.address}\n`);
|
||||
|
||||
// Create wallet B in memory (not saved)
|
||||
const walletB = Wallet.create({ network: NETWORK });
|
||||
const keysB = {
|
||||
viewSecretKey: walletB.viewSecretKey,
|
||||
spendSecretKey: walletB.spendSecretKey,
|
||||
viewPublicKey: walletB.viewPublicKey,
|
||||
spendPublicKey: walletB.spendPublicKey,
|
||||
};
|
||||
const addressB = walletB.getAddress();
|
||||
console.log(`Wallet B (ephemeral):`);
|
||||
console.log(` Address: ${addressB}\n`);
|
||||
|
||||
// Sync wallet A
|
||||
const syncA = await syncWallet('Wallet A', keysA);
|
||||
|
||||
if (syncA.spendableBalance === 0n) {
|
||||
console.log('\nWallet A has no spendable balance. Mine more blocks.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Test 1: Transfer 100 SAL (A → B)
|
||||
const amount1 = 10_000_000_000n; // 100 SAL (1 SAL = 1e8 atomic)
|
||||
const result1 = await testTransfer(keysA, syncA.storage, addressB, amount1, 'Transfer: 100 SAL (A → B)');
|
||||
|
||||
if (!result1) {
|
||||
console.log('\nFirst transfer failed, stopping.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Test 2: Multiple small transfers to fracture UTXOs
|
||||
console.log('\n=== Multiple small transfers (A → B) ===');
|
||||
let successCount = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const amount = BigInt(Math.floor(Math.random() * 50_00_000_000 + 1_00_000_000)); // 1–51 SAL random
|
||||
const result = await testTransfer(keysA, syncA.storage, addressB, amount, `Small transfer ${i + 1}/3`);
|
||||
if (result) successCount++;
|
||||
}
|
||||
console.log(`\n${successCount}/3 transfers succeeded`);
|
||||
|
||||
// Test 3: Stake 500 SAL
|
||||
console.log('\n=== Stake Test ===');
|
||||
const stakeAmt = 500_00_000_000n; // 500 SAL
|
||||
console.log(`\n--- Stake: 500 SAL ---`);
|
||||
console.log(` Lock period: 20 blocks (testnet)`);
|
||||
try {
|
||||
const stakeResult = await stake({
|
||||
wallet: { keys: keysA, storage: syncA.storage },
|
||||
daemon,
|
||||
amount: stakeAmt,
|
||||
options: { priority: 'default', network: NETWORK, dryRun: DRY_RUN }
|
||||
});
|
||||
console.log(` TX Hash: ${stakeResult.txHash}`);
|
||||
console.log(` Fee: ${Number(stakeResult.fee) / 1e8} SAL`);
|
||||
console.log(` Staked: ${Number(stakeResult.stakeAmount) / 1e8} SAL`);
|
||||
console.log(` Lock: ${stakeResult.lockPeriod} blocks`);
|
||||
console.log(` Inputs: ${stakeResult.inputCount}, Outputs: ${stakeResult.outputCount}`);
|
||||
console.log(` Serialized: ${stakeResult.serializedHex.length / 2} bytes`);
|
||||
console.log(` ${DRY_RUN ? '(dry run — not broadcast)' : 'BROADCAST OK'}`);
|
||||
} catch (e) {
|
||||
console.error(` FAILED: ${e.message}`);
|
||||
if (e.stack) console.error(e.stack.split('\n').slice(1, 4).join('\n'));
|
||||
}
|
||||
|
||||
console.log('\n=== Test Complete ===');
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('Fatal:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -79,9 +79,20 @@ test('Wallet.create() accepts network option', () => {
|
||||
assertEqual(wallet.network, NETWORK.TESTNET);
|
||||
});
|
||||
|
||||
test('Wallet.create() defaults to legacy format', () => {
|
||||
test('Wallet.create() derives both legacy and CARROT keys', () => {
|
||||
const wallet = Wallet.create();
|
||||
assertEqual(wallet.format, ADDRESS_FORMAT.LEGACY);
|
||||
// Primary keys are legacy CN keys
|
||||
assert(wallet._spendSecretKey, 'Should have legacy spend secret key');
|
||||
assert(wallet._viewSecretKey, 'Should have legacy view secret key');
|
||||
// CARROT keys also present
|
||||
assert(wallet._carrotKeys, 'Should have CARROT keys');
|
||||
assert(wallet._carrotKeys.proveSpendKey, 'Should have prove spend key');
|
||||
assert(wallet._carrotKeys.accountSpendPubkey, 'Should have account spend pubkey');
|
||||
// Both addresses work
|
||||
const legacy = wallet.getLegacyAddress();
|
||||
const carrot = wallet.getCarrotAddress();
|
||||
assertTrue(legacy.startsWith('SaLv'), 'Legacy address has SaLv prefix');
|
||||
assertTrue(carrot.startsWith('SC1'), 'CARROT address has SC1 prefix');
|
||||
});
|
||||
|
||||
test('createWallet() convenience function works', () => {
|
||||
@@ -355,10 +366,12 @@ test('toJSON() includes all required fields', () => {
|
||||
|
||||
assert(json.type, 'Should have type');
|
||||
assert(json.network, 'Should have network');
|
||||
assert(json.format, 'Should have format');
|
||||
assertEqual(json.version, 3, 'Should be version 3');
|
||||
assert(json.spendPublicKey, 'Should have spend public key');
|
||||
assert(json.viewPublicKey, 'Should have view public key');
|
||||
assert(json.address, 'Should have address');
|
||||
assert(json.address, 'Should have legacy address');
|
||||
assert(json.carrotAddress, 'Should have carrot address');
|
||||
assert(json.carrotKeys, 'Should have carrot keys');
|
||||
});
|
||||
|
||||
test('toJSON() includes secrets by default', () => {
|
||||
|
||||
Reference in New Issue
Block a user