Add transaction sending to testnet with full CLSAG ring signature pipeline

Exercises the complete transfer flow on the self-contained testnet:
UTXO selection, gamma-distribution decoy selection, ring member
fetching, buildTransaction with CLSAG signing, mempool submission,
block mining, and recipient wallet scanning.

Key changes:
- TestnetNode: global output index, spent key image tracking,
  getOuts/getOutputDistribution/sendRawTransaction/isKeyImageSpent RPCs
- TestnetMiner: includes mempool user transactions in mined blocks
- Testnet.transfer(): end-to-end send from wallet to wallet
- WalletSync: fallback to JSON-based tx processing when as_hex unavailable
- 5 new tests covering transfer, change outputs, double-spend rejection
This commit is contained in:
Matt Hess
2026-01-31 17:24:21 +00:00
parent c7643663af
commit 4bbcb8afe1
5 changed files with 494 additions and 9 deletions
+146
View File
@@ -15,6 +15,14 @@ import { MemoryStorage } from '../wallet-store.js';
import { WalletSync } from '../wallet-sync.js';
import { NETWORK_ID, CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW } from '../consensus.js';
import { generateSeed, deriveKeys, deriveCarrotKeys } from '../carrot.js';
import { generateKeyDerivation, deriveSecretKey } from '../scanning.js';
import { selectUTXOs } from '../transaction/utxo.js';
import {
buildTransaction,
prepareInputs,
estimateTransactionFee,
} from '../transaction.js';
import { getTxPrefixHash } from '../transaction/serialization.js';
/**
* Self-contained Salvium testnet
@@ -161,6 +169,144 @@ export class Testnet {
return { balance, unlockedBalance };
}
/**
* Send SAL from one wallet to another
*
* @param {Object} fromWallet - Sender wallet (from createWallet/getMinerWallet)
* @param {{ viewPublicKey: string|Uint8Array, spendPublicKey: string|Uint8Array }} toAddress - Recipient public keys
* @param {bigint} amount - Amount to send (atomic units)
* @param {Object} options - Options
* @param {boolean} options.mine - Mine a block after submitting (default: true)
* @returns {{ txHash: string, fee: bigint }}
*/
async transfer(fromWallet, toAddress, amount, options = {}) {
const { mine = true } = options;
amount = typeof amount === 'bigint' ? amount : BigInt(amount);
// 1. Sync wallet to get latest outputs
await this.syncWallet(fromWallet);
const currentHeight = this.node.getHeight();
// 2. Get spendable outputs
const allOutputs = await fromWallet.storage.getOutputs({});
const spendable = allOutputs.filter(o =>
!o.isSpent && !o.isFrozen && o.keyImage &&
(currentHeight - o.blockHeight) >= CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW
);
// 3. Estimate fee
const fee = estimateTransactionFee(1, 2); // Rough estimate with 1 input, 2 outputs
// 4. Select UTXOs
const { selected } = selectUTXOs(spendable, amount + fee, 0n, {
currentHeight,
minConfirmations: CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW,
dustThreshold: 0n,
});
// 5. Assign global indices to selected outputs (needed for ring selection)
for (const output of selected) {
if (output.globalIndex === null || output.globalIndex === undefined) {
output.globalIndex = this.node.getGlobalIndex(output.txHash, output.outputIndex);
}
}
// 6. Re-derive one-time secret keys for each selected output
const viewSecKey = fromWallet.keys.viewSecretKey;
const spendSecKey = fromWallet.keys.spendSecretKey;
for (const output of selected) {
// Get the tx pubkey from the stored transaction
const stored = this.node.txsByHash.get(output.txHash);
if (!stored) throw new Error(`Transaction ${output.txHash} not found`);
const txSecretKeyHex = stored.txSecretKey;
if (txSecretKeyHex) {
// We have the tx secret key — derive the output public key directly
// For coinbase outputs where we're the miner, we can use the tx secret key
// to compute the derivation: D = txSecretKey * viewPublicKey (but we need
// the standard derivation: D = viewSecretKey * txPublicKey)
}
// Standard approach: derive using tx public key from the transaction
let txPubKey;
const tx = stored.tx;
if (tx?.extra?.txPubKey) {
txPubKey = typeof tx.extra.txPubKey === 'string'
? hexToBytes(tx.extra.txPubKey)
: tx.extra.txPubKey;
} else {
throw new Error(`Cannot find tx pubkey for ${output.txHash}`);
}
const derivation = generateKeyDerivation(txPubKey, viewSecKey);
const outputSecretKey = deriveSecretKey(derivation, output.outputIndex, spendSecKey);
output.secretKey = outputSecretKey;
// Coinbase outputs have null mask — set to identity scalar (blinding factor = 1)
if (!output.mask) {
output.mask = '0100000000000000000000000000000000000000000000000000000000000000';
}
// Coinbase outputs have null commitment — compute zeroCommit(amount)
if (!output.commitment) {
// For coinbase: C = G + amount*H (blinding factor 1)
// We store the identity mask and let buildTransaction handle it
// The commitment is needed for ring signature verification
const { commit } = await import('../transaction/serialization.js');
const scalarOne = hexToBytes('0100000000000000000000000000000000000000000000000000000000000000');
output.commitment = bytesToHex(commit(output.amount, scalarOne));
}
}
// 7. Prepare inputs (decoy selection + ring member fetching)
const preparedInputs = await prepareInputs(selected, this.node, {
ringSize: 16,
});
// 8. Build and sign the transaction
const viewPub = toAddress.viewPublicKey;
const spendPub = toAddress.spendPublicKey;
const viewPubHex = typeof viewPub === 'string' ? viewPub : bytesToHex(viewPub);
const spendPubHex = typeof spendPub === 'string' ? spendPub : bytesToHex(spendPub);
// Change goes back to sender
const senderViewPub = bytesToHex(fromWallet.keys.viewPublicKey);
const senderSpendPub = bytesToHex(fromWallet.keys.spendPublicKey);
const tx = buildTransaction({
inputs: preparedInputs,
destinations: [{
viewPublicKey: viewPubHex,
spendPublicKey: spendPubHex,
amount,
}],
changeAddress: {
viewPublicKey: senderViewPub,
spendPublicKey: senderSpendPub,
},
fee,
});
// 9. Compute tx hash and attach to transaction
const txHashBytes = getTxPrefixHash(tx.prefix);
const txHashHex = bytesToHex(txHashBytes);
tx.txHash = txHashHex;
if (!tx._meta) tx._meta = {};
tx._meta.txHash = txHashHex;
// 10. Submit to mempool
const result = await this.node.sendRawTransaction(tx);
if (!result.success) {
throw new Error(`Transaction rejected: ${result.error?.message}`);
}
// 11. Mine a block to confirm (optional)
if (mine) {
await this.miner.mineBlock();
}
return { tx, fee, txHash: txHashHex };
}
/**
* Clean up resources
*/
+38 -3
View File
@@ -106,9 +106,15 @@ export class TestnetMiner {
const { tx: protocolTx, txHash: protocolTxHash } =
createEmptyProtocolTransaction(height);
// Drain mempool
// Drain mempool (user transactions)
const mempoolTxs = this.node.drainMempool();
// Collect user transaction hashes for the block (as Uint8Array for merkle tree)
const userTxHashes = mempoolTxs.map(mtx => {
const h = mtx._meta?.txHash || mtx.txHash;
return typeof h === 'string' ? hexToBytes(h) : h;
});
// Assemble block
const prevHash = height > 0
? hexToBytes(this.node.getTopBlockHash())
@@ -122,14 +128,15 @@ export class TestnetMiner {
nonce: 0,
miner_tx: minerTx,
protocol_tx: protocolTx,
tx_hashes: [],
tx_hashes: userTxHashes,
};
// Build hashing blob for PoW
// Transaction hashes for tree: [minerTxHash, protocolTxHash, ...mempoolTxHashes]
const allTxHashes = [minerTxHash, protocolTxHash];
for (const mtx of mempoolTxs) {
allTxHashes.push(mtx.txHash);
const h = mtx._meta?.txHash || mtx.txHash;
allTxHashes.push(typeof h === 'string' ? hexToBytes(h) : h);
}
const hashingBlob = constructBlockHashingBlob(block, allTxHashes);
@@ -155,10 +162,38 @@ export class TestnetMiner {
const blockHashBytes = getBlockHash(block);
const blockHash = bytesToHex(blockHashBytes);
// Build user transaction data for the node
const userTxs = mempoolTxs.map(mtx => {
const txHashHex = typeof (mtx._meta?.txHash || mtx.txHash) === 'string'
? (mtx._meta?.txHash || mtx.txHash)
: bytesToHex(mtx._meta?.txHash || mtx.txHash);
return {
txHash: txHashHex,
keyImages: mtx._meta?.keyImages || [],
outputs: (mtx.prefix?.vout || []).map((vout, i) => ({
key: typeof vout.target === 'string' ? vout.target : bytesToHex(vout.target),
mask: mtx.rct?.outPk?.[i] || null,
commitment: mtx.rct?.outPk?.[i] || null,
})),
tx: mtx,
};
});
// Store user transactions for lookup
for (const utx of userTxs) {
this.node.txsByHash.set(utx.txHash, {
tx: utx.tx,
txHash: utx.txHash,
blockHeight: height,
txSecretKey: utx.tx._meta?.txSecretKey || null,
});
}
// Add block to chain
this.node.addBlock(block, blockHash, bytesToHex(minerTxHash), bytesToHex(protocolTxHash), {
minerTx: { txSecretKey: bytesToHex(txSecretKey) },
protocolTx: {},
userTxs,
}, reward);
return { height, hash: blockHash, reward };
+230 -3
View File
@@ -28,6 +28,12 @@ export class TestnetNode {
/** @type {Array<Object>} Pending mempool transactions */
this.mempool = [];
/** @type {Array<Object>} Global output index: { key, mask, height, txid, unlocked } */
this.globalOutputs = [];
/** @type {Set<string>} Spent key images (hex) */
this.spentKeyImages = new Set();
}
// ===========================================================================
@@ -79,6 +85,48 @@ export class TestnetNode {
...txData.protocolTx,
});
}
// Index miner_tx outputs into globalOutputs
if (block.miner_tx?.outputs) {
for (const output of block.miner_tx.outputs) {
const key = typeof output.target === 'string' ? output.target : bytesToHex(output.target);
// Coinbase outputs have mask = identity (scalar 1)
const mask = output.commitment || '0100000000000000000000000000000000000000000000000000000000000000';
this.globalOutputs.push({
key,
mask,
height,
txid: minerTxHash,
unlocked: false,
commitment: output.commitment || null,
});
}
}
// Index user transaction outputs
if (entry.txData.userTxs) {
for (const utx of entry.txData.userTxs) {
// Record spent key images
if (utx.keyImages) {
for (const ki of utx.keyImages) {
this.spentKeyImages.add(ki);
}
}
// Index outputs
if (utx.outputs) {
for (const out of utx.outputs) {
this.globalOutputs.push({
key: out.key,
mask: out.mask || '0100000000000000000000000000000000000000000000000000000000000000',
height,
txid: utx.txHash,
unlocked: false,
commitment: out.commitment || null,
});
}
}
}
}
}
getHeight() { return this.blocks.length; }
@@ -149,9 +197,12 @@ export class TestnetNode {
const block = entry.block;
// Build the JSON representation that WalletSync._processBlock expects
const txHashes = (block.tx_hashes || []).map(h =>
typeof h === 'string' ? h : bytesToHex(h)
);
const blockJson = {
miner_tx: this._txToJson(block.miner_tx, entry.txData.minerTx),
tx_hashes: [],
tx_hashes: txHashes,
};
if (block.protocol_tx) {
blockJson.protocol_tx = this._txToJson(block.protocol_tx, entry.txData.protocolTx);
@@ -173,9 +224,12 @@ export class TestnetNode {
if (h >= 0 && h < this.blocks.length) {
const entry = this.blocks[h];
const block = entry.block;
const txHashesHex = (block.tx_hashes || []).map(h =>
typeof h === 'string' ? h : bytesToHex(h)
);
const blockJson = {
miner_tx: this._txToJson(block.miner_tx, entry.txData.minerTx),
tx_hashes: [],
tx_hashes: txHashesHex,
};
if (block.protocol_tx) {
blockJson.protocol_tx = this._txToJson(block.protocol_tx, entry.txData.protocolTx);
@@ -197,9 +251,18 @@ export class TestnetNode {
for (const hash of hashes) {
const stored = this.txsByHash.get(hash);
if (stored) {
// Determine if this is a user TX (from buildTransaction) or internal TX
let txJson;
if (stored.tx?.prefix) {
// User transaction from buildTransaction()
txJson = this._userTxToJson(stored.tx);
} else {
// Internal TX (miner/protocol)
txJson = this._txToJson(stored.tx, stored);
}
txs.push({
tx_hash: hash,
as_json: JSON.stringify(this._txToJson(stored.tx, stored)),
as_json: JSON.stringify(txJson),
});
}
}
@@ -210,10 +273,174 @@ export class TestnetNode {
return { success: true, result: { transactions: [] } };
}
// ===========================================================================
// Transaction Sending RPC Methods
// ===========================================================================
/**
* Get output keys and masks by global index (for ring member fetching)
* Matches daemon.getOuts() signature
*/
async getOuts({ outputs, get_txid = false }) {
const outs = [];
for (const req of outputs) {
const idx = req.index;
if (idx >= 0 && idx < this.globalOutputs.length) {
const o = this.globalOutputs[idx];
const entry = {
key: o.key,
mask: o.mask,
unlocked: true, // Simplified: all outputs available as ring members
height: o.height,
};
if (get_txid) entry.txid = o.txid;
outs.push(entry);
} else {
outs.push({ key: '0'.repeat(64), mask: '0'.repeat(64), unlocked: false, height: 0 });
}
}
return { outs };
}
/**
* Get output distribution (cumulative output counts per block)
* Used by GammaPicker for decoy selection
*/
async getOutputDistribution(amounts = [0], options = {}) {
// Build cumulative output count per block height
const distribution = [];
let cumulative = 0;
for (let h = 0; h < this.blocks.length; h++) {
// Count outputs added at this height
const outputsAtHeight = this.globalOutputs.filter(o => o.height === h).length;
cumulative += outputsAtHeight;
distribution.push(cumulative);
}
return {
distributions: [{
amount: 0,
start_height: 0,
base: 0,
distribution,
}],
};
}
/**
* Get output histogram (alternative format used by prepareInputs)
*/
async getOutputHistogram({ amounts = [0] } = {}) {
const dist = await this.getOutputDistribution(amounts);
return {
histogram: [{
amount: 0,
recent_outputs_offsets: dist.distributions[0].distribution,
}],
};
}
/**
* Submit a raw transaction to the mempool
* For testnet, accepts tx object directly (no hex serialization needed)
*/
async sendRawTransaction(txOrHex, options = {}) {
const tx = typeof txOrHex === 'string' ? JSON.parse(txOrHex) : txOrHex;
// Validate key images not already spent
const keyImages = tx._meta?.keyImages || [];
for (const ki of keyImages) {
if (this.spentKeyImages.has(ki)) {
return {
success: false,
error: { message: `Double spend: key image ${ki.slice(0, 16)}... already spent` },
};
}
}
this.mempool.push(tx);
return { success: true, result: { status: 'OK' } };
}
/**
* Check if key images are spent
*/
async isKeyImageSpent(keyImages) {
const spentStatus = keyImages.map(ki => this.spentKeyImages.has(ki) ? 1 : 0);
return { spent_status: spentStatus };
}
/**
* Get the global output index for an output by txHash and outputIndex
*/
getGlobalIndex(txHash, outputIndex) {
let idx = 0;
for (const o of this.globalOutputs) {
if (o.txid === txHash) {
if (outputIndex === 0) return idx;
outputIndex--;
}
idx++;
}
return -1;
}
// ===========================================================================
// Internal: Convert internal tx format to JSON format WalletSync expects
// ===========================================================================
/**
* Convert a buildTransaction() output to JSON format for wallet scanning
*/
_userTxToJson(tx) {
if (!tx || !tx.prefix) return null;
const prefix = tx.prefix;
const json = {
version: prefix.version,
unlock_time: Number(prefix.unlockTime || 0),
vin: (prefix.vin || []).map(input => {
if (input.type === 'gen' || input.type === 0xff) {
return { gen: { height: input.height || 0 } };
}
return {
key: {
amount: Number(input.amount || 0),
key_offsets: (input.keyOffsets || []).map(Number),
k_image: typeof input.keyImage === 'string'
? input.keyImage
: bytesToHex(input.keyImage),
},
};
}),
vout: (prefix.vout || []).map(output => ({
amount: 0, // RingCT: always 0
target: {
key: typeof output.target === 'string' ? output.target : bytesToHex(output.target),
},
})),
extra: [],
rct_signatures: {
type: tx.rct?.type || 0,
txnFee: Number(tx.rct?.fee || 0),
ecdhInfo: (tx.rct?.ecdhInfo || []).map(e =>
typeof e === 'string' ? { amount: e } : e
),
outPk: tx.rct?.outPk || [],
},
};
// Extra: tx pubkey
if (prefix.extra?.txPubKey) {
const pkHex = typeof prefix.extra.txPubKey === 'string'
? prefix.extra.txPubKey : bytesToHex(prefix.extra.txPubKey);
const pkBytes = hexToBytes(pkHex);
json.extra = [0x01, ...pkBytes];
}
return json;
}
/**
* Convert a tx object to the JSON representation that WalletSync parses
*
+7 -1
View File
@@ -610,7 +610,13 @@ export class WalletSync {
if (txsResponse.success && txsResponse.result.txs) {
for (const txData of txsResponse.result.txs) {
await this._processTransaction(txData, header, { isMinerTx: false, isProtocolTx: false });
if (txData.as_hex) {
await this._processTransaction(txData, header, { isMinerTx: false, isProtocolTx: false });
} else if (txData.as_json) {
// Fallback for testnet/in-memory nodes that don't have binary serialization
const txJson = typeof txData.as_json === 'string' ? JSON.parse(txData.as_json) : txData.as_json;
await this._processEmbeddedTransaction(txJson, txData.tx_hash, header, { isMinerTx: false, isProtocolTx: false });
}
}
}
}
+73 -2
View File
@@ -195,6 +195,77 @@ describe('Testnet Integration', () => {
}, 30000);
});
describe('Testnet Transactions', () => {
let testnet;
beforeAll(async () => {
testnet = new Testnet();
await testnet.init();
// Mine 70+ blocks for enough outputs for ring size 16,
// plus 60 for maturity window = 130 total needed
await testnet.mineBlocks(130);
}, 60000);
test('chain has enough outputs for ring selection', () => {
expect(testnet.node.globalOutputs.length).toBeGreaterThanOrEqual(70);
});
test('miner wallet has unlocked balance', async () => {
const wallet = testnet.getMinerWallet();
await testnet.syncWallet(wallet);
const { unlockedBalance } = await testnet.getBalance(wallet);
expect(unlockedBalance).toBeGreaterThan(0n);
}, 30000);
test('basic transfer: miner → new wallet', async () => {
const minerWallet = testnet.getMinerWallet();
const recipient = testnet.createWallet();
const transferAmount = 1000000000n; // 1 SAL
const { tx, fee } = await testnet.transfer(
minerWallet,
{
viewPublicKey: recipient.keys.viewPublicKey,
spendPublicKey: recipient.keys.spendPublicKey,
},
transferAmount,
);
expect(tx).toBeDefined();
expect(fee).toBeGreaterThan(0n);
// Sync recipient and verify balance
await testnet.syncWallet(recipient);
const { balance } = await testnet.getBalance(recipient);
expect(balance).toBe(transferAmount);
}, 30000);
test('sender has change output after transfer', async () => {
const minerWallet = testnet.getMinerWallet();
await testnet.syncWallet(minerWallet);
const { balance } = await testnet.getBalance(minerWallet);
// Miner should still have funds (change from previous transfer + new coinbase)
expect(balance).toBeGreaterThan(0n);
}, 30000);
test('double-spend is rejected', async () => {
// Key images from the first transfer should be marked as spent
expect(testnet.node.spentKeyImages.size).toBeGreaterThan(0);
const spent = await testnet.node.isKeyImageSpent(
[...testnet.node.spentKeyImages].slice(0, 1)
);
expect(spent.spent_status[0]).toBe(1);
// Attempting to resubmit a TX with the same key image should fail
const fakeTx = { _meta: { keyImages: [...testnet.node.spentKeyImages].slice(0, 1) } };
const result = await testnet.node.sendRawTransaction(fakeTx);
expect(result.success).toBe(false);
});
});
describe('Testnet CARROT Hard Fork', () => {
let testnet;
@@ -240,7 +311,7 @@ describe('Testnet CARROT Hard Fork', () => {
for (const o of carrotOutputs) {
expect(o.amount).toBeGreaterThan(0n);
}
});
}, 120000);
test('post-fork blocks use carrot_v1 output format', async () => {
const block = await testnet.node.getBlock({ height: 1100 });
@@ -260,5 +331,5 @@ describe('Testnet CARROT Hard Fork', () => {
expect(balance).toBeGreaterThan(0n);
// With 1100+ blocks mined, most should be unlocked (60-block window)
expect(unlockedBalance).toBeGreaterThan(0n);
});
}, 120000);
});