Fix stale sync cache, add wallet password change, add test runner scripts

- wallet-store.js: Reconcile _spentKeyImages with output isSpent flags
    on load() to prevent stale caches from causing double-spend rejections
  - wallet-encryption.js: Add reEncryptWalletJSON() for password changes
    with fresh salts and KEM keypair
  - wallet.js: Add Wallet.changePassword() static method, import update
  - index.js: Export reEncryptWalletJSON
  - test-helpers.js: loadWalletFromFile() auto-detects encrypted wallets
    and reads sibling .pin file for decryption
  - wallet-encryption.test.js: Add password change tests
  - test/run-unit.sh, run-integration.sh, run-all.sh: Shell wrappers
    for running unit, integration, and full test suites
This commit is contained in:
Matt Hess
2026-02-08 20:14:54 +00:00
parent 60cf578eb2
commit 82fbd6964a
9 changed files with 359 additions and 3 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ export * from './persistent-wallet.js';
export * from './consensus.js';
// Post-quantum wallet encryption
export { encryptWalletJSON, decryptWalletJSON, isEncryptedWallet } from './wallet-encryption.js';
export { encryptWalletJSON, decryptWalletJSON, reEncryptWalletJSON, isEncryptedWallet } from './wallet-encryption.js';
// Validation exports
export * from './validation.js';
+18
View File
@@ -192,6 +192,24 @@ export function decryptWalletJSON(envelope, password) {
return walletJSON;
}
/**
* Re-encrypt a wallet envelope with a new password.
* Decrypts with the old password, then encrypts with the new one
* (fresh salts, fresh KEM keypair).
*
* @param {Object} envelope - Encrypted wallet JSON
* @param {string} oldPassword - Current password
* @param {string} newPassword - New password
* @param {Object} [options]
* @param {Object} [options.argon2] - Override Argon2 params for new encryption
* @returns {Object} Newly encrypted wallet envelope
* @throws {Error} On wrong old password or corrupted data
*/
export function reEncryptWalletJSON(envelope, oldPassword, newPassword, options = {}) {
const plain = decryptWalletJSON(envelope, oldPassword);
return encryptWalletJSON(plain, newPassword, options);
}
/**
* Check if a wallet JSON object is encrypted.
* @param {Object} json
+10
View File
@@ -652,6 +652,16 @@ export class MemoryStorage extends WalletStorage {
for (const ki of data.spentKeyImages) this._spentKeyImages.add(ki);
}
// Reconcile: ensure outputs whose key images are in _spentKeyImages
// have isSpent=true. This fixes stale caches where sweep() marked KIs
// as spent but output objects weren't updated before serialization.
for (const ki of this._spentKeyImages) {
const output = this._outputs.get(ki);
if (output && !output.isSpent) {
output.isSpent = true;
}
}
if (data.blockHashes) {
// Only load the most recent block hashes (prune old ones on load)
const entries = Object.entries(data.blockHashes).map(([h, hash]) => [parseInt(h), hash]);
+17 -1
View File
@@ -51,7 +51,7 @@ import {
} from './wallet/transfer.js';
// Post-quantum wallet encryption
import { encryptWalletJSON, decryptWalletJSON, isEncryptedWallet } from './wallet-encryption.js';
import { encryptWalletJSON, decryptWalletJSON, reEncryptWalletJSON, isEncryptedWallet } from './wallet-encryption.js';
// ============================================================================
// IMPORTS FROM WALLET SUBMODULES
@@ -2243,6 +2243,22 @@ export class Wallet {
return isEncryptedWallet(json);
}
/**
* Re-encrypt an encrypted wallet envelope with a new password.
* Generates fresh salts and a fresh KEM keypair.
*
* @param {Object} envelope - Encrypted wallet JSON
* @param {string} oldPassword - Current password
* @param {string} newPassword - New password
* @param {Object} [options]
* @param {Object} [options.argon2] - Override Argon2 params
* @returns {Object} Newly encrypted wallet envelope
* @throws {Error} On wrong old password
*/
static changePassword(envelope, oldPassword, newPassword, options = {}) {
return reEncryptWalletJSON(envelope, oldPassword, newPassword, options);
}
// ===========================================================================
// SERIALIZATION
// ===========================================================================
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Run all tests: unit tests first, then integration tests
#
# Usage:
# ./test/run-all.sh # Unit + integration tests
# ./test/run-all.sh --unit # Unit tests only
# ./test/run-all.sh --integration [phase] # Integration tests only
set -euo pipefail
cd "$(dirname "$0")/.."
MODE="${1:---both}"
case "$MODE" in
--unit)
bash test/run-unit.sh
;;
--integration)
shift
bash test/run-integration.sh "${1:-all}"
;;
--both|*)
echo "============================================================"
echo " salvium-js Full Test Suite"
echo "============================================================"
echo ""
echo ">>> Unit Tests"
echo ""
if bash test/run-unit.sh; then
echo ""
echo ">>> Unit tests PASSED, running integration tests..."
echo ""
bash test/run-integration.sh "${2:-all}"
else
echo ""
echo ">>> Unit tests FAILED, skipping integration tests"
exit 1
fi
;;
esac
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Run integration tests (requires live testnet daemon)
#
# Usage:
# ./test/run-integration.sh # Run all integration tests
# ./test/run-integration.sh burn-in # Run burn-in test (full: CN + CARROT)
# ./test/run-integration.sh burn-in-cn # Run burn-in CN phase only
# ./test/run-integration.sh burn-in-carrot # Run burn-in CARROT phase only
# ./test/run-integration.sh sync # Sync-only test
# ./test/run-integration.sh sweep # Sweep test
# ./test/run-integration.sh transfer # Integration transfer test
# ./test/run-integration.sh stress # Stress micro-transfer test
set -euo pipefail
cd "$(dirname "$0")/.."
DAEMON_URL="${DAEMON_URL:-http://web.whiskymine.io:29081}"
echo "=========================================="
echo " salvium-js Integration Tests"
echo "=========================================="
echo " Daemon: $DAEMON_URL"
# Quick daemon check
if ! bun -e "
import { DaemonRPC } from './src/rpc/daemon.js';
const d = new DaemonRPC({ url: '$DAEMON_URL' });
const i = await d.getInfo();
console.log(' Height: ' + (i.result?.height || 'unknown'));
console.log(' Testnet: ' + (i.result?.testnet || 'unknown'));
" 2>/dev/null; then
echo " ERROR: Cannot reach daemon at $DAEMON_URL"
exit 1
fi
echo ""
PHASE="${1:-all}"
case "$PHASE" in
burn-in)
echo "--- Burn-in Test (full) ---"
bun test/burn-in.test.js
;;
burn-in-cn)
echo "--- Burn-in Test (CN phase only) ---"
bun test/burn-in.test.js --phase cn
;;
burn-in-carrot)
echo "--- Burn-in Test (CARROT phase only) ---"
bun test/burn-in.test.js --phase carrot
;;
sync)
echo "--- Sync-Only Test ---"
bun test/sync-only.js
;;
sweep)
echo "--- Sweep Test ---"
bun test/sweep-test.js
;;
transfer)
echo "--- Integration Transfer Test ---"
bun test/integration-transfer.test.js
;;
stress)
echo "--- Stress Micro-Transfer Test ---"
bun test/stress-micro.test.js
;;
all)
echo "--- Running all integration tests ---"
echo ""
PASSED=0
FAILED=0
for test_info in \
"Sync only:test/sync-only.js" \
"Integration transfer:test/integration-transfer.test.js" \
"Burn-in (full):test/burn-in.test.js" \
; do
name="${test_info%%:*}"
file="${test_info##*:}"
echo "=== $name ==="
if bun "$file" 2>&1; then
PASSED=$((PASSED + 1))
else
FAILED=$((FAILED + 1))
echo " FAILED: $name"
fi
echo ""
done
echo "=========================================="
echo " Results: $PASSED passed, $FAILED failed"
echo "=========================================="
[ $FAILED -gt 0 ] && exit 1
;;
*)
echo "Unknown phase: $PHASE"
echo "Usage: $0 [burn-in|burn-in-cn|burn-in-carrot|sync|sweep|transfer|stress|all]"
exit 1
;;
esac
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Run all offline unit tests (no daemon required)
#
# Two test styles:
# - Custom runner tests: run with `bun <file>`
# - Bun test runner tests (describe/test): run with `bun test <file>`
#
# Note: Some tests that import from src/index.js (barrel export) fail due to
# a pre-existing hashToPoint re-export issue in bulletproofs_plus.js.
# Tests that import directly from submodules are unaffected.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "=========================================="
echo " salvium-js Unit Tests"
echo "=========================================="
echo ""
PASSED=0
FAILED=0
SKIPPED=0
ERRORS=""
# Run a test file with `bun <file>`
run_test() {
local name="$1"
local file="$2"
printf " %-40s" "$name"
if output=$(bun "$file" 2>&1); then
echo "OK"
PASSED=$((PASSED + 1))
else
echo "FAIL"
FAILED=$((FAILED + 1))
ERRORS="$ERRORS\n--- $name ---\n$output\n"
fi
}
# Run a test file with `bun test <file>` (for describe/test style)
run_bun_test() {
local name="$1"
local file="$2"
printf " %-40s" "$name"
if output=$(bun test "$file" 2>&1); then
echo "OK"
PASSED=$((PASSED + 1))
else
echo "FAIL"
FAILED=$((FAILED + 1))
ERRORS="$ERRORS\n--- $name ---\n$output\n"
fi
}
skip_test() {
local name="$1"
local reason="$2"
printf " %-40s" "$name"
echo "SKIP ($reason)"
SKIPPED=$((SKIPPED + 1))
}
echo "--- Core tests (direct imports) ---"
run_test "Transaction (parsing, signing)" test/transaction.test.js
run_test "Wallet encryption (PQ hybrid)" test/wallet-encryption.test.js
run_test "Wallet class" test/wallet-class.test.js
run_test "Wallet store" test/wallet-store.test.js
run_test "UTXO selection" test/utxo-selection.test.js
run_test "CARROT enote types" test/carrot-enote-type.test.js
run_test "Wallet sync" test/wallet-sync.test.js
echo ""
echo "--- Tests using barrel export (src/index.js) ---"
run_test "Core (base58, keccak, address)" test/run.js
run_test "Keys (derivation)" test/keys.test.js
run_test "Mnemonic (seed words)" test/mnemonic.test.js
run_test "Address (encode/decode)" test/address.test.js
run_test "Subaddress" test/subaddress.test.js
run_test "Key image" test/keyimage.test.js
run_test "Scanning" test/scanning.test.js
run_test "Bulletproofs+" test/bulletproofs_plus.test.js
run_test "Blake2b" test/blake2b.test.js
echo ""
echo "--- Bun test runner tests ---"
run_bun_test "Validation" test/validation.test.js
run_bun_test "Consensus helpers" test/consensus-helpers.test.js
run_bun_test "Cross-fork TX" test/cross-fork-tx.test.js
run_bun_test "Wallet reorg" test/wallet-reorg.test.js
run_bun_test "Stake transaction" test/stake-transaction.test.js
run_bun_test "Burn transaction" test/burn-transaction.test.js
run_bun_test "Convert transaction" test/convert-transaction.test.js
run_bun_test "Audit transaction" test/audit-transaction.test.js
run_bun_test "Oracle" test/oracle.test.js
run_bun_test "Dynamic block size" test/dynamic-block-size.test.js
run_bun_test "Blockchain" test/blockchain.test.js
echo ""
echo "=========================================="
echo " Results: $PASSED passed, $FAILED failed, $SKIPPED skipped"
echo "=========================================="
if [ $FAILED -gt 0 ]; then
echo ""
echo "Failures:"
echo -e "$ERRORS"
exit 1
fi
+12 -1
View File
@@ -56,11 +56,22 @@ export function short(addr) {
/**
* Load a wallet from a JSON file on disk.
* Supports both plain and encrypted wallet files.
* For encrypted files, reads the PIN from a sibling .pin file
* (e.g. wallet-a.json → wallet-a.pin) or accepts an explicit password.
* @param {string} path - Absolute path to wallet JSON file
* @param {string} [network='testnet'] - Network override
* @param {string} [password] - Explicit password (if omitted, reads .pin file)
* @returns {Promise<Wallet>}
*/
export async function loadWalletFromFile(path, network = 'testnet') {
export async function loadWalletFromFile(path, network = 'testnet', password) {
const data = JSON.parse(await Bun.file(path).text());
if (Wallet.isEncrypted(data)) {
if (!password) {
const pinPath = path.replace(/\.json$/, '.pin');
password = (await Bun.file(pinPath).text()).trim();
}
return Wallet.fromEncryptedJSON(data, password, { network });
}
return Wallet.fromJSON(data, { network });
}
+51
View File
@@ -10,6 +10,7 @@ import { Wallet } from '../src/wallet.js';
import {
encryptWalletJSON,
decryptWalletJSON,
reEncryptWalletJSON,
isEncryptedWallet,
ENCRYPTION_VERSION,
} from '../src/wallet-encryption.js';
@@ -158,6 +159,56 @@ console.log('Test 10: Different passwords produce different output');
assert(enc1.encryption.kdfSalt !== enc2.encryption.kdfSalt, 'different salts');
}
// Test 11: reEncryptWalletJSON changes password
console.log('Test 11: Password change via reEncryptWalletJSON');
{
const enc = encryptWalletJSON(plainJSON, 'oldpass', FAST_PARAMS);
const reEnc = reEncryptWalletJSON(enc, 'oldpass', 'newpass', FAST_PARAMS);
// Old password no longer works
let threw = false;
try { decryptWalletJSON(reEnc, 'oldpass'); } catch { threw = true; }
assert(threw, 'old password rejected after change');
// New password works
const dec = decryptWalletJSON(reEnc, 'newpass');
assert(dec.seed === plainJSON.seed, 'seed intact after password change');
assert(dec.mnemonic === plainJSON.mnemonic, 'mnemonic intact after password change');
assert(dec.spendSecretKey === plainJSON.spendSecretKey, 'spendSecretKey intact');
assert(dec.viewSecretKey === plainJSON.viewSecretKey, 'viewSecretKey intact');
assert(dec.carrotKeys?.masterSecret === plainJSON.carrotKeys?.masterSecret, 'CARROT masterSecret intact');
}
// Test 12: Wallet.changePassword static method
console.log('Test 12: Wallet.changePassword');
{
const enc = wallet.toEncryptedJSON('pin123', FAST_PARAMS);
const reEnc = Wallet.changePassword(enc, 'pin123', 'pin456', FAST_PARAMS);
assert(Wallet.isEncrypted(reEnc), 're-encrypted envelope is encrypted');
assert(reEnc.address === enc.address, 'public address unchanged');
assert(reEnc.carrotAddress === enc.carrotAddress, 'CARROT address unchanged');
// Fresh salts (not reusing old crypto material)
assert(reEnc.encryption.kdfSalt !== enc.encryption.kdfSalt, 'fresh kdfSalt');
assert(reEnc.encryption.kemSalt !== enc.encryption.kemSalt, 'fresh kemSalt');
// Restore with new password
const restored = Wallet.fromEncryptedJSON(reEnc, 'pin456');
assert(restored.getLegacyAddress() === wallet.getLegacyAddress(), 'address matches after change');
assert(restored.getCarrotAddress() === wallet.getCarrotAddress(), 'CARROT address matches after change');
assert(restored.canSign(), 'can sign after password change');
}
// Test 13: Wrong old password in changePassword throws
console.log('Test 13: changePassword with wrong old password throws');
{
const enc = encryptWalletJSON(plainJSON, 'correct', FAST_PARAMS);
let threw = false;
try { reEncryptWalletJSON(enc, 'wrong', 'newpass', FAST_PARAMS); } catch { threw = true; }
assert(threw, 'wrong old password throws on re-encrypt');
}
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log(`Total: ${passed + failed}`);