6e6743269e
- 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
350 lines
14 KiB
JavaScript
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);
|
|
});
|