diff --git a/src/testnet/index.js b/src/testnet/index.js index 7eb3597..79c0bae 100644 --- a/src/testnet/index.js +++ b/src/testnet/index.js @@ -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 */ diff --git a/src/testnet/miner.js b/src/testnet/miner.js index c69421c..8a63e17 100644 --- a/src/testnet/miner.js +++ b/src/testnet/miner.js @@ -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 }; diff --git a/src/testnet/node.js b/src/testnet/node.js index 8826084..67a2241 100644 --- a/src/testnet/node.js +++ b/src/testnet/node.js @@ -28,6 +28,12 @@ export class TestnetNode { /** @type {Array} Pending mempool transactions */ this.mempool = []; + + /** @type {Array} Global output index: { key, mask, height, txid, unlocked } */ + this.globalOutputs = []; + + /** @type {Set} 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 * diff --git a/src/wallet-sync.js b/src/wallet-sync.js index 0676a14..0a92bf6 100644 --- a/src/wallet-sync.js +++ b/src/wallet-sync.js @@ -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 }); + } } } } diff --git a/test/testnet.test.js b/test/testnet.test.js index aac061b..d6c7619 100644 --- a/test/testnet.test.js +++ b/test/testnet.test.js @@ -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); });