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
This commit is contained in:
Matt Hess
2026-01-25 13:11:31 +00:00
parent bc9412b5da
commit 6e6743269e
4 changed files with 1185 additions and 3 deletions
+144 -3
View File
@@ -2962,10 +2962,11 @@ export function buildTransaction(params, options = {}) {
if (!inputs || inputs.length === 0) {
throw new Error('At least one input is required');
}
// STAKE and BURN transactions can have no payment destinations (only change)
// The "burned" amount goes to amount_burnt field, not to outputs
// STAKE, BURN, and CONVERT transactions can have no payment destinations (only change)
// - STAKE/BURN: The "burned" amount goes to amount_burnt field, not to outputs
// - CONVERT: The converted output is created by the protocol_tx at block mining time
if ((!destinations || destinations.length === 0) &&
txType !== TX_TYPE.STAKE && txType !== TX_TYPE.BURN) {
txType !== TX_TYPE.STAKE && txType !== TX_TYPE.BURN && txType !== TX_TYPE.CONVERT) {
throw new Error('At least one destination is required');
}
@@ -3377,6 +3378,146 @@ export function buildBurnTransaction(params, options = {}) {
);
}
/**
* Build a CONVERT transaction
*
* CONVERT transactions convert between asset types (SAL <-> VSD) using oracle pricing.
* The actual conversion happens at the protocol layer when the block is mined.
*
* NOTE: CONVERT transactions are currently gated behind hard fork version 255
* and are not yet enabled on mainnet.
*
* @param {Object} params - Transaction parameters:
* - inputs: Array of inputs to spend
* - convertAmount: Amount to convert (in source asset)
* - sourceAsset: Asset type to convert FROM ('SAL' or 'VSD')
* - destAsset: Asset type to convert TO ('VSD' or 'SAL')
* - slippageLimit: Maximum acceptable slippage (default: 3.125% = amount/32)
* - changeAddress: Address object for change output
* - returnAddress: Public key for receiving converted amount (derived from wallet keys)
* - returnPubkey: TX public key for ECDH (derived from wallet keys)
* - fee: Transaction fee
* @param {Object} options - Optional settings:
* - txSecretKey: Pre-set transaction secret key
* - useCarrot: Use CARROT output format
* @returns {Object} Built transaction ready for broadcast
*/
export function buildConvertTransaction(params, options = {}) {
const {
inputs,
convertAmount,
sourceAsset,
destAsset,
slippageLimit,
changeAddress,
returnAddress,
returnPubkey,
fee
} = params;
const {
txSecretKey,
useCarrot = false
} = options;
// Validate inputs
if (!inputs || inputs.length === 0) {
throw new Error('At least one input is required');
}
if (!convertAmount || convertAmount <= 0n) {
throw new Error('Convert amount must be positive');
}
if (!sourceAsset) {
throw new Error('Source asset type is required');
}
if (!destAsset) {
throw new Error('Destination asset type is required');
}
if (sourceAsset === destAsset) {
throw new Error('Source and destination asset types must be different');
}
// Only SAL <-> VSD conversions are valid
const validPairs = [
['SAL', 'VSD'],
['VSD', 'SAL']
];
const isValidPair = validPairs.some(
([from, to]) => from === sourceAsset && to === destAsset
);
if (!isValidPair) {
throw new Error(`Invalid conversion pair: ${sourceAsset} -> ${destAsset}. Only SAL <-> VSD conversions are allowed`);
}
if (!changeAddress) {
throw new Error('Change address is required for convert transaction');
}
if (!returnAddress) {
throw new Error('Return address is required for convert transaction');
}
if (!returnPubkey) {
throw new Error('Return pubkey is required for convert transaction');
}
const convertAmountBig = typeof convertAmount === 'bigint' ? convertAmount : BigInt(convertAmount);
const feeBig = typeof fee === 'bigint' ? fee : BigInt(fee);
// Calculate slippage limit - default is 1/32 (3.125%) of convert amount
// User can specify lower but not lower than protocol minimum
const defaultSlippage = convertAmountBig >> 5n; // amount / 32
let slippageLimitBig;
if (slippageLimit !== undefined && slippageLimit !== null) {
slippageLimitBig = typeof slippageLimit === 'bigint' ? slippageLimit : BigInt(slippageLimit);
// Slippage limit must be >= protocol slippage (1/32) for conversion to succeed
if (slippageLimitBig < defaultSlippage) {
throw new Error(`Slippage limit ${slippageLimitBig} is below protocol minimum ${defaultSlippage} (3.125%)`);
}
} else {
slippageLimitBig = defaultSlippage;
}
// Calculate total input amount
let totalInputAmount = 0n;
for (const input of inputs) {
const amount = typeof input.amount === 'bigint' ? input.amount : BigInt(input.amount);
totalInputAmount += amount;
}
// For CONVERT: converted amount goes in amount_burnt, only change output in this tx
// The converted output is created by the protocol_tx at block mining time
const changeAmount = totalInputAmount - convertAmountBig - feeBig;
if (changeAmount < 0n) {
throw new Error(`Insufficient funds: inputs=${totalInputAmount}, convert=${convertAmountBig}, fee=${feeBig}`);
}
// CONVERT has no direct destinations - converted output comes from protocol_tx
// Only change output is included in this transaction
const destinations = [];
// Build using base buildTransaction with CONVERT options
return buildTransaction(
{
inputs,
destinations, // Empty - converted output created by protocol_tx
changeAddress,
fee
},
{
unlockTime: 0, // CONVERT has no lock period
txSecretKey,
useCarrot,
txType: TX_TYPE.CONVERT,
amountBurnt: convertAmountBig, // Amount being converted
sourceAssetType: sourceAsset,
destinationAssetType: destAsset,
returnAddress,
returnPubkey,
protocolTxData: null,
amountSlippageLimit: slippageLimitBig
}
);
}
/**
* Sign an unsigned transaction
*
+164
View File
@@ -25,6 +25,7 @@ import {
buildTransaction,
buildStakeTransaction,
buildBurnTransaction,
buildConvertTransaction,
signTransaction,
prepareInputs,
selectUTXOs,
@@ -1460,6 +1461,169 @@ export class Wallet {
return tx;
}
/**
* Create a CONVERT transaction (Salvium-specific)
*
* CONVERT transactions convert between asset types (SAL <-> VSD) using oracle pricing.
* The actual conversion happens at the protocol layer when the block is mined.
*
* NOTE: CONVERT transactions are currently gated behind hard fork version 255
* and are not yet enabled on mainnet. This function will build valid transactions
* but they will be rejected by nodes until the feature is activated.
*
* @param {bigint|number|string} amount - Amount to convert (in source asset atomic units)
* @param {Object} options - Options:
* - sourceAsset: Asset to convert FROM ('SAL' or 'VSD'), default 'SAL'
* - destAsset: Asset to convert TO ('VSD' or 'SAL'), default 'VSD'
* - slippageLimit: Maximum acceptable slippage (default: 3.125% = amount/32)
* - accountIndex: Source account index (default: 0)
* - ringSize: Ring size for anonymity (default: 16)
* - priority: Fee priority ('low'|'default'|'elevated'|'priority')
* - rpcClient: RPC client for fetching ring members
* @returns {Promise<Object>} Convert transaction ready for broadcast
*/
async createConvertTransaction(amount, options = {}) {
if (!this.canSign()) {
throw new Error('Full wallet required to create convert transactions');
}
const {
sourceAsset = 'SAL',
destAsset = 'VSD',
slippageLimit = null, // null = use default (3.125%)
accountIndex = 0,
ringSize = 16,
priority = 'default',
rpcClient = null
} = options;
// Validate asset types
const validAssets = ['SAL', 'VSD'];
if (!validAssets.includes(sourceAsset)) {
throw new Error(`Invalid source asset: ${sourceAsset}. Must be SAL or VSD`);
}
if (!validAssets.includes(destAsset)) {
throw new Error(`Invalid destination asset: ${destAsset}. Must be SAL or VSD`);
}
if (sourceAsset === destAsset) {
throw new Error('Source and destination assets must be different for conversion');
}
// Convert amount to bigint
const convertAmount = typeof amount === 'bigint' ? amount :
typeof amount === 'string' ? BigInt(amount) : BigInt(Math.floor(amount));
if (convertAmount <= 0n) {
throw new Error('Convert amount must be positive');
}
// Calculate slippage limit
const defaultSlippage = convertAmount >> 5n; // 1/32 = 3.125%
let slippageLimitBig;
if (slippageLimit !== null && slippageLimit !== undefined) {
slippageLimitBig = typeof slippageLimit === 'bigint' ? slippageLimit :
typeof slippageLimit === 'string' ? BigInt(slippageLimit) :
BigInt(Math.floor(slippageLimit));
if (slippageLimitBig < defaultSlippage) {
throw new Error(`Slippage limit ${slippageLimitBig} is below protocol minimum ${defaultSlippage} (3.125%)`);
}
} else {
slippageLimitBig = defaultSlippage;
}
// Estimate fee (CONVERT tx has 1 input minimum, 1 output - change only)
// The converted output is created by the protocol_tx at block time
const estimatedFee = estimateTransactionFee(
1, // inputs
1, // outputs (change only)
{ priority, ringSize }
);
// Select UTXOs from specified account
const availableUTXOs = this.getUTXOs({
unlockedOnly: true,
accountIndex,
assetType: sourceAsset
});
if (availableUTXOs.length === 0) {
throw new Error(`No unlocked ${sourceAsset} outputs available for conversion`);
}
// Select UTXOs to cover convert amount + fee
const { selected, changeAmount } = selectUTXOs(
availableUTXOs,
convertAmount,
estimatedFee,
{
strategy: UTXO_STRATEGY.LARGEST_FIRST,
currentHeight: this._syncHeight,
dustThreshold: 1000000n
}
);
if (selected.length === 0) {
throw new Error(`Insufficient ${sourceAsset} balance for conversion of ${convertAmount} + fee ${estimatedFee}`);
}
// Prepare inputs with ring members (decoys)
const preparedInputs = await prepareInputs(selected, rpcClient, { ringSize });
// Recalculate fee with actual input count
const actualFee = estimateTransactionFee(
preparedInputs.length,
1, // Only change output (converted amount from protocol_tx)
{ priority, ringSize }
);
// Change address is own address
const changeAddress = {
viewPublicKey: this._viewPublicKey,
spendPublicKey: this._spendPublicKey,
isSubaddress: false
};
// Return address for receiving converted amount (also own address)
// This is where the protocol_tx will send the converted output
const returnAddress = this._spendPublicKey;
const returnPubkey = this._viewPublicKey;
// Build the convert transaction
const tx = buildConvertTransaction(
{
inputs: preparedInputs,
convertAmount,
sourceAsset,
destAsset,
slippageLimit: slippageLimitBig,
changeAddress,
returnAddress,
returnPubkey,
fee: actualFee
},
{
useCarrot: false // TODO: Support CARROT conversion when needed
}
);
// Validate
const validation = validateTransaction(tx);
if (!validation.valid) {
throw new Error(`Convert transaction validation failed: ${validation.errors.join(', ')}`);
}
// Add metadata for tracking
tx._meta = tx._meta || {};
tx._meta.txType = 'CONVERT';
tx._meta.convertAmount = convertAmount.toString();
tx._meta.sourceAsset = sourceAsset;
tx._meta.destAsset = destAsset;
tx._meta.slippageLimit = slippageLimitBig.toString();
tx._meta.expectedSlippage = defaultSlippage.toString();
return tx;
}
/**
* Create an audit transaction (Salvium-specific)
* @param {Object} options - Options
+349
View File
@@ -0,0 +1,349 @@
#!/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);
});
+528
View File
@@ -0,0 +1,528 @@
#!/usr/bin/env bun
/**
* CONVERT Transaction Tests
*
* Tests for Salvium CONVERT transaction creation and validation.
* CONVERT enables SAL <-> VSD asset conversion using oracle pricing.
*
* NOTE: CONVERT transactions are currently gated behind HF version 255
* and are not yet enabled on mainnet.
*/
import { describe, test, expect } from 'bun:test';
import {
buildConvertTransaction,
serializeTxPrefix,
parseTransaction,
TX_TYPE,
scRandom
} from '../src/transaction.js';
import { scalarMultBase } from '../src/ed25519.js';
import { bytesToHex, hexToBytes } from '../src/address.js';
// Generate test keys
function generateTestKeys() {
const secretKey = scRandom();
const publicKey = scalarMultBase(secretKey);
return { secretKey, publicKey };
}
// Generate a mock input for testing
function generateMockInput(amount = 100000000000n) {
const { secretKey, publicKey } = generateTestKeys();
const mask = scRandom();
// Generate ring members
const ringSize = 11;
const realIndex = Math.floor(Math.random() * ringSize);
const ring = [];
const ringCommitments = [];
const ringIndices = [];
for (let i = 0; i < ringSize; i++) {
if (i === realIndex) {
ring.push(publicKey);
ringCommitments.push(scalarMultBase(mask)); // Simplified commitment
} else {
const { publicKey: decoyPk } = generateTestKeys();
ring.push(decoyPk);
ringCommitments.push(scalarMultBase(scRandom()));
}
ringIndices.push(i * 1000 + i); // Fake global indices
}
return {
secretKey,
publicKey,
amount,
mask,
ring,
ringCommitments,
ringIndices,
realIndex
};
}
describe('CONVERT Transaction', () => {
describe('buildConvertTransaction', () => {
test('creates valid CONVERT transaction structure (SAL -> VSD)', () => {
const input = generateMockInput(100000000000n); // 1000 SAL
const convertAmount = 10000000000n; // 100 SAL
const fee = 100000000n; // 0.001 SAL
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
const tx = buildConvertTransaction(
{
inputs: [input],
convertAmount,
sourceAsset: 'SAL',
destAsset: 'VSD',
slippageLimit: convertAmount >> 5n, // 3.125%
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee
}
);
expect(tx).toBeDefined();
expect(tx.prefix).toBeDefined();
expect(tx.prefix.txType).toBe(TX_TYPE.CONVERT);
expect(tx.prefix.amount_burnt).toBe(convertAmount);
expect(tx.prefix.source_asset_type).toBe('SAL');
expect(tx.prefix.destination_asset_type).toBe('VSD');
expect(tx.prefix.unlockTime).toBe(0); // CONVERT has no lock period
});
test('creates valid CONVERT transaction structure (VSD -> SAL)', () => {
const input = generateMockInput(100000000000n);
const convertAmount = 10000000000n;
const fee = 100000000n;
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
const tx = buildConvertTransaction(
{
inputs: [input],
convertAmount,
sourceAsset: 'VSD',
destAsset: 'SAL',
slippageLimit: convertAmount >> 5n,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee
}
);
expect(tx.prefix.txType).toBe(TX_TYPE.CONVERT);
expect(tx.prefix.source_asset_type).toBe('VSD');
expect(tx.prefix.destination_asset_type).toBe('SAL');
});
test('sets amount_slippage_limit correctly', () => {
const input = generateMockInput();
const convertAmount = 32000000000n; // 320 SAL
const expectedSlippage = convertAmount >> 5n; // 10 SAL (3.125%)
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
const tx = buildConvertTransaction(
{
inputs: [input],
convertAmount,
sourceAsset: 'SAL',
destAsset: 'VSD',
slippageLimit: expectedSlippage,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee: 100000000n
}
);
expect(tx.prefix.amount_slippage_limit).toBe(expectedSlippage);
});
test('throws error for zero convert amount', () => {
const input = generateMockInput();
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
expect(() => {
buildConvertTransaction(
{
inputs: [input],
convertAmount: 0n,
sourceAsset: 'SAL',
destAsset: 'VSD',
slippageLimit: 0n,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee: 100000000n
}
);
}).toThrow('Convert amount must be positive');
});
test('throws error for same source and destination asset', () => {
const input = generateMockInput();
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
expect(() => {
buildConvertTransaction(
{
inputs: [input],
convertAmount: 100000000n,
sourceAsset: 'SAL',
destAsset: 'SAL', // Same as source
slippageLimit: 3125000n,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee: 100000000n
}
);
}).toThrow('Source and destination asset types must be different');
});
test('throws error for invalid conversion pair', () => {
const input = generateMockInput();
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
expect(() => {
buildConvertTransaction(
{
inputs: [input],
convertAmount: 100000000n,
sourceAsset: 'SAL',
destAsset: 'BTC', // Invalid - only SAL <-> VSD allowed
slippageLimit: 3125000n,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee: 100000000n
}
);
}).toThrow('Invalid conversion pair');
});
test('throws error for insufficient funds', () => {
const input = generateMockInput(100000000n); // 1 SAL
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
expect(() => {
buildConvertTransaction(
{
inputs: [input],
convertAmount: 200000000n, // 2 SAL - more than input
sourceAsset: 'SAL',
destAsset: 'VSD',
slippageLimit: 6250000n,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee: 100000000n
}
);
}).toThrow('Insufficient funds');
});
test('throws error for missing inputs', () => {
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
expect(() => {
buildConvertTransaction(
{
inputs: [],
convertAmount: 100000000n,
sourceAsset: 'SAL',
destAsset: 'VSD',
slippageLimit: 3125000n,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee: 100000000n
}
);
}).toThrow('At least one input is required');
});
test('throws error for missing return address', () => {
const input = generateMockInput();
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
expect(() => {
buildConvertTransaction(
{
inputs: [input],
convertAmount: 100000000n,
sourceAsset: 'SAL',
destAsset: 'VSD',
slippageLimit: 3125000n,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: null, // Missing
returnPubkey: returnPub,
fee: 100000000n
}
);
}).toThrow('Return address is required');
});
test('throws error for missing return pubkey', () => {
const input = generateMockInput();
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
expect(() => {
buildConvertTransaction(
{
inputs: [input],
convertAmount: 100000000n,
sourceAsset: 'SAL',
destAsset: 'VSD',
slippageLimit: 3125000n,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: null, // Missing
fee: 100000000n
}
);
}).toThrow('Return pubkey is required');
});
test('throws error for slippage limit below protocol minimum', () => {
const input = generateMockInput();
const convertAmount = 100000000n;
const protocolMinSlippage = convertAmount >> 5n; // 3125000
const tooLowSlippage = protocolMinSlippage - 1n;
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
expect(() => {
buildConvertTransaction(
{
inputs: [input],
convertAmount,
sourceAsset: 'SAL',
destAsset: 'VSD',
slippageLimit: tooLowSlippage,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee: 100000000n
}
);
}).toThrow('below protocol minimum');
});
test('includes CLSAG signatures', () => {
const input = generateMockInput();
const convertAmount = 100000000n;
const fee = 100000000n;
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
const tx = buildConvertTransaction(
{
inputs: [input],
convertAmount,
sourceAsset: 'SAL',
destAsset: 'VSD',
slippageLimit: convertAmount >> 5n,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee
}
);
expect(tx.rct).toBeDefined();
expect(tx.rct.CLSAGs).toBeDefined();
expect(tx.rct.CLSAGs.length).toBe(1);
expect(tx.rct.CLSAGs[0].s.length).toBe(11); // Ring size
expect(tx.rct.CLSAGs[0].c1).toBeDefined();
expect(tx.rct.CLSAGs[0].I).toBeDefined(); // Key image
expect(tx.rct.CLSAGs[0].D).toBeDefined(); // Commitment key image
});
});
describe('serializeTxPrefix with CONVERT fields', () => {
test('serializes CONVERT transaction type', () => {
const prefix = {
version: 4,
unlockTime: 0,
inputs: [],
outputs: [],
extra: {},
txType: TX_TYPE.CONVERT,
amount_burnt: 100000000n,
source_asset_type: 'SAL',
destination_asset_type: 'VSD',
return_address: new Uint8Array(32),
return_pubkey: new Uint8Array(32),
amount_slippage_limit: 3125000n
};
const serialized = serializeTxPrefix(prefix);
expect(serialized).toBeInstanceOf(Uint8Array);
expect(serialized.length).toBeGreaterThan(0);
});
});
describe('slippage calculation', () => {
test('default slippage is 1/32 (3.125%) of convert amount', () => {
const input = generateMockInput();
const convertAmount = 32000000000n; // 320 SAL
const expectedSlippage = 1000000000n; // 10 SAL = 320/32
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
// When slippageLimit is not provided, use default
const tx = buildConvertTransaction(
{
inputs: [input],
convertAmount,
sourceAsset: 'SAL',
destAsset: 'VSD',
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee: 100000000n
}
);
expect(tx.prefix.amount_slippage_limit).toBe(expectedSlippage);
});
test('allows slippage limit higher than default', () => {
const input = generateMockInput();
const convertAmount = 32000000000n;
const higherSlippage = 2000000000n; // 20 SAL (6.25%)
const { publicKey: viewPubKey } = generateTestKeys();
const { publicKey: spendPubKey } = generateTestKeys();
const { publicKey: returnAddr } = generateTestKeys();
const { publicKey: returnPub } = generateTestKeys();
const tx = buildConvertTransaction(
{
inputs: [input],
convertAmount,
sourceAsset: 'SAL',
destAsset: 'VSD',
slippageLimit: higherSlippage,
changeAddress: {
viewPublicKey: viewPubKey,
spendPublicKey: spendPubKey,
isSubaddress: false
},
returnAddress: returnAddr,
returnPubkey: returnPub,
fee: 100000000n
}
);
expect(tx.prefix.amount_slippage_limit).toBe(higherSlippage);
});
});
});
console.log('\n=== CONVERT Transaction Tests ===\n');