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