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:
Matt Hess
2026-02-02 01:00:11 +00:00
parent fe0e8af07d
commit 949719c2e7
13 changed files with 1120 additions and 221 deletions
+84 -38
View File
@@ -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
*/
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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));
+24 -46
View File
@@ -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);
}
+4
View File
@@ -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,
+5
View File
@@ -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
View File
@@ -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
});
}
+3
View File
@@ -25,3 +25,6 @@ export {
// Account Class
export { Account } from './account.js';
// Transfer, Sweep & Stake
export { transfer, sweep, stake } from './transfer.js';
+566
View File
@@ -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
};
}
+35 -17
View File
@@ -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', () => {
+177
View File
@@ -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)); // 151 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);
});
+17 -4
View File
@@ -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', () => {