Files
salvium-rs/test/testnet.test.js
T
Matt Hess 4bbcb8afe1 Add transaction sending to testnet with full CLSAG ring signature pipeline
Exercises the complete transfer flow on the self-contained testnet:
UTXO selection, gamma-distribution decoy selection, ring member
fetching, buildTransaction with CLSAG signing, mempool submission,
block mining, and recipient wallet scanning.

Key changes:
- TestnetNode: global output index, spent key image tracking,
  getOuts/getOutputDistribution/sendRawTransaction/isKeyImageSpent RPCs
- TestnetMiner: includes mempool user transactions in mined blocks
- Testnet.transfer(): end-to-end send from wallet to wallet
- WalletSync: fallback to JSON-based tx processing when as_hex unavailable
- 5 new tests covering transfer, change outputs, double-spend rejection
2026-01-31 17:24:44 +00:00

336 lines
12 KiB
JavaScript

/**
* Testnet Integration Tests
*
* Tests the self-contained JavaScript testnet:
* - Genesis block creation
* - Block mining with real RandomX PoW
* - Wallet sync and output detection
* - Balance tracking with maturity window
* - Mining past CARROT hard fork (height 1100)
*/
import { describe, test, expect, beforeAll } from 'bun:test';
import { Testnet } from '../src/testnet/index.js';
import { createMinerTransaction, createEmptyProtocolTransaction } from '../src/testnet/miner-tx.js';
import { TestnetNode } from '../src/testnet/node.js';
import { bytesToHex } from '../src/address.js';
import { PREMINE_AMOUNT } from '../src/consensus.js';
import { generateSeed, deriveKeys } from '../src/carrot.js';
// =============================================================================
// Unit Tests (no RandomX needed)
// =============================================================================
describe('createMinerTransaction', () => {
const keys = deriveKeys(generateSeed());
const spendPub = bytesToHex(keys.spendPublicKey);
const viewPub = bytesToHex(keys.viewPublicKey);
test('creates valid genesis coinbase tx (no stake deduction)', () => {
const { tx, txHash, txSecretKey } = createMinerTransaction(0, PREMINE_AMOUNT, viewPub, spendPub, 1);
expect(tx.version).toBe(2);
expect(tx.txType).toBe(1); // MINER
expect(tx.inputs).toHaveLength(1);
expect(tx.inputs[0].type).toBe('gen');
expect(tx.inputs[0].height).toBe(0);
expect(tx.outputs).toHaveLength(1);
expect(tx.amount_burnt).toBe(0n); // No stake deduction at genesis
expect(tx.outputs[0].amount).toBe(PREMINE_AMOUNT);
expect(txHash).toBeInstanceOf(Uint8Array);
expect(txHash.length).toBe(32);
expect(txSecretKey.length).toBe(32);
});
test('applies 20% stake deduction post-genesis', () => {
const reward = 1000000000n;
const { tx } = createMinerTransaction(100, reward, viewPub, spendPub, 1);
expect(tx.amount_burnt).toBe(200000000n); // 20%
expect(tx.outputs[0].amount).toBe(800000000n); // 80%
});
test('output has tagged key with view tag', () => {
const { tx } = createMinerTransaction(1, 1000000000n, viewPub, spendPub, 1);
const output = tx.outputs[0];
expect(output.viewTag).toBeDefined();
expect(typeof output.viewTag).toBe('number');
expect(output.viewTag).toBeGreaterThanOrEqual(0);
expect(output.viewTag).toBeLessThanOrEqual(255);
expect(typeof output.target).toBe('string'); // hex key
expect(output.target.length).toBe(64); // 32 bytes hex
});
});
describe('createEmptyProtocolTransaction', () => {
test('creates valid protocol tx', () => {
const { tx, txHash } = createEmptyProtocolTransaction(100);
expect(tx.version).toBe(2);
expect(tx.txType).toBe(2); // PROTOCOL
expect(tx.outputs).toHaveLength(0);
expect(tx.amount_burnt).toBe(0n);
expect(txHash.length).toBe(32);
});
});
describe('TestnetNode', () => {
test('starts empty', () => {
const node = new TestnetNode();
expect(node.getHeight()).toBe(0);
expect(node.getTopBlockHash()).toBe('0'.repeat(64));
expect(node.getTotalGeneratedCoins()).toBe(0n);
});
test('getInfo returns correct height', async () => {
const node = new TestnetNode();
const info = await node.getInfo();
expect(info.success).toBe(true);
expect(info.result.height).toBe(0);
});
test('getBlockHeaderByHeight returns error for missing blocks', async () => {
const node = new TestnetNode();
const result = await node.getBlockHeaderByHeight(0);
expect(result.success).toBe(false);
});
});
// =============================================================================
// Integration Tests (require RandomX — slower startup)
// =============================================================================
describe('Testnet Integration', () => {
let testnet;
beforeAll(async () => {
testnet = new Testnet();
await testnet.init();
}, 30000); // RandomX init can take a few seconds
test('genesis block exists after init', () => {
expect(testnet.node.getHeight()).toBe(1);
expect(testnet.node.getTotalGeneratedCoins()).toBe(PREMINE_AMOUNT);
});
test('genesis block has correct reward', async () => {
const header = await testnet.node.getBlockHeaderByHeight(0);
expect(header.success).toBe(true);
expect(header.result.block_header.height).toBe(0);
});
test('can mine additional blocks', async () => {
const results = await testnet.mineBlocks(5);
expect(results).toHaveLength(5);
expect(testnet.node.getHeight()).toBe(6);
// Each result should have height, hash, reward
for (const r of results) {
expect(r.height).toBeDefined();
expect(r.hash).toBeDefined();
expect(r.hash.length).toBe(64);
expect(r.reward).toBeGreaterThan(0n);
}
});
test('block headers are retrievable', async () => {
const headers = await testnet.node.getBlockHeadersRange(0, 5);
expect(headers.success).toBe(true);
expect(headers.result.headers).toHaveLength(6);
});
test('getBlock returns parseable JSON', async () => {
const block = await testnet.node.getBlock({ height: 1 });
expect(block.success).toBe(true);
const json = JSON.parse(block.result.json);
expect(json.miner_tx).toBeDefined();
expect(json.miner_tx.vin).toHaveLength(1);
expect(json.miner_tx.vin[0].gen).toBeDefined();
expect(json.miner_tx.vout).toHaveLength(1);
expect(json.miner_tx.vout[0].target.tagged_key).toBeDefined();
expect(json.miner_tx.extra).toBeInstanceOf(Array);
expect(json.miner_tx.extra[0]).toBe(0x01); // TX_EXTRA_TAG_PUBKEY
expect(json.miner_tx.extra.length).toBe(33); // 1 tag + 32 bytes pubkey
});
test('miner wallet can sync and detect coinbase outputs', async () => {
const wallet = testnet.getMinerWallet();
await testnet.syncWallet(wallet);
const outputs = await wallet.storage.getOutputs({});
expect(outputs.length).toBeGreaterThan(0);
// All outputs should belong to the miner
for (const output of outputs) {
expect(output.amount).toBeGreaterThan(0n);
expect(output.blockHeight).toBeDefined();
}
});
test('non-miner wallet finds no outputs', async () => {
const wallet = testnet.createWallet();
await testnet.syncWallet(wallet);
const outputs = await wallet.storage.getOutputs({});
expect(outputs.length).toBe(0);
});
test('balance tracks total and unlocked', async () => {
// Mine enough blocks for some to mature (60 block unlock window)
const currentHeight = testnet.node.getHeight();
const blocksNeeded = 60 - currentHeight + 2; // +2 for safety
if (blocksNeeded > 0) {
await testnet.mineBlocks(blocksNeeded);
}
const wallet = testnet.getMinerWallet();
await testnet.syncWallet(wallet);
const { balance, unlockedBalance } = await testnet.getBalance(wallet);
expect(balance).toBeGreaterThan(0n);
expect(unlockedBalance).toBeGreaterThan(0n);
expect(unlockedBalance).toBeLessThanOrEqual(balance);
}, 30000);
});
describe('Testnet Transactions', () => {
let testnet;
beforeAll(async () => {
testnet = new Testnet();
await testnet.init();
// Mine 70+ blocks for enough outputs for ring size 16,
// plus 60 for maturity window = 130 total needed
await testnet.mineBlocks(130);
}, 60000);
test('chain has enough outputs for ring selection', () => {
expect(testnet.node.globalOutputs.length).toBeGreaterThanOrEqual(70);
});
test('miner wallet has unlocked balance', async () => {
const wallet = testnet.getMinerWallet();
await testnet.syncWallet(wallet);
const { unlockedBalance } = await testnet.getBalance(wallet);
expect(unlockedBalance).toBeGreaterThan(0n);
}, 30000);
test('basic transfer: miner → new wallet', async () => {
const minerWallet = testnet.getMinerWallet();
const recipient = testnet.createWallet();
const transferAmount = 1000000000n; // 1 SAL
const { tx, fee } = await testnet.transfer(
minerWallet,
{
viewPublicKey: recipient.keys.viewPublicKey,
spendPublicKey: recipient.keys.spendPublicKey,
},
transferAmount,
);
expect(tx).toBeDefined();
expect(fee).toBeGreaterThan(0n);
// Sync recipient and verify balance
await testnet.syncWallet(recipient);
const { balance } = await testnet.getBalance(recipient);
expect(balance).toBe(transferAmount);
}, 30000);
test('sender has change output after transfer', async () => {
const minerWallet = testnet.getMinerWallet();
await testnet.syncWallet(minerWallet);
const { balance } = await testnet.getBalance(minerWallet);
// Miner should still have funds (change from previous transfer + new coinbase)
expect(balance).toBeGreaterThan(0n);
}, 30000);
test('double-spend is rejected', async () => {
// Key images from the first transfer should be marked as spent
expect(testnet.node.spentKeyImages.size).toBeGreaterThan(0);
const spent = await testnet.node.isKeyImageSpent(
[...testnet.node.spentKeyImages].slice(0, 1)
);
expect(spent.spent_status[0]).toBe(1);
// Attempting to resubmit a TX with the same key image should fail
const fakeTx = { _meta: { keyImages: [...testnet.node.spentKeyImages].slice(0, 1) } };
const result = await testnet.node.sendRawTransaction(fakeTx);
expect(result.success).toBe(false);
});
});
describe('Testnet CARROT Hard Fork', () => {
let testnet;
beforeAll(async () => {
testnet = new Testnet();
await testnet.init();
// Mine past CARROT activation height (1100 on testnet)
// We already have genesis (height 0), need to get to 1101+
await testnet.mineBlocks(1105);
}, 180000); // Mining 1100+ blocks with RandomX
test('chain height is past CARROT activation', () => {
expect(testnet.node.getHeight()).toBeGreaterThan(1100);
});
test('blocks before and after fork have correct major version', async () => {
// Before CARROT (height 1099, should be HF 9 = AUDIT2_PAUSE)
const preFork = await testnet.node.getBlockHeaderByHeight(1099);
expect(preFork.success).toBe(true);
expect(preFork.result.block_header.major_version).toBeLessThan(10);
// After CARROT (height 1100+)
const postFork = await testnet.node.getBlockHeaderByHeight(1100);
expect(postFork.success).toBe(true);
expect(postFork.result.block_header.major_version).toBe(10);
});
test('miner wallet detects outputs across hard fork boundary', async () => {
const wallet = testnet.getMinerWallet();
await testnet.syncWallet(wallet);
const outputs = await wallet.storage.getOutputs({});
// Should have outputs from both pre and post CARROT
expect(outputs.length).toBeGreaterThan(1100);
// Verify we have both legacy and CARROT outputs
const legacyOutputs = outputs.filter(o => !o.isCarrot);
const carrotOutputs = outputs.filter(o => o.isCarrot);
expect(legacyOutputs.length).toBe(1100); // Heights 0-1099
expect(carrotOutputs.length).toBeGreaterThan(0); // Heights 1100+
// All CARROT outputs should have non-zero amounts
for (const o of carrotOutputs) {
expect(o.amount).toBeGreaterThan(0n);
}
}, 120000);
test('post-fork blocks use carrot_v1 output format', async () => {
const block = await testnet.node.getBlock({ height: 1100 });
expect(block.success).toBe(true);
const json = JSON.parse(block.result.json);
expect(json.miner_tx.vout[0].target.carrot_v1).toBeDefined();
expect(json.miner_tx.vout[0].target.carrot_v1.key).toBeDefined();
expect(json.miner_tx.vout[0].target.carrot_v1.view_tag).toBeDefined();
expect(json.miner_tx.vout[0].target.carrot_v1.encrypted_janus_anchor).toBeDefined();
});
test('balance includes both pre and post CARROT outputs', async () => {
const wallet = testnet.getMinerWallet();
await testnet.syncWallet(wallet);
const { balance, unlockedBalance } = await testnet.getBalance(wallet);
expect(balance).toBeGreaterThan(0n);
// With 1100+ blocks mined, most should be unlocked (60-block window)
expect(unlockedBalance).toBeGreaterThan(0n);
}, 120000);
});