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:
+1
-1
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
// ===========================================================================
|
||||
|
||||
Executable
+41
@@ -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
|
||||
Executable
+101
@@ -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
|
||||
Executable
+108
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user