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:
+144
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user