Files
salvium-rs/test/convert-integration.test.js
T
Matt Hess 6e6743269e Add CONVERT transaction support for SAL<->VSD conversion
- Add buildConvertTransaction() in transaction.js
  - Add createConvertTransaction() in wallet.js
  - Oracle-priced conversion with 3.125% slippage
  - SAL <-> VSD only (other pairs rejected)
  - 15 unit tests + integration test
  - Note: Gated at HF v255, not yet enabled on mainnet
2026-01-25 13:11:31 +00:00

350 lines
14 KiB
JavaScript

#!/usr/bin/env bun
/**
* CONVERT Transaction Integration Test
*
* Tests creating and broadcasting a CONVERT transaction on a real network.
*
* NOTE: CONVERT transactions are currently gated behind hard fork version 255
* and are not yet enabled on mainnet. This test will build valid transactions
* but they will be rejected by nodes until the feature is activated.
*
* Usage:
* WALLET_SEED="your 25 word mnemonic" bun test/convert-integration.test.js
*
* Options:
* WALLET_SEED - 25 word mnemonic (required)
* CONVERT_AMOUNT - Amount to convert in SAL (default: 1.0)
* SOURCE_ASSET - Asset to convert from: SAL or VSD (default: SAL)
* DEST_ASSET - Asset to convert to: VSD or SAL (default: VSD)
* SLIPPAGE_PCT - Slippage tolerance percentage (default: 3.125)
* DAEMON_URL - Daemon RPC URL (default: http://seed01.salvium.io:19081)
* DRY_RUN - If "true", build tx but don't broadcast (default: true)
*/
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 { buildConvertTransaction, serializeTransaction, TX_TYPE } from '../src/transaction.js';
// ============================================================================
// Configuration
// ============================================================================
const DAEMON_URL = process.env.DAEMON_URL || 'http://seed01.salvium.io:19081';
const CONVERT_AMOUNT_SAL = parseFloat(process.env.CONVERT_AMOUNT || '1.0');
const CONVERT_AMOUNT = BigInt(Math.floor(CONVERT_AMOUNT_SAL * 1e8)); // Convert to atomic units
const SOURCE_ASSET = process.env.SOURCE_ASSET || 'SAL';
const DEST_ASSET = process.env.DEST_ASSET || 'VSD';
const SLIPPAGE_PCT = parseFloat(process.env.SLIPPAGE_PCT || '3.125');
const DRY_RUN = process.env.DRY_RUN !== 'false'; // Default to dry run for safety
const FEE = 100000000n; // 0.001 SAL fee (standard)
// ============================================================================
// Main
// ============================================================================
async function runConvertIntegrationTest() {
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ CONVERT Transaction Integration Test ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
console.log('NOTE: CONVERT transactions are currently gated behind HF v255');
console.log(' and will be REJECTED by nodes until the feature is activated.\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/convert-integration.test.js');
console.log('');
console.log('Options:');
console.log(' CONVERT_AMOUNT=1.0 Amount to convert (default: 1.0)');
console.log(' SOURCE_ASSET=SAL Asset to convert from: SAL or VSD (default: SAL)');
console.log(' DEST_ASSET=VSD Asset to convert to: VSD or SAL (default: VSD)');
console.log(' SLIPPAGE_PCT=3.125 Slippage tolerance % (default: 3.125)');
console.log(' DRY_RUN=true Build tx but do not broadcast (default: true)');
process.exit(1);
}
// Validate asset types
const validAssets = ['SAL', 'VSD'];
if (!validAssets.includes(SOURCE_ASSET) || !validAssets.includes(DEST_ASSET)) {
console.error(`ERROR: Invalid asset types. Must be SAL or VSD.`);
process.exit(1);
}
if (SOURCE_ASSET === DEST_ASSET) {
console.error('ERROR: Source and destination assets must be different.');
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);
// Calculate slippage limit
const defaultSlippage = CONVERT_AMOUNT >> 5n; // 3.125%
const userSlippage = BigInt(Math.floor((Number(CONVERT_AMOUNT) * SLIPPAGE_PCT) / 100));
const slippageLimit = userSlippage > defaultSlippage ? userSlippage : defaultSlippage;
console.log('--- Configuration ---');
console.log(`Daemon URL: ${DAEMON_URL}`);
console.log(`Convert: ${CONVERT_AMOUNT_SAL} ${SOURCE_ASSET} -> ${DEST_ASSET}`);
console.log(`Slippage limit: ${Number(slippageLimit) / 1e8} ${SOURCE_ASSET} (${SLIPPAGE_PCT}%)`);
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 = CONVERT_AMOUNT + FEE;
if (balance < requiredAmount) {
console.error(`ERROR: Insufficient funds. Need ${Number(requiredAmount) / 1e8} ${SOURCE_ASSET}, 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} ${SOURCE_ASSET}`);
}
console.log(`Total selected: ${Number(selectedAmount) / 1e8} ${SOURCE_ASSET}\n`);
if (selectedInputs.length === 0) {
console.error('ERROR: Could not select any valid inputs');
await storage.close();
process.exit(1);
}
// Build the CONVERT transaction
console.log('Building CONVERT transaction...');
try {
const tx = buildConvertTransaction(
{
inputs: selectedInputs,
convertAmount: CONVERT_AMOUNT,
sourceAsset: SOURCE_ASSET,
destAsset: DEST_ASSET,
slippageLimit,
changeAddress: {
viewPublicKey: keys.viewPublicKey,
spendPublicKey: keys.spendPublicKey,
isSubaddress: false
},
returnAddress: keys.spendPublicKey,
returnPubkey: keys.viewPublicKey,
fee: FEE
}
);
console.log('\n--- Transaction Built ---');
console.log(`TX Type: ${tx.prefix.txType} (CONVERT)`);
console.log(`Convert Amount: ${Number(tx.prefix.amount_burnt) / 1e8} ${SOURCE_ASSET}`);
console.log(`Source Asset: ${tx.prefix.source_asset_type}`);
console.log(`Destination Asset: ${tx.prefix.destination_asset_type}`);
console.log(`Slippage Limit: ${Number(tx.prefix.amount_slippage_limit) / 1e8} ${SOURCE_ASSET}`);
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');
console.log('\nWARNING: CONVERT is gated at HF v255 - this TX would be REJECTED by the network!');
} else {
console.log('\nBroadcasting transaction...');
console.log('WARNING: CONVERT is gated at HF v255 - this TX will likely be REJECTED!');
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');
console.log('\nThis is EXPECTED - CONVERT is not yet enabled on the network.');
}
}
} 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
runConvertIntegrationTest().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});