diff --git a/src/transaction.js b/src/transaction.js index a038f2d..1bcab8a 100644 --- a/src/transaction.js +++ b/src/transaction.js @@ -2962,11 +2962,12 @@ export function buildTransaction(params, options = {}) { if (!inputs || inputs.length === 0) { throw new Error('At least one input is required'); } - // STAKE, BURN, and CONVERT transactions can have no payment destinations (only change) + // STAKE, BURN, CONVERT, and AUDIT transactions can have no payment destinations // - 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 + // - AUDIT: All coins locked (change-is-zero), returned via protocol_tx after maturity if ((!destinations || destinations.length === 0) && - txType !== TX_TYPE.STAKE && txType !== TX_TYPE.BURN && txType !== TX_TYPE.CONVERT) { + txType !== TX_TYPE.STAKE && txType !== TX_TYPE.BURN && txType !== TX_TYPE.CONVERT && txType !== TX_TYPE.AUDIT) { throw new Error('At least one destination is required'); } @@ -3518,6 +3519,140 @@ export function buildConvertTransaction(params, options = {}) { ); } +/** + * Build an AUDIT transaction + * + * AUDIT transactions enable users to participate in periodic compliance/transparency + * audits during designated AUDIT hard fork periods. Users voluntarily lock their + * holdings for a defined period, providing cryptographic proofs of ownership. + * + * NOTE: AUDIT transactions are only valid during specific AUDIT hard fork periods + * (HF v6, v8). Transactions submitted outside these windows will be rejected. + * + * @param {Object} params - Transaction parameters: + * - inputs: Array of inputs to spend (all coins from the wallet/subaddress) + * - auditAmount: Total amount being audited (locked) + * - sourceAsset: Asset type being audited ('SAL' or 'SAL1' depending on HF) + * - destAsset: Asset type received after maturity ('SAL1') + * - unlockHeight: Block height when coins unlock (current_height + lock_period) + * - returnAddress: Public key for receiving coins after maturity + * - returnPubkey: TX public key for ECDH + * - fee: Transaction fee + * @param {Object} options - Optional settings: + * - txSecretKey: Pre-set transaction secret key + * - useCarrot: Use CARROT output format + * - viewSecretKey: View secret key for audit disclosure (encrypted in tx) + * - spendPublicKey: Spend public key for audit verification + * @returns {Object} Built transaction ready for broadcast + */ +export function buildAuditTransaction(params, options = {}) { + const { + inputs, + auditAmount, + sourceAsset, + destAsset, + unlockHeight, + returnAddress, + returnPubkey, + fee + } = params; + + const { + txSecretKey, + useCarrot = false, + viewSecretKey = null, + spendPublicKey = null + } = options; + + // Validate inputs + if (!inputs || inputs.length === 0) { + throw new Error('At least one input is required'); + } + if (!auditAmount || auditAmount <= 0n) { + throw new Error('Audit amount must be positive'); + } + if (!sourceAsset) { + throw new Error('Source asset type is required'); + } + if (!destAsset) { + throw new Error('Destination asset type is required'); + } + + // AUDIT transactions convert SAL -> SAL1 or audit SAL1 -> SAL1 + const validPairs = [ + ['SAL', 'SAL1'], + ['SAL1', 'SAL1'] + ]; + const isValidPair = validPairs.some( + ([from, to]) => from === sourceAsset && to === destAsset + ); + if (!isValidPair) { + throw new Error(`Invalid audit asset pair: ${sourceAsset} -> ${destAsset}. AUDIT uses SAL->SAL1 or SAL1->SAL1`); + } + + if (!returnAddress) { + throw new Error('Return address is required for audit transaction'); + } + if (!returnPubkey) { + throw new Error('Return pubkey is required for audit transaction'); + } + if (!unlockHeight || unlockHeight <= 0) { + throw new Error('Unlock height must be positive'); + } + + const auditAmountBig = typeof auditAmount === 'bigint' ? auditAmount : BigInt(auditAmount); + const feeBig = typeof fee === 'bigint' ? fee : BigInt(fee); + + // 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 AUDIT: all coins are locked (minus fee), no change output + // The change-is-zero proof requires that change = 0 + const expectedAudit = totalInputAmount - feeBig; + if (auditAmountBig !== expectedAudit) { + throw new Error( + `AUDIT requires all inputs minus fee. Expected audit amount: ${expectedAudit}, got: ${auditAmountBig}. ` + + `AUDIT transactions must lock all coins (no change allowed).` + ); + } + + // AUDIT has no change output - change-is-zero is a requirement + // The locked coins return via protocol_tx after maturity + const destinations = []; + + // Build using base buildTransaction with AUDIT options + return buildTransaction( + { + inputs, + destinations, // Empty - AUDIT has no payment destinations, no change + changeAddress: null, // No change for AUDIT + fee + }, + { + unlockTime: unlockHeight, // Unlock after the audit period + txSecretKey, + useCarrot, + txType: TX_TYPE.AUDIT, + amountBurnt: auditAmountBig, // Amount being locked for audit + sourceAssetType: sourceAsset, + destinationAssetType: destAsset, + returnAddress, + returnPubkey, + protocolTxData: null, + amountSlippageLimit: 0n, // Not used for AUDIT + // AUDIT-specific options for the special proofs + auditData: { + viewSecretKey, // For encrypted view key disclosure + spendPublicKey // For spend authority verification + } + } + ); +} + /** * Sign an unsigned transaction * diff --git a/src/wallet.js b/src/wallet.js index cd91363..ffa713f 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -26,6 +26,7 @@ import { buildStakeTransaction, buildBurnTransaction, buildConvertTransaction, + buildAuditTransaction, signTransaction, prepareInputs, selectUTXOs, @@ -1625,14 +1626,150 @@ export class Wallet { } /** - * Create an audit transaction (Salvium-specific) - * @param {Object} options - Options - * @returns {Promise} Audit transaction + * Create an AUDIT transaction (Salvium-specific) + * + * AUDIT transactions enable users to participate in periodic compliance/transparency + * audits during designated AUDIT hard fork periods. Users voluntarily lock ALL their + * holdings (or from a specific account/subaddress) for a defined period. + * + * NOTE: AUDIT transactions are only valid during specific AUDIT hard fork periods + * (HF v6, v8). Transactions submitted outside these windows will be rejected. + * + * The change-is-zero requirement means ALL coins must be locked - no partial audits. + * Coins are returned via protocol_tx after the lock period expires. + * + * @param {Object} options - Options: + * - sourceAsset: Asset to audit ('SAL' or 'SAL1' depending on HF), default 'SAL' + * - destAsset: Asset received after maturity ('SAL1'), default 'SAL1' + * - accountIndex: Source account index (default: 0) + * - subaddressIndices: Specific subaddresses to audit (default: all) + * - lockPeriod: Lock period in blocks (default: network-specific from AUDIT_HARD_FORKS) + * - ringSize: Ring size for anonymity (default: 16) + * - priority: Fee priority ('low'|'default'|'elevated'|'priority') + * - rpcClient: RPC client for fetching ring members + * @returns {Promise} Audit transaction ready for broadcast */ async createAuditTransaction(options = {}) { - // TODO: Implement audit transaction following Salvium spec - // This creates a TX_TYPE.AUDIT transaction - throw new Error('Audit transactions not yet implemented'); + if (!this.canSign()) { + throw new Error('Full wallet required to create audit transactions'); + } + + const { + sourceAsset = 'SAL', + destAsset = 'SAL1', + accountIndex = 0, + subaddressIndices = null, // null = all subaddresses in account + lockPeriod = null, // null = use network default from AUDIT_HARD_FORKS + ringSize = 16, + priority = 'default', + rpcClient = null + } = options; + + // Validate asset types + const validSourceAssets = ['SAL', 'SAL1']; + if (!validSourceAssets.includes(sourceAsset)) { + throw new Error(`Invalid source asset: ${sourceAsset}. Must be SAL or SAL1`); + } + if (destAsset !== 'SAL1') { + throw new Error(`Invalid destination asset: ${destAsset}. AUDIT destination must be SAL1`); + } + + // Get all UTXOs from the specified account/subaddresses + const utxoOptions = { + unlockedOnly: true, + accountIndex, + assetType: sourceAsset + }; + if (subaddressIndices) { + utxoOptions.subaddressIndices = subaddressIndices; + } + + const availableUTXOs = this.getUTXOs(utxoOptions); + + if (availableUTXOs.length === 0) { + throw new Error(`No unlocked ${sourceAsset} outputs available for audit`); + } + + // Calculate total amount to audit (ALL coins - change-is-zero requirement) + let totalAmount = 0n; + for (const utxo of availableUTXOs) { + totalAmount += typeof utxo.amount === 'bigint' ? utxo.amount : BigInt(utxo.amount); + } + + // Estimate fee for all inputs, 0 outputs (AUDIT has no outputs) + const estimatedFee = estimateTransactionFee( + availableUTXOs.length, + 0, // AUDIT has 0 outputs (change-is-zero) + { priority, ringSize } + ); + + // Audit amount is total minus fee + const auditAmount = totalAmount - estimatedFee; + if (auditAmount <= 0n) { + throw new Error(`Insufficient funds: total ${totalAmount} minus fee ${estimatedFee} <= 0`); + } + + // Prepare inputs with ring members (decoys) + const preparedInputs = await prepareInputs(availableUTXOs, rpcClient, { ringSize }); + + // Recalculate fee with actual input count + const actualFee = estimateTransactionFee( + preparedInputs.length, + 0, // AUDIT has 0 outputs + { priority, ringSize } + ); + + // Recalculate audit amount with actual fee + const actualAuditAmount = totalAmount - actualFee; + if (actualAuditAmount <= 0n) { + throw new Error(`Insufficient funds after fee calculation`); + } + + // Calculate unlock height + // Default lock periods from C++: mainnet 30*24*10 or 30*24*14, testnet 30 or 40 + const defaultLockPeriod = 30 * 24 * 10; // ~10 days on mainnet (1 block/min) + const lockBlocks = lockPeriod || defaultLockPeriod; + const unlockHeight = this._syncHeight + lockBlocks; + + // Return address and pubkey for receiving coins after maturity + const returnAddress = this._spendPublicKey; + const returnPubkey = this._viewPublicKey; + + // Build the audit transaction + const tx = buildAuditTransaction( + { + inputs: preparedInputs, + auditAmount: actualAuditAmount, + sourceAsset, + destAsset, + unlockHeight, + returnAddress, + returnPubkey, + fee: actualFee + }, + { + useCarrot: false, + viewSecretKey: this._viewSecretKey, // For audit disclosure + spendPublicKey: this._spendPublicKey // For spend authority verification + } + ); + + // Validate + const validation = validateTransaction(tx); + if (!validation.valid) { + throw new Error(`Audit transaction validation failed: ${validation.errors.join(', ')}`); + } + + // Add metadata for tracking + tx._meta = tx._meta || {}; + tx._meta.txType = 'AUDIT'; + tx._meta.auditAmount = actualAuditAmount.toString(); + tx._meta.sourceAsset = sourceAsset; + tx._meta.destAsset = destAsset; + tx._meta.unlockHeight = unlockHeight; + tx._meta.lockPeriod = lockBlocks; + + return tx; } /** diff --git a/test/audit-transaction.test.js b/test/audit-transaction.test.js new file mode 100644 index 0000000..8b13c16 --- /dev/null +++ b/test/audit-transaction.test.js @@ -0,0 +1,434 @@ +#!/usr/bin/env bun +/** + * AUDIT Transaction Tests + * + * Tests for Salvium AUDIT transaction creation and validation. + * AUDIT enables users to participate in compliance/transparency audits + * by locking all their coins during designated hard fork periods. + * + * NOTE: AUDIT transactions are only valid during specific AUDIT hard fork + * periods (HF v6, v8) and will be rejected outside these windows. + */ + +import { describe, test, expect } from 'bun:test'; +import { + buildAuditTransaction, + serializeTxPrefix, + 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)); + } else { + const { publicKey: decoyPk } = generateTestKeys(); + ring.push(decoyPk); + ringCommitments.push(scalarMultBase(scRandom())); + } + ringIndices.push(i * 1000 + i); + } + + return { + secretKey, + publicKey, + amount, + mask, + ring, + ringCommitments, + ringIndices, + realIndex + }; +} + +describe('AUDIT Transaction', () => { + + describe('buildAuditTransaction', () => { + + test('creates valid AUDIT transaction structure (SAL -> SAL1)', () => { + const inputAmount = 100000000000n; // 1000 SAL + const fee = 100000000n; // 0.001 SAL + const auditAmount = inputAmount - fee; // All coins minus fee + const input = generateMockInput(inputAmount); + + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + const tx = buildAuditTransaction( + { + inputs: [input], + auditAmount, + sourceAsset: 'SAL', + destAsset: 'SAL1', + unlockHeight: 500000, + returnAddress: returnAddr, + returnPubkey: returnPub, + fee + } + ); + + expect(tx).toBeDefined(); + expect(tx.prefix).toBeDefined(); + expect(tx.prefix.txType).toBe(TX_TYPE.AUDIT); + expect(tx.prefix.amount_burnt).toBe(auditAmount); + expect(tx.prefix.source_asset_type).toBe('SAL'); + expect(tx.prefix.destination_asset_type).toBe('SAL1'); + expect(tx.prefix.unlockTime).toBe(500000); + }); + + test('creates valid AUDIT transaction structure (SAL1 -> SAL1)', () => { + const inputAmount = 50000000000n; + const fee = 100000000n; + const auditAmount = inputAmount - fee; + const input = generateMockInput(inputAmount); + + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + const tx = buildAuditTransaction( + { + inputs: [input], + auditAmount, + sourceAsset: 'SAL1', + destAsset: 'SAL1', + unlockHeight: 600000, + returnAddress: returnAddr, + returnPubkey: returnPub, + fee + } + ); + + expect(tx.prefix.txType).toBe(TX_TYPE.AUDIT); + expect(tx.prefix.source_asset_type).toBe('SAL1'); + expect(tx.prefix.destination_asset_type).toBe('SAL1'); + }); + + test('enforces change-is-zero requirement', () => { + const inputAmount = 100000000000n; + const fee = 100000000n; + const incorrectAuditAmount = inputAmount - fee - 1000n; // Leaves change + const input = generateMockInput(inputAmount); + + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + expect(() => { + buildAuditTransaction( + { + inputs: [input], + auditAmount: incorrectAuditAmount, // Not all coins + sourceAsset: 'SAL', + destAsset: 'SAL1', + unlockHeight: 500000, + returnAddress: returnAddr, + returnPubkey: returnPub, + fee + } + ); + }).toThrow('AUDIT requires all inputs minus fee'); + }); + + test('throws error for zero audit amount', () => { + const input = generateMockInput(); + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + expect(() => { + buildAuditTransaction( + { + inputs: [input], + auditAmount: 0n, + sourceAsset: 'SAL', + destAsset: 'SAL1', + unlockHeight: 500000, + returnAddress: returnAddr, + returnPubkey: returnPub, + fee: 100000000n + } + ); + }).toThrow('Audit amount must be positive'); + }); + + test('throws error for invalid asset pair (SAL -> SAL)', () => { + const inputAmount = 100000000000n; + const fee = 100000000n; + const auditAmount = inputAmount - fee; + const input = generateMockInput(inputAmount); + + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + expect(() => { + buildAuditTransaction( + { + inputs: [input], + auditAmount, + sourceAsset: 'SAL', + destAsset: 'SAL', // Invalid - must be SAL1 + unlockHeight: 500000, + returnAddress: returnAddr, + returnPubkey: returnPub, + fee + } + ); + }).toThrow('Invalid audit asset pair'); + }); + + test('throws error for invalid asset pair (VSD -> SAL1)', () => { + const inputAmount = 100000000000n; + const fee = 100000000n; + const auditAmount = inputAmount - fee; + const input = generateMockInput(inputAmount); + + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + expect(() => { + buildAuditTransaction( + { + inputs: [input], + auditAmount, + sourceAsset: 'VSD', // Invalid for AUDIT + destAsset: 'SAL1', + unlockHeight: 500000, + returnAddress: returnAddr, + returnPubkey: returnPub, + fee + } + ); + }).toThrow('Invalid audit asset pair'); + }); + + test('throws error for missing inputs', () => { + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + expect(() => { + buildAuditTransaction( + { + inputs: [], + auditAmount: 100000000n, + sourceAsset: 'SAL', + destAsset: 'SAL1', + unlockHeight: 500000, + returnAddress: returnAddr, + returnPubkey: returnPub, + fee: 100000000n + } + ); + }).toThrow('At least one input is required'); + }); + + test('throws error for missing return address', () => { + const inputAmount = 100000000000n; + const fee = 100000000n; + const auditAmount = inputAmount - fee; + const input = generateMockInput(inputAmount); + + const { publicKey: returnPub } = generateTestKeys(); + + expect(() => { + buildAuditTransaction( + { + inputs: [input], + auditAmount, + sourceAsset: 'SAL', + destAsset: 'SAL1', + unlockHeight: 500000, + returnAddress: null, + returnPubkey: returnPub, + fee + } + ); + }).toThrow('Return address is required'); + }); + + test('throws error for missing return pubkey', () => { + const inputAmount = 100000000000n; + const fee = 100000000n; + const auditAmount = inputAmount - fee; + const input = generateMockInput(inputAmount); + + const { publicKey: returnAddr } = generateTestKeys(); + + expect(() => { + buildAuditTransaction( + { + inputs: [input], + auditAmount, + sourceAsset: 'SAL', + destAsset: 'SAL1', + unlockHeight: 500000, + returnAddress: returnAddr, + returnPubkey: null, + fee + } + ); + }).toThrow('Return pubkey is required'); + }); + + test('throws error for invalid unlock height', () => { + const inputAmount = 100000000000n; + const fee = 100000000n; + const auditAmount = inputAmount - fee; + const input = generateMockInput(inputAmount); + + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + expect(() => { + buildAuditTransaction( + { + inputs: [input], + auditAmount, + sourceAsset: 'SAL', + destAsset: 'SAL1', + unlockHeight: 0, // Invalid + returnAddress: returnAddr, + returnPubkey: returnPub, + fee + } + ); + }).toThrow('Unlock height must be positive'); + }); + + test('includes CLSAG signatures', () => { + const inputAmount = 100000000000n; + const fee = 100000000n; + const auditAmount = inputAmount - fee; + const input = generateMockInput(inputAmount); + + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + const tx = buildAuditTransaction( + { + inputs: [input], + auditAmount, + sourceAsset: 'SAL', + destAsset: 'SAL1', + unlockHeight: 500000, + 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 + }); + + test('has zero outputs (change-is-zero)', () => { + const inputAmount = 100000000000n; + const fee = 100000000n; + const auditAmount = inputAmount - fee; + const input = generateMockInput(inputAmount); + + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + const tx = buildAuditTransaction( + { + inputs: [input], + auditAmount, + sourceAsset: 'SAL', + destAsset: 'SAL1', + unlockHeight: 500000, + returnAddress: returnAddr, + returnPubkey: returnPub, + fee + } + ); + + // AUDIT transactions should have 0 outputs (change-is-zero) + expect(tx.prefix.vout.length).toBe(0); + }); + + test('handles multiple inputs correctly', () => { + const input1Amount = 50000000000n; + const input2Amount = 50000000000n; + const fee = 100000000n; + const totalInput = input1Amount + input2Amount; + const auditAmount = totalInput - fee; + + const input1 = generateMockInput(input1Amount); + const input2 = generateMockInput(input2Amount); + + const { publicKey: returnAddr } = generateTestKeys(); + const { publicKey: returnPub } = generateTestKeys(); + + const tx = buildAuditTransaction( + { + inputs: [input1, input2], + auditAmount, + sourceAsset: 'SAL', + destAsset: 'SAL1', + unlockHeight: 500000, + returnAddress: returnAddr, + returnPubkey: returnPub, + fee + } + ); + + expect(tx.prefix.txType).toBe(TX_TYPE.AUDIT); + expect(tx.prefix.amount_burnt).toBe(auditAmount); + expect(tx.prefix.vin.length).toBe(2); + expect(tx.rct.CLSAGs.length).toBe(2); + }); + + }); + + describe('serializeTxPrefix with AUDIT fields', () => { + + test('serializes AUDIT transaction type', () => { + const prefix = { + version: 4, + unlockTime: 500000, + inputs: [], + outputs: [], + extra: {}, + txType: TX_TYPE.AUDIT, + amount_burnt: 100000000n, + source_asset_type: 'SAL', + destination_asset_type: 'SAL1', + return_address: new Uint8Array(32), + return_pubkey: new Uint8Array(32), + amount_slippage_limit: 0n + }; + + const serialized = serializeTxPrefix(prefix); + expect(serialized).toBeInstanceOf(Uint8Array); + expect(serialized.length).toBeGreaterThan(0); + }); + + }); + +}); + +console.log('\n=== AUDIT Transaction Tests ===\n');