Refactor tests to use Wallet class, add PQ encryption, fix sweep spentKeyImages

- Refactor all test files to use Wallet.fromJSON/setDaemon/syncWithDaemon/transfer/sweep
     instead of manual MemoryStorage/createWalletSync/markOutputSpent wiring
   - Extract shared test helpers (getHeight, waitForHeight, fmt, short, loadWalletFromFile)
     into test/test-helpers.js
   - Add ML-KEM-768 + Argon2id + AES-256-GCM hybrid post-quantum wallet encryption
     (src/wallet-encryption.js, 47 unit tests)
   - Fix sweep() not returning spentKeyImages, causing wallet._markSpent() to silently
     skip marking spent outputs after sweeps (led to double_spend errors on next sweep)
   - Fix megaSweep test helper to always wait for maturity between rounds
   - Burn-in tested: 1295 TXs across CN and CARROT eras (1000 CARROT micro transfers,
     stakes, burns, sweeps) with 98% success rate (failures all sweep-related pre-fix)
This commit is contained in:
Matt Hess
2026-02-08 01:09:02 +00:00
parent c159b04e35
commit 60cf578eb2
14 changed files with 1353 additions and 865 deletions
+4 -1
View File
@@ -29,6 +29,7 @@
"./mining": "./src/mining.js",
"./stratum": "./src/stratum/index.js",
"./wallet": "./src/wallet.js",
"./wallet-encryption": "./src/wallet-encryption.js",
"./randomx": "./src/randomx/index.js"
},
"scripts": {
@@ -81,9 +82,11 @@
"./src/keccak.js": "./src/keccak.browser.js"
},
"dependencies": {
"@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1",
"@noble/ed25519": "^3.0.0",
"@noble/hashes": "^2.0.1"
"@noble/hashes": "^2.0.1",
"@noble/post-quantum": "^0.5.4"
},
"devDependencies": {
"assemblyscript": "^0.27.0"
+3
View File
@@ -40,6 +40,9 @@ export { transfer, sweep, stake } from './wallet/transfer.js';
export * from './persistent-wallet.js';
export * from './consensus.js';
// Post-quantum wallet encryption
export { encryptWalletJSON, decryptWalletJSON, isEncryptedWallet } from './wallet-encryption.js';
// Validation exports
export * from './validation.js';
+202
View File
@@ -0,0 +1,202 @@
/**
* Post-Quantum Wallet Encryption
*
* Hybrid encryption for wallet data at rest:
* 1. Argon2id(password, salt1) → classicalKey (32 bytes)
* 2. Argon2id(password, salt2) → 64-byte seed → ML-KEM-768 keypair
* encapsulate(pk) → (kyberCT, quantumKey 32 bytes)
* 3. HKDF-SHA256(classicalKey || quantumKey) → encryptionKey (32 bytes)
* 4. AES-256-GCM(encryptionKey, iv, secrets) → ciphertext
*
* The Kyber keypair is derived deterministically from the password (not the
* wallet seed) so no extra key material needs to be managed, and a seed
* compromise alone does not break encryption.
*
* @module wallet-encryption
*/
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
import { gcm } from '@noble/ciphers/aes.js';
import { argon2id } from '@noble/hashes/argon2.js';
import { hkdf } from '@noble/hashes/hkdf.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { randomBytes, concatBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { bytesToHex, hexToBytes } from './address.js';
export const ENCRYPTION_VERSION = 1;
// Default Argon2id parameters (OWASP minimums)
const ARGON2_DEFAULTS = { t: 3, m: 65536, p: 4 };
// Domain separation
const KEM_DOMAIN = utf8ToBytes('salvium-wallet-kem-v1');
const HKDF_INFO = utf8ToBytes('salvium-wallet-encryption-key-v1');
// Fields that contain secrets and must be encrypted
const SENSITIVE_KEYS = ['seed', 'mnemonic', 'spendSecretKey', 'viewSecretKey'];
// CARROT public-only keys (stay plaintext for wallet identification)
const CARROT_PUBLIC_KEYS = ['accountSpendPubkey', 'primaryAddressViewPubkey', 'accountViewPubkey'];
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function deriveClassicalKey(password, salt, params) {
return argon2id(utf8ToBytes(password), salt, {
t: params.t, m: params.m, p: params.p, dkLen: 32,
});
}
function deriveKyberKeypair(password, kemSalt, params) {
const seed = argon2id(utf8ToBytes(password), concatBytes(kemSalt, KEM_DOMAIN), {
t: params.t, m: params.m, p: params.p, dkLen: 64,
});
return ml_kem768.keygen(seed);
}
function combineKeys(classicalKey, quantumKey) {
return hkdf(sha256, concatBytes(classicalKey, quantumKey), undefined, HKDF_INFO, 32);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Encrypt sensitive wallet fields with hybrid PQ encryption.
*
* @param {Object} walletJSON - Plain wallet JSON (from wallet.toJSON(true))
* @param {string} password - User-chosen password
* @param {Object} [options]
* @param {Object} [options.argon2] - Override { t, m, p }
* @returns {Object} Encrypted wallet envelope (JSON-serializable)
*/
export function encryptWalletJSON(walletJSON, password, options = {}) {
const params = { ...ARGON2_DEFAULTS, ...options.argon2 };
// 1. Collect sensitive fields
const secrets = {};
for (const key of SENSITIVE_KEYS) {
if (walletJSON[key] !== undefined) secrets[key] = walletJSON[key];
}
if (walletJSON.carrotKeys) {
const carrotSecrets = {};
for (const [k, v] of Object.entries(walletJSON.carrotKeys)) {
if (!CARROT_PUBLIC_KEYS.includes(k)) carrotSecrets[k] = v;
}
if (Object.keys(carrotSecrets).length > 0) secrets.carrotSecrets = carrotSecrets;
}
const plaintext = utf8ToBytes(JSON.stringify(secrets));
// 2. Random salts
const kdfSalt = randomBytes(32);
const kemSalt = randomBytes(32);
// 3. Classical key from password
const classicalKey = deriveClassicalKey(password, kdfSalt, params);
// 4. ML-KEM-768 encapsulation
const { publicKey: kyberPK } = deriveKyberKeypair(password, kemSalt, params);
const { cipherText: kyberCT, sharedSecret: quantumKey } = ml_kem768.encapsulate(kyberPK);
// 5. Combined encryption key
const encryptionKey = combineKeys(classicalKey, quantumKey);
// 6. AES-256-GCM encrypt
const iv = randomBytes(12);
const ciphertext = gcm(encryptionKey, iv).encrypt(plaintext);
// 7. Build public (unencrypted) portion
const publicData = {};
for (const k of ['version', 'type', 'network', 'spendPublicKey', 'viewPublicKey',
'address', 'carrotAddress', 'syncHeight', 'accounts', 'nextSubaddressIndex']) {
if (walletJSON[k] !== undefined) publicData[k] = walletJSON[k];
}
if (walletJSON.carrotKeys) {
publicData.carrotKeys = {};
for (const k of CARROT_PUBLIC_KEYS) {
if (walletJSON.carrotKeys[k]) publicData.carrotKeys[k] = walletJSON.carrotKeys[k];
}
}
return {
encrypted: true,
encryptionVersion: ENCRYPTION_VERSION,
...publicData,
encryption: {
kdfSalt: bytesToHex(kdfSalt),
kemSalt: bytesToHex(kemSalt),
kyberCiphertext: bytesToHex(kyberCT),
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext),
argon2: { t: params.t, m: params.m, p: params.p },
},
};
}
/**
* Decrypt an encrypted wallet envelope.
*
* @param {Object} envelope - Encrypted wallet JSON
* @param {string} password - User password
* @returns {Object} Plain wallet JSON (same format as toJSON)
* @throws {Error} On wrong password or corrupted data
*/
export function decryptWalletJSON(envelope, password) {
if (!envelope.encrypted) return envelope;
const enc = envelope.encryption;
if (!enc) throw new Error('Missing encryption metadata');
const params = enc.argon2 || ARGON2_DEFAULTS;
// 1. Derive classical key
const classicalKey = deriveClassicalKey(password, hexToBytes(enc.kdfSalt), params);
// 2. Derive Kyber keypair & decapsulate
const { secretKey: kyberSK } = deriveKyberKeypair(password, hexToBytes(enc.kemSalt), params);
const quantumKey = ml_kem768.decapsulate(hexToBytes(enc.kyberCiphertext), kyberSK);
// 3. Combined key
const encryptionKey = combineKeys(classicalKey, quantumKey);
// 4. AES-256-GCM decrypt (throws on auth failure = wrong password)
let plaintext;
try {
plaintext = gcm(encryptionKey, hexToBytes(enc.iv)).decrypt(hexToBytes(enc.ciphertext));
} catch {
throw new Error('Decryption failed: incorrect password or corrupted data');
}
// 5. Parse secrets and merge with public data
const secrets = JSON.parse(new TextDecoder().decode(plaintext));
const walletJSON = {};
for (const k of ['version', 'type', 'network', 'spendPublicKey', 'viewPublicKey',
'address', 'carrotAddress', 'syncHeight', 'accounts', 'nextSubaddressIndex']) {
if (envelope[k] !== undefined) walletJSON[k] = envelope[k];
}
Object.assign(walletJSON, secrets);
// Merge CARROT public + secret keys
if (envelope.carrotKeys || secrets.carrotSecrets) {
walletJSON.carrotKeys = {
...(envelope.carrotKeys || {}),
...(secrets.carrotSecrets || {}),
};
delete walletJSON.carrotSecrets;
}
return walletJSON;
}
/**
* Check if a wallet JSON object is encrypted.
* @param {Object} json
* @returns {boolean}
*/
export function isEncryptedWallet(json) {
return json?.encrypted === true && json?.encryption != null;
}
+308 -1
View File
@@ -39,6 +39,20 @@ import { NETWORK, ADDRESS_FORMAT } from './constants.js';
import { getNetworkConfig, HF_VERSION, getHfVersionForHeight, isCarrotActive, NETWORK_ID } from './consensus.js';
import { seedToMnemonic, mnemonicToSeed, validateMnemonic } from './mnemonic.js';
// Storage, sync, and transfer integration
import { MemoryStorage } from './wallet-store.js';
import { createWalletSync } from './wallet-sync.js';
import {
transfer as _transfer,
sweep as _sweep,
stake as _stake,
burn as _burn,
convert as _convert,
} from './wallet/transfer.js';
// Post-quantum wallet encryption
import { encryptWalletJSON, decryptWalletJSON, isEncryptedWallet } from './wallet-encryption.js';
// ============================================================================
// IMPORTS FROM WALLET SUBMODULES
// ============================================================================
@@ -133,6 +147,11 @@ export class Wallet {
// Previous balance (for change detection)
this._previousBalance = new Map(); // Map<assetType, { balance, unlocked }>
// Integrated storage/sync/daemon (lazy-initialized)
this._storage = null; // MemoryStorage instance
this._daemon = null; // DaemonRPC instance
this._walletSync = null; // WalletSync instance
}
// ===========================================================================
@@ -1944,12 +1963,297 @@ export class Wallet {
return data;
}
// ===========================================================================
// INTEGRATED DAEMON / SYNC / TRANSFER API
// ===========================================================================
/**
* Set the daemon RPC connection.
* @param {Object} daemon - DaemonRPC instance
*/
setDaemon(daemon) {
this._daemon = daemon;
this._walletSync = null; // reset so next sync picks up new daemon
}
/** @private Lazy-init MemoryStorage */
_ensureStorage() {
if (!this._storage) this._storage = new MemoryStorage();
return this._storage;
}
/** @private Lazy-init WalletSync with current keys + storage + daemon */
_ensureSync() {
if (!this._daemon) throw new Error('No daemon set. Call setDaemon(daemon) first.');
if (!this.canScan()) throw new Error('View secret key required for sync.');
this._ensureStorage();
if (!this._walletSync) {
this._walletSync = createWalletSync({
storage: this._storage,
daemon: this._daemon,
keys: {
viewSecretKey: this._viewSecretKey,
spendSecretKey: this._spendSecretKey,
viewPublicKey: this._viewPublicKey,
spendPublicKey: this._spendPublicKey,
},
carrotKeys: this._carrotKeys || null,
network: this.network,
});
}
return this._walletSync;
}
/** @private Build adapter object for transfer.js functions */
_walletAdapter() {
this._ensureStorage();
return {
keys: {
viewSecretKey: this._viewSecretKey,
spendSecretKey: this._spendSecretKey,
viewPublicKey: this._viewPublicKey,
spendPublicKey: this._spendPublicKey,
},
storage: this._storage,
carrotKeys: this._carrotKeys || null,
};
}
/** @private Mark spent outputs after a successful transaction */
async _markSpent(result) {
if (result.spentKeyImages && this._storage) {
for (const ki of result.spentKeyImages) {
await this._storage.markOutputSpent(ki, result.txHash);
}
}
}
/**
* Sync wallet with blockchain using WalletSync engine.
* Replaces the naive block-fetching sync with the full scanning pipeline.
*
* @param {Object} [daemon] - DaemonRPC (uses stored daemon if omitted)
* @returns {Promise<{ syncHeight: number }>}
*/
async syncWithDaemon(daemon) {
if (daemon) this.setDaemon(daemon);
const ws = this._ensureSync();
await ws.start();
this._syncHeight = this._storage._syncHeight || this._syncHeight;
return { syncHeight: this._syncHeight };
}
/**
* Get balance from storage-backed outputs.
* @param {Object} [options]
* @param {string} [options.assetType='SAL']
* @returns {Promise<{ balance: bigint, unlockedBalance: bigint, lockedBalance: bigint }>}
*/
async getStorageBalance(options = {}) {
if (!this._storage) return this.getBalance(options);
const allOutputs = await this._storage.getOutputs({ isSpent: false });
let balance = 0n, unlockedBalance = 0n;
for (const o of allOutputs) {
balance += o.amount;
if (o.isSpendable(this._syncHeight)) unlockedBalance += o.amount;
}
return { balance, unlockedBalance, lockedBalance: balance - unlockedBalance };
}
/**
* Transfer SAL to one or more destinations.
* Builds, signs, broadcasts, and marks spent outputs.
*
* @param {Array<{address: string, amount: bigint}>} destinations
* @param {Object} [options] - { priority, dryRun, assetType }
* @returns {Promise<Object>} { txHash, fee, amount, tx, serializedHex, spentKeyImages }
*/
async transfer(destinations, options = {}) {
if (!this.canSign()) throw new Error('Full wallet required');
if (!this._daemon) throw new Error('No daemon set');
this._ensureStorage();
const result = await _transfer({
wallet: this._walletAdapter(),
daemon: this._daemon,
destinations,
options: { network: this.network, ...options },
});
if (!options.dryRun) await this._markSpent(result);
return result;
}
/**
* Sweep all spendable outputs to a single address.
*
* @param {string} address - Destination address
* @param {Object} [options] - { priority, dryRun, assetType }
* @returns {Promise<Object>} { txHash, fee, amount, inputCount, outputCount }
*/
async sweep(address, options = {}) {
if (!this.canSign()) throw new Error('Full wallet required');
if (!this._daemon) throw new Error('No daemon set');
this._ensureStorage();
const result = await _sweep({
wallet: this._walletAdapter(),
daemon: this._daemon,
address,
options: { network: this.network, ...options },
});
if (!options.dryRun) await this._markSpent(result);
return result;
}
/**
* Stake SAL to earn yield.
*
* @param {bigint} amount - Amount to stake
* @param {Object} [options] - { priority, dryRun }
* @returns {Promise<Object>} { txHash, fee, amount }
*/
async stake(amount, options = {}) {
if (!this.canSign()) throw new Error('Full wallet required');
if (!this._daemon) throw new Error('No daemon set');
this._ensureStorage();
const result = await _stake({
wallet: this._walletAdapter(),
daemon: this._daemon,
amount,
options: { network: this.network, ...options },
});
if (!options.dryRun) await this._markSpent(result);
return result;
}
/**
* Burn SAL permanently.
*
* @param {bigint} amount - Amount to burn
* @param {Object} [options] - { priority, dryRun }
* @returns {Promise<Object>} { txHash, fee, amount }
*/
async burn(amount, options = {}) {
if (!this.canSign()) throw new Error('Full wallet required');
if (!this._daemon) throw new Error('No daemon set');
this._ensureStorage();
const result = await _burn({
wallet: this._walletAdapter(),
daemon: this._daemon,
amount,
options: { network: this.network, ...options },
});
if (!options.dryRun) await this._markSpent(result);
return result;
}
/**
* Convert between asset types.
*
* @param {bigint} amount
* @param {string} sourceAssetType
* @param {string} destAssetType
* @param {string} destAddress
* @param {Object} [options]
* @returns {Promise<Object>} { txHash, fee, amount }
*/
async convert(amount, sourceAssetType, destAssetType, destAddress, options = {}) {
if (!this.canSign()) throw new Error('Full wallet required');
if (!this._daemon) throw new Error('No daemon set');
this._ensureStorage();
const result = await _convert({
wallet: this._walletAdapter(),
daemon: this._daemon,
amount,
sourceAssetType,
destAssetType,
destAddress,
options: { network: this.network, ...options },
});
if (!options.dryRun) await this._markSpent(result);
return result;
}
/**
* Load a previously-saved sync cache into internal storage.
* Call before syncWithDaemon() to resume from saved state.
*
* @param {Object|string} data - Storage snapshot (Object or JSON string)
*/
loadSyncCache(data) {
this._ensureStorage();
const obj = typeof data === 'string' ? JSON.parse(data) : data;
this._storage.load(obj);
this._syncHeight = this._storage._syncHeight || 0;
}
/**
* Dump current sync state for persistence.
* @returns {Object} Serializable snapshot
*/
dumpSyncCache() {
return this._storage ? this._storage.dump() : null;
}
/**
* Dump sync state as JSON string.
* @returns {string}
*/
dumpSyncCacheJSON() {
return this._storage ? this._storage.dumpJSON() : '{}';
}
// ===========================================================================
// POST-QUANTUM ENCRYPTION
// ===========================================================================
/**
* Export wallet to encrypted JSON using ML-KEM-768 + Argon2id hybrid encryption.
*
* Public data (addresses, public keys) remain readable without the password.
* Secret keys, seed, and mnemonic are encrypted.
*
* @param {string} password - Encryption password
* @param {Object} [options] - { argon2: { t, m, p } }
* @returns {Object} Encrypted wallet envelope
*/
toEncryptedJSON(password, options = {}) {
return encryptWalletJSON(this.toJSON(true), password, options);
}
/**
* Restore wallet from encrypted JSON.
*
* @param {Object} data - Encrypted wallet envelope
* @param {string} password - Decryption password
* @param {Object} [options] - { network }
* @returns {Wallet}
* @throws {Error} On wrong password or corrupted data
*/
static fromEncryptedJSON(data, password, options = {}) {
const plain = decryptWalletJSON(data, password);
if (options.network) plain.network = options.network;
return Wallet.fromJSON(plain);
}
/**
* Check if wallet JSON is encrypted.
* @param {Object} json
* @returns {boolean}
*/
static isEncrypted(json) {
return isEncryptedWallet(json);
}
// ===========================================================================
// SERIALIZATION
// ===========================================================================
/**
* Import wallet from JSON
* @param {Object} data - Wallet data
* @param {Object} [options] - { network }
* @returns {Wallet} Wallet instance
*/
static fromJSON(data) {
static fromJSON(data, options = {}) {
let wallet;
if (data.seed) {
@@ -1996,6 +2300,9 @@ export class Wallet {
);
}
// Override network if requested
if (options.network) wallet.network = options.network;
return wallet;
}
}
+4 -1
View File
@@ -598,6 +598,8 @@ export async function sweep({ wallet, daemon, address, options = {} }) {
const { keccak256 } = await import('../crypto/index.js');
const txHash = bytesToHex(keccak256(serialized));
const spentKeyImages = spendable.map(o => o.keyImage).filter(Boolean);
return {
txHash,
fee: estimatedFee,
@@ -605,7 +607,8 @@ export async function sweep({ wallet, daemon, address, options = {} }) {
tx,
serializedHex: txHex,
inputCount: preparedInputs.length,
outputCount: tx.prefix.vout.length
outputCount: tx.prefix.vout.length,
spentKeyImages
};
}
+383 -387
View File
File diff suppressed because it is too large Load Diff
+48 -172
View File
@@ -17,36 +17,25 @@
import { setCryptoBackend } from '../src/crypto/index.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, burn, convert } from '../src/wallet/transfer.js';
import { existsSync } from 'node:fs';
import { getHeight, waitForHeight, fmt, loadWalletFromFile } from './test-helpers.js';
// Initialize WASM backend for correct hash-to-point (matches C++ ge_fromfe_frombytes_vartime)
await setCryptoBackend('wasm');
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-a.json`;
const NETWORK = process.env.NETWORK || 'testnet';
const DRY_RUN = process.env.DRY_RUN !== '0';
// Optional sync cache — set to a file path to persist wallet sync state between runs.
// Set SYNC_CACHE=0 to disable. Default: alongside wallet file.
const SYNC_CACHE = process.env.SYNC_CACHE === '0' ? null
: (process.env.SYNC_CACHE || WALLET_FILE.replace(/\.json$/, '-sync.json'));
let daemon;
async function syncWallet(label, keys, cacheFile = null, carrotKeys = null) {
const storage = new MemoryStorage();
// Try loading cached sync state
let cachedHeight = 0;
async function syncAndReport(wallet, label, cacheFile = null) {
// Load sync cache
if (cacheFile && existsSync(cacheFile)) {
try {
const cached = JSON.parse(await Bun.file(cacheFile).text());
storage.load(cached);
cachedHeight = await storage.getSyncHeight();
const cachedHeight = cached.syncHeight || 0;
wallet.loadSyncCache(cached);
console.log(`Syncing ${label}... (resuming from block ${cachedHeight})`);
} catch (e) {
console.log(`Syncing ${label}... (cache unreadable, starting fresh)`);
@@ -55,76 +44,39 @@ async function syncWallet(label, keys, cacheFile = null, carrotKeys = null) {
console.log(`Syncing ${label}...`);
}
const sync = createWalletSync({
daemon,
keys,
carrotKeys,
storage,
network: NETWORK
});
const prevHeight = wallet.getSyncHeight();
await wallet.syncWithDaemon();
await sync.start();
// Save sync state for next run
// Save sync state
if (cacheFile) {
await Bun.write(cacheFile, storage.dumpJSON());
const newHeight = await storage.getSyncHeight();
if (newHeight > cachedHeight) {
console.log(` Synced ${newHeight - cachedHeight} new blocks (saved to ${cacheFile})`);
await Bun.write(cacheFile, wallet.dumpSyncCacheJSON());
const newHeight = wallet.getSyncHeight();
if (newHeight > prevHeight) {
console.log(` Synced ${newHeight - prevHeight} new blocks (saved to ${cacheFile})`);
}
}
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;
// Detect dominant asset type
const assetCounts = {};
for (const o of spendable) {
const a = o.assetType || 'SAL';
assetCounts[a] = (assetCounts[a] || 0) + 1;
}
const detectedAsset = Object.entries(assetCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'SAL';
console.log(` ${allOutputs.length} unspent (${spendable.length} spendable)`);
console.log(` Asset types: ${JSON.stringify(assetCounts)}`);
console.log(` Balance: ${Number(balance) / 1e8} ${detectedAsset} (${Number(spendableBalance) / 1e8} ${detectedAsset} spendable)`);
return { sync, storage, balance, spendableBalance, outputs: allOutputs, spendable };
const { balance, unlockedBalance } = await wallet.getStorageBalance();
console.log(` Balance: ${fmt(balance)} (${fmt(unlockedBalance)} spendable)`);
return { balance, unlockedBalance };
}
async function testTransfer(keys, storage, toAddress, amount, label, carrotKeys = null) {
async function testTransfer(wallet, 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, carrotKeys },
daemon,
destinations: [{ address: toAddress, amount }],
options: { priority: 'default', network: NETWORK, dryRun: DRY_RUN }
});
const result = await wallet.transfer(
[{ address: toAddress, amount }],
{ 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'}`);
// Mark spent outputs so subsequent transfers don't re-use them
if (!DRY_RUN && result.spentKeyImages) {
for (const keyImage of result.spentKeyImages) {
await storage.markOutputSpent(keyImage);
}
}
return result;
} catch (e) {
console.error(` FAILED: ${e.message}`);
@@ -139,7 +91,7 @@ async function main() {
console.log(`Network: ${NETWORK}`);
console.log(`Dry run: ${DRY_RUN}\n`);
daemon = new DaemonRPC({ url: DAEMON_URL });
const daemon = new DaemonRPC({ url: DAEMON_URL });
const info = await daemon.getInfo();
if (!info.success) throw new Error('Cannot reach daemon');
@@ -148,63 +100,46 @@ async function main() {
// 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,
};
const carrotKeysA = walletAJson.carrotKeys || null;
console.log(` Address: ${walletAJson.address}\n`);
const walletA = await loadWalletFromFile(WALLET_FILE, NETWORK);
walletA.setDaemon(daemon);
console.log(` Address: ${walletA.getLegacyAddress()}\n`);
// Create wallet B in memory (not saved)
// Create wallet B in memory (ephemeral)
const walletB = Wallet.create({ network: NETWORK });
const keysB = {
viewSecretKey: walletB.viewSecretKey,
spendSecretKey: walletB.spendSecretKey,
viewPublicKey: walletB.viewPublicKey,
spendPublicKey: walletB.spendPublicKey,
};
const carrotKeysB = walletB.carrotKeys || null;
// At CARROT heights, use CARROT address so outputs are created with CARROT keys
// (K^0_v, K_s) for X25519 ECDH. Legacy addresses use CN keys which can't be scanned
// by the CARROT scanner.
const addressB = walletB.getCarrotAddress() || walletB.getAddress();
walletB.setDaemon(daemon);
const addressB = walletB.getAddress();
console.log(`Wallet B (ephemeral):`);
console.log(` Address: ${addressB}`);
console.log(` Address format: ${addressB.startsWith('SC1') ? 'CARROT' : 'legacy'}`);
console.log(` CARROT keys: ${carrotKeysB ? 'yes' : 'no'}\n`);
console.log(` CARROT keys: ${walletB.carrotKeys ? 'yes' : 'no'}\n`);
// Sync wallet A
const syncA = await syncWallet('Wallet A', keysA, SYNC_CACHE, carrotKeysA);
const syncA = await syncAndReport(walletA, 'Wallet A', SYNC_CACHE);
if (syncA.spendableBalance === 0n) {
if (syncA.unlockedBalance === 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)', carrotKeysA);
const amount1 = 10_000_000_000n;
const result1 = await testTransfer(walletA, addressB, amount1, 'Transfer: 100 SAL (A → B)');
if (!result1) {
console.log('\nFirst transfer failed, stopping.');
return;
}
// Test 2: Many fractional transfers to test input selection and create many UTXOs
// Test 2: Many fractional transfers
console.log('\n=== Many fractional transfers (A → B) ===');
console.log(' (This tests input selection with many small UTXOs)');
const numTransfers = 15;
let successCount = 0;
for (let i = 0; i < numTransfers; i++) {
// Random amounts between 0.5 and 10 SAL to create diverse UTXO sizes
const amount = BigInt(Math.floor(Math.random() * 9_50_000_000 + 50_000_000)); // 0.510 SAL random
const result = await testTransfer(keysA, syncA.storage, addressB, amount, `Fractional transfer ${i + 1}/${numTransfers}`, carrotKeysA);
const amount = BigInt(Math.floor(Math.random() * 9_50_000_000 + 50_000_000));
const result = await testTransfer(walletA, addressB, amount, `Fractional transfer ${i + 1}/${numTransfers}`);
if (result) successCount++;
// If we've had too many failures, stop early
if (i - successCount >= 3) {
console.log(` Too many failures (${i + 1 - successCount}), stopping early`);
break;
@@ -215,17 +150,15 @@ async function main() {
// Verify wallet B received the transfers
console.log('\n=== Verify Wallet B Received Funds ===');
// Wait for transfers to be mined before syncing wallet B
if (!DRY_RUN) {
console.log(' Waiting for transfers to be mined...');
const startHeight = height;
let currentH = startHeight;
const maxWait = 120; // seconds
const maxWait = 120;
const startTime = Date.now();
while (currentH < startHeight + 2 && (Date.now() - startTime) < maxWait * 1000) {
await new Promise(r => setTimeout(r, 5000));
const resp = await daemon.getInfo();
currentH = resp.result?.height || resp.data?.height || currentH;
currentH = await getHeight(daemon);
console.log(` Height: ${currentH} (waiting for ${startHeight + 2})...`);
}
if (currentH >= startHeight + 2) {
@@ -235,29 +168,24 @@ async function main() {
}
}
const syncB = await syncWallet('Wallet B', keysB, null, carrotKeysB); // No cache for ephemeral wallet
const expectedMin = DRY_RUN ? 0n : amount1; // In dry run, no transfers were broadcast
const syncB = await syncAndReport(walletB, 'Wallet B');
const expectedMin = DRY_RUN ? 0n : amount1;
if (DRY_RUN) {
console.log(` (dry run — no transfers broadcast, balance expected to be 0)`);
console.log(` Balance: ${Number(syncB.balance) / 1e8} SAL`);
console.log(` Balance: ${fmt(syncB.balance)}`);
} else if (syncB.balance >= expectedMin) {
console.log(` Wallet B received funds: ${Number(syncB.balance) / 1e8} SAL`);
console.log(` Wallet B received funds: ${fmt(syncB.balance)}`);
} else {
console.log(` Wallet B balance too low: ${Number(syncB.balance) / 1e8} SAL (expected >= ${Number(expectedMin) / 1e8})`);
console.log(` Wallet B balance too low: ${fmt(syncB.balance)} (expected >= ${fmt(expectedMin)})`);
}
// Test 3: Stake 500 SAL
console.log('\n=== Stake Test ===');
const stakeAmt = 500_00_000_000n; // 500 SAL
const stakeAmt = 500_00_000_000n;
console.log(`\n--- Stake: 500 SAL ---`);
console.log(` Lock period: 20 blocks (testnet)`);
try {
const stakeResult = await stake({
wallet: { keys: keysA, storage: syncA.storage, carrotKeys: carrotKeysA },
daemon,
amount: stakeAmt,
options: { priority: 'default', network: NETWORK, dryRun: DRY_RUN }
});
const stakeResult = await walletA.stake(stakeAmt, { priority: 'default', 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`);
@@ -265,13 +193,6 @@ async function main() {
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'}`);
// Mark spent outputs so sweep doesn't re-use them
if (!DRY_RUN && stakeResult.spentKeyImages) {
for (const keyImage of stakeResult.spentKeyImages) {
await syncA.storage.markOutputSpent(keyImage);
}
}
} catch (e) {
console.error(` FAILED: ${e.message}`);
if (e.stack) console.error(e.stack.split('\n').slice(1, 4).join('\n'));
@@ -279,71 +200,26 @@ async function main() {
// Test 4: Burn 1 SAL
console.log('\n=== Burn Test ===');
const burnAmt = 1_00_000_000n; // 1 SAL
const burnAmt = 1_00_000_000n;
console.log(`\n--- Burn: 1 SAL ---`);
try {
const burnResult = await burn({
wallet: { keys: keysA, storage: syncA.storage, carrotKeys: carrotKeysA },
daemon,
amount: burnAmt,
options: { priority: 'default', network: NETWORK, dryRun: DRY_RUN }
});
const burnResult = await walletA.burn(burnAmt, { priority: 'default', dryRun: DRY_RUN });
console.log(` TX Hash: ${burnResult.txHash}`);
console.log(` Fee: ${Number(burnResult.fee) / 1e8} SAL`);
console.log(` Burned: ${Number(burnResult.burnAmount) / 1e8} SAL`);
console.log(` Inputs: ${burnResult.inputCount}, Outputs: ${burnResult.outputCount}`);
console.log(` Serialized: ${burnResult.serializedHex.length / 2} bytes`);
console.log(` ${DRY_RUN ? '(dry run — not broadcast)' : 'BROADCAST OK'}`);
// Mark spent outputs
if (!DRY_RUN && burnResult.spentKeyImages) {
for (const keyImage of burnResult.spentKeyImages) {
await syncA.storage.markOutputSpent(keyImage);
}
}
} catch (e) {
console.error(` FAILED: ${e.message}`);
if (e.stack) console.error(e.stack.split('\n').slice(1, 4).join('\n'));
}
// Test 5: Convert (commented out - requires another asset type to exist on testnet)
// console.log('\n=== Convert Test ===');
// const convertAmt = 10_00_000_000n; // 10 SAL
// console.log(`\n--- Convert: 10 SAL → OTHER_ASSET ---`);
// try {
// const convertResult = await convert({
// wallet: { keys: keysA, storage: syncA.storage },
// daemon,
// amount: convertAmt,
// sourceAssetType: 'SAL',
// destAssetType: 'OTHER_ASSET', // Replace with actual asset type
// destAddress: walletAJson.address,
// options: { priority: 'default', network: NETWORK, dryRun: DRY_RUN }
// });
// console.log(` TX Hash: ${convertResult.txHash}`);
// console.log(` Fee: ${Number(convertResult.fee) / 1e8} SAL`);
// console.log(` Converted: ${Number(convertResult.convertAmount) / 1e8} ${convertResult.sourceAssetType} → ${convertResult.destAssetType}`);
// console.log(` ${DRY_RUN ? '(dry run — not broadcast)' : 'BROADCAST OK'}`);
//
// if (!DRY_RUN && convertResult.spentKeyImages) {
// for (const keyImage of convertResult.spentKeyImages) {
// await syncA.storage.markOutputSpent(keyImage);
// }
// }
// } catch (e) {
// console.error(` FAILED: ${e.message}`);
// }
// Test 6: Sweep all remaining to self (wallet A) - recombines fractional UTXOs
// Test 5: Sweep all remaining to self (wallet A)
console.log('\n=== Sweep Test (A → A) ===');
console.log(' (Recombines fractional UTXOs from previous transfers)');
try {
const sweepResult = await sweep({
wallet: { keys: keysA, storage: syncA.storage, carrotKeys: carrotKeysA },
daemon,
address: walletAJson.address,
options: { priority: 'default', network: NETWORK, dryRun: DRY_RUN }
});
const sweepResult = await walletA.sweep(walletA.getAddress(), { priority: 'default', dryRun: DRY_RUN });
console.log(` TX Hash: ${sweepResult.txHash}`);
console.log(` Fee: ${Number(sweepResult.fee) / 1e8} SAL`);
console.log(` Amount: ${Number(sweepResult.amount) / 1e8} SAL`);
+68 -178
View File
@@ -17,11 +17,8 @@
import { setCryptoBackend } from '../src/crypto/index.js';
import { DaemonRPC } from '../src/rpc/daemon.js';
import { createWalletSync } from '../src/wallet-sync.js';
import { MemoryStorage } from '../src/wallet-store.js';
import { transfer, sweep } from '../src/wallet/transfer.js';
import { bytesToHex } from '../src/address.js';
import { existsSync } from 'node:fs';
import { getHeight, waitForHeight, fmt, short, loadWalletFromFile } from './test-helpers.js';
await setCryptoBackend('wasm');
@@ -32,7 +29,6 @@ await setCryptoBackend('wasm');
const DAEMON_URL = process.env.DAEMON_URL || 'http://web.whiskymine.io:29081';
const NETWORK = 'testnet';
const SPENDABLE_AGE = 10;
const CARROT_FORK_HEIGHT = 1100;
const WALLET_A_FILE = process.env.WALLET_A || `${process.env.HOME}/testnet-wallet/wallet-a.json`;
const WALLET_B_FILE = process.env.WALLET_B || `${process.env.HOME}/testnet-wallet/wallet-b.json`;
@@ -59,61 +55,8 @@ const PHASE = getArg('phase', 'all');
const daemon = new DaemonRPC({ url: DAEMON_URL });
function fmt(atomic) {
return `${(Number(atomic) / 1e8).toFixed(8)} SAL`;
}
function short(addr) {
return addr ? addr.slice(0, 20) + '...' : 'N/A';
}
async function getHeight() {
const info = await daemon.getInfo();
return info.result?.height || info.data?.height || 0;
}
async function waitForHeight(target, label = '') {
let h = await getHeight();
if (h >= target) return h;
const tag = label ? ` [${label}]` : '';
process.stdout.write(` Waiting for height ${target}${tag}... (at ${h})`);
while (h < target) {
await new Promise(r => setTimeout(r, 3000));
h = await getHeight();
process.stdout.write(`\r Waiting for height ${target}${tag}... (at ${h}) `);
}
process.stdout.write('\n');
return h;
}
function toHex(val) {
if (typeof val === 'string') return val;
if (val instanceof Uint8Array) return bytesToHex(val);
if (val && typeof val === 'object' && '0' in val) {
const len = Object.keys(val).length;
const arr = new Uint8Array(len);
for (let i = 0; i < len; i++) arr[i] = val[i];
return bytesToHex(arr);
}
return val;
}
function loadWalletKeys(data) {
return {
keys: {
viewSecretKey: toHex(data.viewSecretKey),
spendSecretKey: toHex(data.spendSecretKey),
viewPublicKey: toHex(data.viewPublicKey),
spendPublicKey: toHex(data.spendPublicKey),
},
carrotKeys: data.carrotKeys || null,
address: data.address,
carrotAddress: data.carrotAddress || null,
};
}
async function syncWallet(label, keys, storage, cacheFile, carrotKeys) {
const currentHeight = await getHeight();
async function syncAndReport(wallet, label, cacheFile = null) {
const currentHeight = await getHeight(daemon);
if (cacheFile && existsSync(cacheFile)) {
try {
@@ -122,32 +65,20 @@ async function syncWallet(label, keys, storage, cacheFile, carrotKeys) {
if (cachedSyncHeight > currentHeight) {
console.log(` ${label}: Cache stale (cached=${cachedSyncHeight}, chain=${currentHeight}), resetting`);
} else {
storage.load(cached);
wallet.loadSyncCache(cached);
}
} catch { /* ignore bad cache */ }
}
const sync = createWalletSync({ daemon, keys, carrotKeys, storage, network: NETWORK });
await sync.start();
await wallet.syncWithDaemon();
if (cacheFile) {
await Bun.write(cacheFile, storage.dumpJSON());
await Bun.write(cacheFile, wallet.dumpSyncCacheJSON());
}
const allOutputs = await storage.getOutputs({ isSpent: false });
const spendable = allOutputs.filter(o => o.isSpendable(currentHeight));
let balance = 0n, spendableBalance = 0n;
for (const o of allOutputs) balance += o.amount;
for (const o of spendable) spendableBalance += o.amount;
const assetCounts = {};
for (const o of spendable) {
const a = o.assetType || 'SAL';
assetCounts[a] = (assetCounts[a] || 0) + 1;
}
console.log(` ${label}: ${allOutputs.length} outputs, ${spendable.length} spendable, balance=${fmt(balance)}, spendable=${fmt(spendableBalance)} ${JSON.stringify(assetCounts)}`);
return { storage, balance, spendableBalance, spendable, allOutputs };
const { balance, unlockedBalance } = await wallet.getStorageBalance();
console.log(` ${label}: balance=${fmt(balance)}, spendable=${fmt(unlockedBalance)}`);
return { balance, unlockedBalance };
}
// =============================================================================
@@ -155,10 +86,9 @@ async function syncWallet(label, keys, storage, cacheFile, carrotKeys) {
// =============================================================================
async function phaseSend(walletA, walletB) {
const h = await getHeight();
const useCarrot = h >= CARROT_FORK_HEIGHT;
const addrB = useCarrot ? (walletB.carrotAddress || walletB.address) : walletB.address;
const era = useCarrot ? 'CARROT' : 'CN';
const h = await getHeight(daemon);
const addrB = walletB.getAddress();
const era = walletA.isCarrotEnabled() ? 'CARROT' : 'CN';
console.log(`\n${'='.repeat(72)}`);
console.log(` PHASE: SEND ${MICRO_COUNT} MICRO TRANSFERS A -> B (${era})`);
@@ -167,45 +97,36 @@ async function phaseSend(walletA, walletB) {
console.log(` Amount range: 0.10 - 0.90 SAL each`);
console.log(` Expected total: ~${(MICRO_COUNT * 0.5).toFixed(0)} SAL\n`);
const storageA = new MemoryStorage();
let syncA = await syncWallet('A', walletA.keys, storageA, SYNC_CACHE_A, walletA.carrotKeys);
await syncAndReport(walletA, 'A', SYNC_CACHE_A);
let sent = 0, failed = 0, consecutiveFails = 0;
let totalSent = 0n, totalFees = 0n;
const batchSize = 50; // re-sync every 50 to pick up change outputs
const batchSize = 50;
for (let batch = 0; batch * batchSize < MICRO_COUNT; batch++) {
const batchStart = batch * batchSize;
const batchEnd = Math.min(batchStart + batchSize, MICRO_COUNT);
if (batch > 0) {
// Wait for change outputs to mature
const bh = await getHeight();
await waitForHeight(bh + SPENDABLE_AGE + 2, `batch ${batch + 1} maturity`);
syncA = await syncWallet('A', walletA.keys, storageA, SYNC_CACHE_A, walletA.carrotKeys);
const bh = await getHeight(daemon);
await waitForHeight(daemon, bh + SPENDABLE_AGE + 2, `batch ${batch + 1} maturity`);
await syncAndReport(walletA, 'A', SYNC_CACHE_A);
}
for (let i = batchStart; i < batchEnd; i++) {
// Random 0.10 to 0.90 SAL
const amount = BigInt(Math.floor(Math.random() * 80_000_000) + 10_000_000);
try {
const result = await transfer({
wallet: { keys: walletA.keys, storage: storageA, carrotKeys: walletA.carrotKeys },
daemon,
destinations: [{ address: addrB, amount }],
options: { priority: 'default', network: NETWORK }
});
const result = await walletA.transfer(
[{ address: addrB, amount }],
{ priority: 'default' }
);
sent++;
consecutiveFails = 0;
totalSent += amount;
totalFees += result.fee;
if (result.spentKeyImages) {
for (const ki of result.spentKeyImages) await storageA.markOutputSpent(ki);
}
if ((sent + failed) % 10 === 0 || i === batchEnd - 1) {
console.log(` ${sent + failed}/${MICRO_COUNT} sent (${sent} ok, ${failed} failed) | total: ${fmt(totalSent)} | fees: ${fmt(totalFees)}`);
}
@@ -232,7 +153,6 @@ async function phaseSend(walletA, walletB) {
console.log(` Total transferred: ${fmt(totalSent)}`);
console.log(` Total fees: ${fmt(totalFees)}`);
// Save progress
await Bun.write(LOG_FILE, JSON.stringify({
phase: 'send',
timestamp: new Date().toISOString(),
@@ -250,52 +170,35 @@ async function phaseSend(walletA, walletB) {
// =============================================================================
async function phaseSpend(walletA, walletB) {
const h = await getHeight();
const useCarrot = h >= CARROT_FORK_HEIGHT;
const addrA = useCarrot ? (walletA.carrotAddress || walletA.address) : walletA.address;
const addrB = useCarrot ? (walletB.carrotAddress || walletB.address) : walletB.address;
const era = useCarrot ? 'CARROT' : 'CN';
const h = await getHeight(daemon);
const addrA = walletA.getAddress();
const addrB = walletB.getAddress();
const era = walletA.isCarrotEnabled() ? 'CARROT' : 'CN';
console.log(`\n${'='.repeat(72)}`);
console.log(` PHASE: SPEND B -> A (multi-input UTXO assembly, ${era})`);
console.log(`${'='.repeat(72)}`);
// Wait for all micro outputs to mature
await waitForHeight(h + SPENDABLE_AGE + 2, 'micro output maturity');
await waitForHeight(daemon, h + SPENDABLE_AGE + 2, 'micro output maturity');
const storageB = new MemoryStorage();
let syncB = await syncWallet('B', walletB.keys, storageB, SYNC_CACHE_B, walletB.carrotKeys);
const syncB = await syncAndReport(walletB, 'B', SYNC_CACHE_B);
console.log(`\n B has spendable balance: ${fmt(syncB.unlockedBalance)}`);
console.log(`\n B has ${syncB.spendable.length} spendable outputs totaling ${fmt(syncB.spendableBalance)}`);
if (syncB.spendable.length === 0) {
if (syncB.unlockedBalance === 0n) {
console.log(' ERROR: B has no spendable outputs. Run --phase send first.');
return { sent: 0, failed: 0 };
}
// Strategy: try progressively larger transfers to force multi-input assembly
// Start small (1 SAL), then increase to force combining many tiny UTXOs
const testAmounts = [];
for (let i = 0; i < 10; i++) testAmounts.push(100_000_000n); // 10x 1 SAL
for (let i = 0; i < 10; i++) testAmounts.push(200_000_000n); // 10x 2 SAL
for (let i = 0; i < 5; i++) testAmounts.push(500_000_000n); // 5x 5 SAL
for (let i = 0; i < 5; i++) testAmounts.push(1_000_000_000n); // 5x 10 SAL
for (let i = 0; i < 3; i++) testAmounts.push(2_000_000_000n); // 3x 20 SAL
for (let i = 0; i < 2; i++) testAmounts.push(5_000_000_000n); // 2x 50 SAL
// 10 transfers of 1 SAL (should need 2-10 inputs each)
for (let i = 0; i < 10; i++) testAmounts.push(100_000_000n);
// 10 transfers of 2 SAL (should need 4-20 inputs each)
for (let i = 0; i < 10; i++) testAmounts.push(200_000_000n);
// 5 transfers of 5 SAL (should need 10-50 inputs each)
for (let i = 0; i < 5; i++) testAmounts.push(500_000_000n);
// 5 transfers of 10 SAL (should need 20-100 inputs each)
for (let i = 0; i < 5; i++) testAmounts.push(1_000_000_000n);
// 3 transfers of 20 SAL (heavy multi-input)
for (let i = 0; i < 3; i++) testAmounts.push(2_000_000_000n);
// 2 transfers of 50 SAL
for (let i = 0; i < 2; i++) testAmounts.push(5_000_000_000n);
// 1 sweep to clean up everything
console.log(`\n Will attempt ${testAmounts.length} transfers of increasing size, then 1 sweep`);
console.log(` Amounts: 10x1 SAL, 10x2 SAL, 5x5 SAL, 5x10 SAL, 3x20 SAL, 2x50 SAL`);
@@ -305,35 +208,24 @@ async function phaseSpend(walletA, walletB) {
for (let i = 0; i < testAmounts.length; i++) {
const amount = testAmounts[i];
// Check if we still have enough balance
const currentOutputs = await storageB.getOutputs({ isSpent: false });
const currentH = await getHeight();
const currentSpendable = currentOutputs.filter(o => o.isSpendable(currentH));
let currentBalance = 0n;
for (const o of currentSpendable) currentBalance += o.amount;
if (currentBalance < amount + 50_000_000n) { // need amount + ~fee buffer
console.log(` Skipping ${fmt(amount)}: insufficient balance (${fmt(currentBalance)} available)`);
// Check balance via storage
const { unlockedBalance } = await walletB.getStorageBalance();
if (unlockedBalance < amount + 50_000_000n) {
console.log(` Skipping ${fmt(amount)}: insufficient balance (${fmt(unlockedBalance)} available)`);
continue;
}
try {
const result = await transfer({
wallet: { keys: walletB.keys, storage: storageB, carrotKeys: walletB.carrotKeys },
daemon,
destinations: [{ address: addrA, amount }],
options: { priority: 'default', network: NETWORK }
});
const result = await walletB.transfer(
[{ address: addrA, amount }],
{ priority: 'default' }
);
sent++;
consecutiveFails = 0;
totalSent += amount;
totalFees += result.fee;
if (result.spentKeyImages) {
for (const ki of result.spentKeyImages) await storageB.markOutputSpent(ki);
}
console.log(` [${sent + failed}/${testAmounts.length}] ${fmt(amount)} OK (${result.inputCount} inputs, fee=${fmt(result.fee)})`);
} catch (e) {
failed++;
@@ -349,19 +241,15 @@ async function phaseSpend(walletA, walletB) {
// Final sweep B -> B to consolidate remaining UTXOs
console.log(`\n Attempting final sweep B -> B...`);
const sweepH = await getHeight();
await waitForHeight(sweepH + 3, 'pre-sweep settle');
syncB = await syncWallet('B (pre-sweep)', walletB.keys, storageB, SYNC_CACHE_B, walletB.carrotKeys);
const sweepH = await getHeight(daemon);
await waitForHeight(daemon, sweepH + 3, 'pre-sweep settle');
await syncAndReport(walletB, 'B (pre-sweep)', SYNC_CACHE_B);
if (syncB.spendable.length > 0) {
const { unlockedBalance: preSweepBal } = await walletB.getStorageBalance();
if (preSweepBal > 0n) {
try {
const result = await sweep({
wallet: { keys: walletB.keys, storage: storageB, carrotKeys: walletB.carrotKeys },
daemon,
address: addrB,
options: { priority: 'default', network: NETWORK }
});
console.log(` Sweep OK: consolidated ${syncB.spendable.length} outputs, fee=${fmt(result.fee)}`);
const result = await walletB.sweep(addrB, { priority: 'default' });
console.log(` Sweep OK: fee=${fmt(result.fee)}`);
totalFees += result.fee;
} catch (e) {
console.log(` Sweep FAILED: ${e.message}`);
@@ -374,21 +262,18 @@ async function phaseSpend(walletA, walletB) {
console.log(` Total fees: ${fmt(totalFees)}`);
// Final balances
const fh = await getHeight();
await waitForHeight(fh + SPENDABLE_AGE + 2, 'final settle');
const storageA = new MemoryStorage();
const syncA = await syncWallet('A (final)', walletA.keys, storageA, SYNC_CACHE_A, walletA.carrotKeys);
syncB = await syncWallet('B (final)', walletB.keys, storageB, SYNC_CACHE_B, walletB.carrotKeys);
const fh = await getHeight(daemon);
await waitForHeight(daemon, fh + SPENDABLE_AGE + 2, 'final settle');
await syncAndReport(walletA, 'A (final)', SYNC_CACHE_A);
const finalB = await syncAndReport(walletB, 'B (final)', SYNC_CACHE_B);
// Save log
await Bun.write(LOG_FILE, JSON.stringify({
phase: 'spend',
timestamp: new Date().toISOString(),
sent, failed,
totalSent: totalSent.toString(),
totalFees: totalFees.toString(),
walletABalance: syncA.balance.toString(),
walletBBalance: syncB.balance.toString(),
walletBBalance: finalB.balance.toString(),
}, null, 2));
return { sent, failed, totalSent, totalFees };
@@ -404,19 +289,24 @@ async function main() {
console.log('| MICRO-TRANSFER STRESS TEST |');
console.log('+----------------------------------------------------------------------+');
const h = await getHeight();
const h = await getHeight(daemon);
const walletA = await loadWalletFromFile(WALLET_A_FILE, NETWORK);
const walletB = await loadWalletFromFile(WALLET_B_FILE, NETWORK);
walletA.setDaemon(daemon);
walletB.setDaemon(daemon);
// Set sync height for address selection
walletA._syncHeight = h;
walletB._syncHeight = h;
console.log(` Daemon: ${DAEMON_URL}`);
console.log(` Network: ${NETWORK}`);
console.log(` Height: ${h}`);
console.log(` Phase: ${PHASE}`);
console.log(` Micro count: ${MICRO_COUNT}`);
console.log(` Era: ${h >= CARROT_FORK_HEIGHT ? 'CARROT' : 'CN'}`);
const walletA = loadWalletKeys(JSON.parse(await Bun.file(WALLET_A_FILE).text()));
const walletB = loadWalletKeys(JSON.parse(await Bun.file(WALLET_B_FILE).text()));
console.log(` A addr: ${short(h >= CARROT_FORK_HEIGHT ? walletA.carrotAddress : walletA.address)}`);
console.log(` B addr: ${short(h >= CARROT_FORK_HEIGHT ? walletB.carrotAddress : walletB.address)}`);
console.log(` Era: ${walletA.isCarrotEnabled() ? 'CARROT' : 'CN'}`);
console.log(` A addr: ${short(walletA.getAddress())}`);
console.log(` B addr: ${short(walletB.getAddress())}`);
if (PHASE === 'all' || PHASE === 'send') {
await phaseSend(walletA, walletB);
+26 -35
View File
@@ -8,23 +8,18 @@
import { setCryptoBackend } from '../src/crypto/index.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 { sweep } from '../src/wallet/transfer.js';
import { getHfVersionForHeight } from '../src/consensus.js';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { existsSync } from 'fs';
import { getHeight, fmt, loadWalletFromFile } from './test-helpers.js';
await setCryptoBackend('wasm');
const daemon = new DaemonRPC({ url: 'http://web.whiskymine.io:29081' });
const DRY_RUN = process.env.DRY_RUN !== '0';
const info = await daemon.getInfo();
const h = info.result?.height;
const h = await getHeight(daemon);
const hfVersion = getHfVersionForHeight(h, 1);
const assetType = hfVersion >= 6 ? 'SAL1' : 'SAL';
const useCarrot = hfVersion >= 10;
console.log('=== Sweep Consolidation Test: B → B ===');
console.log(`Height: ${h}, HF: ${hfVersion}, Asset: ${assetType}`);
@@ -33,20 +28,25 @@ console.log(`Dry Run: ${DRY_RUN}\n`);
const pathB = process.env.HOME + '/testnet-wallet/wallet-b-new.json';
const cacheB = pathB.replace('.json', '-sync.json');
const wjB = JSON.parse(readFileSync(pathB));
const walletB = Wallet.fromJSON({ ...wjB, network: 'testnet' });
const addrB = useCarrot ? walletB.getCarrotAddress() : walletB.getLegacyAddress();
const wallet = await loadWalletFromFile(pathB, 'testnet');
wallet.setDaemon(daemon);
const addrB = wallet.getAddress();
// Sync wallet B
const storageB = new MemoryStorage();
if (existsSync(cacheB)) storageB.load(JSON.parse(readFileSync(cacheB)));
// Load sync cache
if (existsSync(cacheB)) {
try {
wallet.loadSyncCache(JSON.parse(await Bun.file(cacheB).text()));
} catch { /* ignore bad cache */ }
}
// Sync
console.log('Syncing Wallet B...');
const syncB = createWalletSync({ daemon, keys: wjB, storage: storageB, network: 'testnet', carrotKeys: walletB.carrotKeys });
await syncB.start();
writeFileSync(cacheB, JSON.stringify(storageB.dump()));
await wallet.syncWithDaemon();
await Bun.write(cacheB, wallet.dumpSyncCacheJSON());
const outputs = await storageB.getOutputs({ isSpent: false });
// Report output status using storage internals for detailed breakdown
const storage = wallet._storage;
const outputs = await storage.getOutputs({ isSpent: false });
const unlocked = outputs.filter(o => o.blockHeight <= h - 60 && o.assetType === assetType);
const locked = outputs.filter(o => o.blockHeight > h - 60 || o.assetType !== assetType);
const totalBal = unlocked.reduce((s, o) => s + BigInt(o.amount), 0n);
@@ -69,20 +69,13 @@ if (unlocked.length < 2) {
console.log(`\nSweeping ${unlocked.length} outputs to self (${addrB.slice(0,30)}...)...\n`);
try {
const result = await sweep({
wallet: { keys: wjB, storage: storageB },
daemon,
address: addrB,
options: {
priority: 'default',
network: 'testnet',
dryRun: DRY_RUN,
assetType,
useCarrot
}
const result = await wallet.sweep(addrB, {
priority: 'default',
dryRun: DRY_RUN,
assetType,
});
console.log('SWEEP SUCCESS!');
console.log('SWEEP SUCCESS!');
console.log(` TX Hash: ${result.txHash}`);
console.log(` Inputs consolidated: ${result.inputCount}`);
console.log(` Output count: ${result.outputCount}`);
@@ -91,16 +84,14 @@ try {
console.log(` TX Size: ${result.serializedHex.length / 2} bytes`);
if (!DRY_RUN) {
for (const ki of result.spentKeyImages || []) {
await storageB.markOutputSpent(ki);
}
writeFileSync(cacheB, JSON.stringify(storageB.dump()));
// Wallet.sweep already marks spent outputs
await Bun.write(cacheB, wallet.dumpSyncCacheJSON());
console.log('\n Wallet cache updated.');
} else {
console.log('\n [DRY RUN - not broadcast]');
}
} catch (e) {
console.log('SWEEP FAILED:', e.message);
console.log('SWEEP FAILED:', e.message);
if (e.stack) console.log(e.stack.split('\n').slice(1, 6).join('\n'));
}
+20 -61
View File
@@ -6,100 +6,59 @@
import { setCryptoBackend } from '../src/crypto/index.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 { sweep } from '../src/wallet/transfer.js';
import { bytesToHex } from '../src/address.js';
import { existsSync } from 'node:fs';
import { getHeight, fmt, loadWalletFromFile } from './test-helpers.js';
await setCryptoBackend('wasm');
const DAEMON_URL = 'http://web.whiskymine.io:29081';
const WALLET_B_PATH = process.env.HOME + '/testnet-wallet/wallet-b.json';
const SYNC_CACHE_B = WALLET_B_PATH.replace(/\.json$/, '-sync.json');
const NETWORK = 'testnet';
function toHex(val) {
if (typeof val === 'string') return val;
if (val instanceof Uint8Array) return bytesToHex(val);
if (val && typeof val === 'object' && '0' in val) {
const len = Object.keys(val).length;
const arr = new Uint8Array(len);
for (let i = 0; i < len; i++) arr[i] = val[i];
return bytesToHex(arr);
}
return val;
}
function loadWalletKeys(data) {
return {
keys: {
viewSecretKey: toHex(data.viewSecretKey),
spendSecretKey: toHex(data.spendSecretKey),
viewPublicKey: toHex(data.viewPublicKey),
spendPublicKey: toHex(data.spendPublicKey),
},
carrotKeys: data.carrotKeys || null,
address: data.address,
carrotAddress: data.carrotAddress || null,
};
}
async function main() {
const daemon = new DaemonRPC({ url: DAEMON_URL });
const info = await daemon.getInfo();
const height = info.result?.height;
const height = await getHeight(daemon);
console.log(`Height: ${height}`);
// Load wallet B
const bData = JSON.parse(await Bun.file(WALLET_B_PATH).text());
const bWallet = loadWalletKeys(bData);
const bAddr = bWallet.address;
console.log(`B CN addr: ${bAddr.slice(0, 20)}...`);
const wallet = await loadWalletFromFile(WALLET_B_PATH, 'testnet');
wallet.setDaemon(daemon);
console.log(`B CN addr: ${wallet.getLegacyAddress().slice(0, 20)}...`);
// Sync wallet B
const storageB = new MemoryStorage();
// Load sync cache if exists
if (existsSync(SYNC_CACHE_B)) {
try {
const cached = JSON.parse(await Bun.file(SYNC_CACHE_B).text());
const cachedSyncHeight = cached.syncHeight || 0;
if (cachedSyncHeight > height) {
console.log(` Cache stale (cached=${cachedSyncHeight}, chain=${height}), resetting`);
if ((cached.syncHeight || 0) > height) {
console.log(` Cache stale (cached=${cached.syncHeight}, chain=${height}), resetting`);
} else {
storageB.load(cached);
wallet.loadSyncCache(cached);
}
} catch { /* ignore bad cache */ }
}
// Sync
console.log('Syncing wallet B...');
const syncB = createWalletSync({ daemon, keys: bWallet.keys, carrotKeys: bWallet.carrotKeys, storage: storageB, network: NETWORK });
await syncB.start();
await Bun.write(SYNC_CACHE_B, storageB.dumpJSON());
await wallet.syncWithDaemon();
await Bun.write(SYNC_CACHE_B, wallet.dumpSyncCacheJSON());
const allOutputs = await storageB.getOutputs({ isSpent: false });
const spendable = allOutputs.filter(o => o.isSpendable(height));
const balance = spendable.reduce((s, o) => s + o.amount, 0n);
console.log(`B: ${spendable.length} spendable outputs, balance=${(Number(balance) / 1e8).toFixed(8)} SAL`);
const { balance, unlockedBalance } = await wallet.getStorageBalance();
console.log(`B: balance=${fmt(balance)}, unlocked=${fmt(unlockedBalance)}`);
if (spendable.length === 0) {
if (unlockedBalance === 0n) {
console.log('No spendable outputs — skipping sweep');
return;
}
// Sweep B -> B (self)
console.log(`Sweeping B -> B (${bAddr.slice(0, 20)}...)`);
const addr = wallet.getAddress();
console.log(`Sweeping B -> B (${addr.slice(0, 20)}...)`);
try {
const result = await sweep({
wallet: { keys: bWallet.keys, storage: storageB, carrotKeys: bWallet.carrotKeys },
daemon,
address: bAddr,
options: { priority: 'default', network: NETWORK }
});
const result = await wallet.sweep(addr);
console.log(`Sweep OK: ${result.txHash}`);
console.log(` fee: ${(Number(result.fee) / 1e8).toFixed(8)} SAL`);
console.log(` amount: ${(Number(result.amount) / 1e8).toFixed(8)} SAL`);
console.log(` fee: ${fmt(result.fee)}`);
console.log(` amount: ${fmt(result.amount)}`);
console.log(` inputs: ${result.inputCount}, outputs: ${result.outputCount}`);
} catch (err) {
console.error(`Sweep FAILED: ${err.message}`);
+14 -29
View File
@@ -3,10 +3,10 @@
* Sync-only test: resync wallet A with memory monitoring.
* Used to verify memory optimizations before running full integration tests.
*/
import { setCryptoBackend } from '../src/crypto/index.js';
import { setCryptoBackend, commit } from '../src/crypto/index.js';
import { DaemonRPC } from '../src/rpc/daemon.js';
import { createWalletSync } from '../src/wallet-sync.js';
import { MemoryStorage } from '../src/wallet-store.js';
import { hexToBytes, bytesToHex } from '../src/address.js';
import { loadWalletFromFile } from './test-helpers.js';
await setCryptoBackend('wasm');
@@ -19,22 +19,17 @@ const info = await daemon.getInfo();
if (!info.success) throw new Error('Cannot reach daemon');
console.log(`Daemon height: ${info.result.height}`);
// Load wallet keys
const walletJson = JSON.parse(await Bun.file(WALLET_FILE).text());
const keys = {
viewSecretKey: walletJson.viewSecretKey,
spendSecretKey: walletJson.spendSecretKey,
viewPublicKey: walletJson.viewPublicKey,
spendPublicKey: walletJson.spendPublicKey,
};
const carrotKeys = walletJson.carrotKeys || null;
// Load wallet
const wallet = await loadWalletFromFile(WALLET_FILE, 'testnet');
wallet.setDaemon(daemon);
const storage = new MemoryStorage();
const sync = createWalletSync({ daemon, keys, carrotKeys, storage, network: 'testnet' });
// Access internals for memory monitoring (test-only)
const storage = wallet._ensureStorage();
const ws = wallet._ensureSync();
// Monitor memory every 5000 blocks
let lastLog = 0;
sync.on('syncProgress', (progress) => {
ws.on('syncProgress', (progress) => {
const h = progress.currentHeight;
if (h - lastLog >= 5000 || h === progress.targetHeight) {
const rss = process.memoryUsage?.() || {};
@@ -45,10 +40,10 @@ sync.on('syncProgress', (progress) => {
console.log('Starting sync from block 0...');
const t0 = Date.now();
await sync.start();
await wallet.syncWithDaemon();
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
const syncHeight = await storage.getSyncHeight();
const syncHeight = wallet.getSyncHeight();
console.log(`\nSync complete: ${syncHeight} blocks in ${elapsed}s`);
console.log(`Outputs: ${storage._outputs.size}`);
console.log(`Transactions: ${storage._transactions.size}`);
@@ -56,7 +51,7 @@ console.log(`Block hashes: ${storage._blockHashes.size}`);
// Save cache
console.log('Saving cache...');
await Bun.write(CACHE_FILE, storage.dumpJSON());
await Bun.write(CACHE_FILE, wallet.dumpSyncCacheJSON());
console.log(`Saved to ${CACHE_FILE}`);
// Verify some outputs
@@ -66,17 +61,7 @@ const withCommitment = carrotOutputs.filter(o => o.commitment);
console.log(`\nUnspent outputs: ${allOutputs.length}`);
console.log(` CARROT: ${carrotOutputs.length} (${withCommitment.length} with commitment)`);
// Check commitment validity on first few CARROT outputs
import { commit } from '../src/crypto/index.js';
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
return bytes;
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
// Check commitment validity on CARROT outputs
let matchCount = 0, mismatchCount = 0;
for (const o of carrotOutputs) {
if (!o.mask || !o.commitment) continue;
+66
View File
@@ -0,0 +1,66 @@
/**
* Shared test helpers for Salvium-JS integration tests.
*
* Extracts common utilities used across sweep, sync, stress, and burn-in tests.
*/
import { Wallet } from '../src/wallet.js';
/**
* Get current blockchain height from daemon.
* @param {Object} daemon - DaemonRPC instance
* @returns {Promise<number>}
*/
export async function getHeight(daemon) {
const info = await daemon.getInfo();
return info.result?.height || info.data?.height || 0;
}
/**
* Poll daemon until target height is reached.
* @param {Object} daemon - DaemonRPC instance
* @param {number} target - Target block height
* @param {string} [label] - Label for progress display
* @returns {Promise<number>} Final height
*/
export async function waitForHeight(daemon, target, label = '') {
let h = await getHeight(daemon);
if (h >= target) return h;
const tag = label ? ` [${label}]` : '';
process.stdout.write(` Waiting for height ${target}${tag}... (at ${h})`);
while (h < target) {
await new Promise(r => setTimeout(r, 3000));
h = await getHeight(daemon);
process.stdout.write(`\r Waiting for height ${target}${tag}... (at ${h}) `);
}
process.stdout.write('\n');
return h;
}
/**
* Format atomic units as "X.XXXXXXXX SAL".
* @param {bigint|number} atomic
* @returns {string}
*/
export function fmt(atomic) {
return `${(Number(atomic) / 1e8).toFixed(8)} SAL`;
}
/**
* Truncate an address for display.
* @param {string} addr
* @returns {string}
*/
export function short(addr) {
return addr ? addr.slice(0, 20) + '...' : 'N/A';
}
/**
* Load a wallet from a JSON file on disk.
* @param {string} path - Absolute path to wallet JSON file
* @param {string} [network='testnet'] - Network override
* @returns {Promise<Wallet>}
*/
export async function loadWalletFromFile(path, network = 'testnet') {
const data = JSON.parse(await Bun.file(path).text());
return Wallet.fromJSON(data, { network });
}
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bun
/**
* Post-Quantum Wallet Encryption Tests
*
* Tests ML-KEM-768 + Argon2id + AES-256-GCM hybrid encryption for wallet data at rest.
*/
import { setCryptoBackend } from '../src/crypto/index.js';
import { Wallet } from '../src/wallet.js';
import {
encryptWalletJSON,
decryptWalletJSON,
isEncryptedWallet,
ENCRYPTION_VERSION,
} from '../src/wallet-encryption.js';
await setCryptoBackend('wasm');
let passed = 0;
let failed = 0;
function assert(condition, msg) {
if (condition) {
passed++;
} else {
failed++;
console.error(` FAIL: ${msg}`);
}
}
// Use fast argon2 params for testing
const FAST_PARAMS = { argon2: { t: 1, m: 1024, p: 1 } };
// Create a test wallet
const wallet = Wallet.create({ network: 'testnet' });
const plainJSON = wallet.toJSON(true);
console.log('\n--- Wallet Encryption Tests ---\n');
// Test 1: encryptWalletJSON / decryptWalletJSON roundtrip
console.log('Test 1: Encrypt/decrypt roundtrip');
{
const enc = encryptWalletJSON(plainJSON, 'testpass', FAST_PARAMS);
const dec = decryptWalletJSON(enc, 'testpass');
assert(dec.seed === plainJSON.seed, 'seed roundtrip');
assert(dec.spendSecretKey === plainJSON.spendSecretKey, 'spendSecretKey roundtrip');
assert(dec.viewSecretKey === plainJSON.viewSecretKey, 'viewSecretKey roundtrip');
assert(dec.mnemonic === plainJSON.mnemonic, 'mnemonic roundtrip');
assert(dec.address === plainJSON.address, 'address roundtrip');
assert(dec.spendPublicKey === plainJSON.spendPublicKey, 'spendPublicKey roundtrip');
assert(dec.network === plainJSON.network, 'network roundtrip');
}
// Test 2: Wrong password throws
console.log('Test 2: Wrong password throws');
{
const enc = encryptWalletJSON(plainJSON, 'correct', FAST_PARAMS);
let threw = false;
try {
decryptWalletJSON(enc, 'wrong');
} catch (e) {
threw = true;
assert(e.message.includes('incorrect password'), 'error message mentions password');
}
assert(threw, 'wrong password throws');
}
// Test 3: isEncryptedWallet detection
console.log('Test 3: isEncryptedWallet detection');
{
const enc = encryptWalletJSON(plainJSON, 'pass', FAST_PARAMS);
assert(isEncryptedWallet(enc) === true, 'encrypted wallet detected');
assert(isEncryptedWallet(plainJSON) === false, 'plain wallet not detected');
assert(isEncryptedWallet(null) === false, 'null not detected');
assert(isEncryptedWallet({}) === false, 'empty object not detected');
}
// Test 4: Public fields visible without password
console.log('Test 4: Public fields visible without decryption');
{
const enc = encryptWalletJSON(plainJSON, 'pass', FAST_PARAMS);
assert(enc.address === plainJSON.address, 'address visible');
assert(enc.carrotAddress === plainJSON.carrotAddress, 'carrotAddress visible');
assert(enc.spendPublicKey === plainJSON.spendPublicKey, 'spendPublicKey visible');
assert(enc.viewPublicKey === plainJSON.viewPublicKey, 'viewPublicKey visible');
assert(enc.network === plainJSON.network, 'network visible');
assert(enc.version === plainJSON.version, 'version visible');
}
// Test 5: Secrets are NOT in encrypted output
console.log('Test 5: Secrets hidden in encrypted output');
{
const enc = encryptWalletJSON(plainJSON, 'pass', FAST_PARAMS);
assert(enc.seed === undefined, 'seed hidden');
assert(enc.mnemonic === undefined, 'mnemonic hidden');
assert(enc.spendSecretKey === undefined, 'spendSecretKey hidden');
assert(enc.viewSecretKey === undefined, 'viewSecretKey hidden');
}
// Test 6: CARROT keys — public visible, secrets hidden
console.log('Test 6: CARROT key separation');
{
const enc = encryptWalletJSON(plainJSON, 'pass', FAST_PARAMS);
assert(enc.carrotKeys?.accountSpendPubkey !== undefined, 'CARROT public key visible');
assert(enc.carrotKeys?.primaryAddressViewPubkey !== undefined, 'CARROT view pub visible');
// Secret CARROT keys should not be on the top-level carrotKeys
assert(enc.carrotKeys?.viewIncomingKey === undefined, 'CARROT viewIncomingKey hidden');
assert(enc.carrotKeys?.generateImageKey === undefined, 'CARROT generateImageKey hidden');
assert(enc.carrotKeys?.proveSpendKey === undefined, 'CARROT proveSpendKey hidden');
// After decryption, all CARROT keys restored
const dec = decryptWalletJSON(enc, 'pass');
assert(dec.carrotKeys?.viewIncomingKey === plainJSON.carrotKeys?.viewIncomingKey, 'CARROT secrets restored');
}
// Test 7: Wallet.toEncryptedJSON / fromEncryptedJSON roundtrip
console.log('Test 7: Wallet class encrypt/decrypt methods');
{
const enc = wallet.toEncryptedJSON('walletpass', FAST_PARAMS);
assert(Wallet.isEncrypted(enc), 'Wallet.isEncrypted works');
const restored = Wallet.fromEncryptedJSON(enc, 'walletpass');
assert(restored.getLegacyAddress() === wallet.getLegacyAddress(), 'address matches');
assert(restored.getCarrotAddress() === wallet.getCarrotAddress(), 'CARROT address matches');
assert(restored.canSign(), 'restored wallet can sign');
assert(restored.canScan(), 'restored wallet can scan');
}
// Test 8: Encryption envelope format
console.log('Test 8: Envelope format');
{
const enc = encryptWalletJSON(plainJSON, 'pass', FAST_PARAMS);
assert(enc.encrypted === true, 'encrypted flag set');
assert(enc.encryptionVersion === ENCRYPTION_VERSION, 'version present');
assert(typeof enc.encryption === 'object', 'encryption metadata present');
assert(typeof enc.encryption.kdfSalt === 'string', 'kdfSalt is hex');
assert(typeof enc.encryption.kemSalt === 'string', 'kemSalt is hex');
assert(typeof enc.encryption.kyberCiphertext === 'string', 'kyberCiphertext is hex');
assert(typeof enc.encryption.iv === 'string', 'iv is hex');
assert(typeof enc.encryption.ciphertext === 'string', 'ciphertext is hex');
assert(enc.encryption.iv.length === 24, 'iv is 12 bytes (24 hex)');
assert(enc.encryption.argon2.t === 1, 'argon2 params stored');
}
// Test 9: Unencrypted passthrough
console.log('Test 9: Unencrypted passthrough');
{
const result = decryptWalletJSON(plainJSON, 'anypassword');
assert(result === plainJSON, 'plain JSON returned as-is');
}
// Test 10: Different passwords produce different ciphertexts
console.log('Test 10: Different passwords produce different output');
{
const enc1 = encryptWalletJSON(plainJSON, 'pass1', FAST_PARAMS);
const enc2 = encryptWalletJSON(plainJSON, 'pass2', FAST_PARAMS);
assert(enc1.encryption.ciphertext !== enc2.encryption.ciphertext, 'different ciphertexts');
assert(enc1.encryption.kdfSalt !== enc2.encryption.kdfSalt, 'different salts');
}
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log(`Total: ${passed + failed}`);
if (failed > 0) {
console.log('\nSome tests FAILED!');
process.exit(1);
} else {
console.log('\nAll wallet encryption tests passed!');
}
+38
View File
@@ -0,0 +1,38 @@
import { Wallet } from '../src/wallet.js';
import { setCryptoBackend } from '../src/crypto/index.js';
await setCryptoBackend('wasm');
const w = Wallet.create({ network: 'mainnet' });
console.log('salvium-js v0.5.0');
console.log('─'.repeat(50));
console.log();
console.log('Wallet Security:');
console.log(' Encryption at rest: ML-KEM-768 (CRYSTALS-Kyber)');
console.log(' Key derivation: Argon2id (64 MB, 3 iterations)');
console.log(' Symmetric cipher: AES-256-GCM');
console.log(' Hybrid scheme: PQ-KEM + classical KDF');
console.log();
console.log('Protocol Support:');
console.log(' Address formats: CryptoNote (SaLv) + CARROT (SC1)');
console.log(' Transaction types: Transfer, Sweep, Stake, Burn, Convert');
console.log(' Ring signatures: TCLSAG (16-member rings)');
console.log(' Range proofs: Bulletproofs+');
console.log(' Amount hiding: Pedersen commitments');
console.log();
// Time the encrypt/decrypt
const start = performance.now();
const enc = w.toEncryptedJSON('benchmark');
const mid = performance.now();
Wallet.fromEncryptedJSON(enc, 'benchmark');
const end = performance.now();
console.log('Performance:');
console.log(' PQ encrypt wallet: ' + (mid - start).toFixed(0) + ' ms');
console.log(' PQ decrypt wallet: ' + (end - mid).toFixed(0) + ' ms');
console.log(' Kyber ciphertext: ' + (enc.encryption.kyberCiphertext.length / 2) + ' bytes');
console.log();
console.log('Your keys are quantum-safe. Are yours?');