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:
+4
-1
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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.5–10 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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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!');
|
||||
}
|
||||
@@ -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?');
|
||||
|
||||
Reference in New Issue
Block a user