Files
salvium-rs/test/burn-integration.test.js
T
Matt Hess bc9412b5da ● Add BURN transaction support and security hardening
- Add buildBurnTransaction() and createBurnTransaction()
  - destination_asset_type = "BURN" for burned coins
  - 9 unit tests + integration test for BURN transactions
  - Remove hardcoded test mnemonics (use env vars)
  - Add pre-commit hook to detect potential secrets
  - Add testnet testing plan (test/TESTNET_PLAN.md)
  - Update .gitignore for secret files
2026-01-25 00:13:39 +00:00

314 lines
11 KiB
JavaScript

#!/usr/bin/env bun
/**
* BURN Transaction Integration Test
*
* Tests creating and broadcasting a BURN transaction on a real network.
*
* Usage:
* WALLET_SEED="your 25 word mnemonic" BURN_AMOUNT=0.01 bun test/burn-integration.test.js
*
* Options:
* WALLET_SEED - 25 word mnemonic (required)
* BURN_AMOUNT - Amount to burn in SAL (default: 0.01)
* DAEMON_URL - Daemon RPC URL (default: http://seed01.salvium.io:19081)
* DRY_RUN - If "true", build tx but don't broadcast (default: true)
* ASSET_TYPE - Asset to burn: SAL or SAL1 (default: SAL)
*/
import { createDaemonRPC } from '../src/rpc/index.js';
import { mnemonicToSeed } from '../src/mnemonic.js';
import { deriveKeys, deriveCarrotKeys } from '../src/carrot.js';
import { hexToBytes, bytesToHex, createAddress } from '../src/address.js';
import { NETWORK, ADDRESS_FORMAT, ADDRESS_TYPE } from '../src/constants.js';
import { MemoryStorage } from '../src/wallet-store.js';
import { WalletSync } from '../src/wallet-sync.js';
import { generateCNSubaddressMap, generateCarrotSubaddressMap, SUBADDRESS_LOOKAHEAD_MAJOR, SUBADDRESS_LOOKAHEAD_MINOR } from '../src/subaddress.js';
import { buildBurnTransaction, serializeTransaction, TX_TYPE } from '../src/transaction.js';
// ============================================================================
// Configuration
// ============================================================================
const DAEMON_URL = process.env.DAEMON_URL || 'http://seed01.salvium.io:19081';
const BURN_AMOUNT_SAL = parseFloat(process.env.BURN_AMOUNT || '0.01');
const BURN_AMOUNT = BigInt(Math.floor(BURN_AMOUNT_SAL * 1e8)); // Convert to atomic units
const DRY_RUN = process.env.DRY_RUN !== 'false'; // Default to dry run for safety
const ASSET_TYPE = process.env.ASSET_TYPE || 'SAL';
const FEE = 100000000n; // 0.001 SAL fee (standard)
// ============================================================================
// Main
// ============================================================================
async function runBurnIntegrationTest() {
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ BURN Transaction Integration Test ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
// Validate input
if (!process.env.WALLET_SEED) {
console.error('ERROR: WALLET_SEED environment variable required.\n');
console.log('Usage:');
console.log(' WALLET_SEED="your 25 word mnemonic" bun test/burn-integration.test.js');
console.log('');
console.log('Options:');
console.log(' BURN_AMOUNT=0.01 Amount to burn in SAL (default: 0.01)');
console.log(' DRY_RUN=true Build tx but do not broadcast (default: true)');
console.log(' ASSET_TYPE=SAL Asset to burn: SAL or SAL1 (default: SAL)');
process.exit(1);
}
// Parse mnemonic
const mnemonic = process.env.WALLET_SEED.trim();
const result = mnemonicToSeed(mnemonic, { language: 'auto' });
if (!result.valid) {
console.error('Invalid mnemonic:', result.error);
process.exit(1);
}
const keys = deriveKeys(result.seed);
const carrotKeys = deriveCarrotKeys(keys.spendSecretKey);
console.log('--- Configuration ---');
console.log(`Daemon URL: ${DAEMON_URL}`);
console.log(`Burn amount: ${BURN_AMOUNT_SAL} ${ASSET_TYPE} (${BURN_AMOUNT} atomic)`);
console.log(`Fee: ${Number(FEE) / 1e8} SAL`);
console.log(`Mode: ${DRY_RUN ? 'DRY RUN (will NOT broadcast)' : 'LIVE (will broadcast!)'}`);
// Generate wallet address for display
const mainAddress = createAddress({
network: NETWORK.MAINNET,
format: ADDRESS_FORMAT.LEGACY,
type: ADDRESS_TYPE.STANDARD,
spendPublicKey: keys.spendPublicKey,
viewPublicKey: keys.viewPublicKey
});
console.log(`Wallet: ${mainAddress.slice(0, 20)}...${mainAddress.slice(-10)}\n`);
// Connect to daemon
console.log('Connecting to daemon...');
const daemon = createDaemonRPC({ url: DAEMON_URL, timeout: 30000 });
const info = await daemon.getInfo();
if (!info.success) {
console.error('ERROR: Failed to connect to daemon:', info.error?.message);
process.exit(1);
}
const daemonHeight = info.result.height;
console.log(`Daemon height: ${daemonHeight}`);
console.log(`Network: ${info.result.nettype || 'mainnet'}\n`);
// Create storage and sync
console.log('Syncing wallet to find UTXOs...');
const storage = new MemoryStorage();
await storage.open();
// Generate subaddress maps
const cnSubaddresses = generateCNSubaddressMap(
keys.spendPublicKey,
keys.viewSecretKey,
SUBADDRESS_LOOKAHEAD_MAJOR,
SUBADDRESS_LOOKAHEAD_MINOR
);
const carrotSubaddresses = generateCarrotSubaddressMap(
hexToBytes(carrotKeys.accountSpendPubkey),
hexToBytes(carrotKeys.accountViewPubkey),
hexToBytes(carrotKeys.generateAddressSecret),
SUBADDRESS_LOOKAHEAD_MAJOR,
SUBADDRESS_LOOKAHEAD_MINOR
);
const carrotKeysForSync = {
viewIncomingKey: hexToBytes(carrotKeys.viewIncomingKey),
accountSpendPubkey: hexToBytes(carrotKeys.accountSpendPubkey),
generateImageKey: hexToBytes(carrotKeys.generateImageKey),
generateAddressSecret: hexToBytes(carrotKeys.generateAddressSecret)
};
const sync = new WalletSync({
storage,
daemon,
keys: {
viewSecretKey: keys.viewSecretKey,
spendPublicKey: keys.spendPublicKey,
spendSecretKey: keys.spendSecretKey
},
carrotKeys: carrotKeysForSync,
subaddresses: cnSubaddresses,
carrotSubaddresses: carrotSubaddresses,
batchSize: 100
});
// Track sync progress
let outputsFound = 0;
sync.on('outputFound', () => outputsFound++);
sync.on('syncProgress', (data) => {
if (data.currentHeight % 5000 === 0) {
console.log(` Height ${data.currentHeight} (${data.percentComplete.toFixed(1)}%) - ${outputsFound} outputs`);
}
});
const syncStart = Date.now();
await sync.start(0);
const syncTime = ((Date.now() - syncStart) / 1000).toFixed(1);
// Get wallet state
const outputs = await storage.getOutputs();
const unspentOutputs = outputs.filter(o => !o.isSpent);
let balance = 0n;
for (const o of unspentOutputs) {
balance += o.amount;
}
console.log(`\nSync complete in ${syncTime}s`);
console.log(`Total outputs: ${outputs.length}`);
console.log(`Unspent outputs: ${unspentOutputs.length}`);
console.log(`Balance: ${Number(balance) / 1e8} SAL\n`);
// Check if we have enough funds
const requiredAmount = BURN_AMOUNT + FEE;
if (balance < requiredAmount) {
console.error(`ERROR: Insufficient funds. Need ${Number(requiredAmount) / 1e8} SAL, have ${Number(balance) / 1e8} SAL`);
await storage.close();
process.exit(1);
}
// Select inputs (simple selection - just use enough outputs)
console.log('Selecting inputs...');
const selectedInputs = [];
let selectedAmount = 0n;
for (const output of unspentOutputs) {
if (selectedAmount >= requiredAmount) break;
// Fetch ring members from daemon
const ringSize = 11;
const globalIndex = output.globalIndex || 0;
// Get ring members (decoys) from the daemon
const outsResponse = await daemon.getOuts({
outputs: [{ amount: 0, index: globalIndex }],
get_txid: true
});
if (!outsResponse.success || !outsResponse.result.outs) {
console.warn(` Skipping output - couldn't fetch ring data`);
continue;
}
// For a real implementation, we'd fetch proper decoys from the daemon
// This is simplified - real implementation needs proper decoy selection
const ring = [];
const ringCommitments = [];
const ringIndices = [];
// Add the real output and generate fake decoys for testing
// In production, use daemon.getOutputDistribution and proper decoy selection
for (let i = 0; i < ringSize; i++) {
if (i === 0) {
// Real output
ring.push(output.outputPublicKey);
ringCommitments.push(output.commitment || output.outputPublicKey);
ringIndices.push(globalIndex);
} else {
// Placeholder - in production, fetch real decoys
ring.push(output.outputPublicKey);
ringCommitments.push(output.commitment || output.outputPublicKey);
ringIndices.push(globalIndex + i);
}
}
selectedInputs.push({
secretKey: output.outputSecretKey || keys.spendSecretKey,
publicKey: output.outputPublicKey,
amount: output.amount,
mask: output.mask || new Uint8Array(32),
ring,
ringCommitments,
ringIndices,
realIndex: 0
});
selectedAmount += output.amount;
console.log(` Selected output: ${Number(output.amount) / 1e8} SAL`);
}
console.log(`Total selected: ${Number(selectedAmount) / 1e8} SAL\n`);
if (selectedInputs.length === 0) {
console.error('ERROR: Could not select any valid inputs');
await storage.close();
process.exit(1);
}
// Build the BURN transaction
console.log('Building BURN transaction...');
try {
const tx = buildBurnTransaction(
{
inputs: selectedInputs,
burnAmount: BURN_AMOUNT,
changeAddress: {
viewPublicKey: keys.viewPublicKey,
spendPublicKey: keys.spendPublicKey,
isSubaddress: false
},
fee: FEE
},
{
assetType: ASSET_TYPE
}
);
console.log('\n--- Transaction Built ---');
console.log(`TX Type: ${tx.prefix.txType} (BURN)`);
console.log(`Amount Burnt: ${Number(tx.prefix.amount_burnt) / 1e8} ${ASSET_TYPE}`);
console.log(`Source Asset: ${tx.prefix.source_asset_type}`);
console.log(`Destination Asset: ${tx.prefix.destination_asset_type}`);
console.log(`Inputs: ${tx.prefix.inputs.length}`);
console.log(`Outputs: ${tx.prefix.outputs.length} (change only)`);
console.log(`CLSAG Signatures: ${tx.rct?.CLSAGs?.length || 0}`);
// Serialize for broadcasting
const txBlob = serializeTransaction(tx);
const txHex = bytesToHex(txBlob);
console.log(`Serialized size: ${txBlob.length} bytes`);
console.log(`TX Hex (first 100): ${txHex.slice(0, 100)}...`);
if (DRY_RUN) {
console.log('\n[DRY RUN] Transaction NOT broadcast.');
console.log('To broadcast for real, run with DRY_RUN=false');
} else {
console.log('\nBroadcasting transaction...');
const submitResult = await daemon.sendRawTransaction(txHex);
if (submitResult.success && !submitResult.result.not_relayed) {
console.log('\n✓ Transaction submitted successfully!');
console.log(`TX Hash: ${submitResult.result.tx_hash || 'pending'}`);
} else {
console.error('\n✗ Transaction failed to submit');
console.error('Reason:', submitResult.result?.reason || submitResult.error?.message || 'Unknown');
}
}
} catch (error) {
console.error('\nERROR building transaction:', error.message);
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
}
await storage.close();
console.log('\n✓ Integration test completed!');
}
// Run
runBurnIntegrationTest().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});