bc9412b5da
- 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
314 lines
11 KiB
JavaScript
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);
|
|
});
|