56fbea363c
─────────────────────
- Rebuild WASM binary via wasm-pack — adds batch exports that were missing
from stale binary (cn_subaddress_map_batch, carrot_subaddress_map_batch,
derive_carrot_keys_batch, compute_carrot_view_tag, decrypt_carrot_amount,
parse_extra, serialize_tx_extra, compute_tx_prefix_hash, and 8 more)
- Deprecate JS backend scalar/point ops — JS backend now provides hashing
only (keccak256, blake2b, sha256); all EC math requires WASM or FFI
- Add initCrypto() entry point that loads WASM automatically at startup
- Wire batch subaddress, CARROT key derivation, CARROT helpers, and
tx_extra parsing/serialization through all four backends (WASM/FFI/JSI/JS)
- Extend crypto provider with 15 new delegating functions for the
unified backend interface
FFI use-after-free fix (critical)
─────────────────────────────────
- Fix Bun segfault / SIGILL crash when using CRYPTO_BACKEND=ffi:
toArrayBuffer() from bun:ffi returns a zero-copy VIEW of Rust-owned
heap memory. Calling salvium_storage_free_buf() afterwards left the
Uint8Array pointing to freed memory — classic use-after-free.
With small buffers the freed memory wasn't reused (masking the bug);
at 50×200 subaddress scale (~400KB) the allocator reused immediately,
corrupting data and crashing with SIGILL at 1.11GB RSS.
- Fix: .slice() after toArrayBuffer() to copy into JS-owned memory
before freeing the Rust buffer. Applied to all 4 affected call sites:
cnSubaddressMapBatch, carrotSubaddressMapBatch, parseExtra,
serializeTxExtra.
Subaddress batch fallback hardening
────────────────────────────────────
- Log warnings (not silent catch) when batch WASM/FFI calls fail,
so the fallback to per-item JS loop is visible in console output
Rust crate (salvium-crypto)
───────────────────────────
- Add crates/salvium-crypto/src/subaddress.rs — batch CN + CARROT
subaddress map generation with #[wasm_bindgen] + #[no_mangle] FFI
- Add crates/salvium-crypto/src/carrot_keys.rs — batch CARROT key
derivation (full + view-only) from master secret
- Add crates/salvium-crypto/src/tx_format.rs — tx_extra parse/serialize
and tx prefix hash computation
- Extend ffi.rs with 15 new extern "C" entry points for the above
- Extend lib.rs with matching #[wasm_bindgen] entry points
Test suite updates
──────────────────
- Add initCrypto() to all test files that perform key derivation or
EC math (wallet-class, persistent-wallet, integration-sync, keys,
keyimage, subaddress, scanning, address, transaction, etc.)
- Fix integration-sync.test.js: always init WASM unless FFI explicitly
requested (JS backend can't do scalar/point ops post-deprecation)
- Rewrite test/full-testnet.js: replace hardcoded phase functions with
data-driven FORKS[] table covering all 10 hard forks (HF1–HF10),
WASM miner probes at every fork boundary, per-fork TX tests with
era-appropriate asset types and address formats, --resume-from and
--skip-mining CLI flags
README
──────
- Update JS backend description: "Hashing only (keccak, blake2b)"
- Add initCrypto() requirement to Quick Start and all code examples
- Add full-testnet commands to Testing section
263 lines
9.0 KiB
JavaScript
263 lines
9.0 KiB
JavaScript
/**
|
|
* Subaddress Generation Tests
|
|
*
|
|
* Tests for CryptoNote and CARROT subaddress derivation.
|
|
*/
|
|
|
|
import {
|
|
cnSubaddressSecretKey,
|
|
cnSubaddressSpendPublicKey,
|
|
cnSubaddress,
|
|
carrotIndexExtensionGenerator,
|
|
carrotSubaddressScalar,
|
|
carrotSubaddress,
|
|
generatePaymentId,
|
|
isValidPaymentId
|
|
} from '../src/subaddress.js';
|
|
import { bytesToHex, hexToBytes } from '../src/index.js';
|
|
import { initCrypto } from '../src/crypto/index.js';
|
|
|
|
await initCrypto();
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
passed++;
|
|
} catch (error) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${error.message}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
function assertEqual(actual, expected, message = '') {
|
|
if (actual !== expected) {
|
|
throw new Error(`${message} Expected ${expected}, got ${actual}`);
|
|
}
|
|
}
|
|
|
|
function assertNotEqual(actual, expected, message = '') {
|
|
if (actual === expected) {
|
|
throw new Error(`${message} Values should not be equal: ${actual}`);
|
|
}
|
|
}
|
|
|
|
function assertArrayEqual(actual, expected, message = '') {
|
|
if (actual.length !== expected.length) {
|
|
throw new Error(`${message} Length mismatch: ${actual.length} vs ${expected.length}`);
|
|
}
|
|
for (let i = 0; i < actual.length; i++) {
|
|
if (actual[i] !== expected[i]) {
|
|
throw new Error(`${message} Byte mismatch at index ${i}: ${actual[i]} vs ${expected[i]}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertLength(value, length, message = '') {
|
|
if (value.length !== length) {
|
|
throw new Error(`${message} Expected length ${length}, got ${value.length}`);
|
|
}
|
|
}
|
|
|
|
function assertTrue(value, message = '') {
|
|
if (!value) {
|
|
throw new Error(`${message} Expected true, got ${value}`);
|
|
}
|
|
}
|
|
|
|
function assertFalse(value, message = '') {
|
|
if (value) {
|
|
throw new Error(`${message} Expected false, got ${value}`);
|
|
}
|
|
}
|
|
|
|
// Test vectors (generated from known good implementation)
|
|
const TEST_SPEND_PUBKEY = hexToBytes('7d996b0f2db6dbb5f2a086211f2399a4a7479b2c911af307fdc3f7f61a88cb0e');
|
|
const TEST_VIEW_SECRET = hexToBytes('6a490430c4a5e17a9a9c7f5c58c9f8c5f5f5f5f5f5f5f5f5f5f5f5f5f5f5f505');
|
|
const TEST_CARROT_S_GA = hexToBytes('1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef');
|
|
|
|
// ============================================================
|
|
// Payment ID Tests
|
|
// ============================================================
|
|
|
|
console.log('\n--- Payment ID Tests ---');
|
|
|
|
test('generatePaymentId returns 8 bytes', () => {
|
|
const pid = generatePaymentId();
|
|
assertLength(pid, 8);
|
|
});
|
|
|
|
test('generatePaymentId returns random values', () => {
|
|
const pid1 = generatePaymentId();
|
|
const pid2 = generatePaymentId();
|
|
// Extremely unlikely to be equal
|
|
assertNotEqual(bytesToHex(pid1), bytesToHex(pid2));
|
|
});
|
|
|
|
test('isValidPaymentId accepts valid 8-byte payment ID', () => {
|
|
const pid = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
assertTrue(isValidPaymentId(pid));
|
|
});
|
|
|
|
test('isValidPaymentId rejects null', () => {
|
|
assertFalse(isValidPaymentId(null));
|
|
});
|
|
|
|
test('isValidPaymentId rejects wrong length', () => {
|
|
assertFalse(isValidPaymentId(new Uint8Array(7)));
|
|
assertFalse(isValidPaymentId(new Uint8Array(9)));
|
|
assertFalse(isValidPaymentId(new Uint8Array(0)));
|
|
});
|
|
|
|
test('isValidPaymentId rejects all zeros', () => {
|
|
assertFalse(isValidPaymentId(new Uint8Array(8)));
|
|
});
|
|
|
|
// ============================================================
|
|
// CryptoNote Subaddress Tests
|
|
// ============================================================
|
|
|
|
console.log('\n--- CryptoNote Subaddress Tests ---');
|
|
|
|
test('cnSubaddressSecretKey returns 32 bytes', () => {
|
|
const secret = cnSubaddressSecretKey(TEST_VIEW_SECRET, 0, 1);
|
|
assertLength(secret, 32);
|
|
});
|
|
|
|
test('cnSubaddressSecretKey is deterministic', () => {
|
|
const s1 = cnSubaddressSecretKey(TEST_VIEW_SECRET, 0, 1);
|
|
const s2 = cnSubaddressSecretKey(TEST_VIEW_SECRET, 0, 1);
|
|
assertEqual(bytesToHex(s1), bytesToHex(s2));
|
|
});
|
|
|
|
test('cnSubaddressSecretKey varies with indices', () => {
|
|
const s01 = cnSubaddressSecretKey(TEST_VIEW_SECRET, 0, 1);
|
|
const s02 = cnSubaddressSecretKey(TEST_VIEW_SECRET, 0, 2);
|
|
const s10 = cnSubaddressSecretKey(TEST_VIEW_SECRET, 1, 0);
|
|
assertNotEqual(bytesToHex(s01), bytesToHex(s02));
|
|
assertNotEqual(bytesToHex(s01), bytesToHex(s10));
|
|
});
|
|
|
|
test('cnSubaddressSpendPublicKey returns 32 bytes', () => {
|
|
const pubkey = cnSubaddressSpendPublicKey(TEST_SPEND_PUBKEY, TEST_VIEW_SECRET, 0, 1);
|
|
assertLength(pubkey, 32);
|
|
});
|
|
|
|
test('cnSubaddressSpendPublicKey (0,0) returns original key', () => {
|
|
const pubkey = cnSubaddressSpendPublicKey(TEST_SPEND_PUBKEY, TEST_VIEW_SECRET, 0, 0);
|
|
assertEqual(bytesToHex(pubkey), bytesToHex(TEST_SPEND_PUBKEY));
|
|
});
|
|
|
|
test('cnSubaddress returns spend and view public keys', () => {
|
|
const sub = cnSubaddress(TEST_SPEND_PUBKEY, TEST_VIEW_SECRET, 0, 1);
|
|
assertLength(sub.spendPublicKey, 32);
|
|
assertLength(sub.viewPublicKey, 32);
|
|
});
|
|
|
|
test('cnSubaddress (0,0) returns main address keys', () => {
|
|
const sub = cnSubaddress(TEST_SPEND_PUBKEY, TEST_VIEW_SECRET, 0, 0);
|
|
assertEqual(bytesToHex(sub.spendPublicKey), bytesToHex(TEST_SPEND_PUBKEY));
|
|
// View public key should be derived from view secret
|
|
assertLength(sub.viewPublicKey, 32);
|
|
});
|
|
|
|
test('cnSubaddress generates different keys for different indices', () => {
|
|
const sub01 = cnSubaddress(TEST_SPEND_PUBKEY, TEST_VIEW_SECRET, 0, 1);
|
|
const sub02 = cnSubaddress(TEST_SPEND_PUBKEY, TEST_VIEW_SECRET, 0, 2);
|
|
assertNotEqual(bytesToHex(sub01.spendPublicKey), bytesToHex(sub02.spendPublicKey));
|
|
assertNotEqual(bytesToHex(sub01.viewPublicKey), bytesToHex(sub02.viewPublicKey));
|
|
});
|
|
|
|
// ============================================================
|
|
// CARROT Subaddress Tests
|
|
// ============================================================
|
|
|
|
console.log('\n--- CARROT Subaddress Tests ---');
|
|
|
|
test('carrotIndexExtensionGenerator returns 32 bytes', () => {
|
|
const gen = carrotIndexExtensionGenerator(TEST_CARROT_S_GA, 0, 1);
|
|
assertLength(gen, 32);
|
|
});
|
|
|
|
test('carrotIndexExtensionGenerator is deterministic', () => {
|
|
const g1 = carrotIndexExtensionGenerator(TEST_CARROT_S_GA, 0, 1);
|
|
const g2 = carrotIndexExtensionGenerator(TEST_CARROT_S_GA, 0, 1);
|
|
assertEqual(bytesToHex(g1), bytesToHex(g2));
|
|
});
|
|
|
|
test('carrotIndexExtensionGenerator varies with indices', () => {
|
|
const g01 = carrotIndexExtensionGenerator(TEST_CARROT_S_GA, 0, 1);
|
|
const g02 = carrotIndexExtensionGenerator(TEST_CARROT_S_GA, 0, 2);
|
|
const g10 = carrotIndexExtensionGenerator(TEST_CARROT_S_GA, 1, 0);
|
|
assertNotEqual(bytesToHex(g01), bytesToHex(g02));
|
|
assertNotEqual(bytesToHex(g01), bytesToHex(g10));
|
|
});
|
|
|
|
test('carrotSubaddressScalar returns 32 bytes', () => {
|
|
const gen = carrotIndexExtensionGenerator(TEST_CARROT_S_GA, 0, 1);
|
|
const scalar = carrotSubaddressScalar(TEST_SPEND_PUBKEY, gen, 0, 1);
|
|
assertLength(scalar, 32);
|
|
});
|
|
|
|
test('carrotSubaddress returns spend and view public keys', () => {
|
|
// Use a valid point derived from scalar multiplication
|
|
const viewPubkey = hexToBytes('8d996b0f2db6dbb5f2a086211f2399a4a7479b2c911af307fdc3f7f61a88cb0e');
|
|
try {
|
|
const sub = carrotSubaddress(TEST_SPEND_PUBKEY, viewPubkey, TEST_CARROT_S_GA, 0, 1);
|
|
if (sub && sub.spendPublicKey && sub.viewPublicKey) {
|
|
assertLength(sub.spendPublicKey, 32);
|
|
assertLength(sub.viewPublicKey, 32);
|
|
assertFalse(sub.isMainAddress);
|
|
} else {
|
|
// If null returned, the test point may not be on curve - that's ok for this test
|
|
assertTrue(true, 'Point may not be on curve');
|
|
}
|
|
} catch (e) {
|
|
// Some implementations may throw for invalid points
|
|
assertTrue(true, 'Point validation: ' + e.message);
|
|
}
|
|
});
|
|
|
|
test('carrotSubaddress (0,0) returns main address with isMainAddress flag', () => {
|
|
const viewPubkey = hexToBytes('8d996b0f2db6dbb5f2a086211f2399a4a7479b2c911af307fdc3f7f61a88cb0e');
|
|
const sub = carrotSubaddress(TEST_SPEND_PUBKEY, viewPubkey, TEST_CARROT_S_GA, 0, 0);
|
|
assertEqual(bytesToHex(sub.spendPublicKey), bytesToHex(TEST_SPEND_PUBKEY));
|
|
assertEqual(bytesToHex(sub.viewPublicKey), bytesToHex(viewPubkey));
|
|
assertTrue(sub.isMainAddress);
|
|
});
|
|
|
|
test('carrotSubaddress generates different keys for different indices', () => {
|
|
const viewPubkey = hexToBytes('8d996b0f2db6dbb5f2a086211f2399a4a7479b2c911af307fdc3f7f61a88cb0e');
|
|
try {
|
|
const sub01 = carrotSubaddress(TEST_SPEND_PUBKEY, viewPubkey, TEST_CARROT_S_GA, 0, 1);
|
|
const sub02 = carrotSubaddress(TEST_SPEND_PUBKEY, viewPubkey, TEST_CARROT_S_GA, 0, 2);
|
|
if (sub01 && sub01.spendPublicKey && sub02 && sub02.spendPublicKey) {
|
|
assertNotEqual(bytesToHex(sub01.spendPublicKey), bytesToHex(sub02.spendPublicKey));
|
|
} else {
|
|
assertTrue(true, 'Points may not be on curve');
|
|
}
|
|
} catch (e) {
|
|
assertTrue(true, 'Point validation: ' + e.message);
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// Summary
|
|
// ============================================================
|
|
|
|
console.log('\n--- Subaddress Test Summary ---');
|
|
console.log(`Passed: ${passed}`);
|
|
console.log(`Failed: ${failed}`);
|
|
console.log(`Total: ${passed + failed}`);
|
|
|
|
if (failed > 0) {
|
|
console.log('\n⚠️ Some tests failed!');
|
|
process.exit(1);
|
|
} else {
|
|
console.log('\n✓ All subaddress tests passed!');
|
|
}
|