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
529 lines
16 KiB
JavaScript
529 lines
16 KiB
JavaScript
#!/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');
|