From bc9412b5dac60fb19956f5e9063711b776b28c7f Mon Sep 17 00:00:00 2001 From: Matt Hess Date: Sun, 25 Jan 2026 00:13:39 +0000 Subject: [PATCH] =?UTF-8?q?=E2=97=8F=20Add=20BURN=20transaction=20support?= =?UTF-8?q?=20and=20security=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add buildBurnTransaction() and createBurnTransaction() - destination_asset_type = "BURN" for burned coins - 9 unit tests + integration test for BURN transactions - Remove hardcoded test mnemonics (use env vars) - Add pre-commit hook to detect potential secrets - Add testnet testing plan (test/TESTNET_PLAN.md) - Update .gitignore for secret files --- .githooks/pre-commit | 82 +++++++++ .gitignore | 13 ++ src/transaction.js | 86 +++++++++ src/wallet.js | 122 +++++++++++++ test/TESTNET_PLAN.md | 323 ++++++++++++++++++++++++++++++++++ test/burn-integration.test.js | 313 ++++++++++++++++++++++++++++++++ test/burn-transaction.test.js | 293 ++++++++++++++++++++++++++++++ test/carrot-self-test.js | 32 +++- test/quick-carrot-test.js | 32 +++- 9 files changed, 1291 insertions(+), 5 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 test/TESTNET_PLAN.md create mode 100644 test/burn-integration.test.js create mode 100644 test/burn-transaction.test.js diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..c3cb220 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,82 @@ +#!/bin/bash +# Pre-commit hook to prevent accidental secret commits + +echo "Checking for potential secrets..." + +# Patterns to detect +PATTERNS=( + # 25-word mnemonic phrases (not obviously fake like "bacon bacon...") + '[a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+' +) + +# Allowed fake test patterns (same word repeated) +ALLOWED_PATTERNS=( + "bacon bacon bacon" + "abbey abbey abbey" + "test test test" + "word word word" +) + +# Get staged files +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|json|md|env)$') + +if [ -z "$STAGED_FILES" ]; then + exit 0 +fi + +FOUND_SECRETS=0 + +for file in $STAGED_FILES; do + # Skip wordlist files + if [[ "$file" == *"wordlist"* ]]; then + continue + fi + + # Check for potential 25-word phrases + MATCHES=$(git diff --cached "$file" | grep -E "^\+" | grep -oE "'[a-z]+ [a-z]+ [a-z]+ [a-z]+ [a-z]+[^']*'" | head -5) + + for match in $MATCHES; do + # Check if it's an allowed fake pattern + IS_ALLOWED=0 + for allowed in "${ALLOWED_PATTERNS[@]}"; do + if echo "$match" | grep -q "$allowed"; then + IS_ALLOWED=1 + break + fi + done + + # If not allowed and looks like 25 words, flag it + if [ $IS_ALLOWED -eq 0 ]; then + WORD_COUNT=$(echo "$match" | tr -cd ' ' | wc -c) + if [ "$WORD_COUNT" -ge 20 ]; then + echo "WARNING: Potential mnemonic in $file" + echo " $match" + FOUND_SECRETS=1 + fi + fi + done + + # Check for 64-char hex that looks like a key (not all zeros/ones/test patterns) + HEX_MATCHES=$(git diff --cached "$file" | grep -E "^\+" | grep -oE "['\"][a-f0-9]{64}['\"]" | grep -v "0000000000" | grep -v "ffffffff" | grep -v "01234567" | grep -v "f5f5f5f5" | head -5) + + for hex in $HEX_MATCHES; do + # Check if it's in a test file with TEST_ prefix nearby + if ! grep -B2 "$hex" "$file" 2>/dev/null | grep -qE "TEST_|test_|const test|// test"; then + echo "WARNING: Potential secret key in $file" + echo " $hex" + FOUND_SECRETS=1 + fi + done +done + +if [ $FOUND_SECRETS -eq 1 ]; then + echo "" + echo "Potential secrets detected! Review carefully before committing." + echo "If these are intentional test values, you can bypass with: git commit --no-verify" + echo "" + # Warning only - don't block (use exit 1 to block) + exit 0 +fi + +echo "No secrets detected." +exit 0 diff --git a/.gitignore b/.gitignore index 9715012..955ab12 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,16 @@ bun.lock node_modules/ build/ TODO + +# Secrets - NEVER commit these +.env +.env.* +*.env +secrets/ +*.key +*.pem +wallet.json +wallet-*.json +*-wallet.json +*.seed +*.mnemonic diff --git a/src/transaction.js b/src/transaction.js index 672414b..5789bd1 100644 --- a/src/transaction.js +++ b/src/transaction.js @@ -3291,6 +3291,92 @@ export function buildStakeTransaction(params, options = {}) { ); } +/** + * Build a BURN transaction + * + * BURN transactions permanently destroy coins. The burned amount goes into + * amount_burnt field with destination_asset_type = "BURN". + * + * Structure: + * - txType: BURN (5) + * - source_asset_type: "SAL" or "SAL1" + * - destination_asset_type: "BURN" + * - amount_burnt: burned amount + * - unlock_time: 0 (no lock) + * - outputs: only change back to sender + * + * @param {Object} params - Transaction parameters: + * @param {Array} params.inputs - Array of input objects with ring data + * @param {BigInt|number} params.burnAmount - Amount to burn + * @param {Object} params.changeAddress - Address for change output + * @param {BigInt|number} params.fee - Transaction fee + * @param {Object} options - Additional options: + * @param {string} options.assetType - Asset type to burn ('SAL' or 'SAL1', default 'SAL') + * @param {Uint8Array} options.txSecretKey - Optional pre-generated tx secret key + * @param {boolean} options.useCarrot - Use CARROT protocol + * @returns {Object} Complete BURN transaction ready for broadcast + */ +export function buildBurnTransaction(params, options = {}) { + const { inputs, burnAmount, changeAddress, fee } = params; + const { + assetType = 'SAL', + txSecretKey, + useCarrot = false + } = options; + + if (!inputs || inputs.length === 0) { + throw new Error('At least one input is required'); + } + if (!burnAmount || burnAmount <= 0n) { + throw new Error('Burn amount must be positive'); + } + if (!changeAddress) { + throw new Error('Change address is required for burn transaction'); + } + + const burnAmountBig = typeof burnAmount === 'bigint' ? burnAmount : BigInt(burnAmount); + 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 BURN: burned amount goes in amount_burnt, only change output + const changeAmount = totalInputAmount - burnAmountBig - feeBig; + if (changeAmount < 0n) { + throw new Error(`Insufficient funds: inputs=${totalInputAmount}, burn=${burnAmountBig}, fee=${feeBig}`); + } + + // BURN has no destinations - only change output + const destinations = []; + + // Build using base buildTransaction with BURN options + return buildTransaction( + { + inputs, + destinations, // Empty - BURN has no payment destinations + changeAddress, + fee + }, + { + unlockTime: 0, // BURN has no lock period + txSecretKey, + useCarrot, + txType: TX_TYPE.BURN, + amountBurnt: burnAmountBig, + sourceAssetType: assetType, + destinationAssetType: 'BURN', // Special marker for BURN transactions + returnAddress: null, + returnPubkey: null, + protocolTxData: null, + amountSlippageLimit: 0n + } + ); +} + /** * Sign an unsigned transaction * diff --git a/src/wallet.js b/src/wallet.js index b848847..1761346 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -24,6 +24,7 @@ import { generateKeyImage } from './keyimage.js'; import { buildTransaction, buildStakeTransaction, + buildBurnTransaction, signTransaction, prepareInputs, selectUTXOs, @@ -1338,6 +1339,127 @@ export class Wallet { return tx; } + /** + * Create a BURN transaction (Salvium-specific) + * + * Burns coins permanently - they are destroyed and cannot be recovered. + * The burned amount is recorded in amount_burnt with destination_asset_type = "BURN". + * + * @param {BigInt|number|string} amount - Amount to burn + * @param {Object} options - Transaction options: + * @param {string} options.assetType - Asset type to burn ('SAL' or 'SAL1', default 'SAL') + * @param {number} options.accountIndex - Account index to burn from (default 0) + * @param {number} options.ringSize - Ring size for CLSAG (default 16) + * @param {string} options.priority - Fee priority ('low', 'default', 'high') + * @param {Object} options.rpcClient - RPC client for fetching decoys + * @returns {Promise} Burn transaction ready for broadcast + */ + async createBurnTransaction(amount, options = {}) { + if (!this.canSign()) { + throw new Error('Full wallet required to create burn transactions'); + } + + const { + assetType = 'SAL', + accountIndex = 0, + ringSize = 16, + priority = 'default', + rpcClient = null + } = options; + + // Validate asset type + if (assetType !== 'SAL' && assetType !== 'SAL1') { + throw new Error('BURN transactions must use SAL or SAL1 asset type'); + } + + // Convert amount to bigint + const burnAmount = typeof amount === 'bigint' ? amount : + typeof amount === 'string' ? BigInt(amount) : BigInt(Math.floor(amount)); + + if (burnAmount <= 0n) { + throw new Error('Burn amount must be positive'); + } + + // Estimate fee (BURN tx has 1 input minimum, 1 output - change only) + const estimatedFee = estimateTransactionFee( + 1, // inputs + 1, // outputs (change only) + { priority, ringSize } + ); + + // Select UTXOs from specified account + const availableUTXOs = this.getUTXOs({ + unlockedOnly: true, + accountIndex, + assetType + }); + + if (availableUTXOs.length === 0) { + throw new Error(`No unlocked ${assetType} outputs available for burning`); + } + + // Select UTXOs to cover burn amount + fee + const { selected, changeAmount } = selectUTXOs( + availableUTXOs, + burnAmount, + estimatedFee, + { + strategy: UTXO_STRATEGY.LARGEST_FIRST, + currentHeight: this._syncHeight, + dustThreshold: 1000000n + } + ); + + if (selected.length === 0) { + throw new Error(`Insufficient ${assetType} balance for burn of ${burnAmount} + fee ${estimatedFee}`); + } + + // Prepare inputs with ring members (decoys) + const preparedInputs = await prepareInputs(selected, rpcClient, { ringSize }); + + // Recalculate fee with actual input count + const actualFee = estimateTransactionFee( + preparedInputs.length, + 1, // Only change output + { priority, ringSize } + ); + + // Change address is own address + const changeAddress = { + viewPublicKey: this._viewPublicKey, + spendPublicKey: this._spendPublicKey, + isSubaddress: false + }; + + // Build the burn transaction + const tx = buildBurnTransaction( + { + inputs: preparedInputs, + burnAmount, + changeAddress, + fee: actualFee + }, + { + assetType, + useCarrot: false // TODO: Support CARROT burning when needed + } + ); + + // Validate + const validation = validateTransaction(tx); + if (!validation.valid) { + throw new Error(`Burn transaction validation failed: ${validation.errors.join(', ')}`); + } + + // Add metadata for tracking + tx._meta = tx._meta || {}; + tx._meta.txType = TX_TYPE.BURN; + tx._meta.burnAmount = burnAmount.toString(); + tx._meta.assetType = assetType; + + return tx; + } + /** * Create an audit transaction (Salvium-specific) * @param {Object} options - Options diff --git a/test/TESTNET_PLAN.md b/test/TESTNET_PLAN.md new file mode 100644 index 0000000..7e103d8 --- /dev/null +++ b/test/TESTNET_PLAN.md @@ -0,0 +1,323 @@ +# Salvium-JS Testnet Testing Plan + +**Status:** Pending - to be executed near project completion + +--- + +## Security: No Secrets in Git + +**Pre-commit hook installed:** `.githooks/pre-commit` scans for potential secrets before commit. + +**Setup:** Run `git config core.hooksPath .githooks` to enable. + +**Protected patterns:** +- 25-word mnemonic phrases +- 64-character hex strings (potential keys) +- Files matching `*.env`, `*.key`, `wallet*.json`, etc. + +**Git history audit (2026-01-25):** No real wallet credentials found in history. Only test patterns (`bacon bacon...`, `abbey abbey...`) which are recognizable fake mnemonics. + +--- + +## Prerequisites + +### Testnet Setup +- [ ] Testnet daemon URL (or run local testnet node) +- [ ] Testnet wallet with funds (need testnet SAL faucet or mining) +- [ ] Second wallet for receiving transactions + +### Environment Variables + +**CRITICAL: All wallet secrets MUST come from environment variables. Never hardcode mnemonics, master keys, view keys, or spend keys in test files.** + +```bash +# Primary wallet (choose one method) +export WALLET_SEED="testnet wallet 25 word mnemonic" +# OR +export MASTER_KEY="64-character-hex-master-key" +# OR (for view-only testing) +export VIEW_SECRET_KEY="64-char-hex" +export SPEND_PUBLIC_KEY="64-char-hex" + +# Secondary wallet for receiving +export WALLET_SEED_2="second wallet mnemonic for receiving" + +# Daemon +export DAEMON_URL="http://localhost:28081" # Testnet port +``` + +### Supported Key Formats + +| Variable | Format | Description | +|----------|--------|-------------| +| `WALLET_SEED` | 25 words | Full wallet from mnemonic | +| `MASTER_KEY` | 64 hex chars | Raw 32-byte seed | +| `VIEW_SECRET_KEY` | 64 hex chars | View-only: view secret | +| `SPEND_PUBLIC_KEY` | 64 hex chars | View-only: spend pubkey | +| `SPEND_SECRET_KEY` | 64 hex chars | Optional: spend secret | + +--- + +## Test Categories + +### 1. Wallet Sync & Balance Detection + +**Test:** Full sync from genesis +```bash +WALLET_SEED="..." bun test/integration-sync.test.js +``` + +**Verify:** +- [ ] CN outputs detected correctly +- [ ] CARROT outputs detected correctly +- [ ] Subaddress outputs detected (both CN and CARROT) +- [ ] Integrated address outputs detected +- [ ] Key images computed correctly +- [ ] Spent outputs marked as spent +- [ ] Balance matches expected value +- [ ] Stake/yield outputs identified + +**Test:** Partial sync (resume from height) +```bash +START_HEIGHT=10000 WALLET_SEED="..." bun test/integration-sync.test.js +``` + +--- + +### 2. TRANSFER Transaction + +**Test:** Send SAL to standard address +```bash +WALLET_SEED="..." \ +RECIPIENT="SLVx..." \ +AMOUNT=1.0 \ +DRY_RUN=false \ +bun test/transfer-integration.test.js +``` + +**Verify:** +- [ ] Transaction builds without error +- [ ] CLSAG signatures valid +- [ ] Bulletproofs+ range proofs valid +- [ ] Transaction accepted by daemon +- [ ] Transaction appears in mempool +- [ ] Transaction confirmed in block +- [ ] Recipient wallet detects the output +- [ ] Change output returns to sender + +**Test:** Send to subaddress +- [ ] CN subaddress recipient +- [ ] CARROT subaddress recipient + +**Test:** Send to integrated address +- [ ] Payment ID embedded correctly +- [ ] Recipient detects with payment ID + +--- + +### 3. STAKE Transaction + +**Test:** Create stake +```bash +WALLET_SEED="..." \ +STAKE_AMOUNT=100.0 \ +DRY_RUN=false \ +bun test/stake-integration.test.js +``` + +**Verify:** +- [ ] txType = 6 (STAKE) +- [ ] unlock_time = current_height + STAKE_LOCK_PERIOD +- [ ] source_asset_type = "SAL" +- [ ] destination_asset_type = "SAL" +- [ ] Transaction accepted by daemon +- [ ] Stake output locked until unlock_time +- [ ] After unlock, RETURN transaction received +- [ ] Yield amount correct per consensus rules + +**Test:** Verify lock period +- [ ] Mainnet: 21600 blocks (~30 days) +- [ ] Testnet: 20 blocks (for quick testing) + +--- + +### 4. BURN Transaction + +**Test:** Burn SAL +```bash +WALLET_SEED="..." \ +BURN_AMOUNT=0.1 \ +DRY_RUN=false \ +bun test/burn-integration.test.js +``` + +**Verify:** +- [ ] txType = 5 (BURN) +- [ ] destination_asset_type = "BURN" +- [ ] amount_burnt matches requested amount +- [ ] unlock_time = 0 (no lock) +- [ ] Transaction accepted by daemon +- [ ] Burned coins permanently removed from supply +- [ ] Change output returns correctly + +**Test:** Burn SAL1 (if available) +```bash +ASSET_TYPE=SAL1 WALLET_SEED="..." BURN_AMOUNT=0.1 bun test/burn-integration.test.js +``` + +--- + +### 5. CONVERT Transaction (When Implemented) + +**Test:** Convert SAL to SAL1 +```bash +WALLET_SEED="..." \ +FROM_ASSET=SAL \ +TO_ASSET=SAL1 \ +AMOUNT=10.0 \ +DRY_RUN=false \ +bun test/convert-integration.test.js +``` + +**Verify:** +- [ ] txType = 4 (CONVERT) +- [ ] source_asset_type correct +- [ ] destination_asset_type correct +- [ ] Oracle price used correctly +- [ ] Slippage limit enforced +- [ ] Transaction accepted by daemon +- [ ] Converted amount received + +--- + +### 6. AUDIT Transaction (When Implemented) + +**Test:** Create audit disclosure +```bash +WALLET_SEED="..." \ +AUDIT_TYPE=full \ +DRY_RUN=false \ +bun test/audit-integration.test.js +``` + +**Verify:** +- [ ] txType = 8 (AUDIT) +- [ ] Disclosure data correct +- [ ] Transaction accepted by daemon + +--- + +### 7. Address Generation + +**Test:** All address types generate correctly +```bash +bun test/address-integration.test.js +``` + +**Verify:** +- [ ] Legacy (CN) main address - starts with SLVx +- [ ] Legacy subaddress - starts with SLVs +- [ ] Legacy integrated address - starts with SLVi +- [ ] CARROT main address - starts with salv +- [ ] CARROT subaddress - starts with salvs +- [ ] CARROT integrated address - starts with salvi +- [ ] All addresses decode back to correct keys + +--- + +### 8. Signature Verification + +**Test:** Verify our signatures against daemon +```bash +bun test/signature-verification.test.js +``` + +**Verify:** +- [ ] CLSAG signatures we create pass daemon verification +- [ ] Bulletproofs+ we create pass daemon verification +- [ ] Message signatures verify correctly + +--- + +### 9. RPC Methods + +**Test:** All daemon RPC methods work +```bash +bun test/rpc.integration.js $DAEMON_URL +``` + +**Verify:** +- [ ] getInfo +- [ ] getBlockCount +- [ ] getBlock / getBlockByHeight +- [ ] getTransactions +- [ ] sendRawTransaction +- [ ] getOuts (for ring member selection) +- [ ] getOutputDistribution +- [ ] Salvium-specific: getSupplyInfo, getYieldInfo + +--- + +### 10. Edge Cases + +**Multi-input transactions:** +- [ ] Transaction with 2+ inputs +- [ ] Mixed CN and CARROT inputs (if applicable) + +**Dust handling:** +- [ ] Very small outputs +- [ ] sweepDust function (when implemented) + +**Error handling:** +- [ ] Insufficient funds error +- [ ] Invalid address error +- [ ] Network errors (daemon unreachable) +- [ ] Invalid transaction rejection + +--- + +## Test Execution Order + +1. **Wallet Sync** - Verify we can scan blockchain correctly +2. **Address Generation** - Verify addresses work +3. **TRANSFER** - Basic send/receive +4. **STAKE** - Staking functionality +5. **BURN** - Burning functionality +6. **CONVERT** - Asset conversion (when ready) +7. **AUDIT** - Compliance features (when ready) +8. **Edge Cases** - Stress testing + +--- + +## Integration Test Scripts Needed + +| Script | Status | Description | +|--------|--------|-------------| +| `integration-sync.test.js` | ✅ Exists | Wallet sync | +| `transfer-integration.test.js` | ❌ TODO | Send transactions | +| `stake-integration.test.js` | ❌ TODO | Stake creation | +| `burn-integration.test.js` | ✅ Exists | Burn transactions | +| `convert-integration.test.js` | ❌ TODO | Asset conversion | +| `audit-integration.test.js` | ❌ TODO | Audit transactions | +| `address-integration.test.js` | ❌ TODO | Address roundtrip | +| `signature-verification.test.js` | ❌ TODO | Sig verification | + +--- + +## Success Criteria + +All tests pass with: +- [ ] Zero transaction rejections +- [ ] Zero balance discrepancies +- [ ] Zero parsing errors +- [ ] All transaction types confirmed on-chain +- [ ] Recipient wallets detect all sent outputs + +--- + +## Notes + +- Always use testnet first - never test with real mainnet funds +- Keep test amounts small (0.01-1 SAL) +- Document any daemon version requirements +- Record block heights of test transactions for debugging diff --git a/test/burn-integration.test.js b/test/burn-integration.test.js new file mode 100644 index 0000000..674d2b7 --- /dev/null +++ b/test/burn-integration.test.js @@ -0,0 +1,313 @@ +#!/usr/bin/env bun +/** + * BURN Transaction Integration Test + * + * Tests creating and broadcasting a BURN transaction on a real network. + * + * Usage: + * WALLET_SEED="your 25 word mnemonic" BURN_AMOUNT=0.01 bun test/burn-integration.test.js + * + * Options: + * WALLET_SEED - 25 word mnemonic (required) + * BURN_AMOUNT - Amount to burn in SAL (default: 0.01) + * DAEMON_URL - Daemon RPC URL (default: http://seed01.salvium.io:19081) + * DRY_RUN - If "true", build tx but don't broadcast (default: true) + * ASSET_TYPE - Asset to burn: SAL or SAL1 (default: SAL) + */ + +import { createDaemonRPC } from '../src/rpc/index.js'; +import { mnemonicToSeed } from '../src/mnemonic.js'; +import { deriveKeys, deriveCarrotKeys } from '../src/carrot.js'; +import { hexToBytes, bytesToHex, createAddress } from '../src/address.js'; +import { NETWORK, ADDRESS_FORMAT, ADDRESS_TYPE } from '../src/constants.js'; +import { MemoryStorage } from '../src/wallet-store.js'; +import { WalletSync } from '../src/wallet-sync.js'; +import { generateCNSubaddressMap, generateCarrotSubaddressMap, SUBADDRESS_LOOKAHEAD_MAJOR, SUBADDRESS_LOOKAHEAD_MINOR } from '../src/subaddress.js'; +import { buildBurnTransaction, serializeTransaction, TX_TYPE } from '../src/transaction.js'; + +// ============================================================================ +// Configuration +// ============================================================================ + +const DAEMON_URL = process.env.DAEMON_URL || 'http://seed01.salvium.io:19081'; +const BURN_AMOUNT_SAL = parseFloat(process.env.BURN_AMOUNT || '0.01'); +const BURN_AMOUNT = BigInt(Math.floor(BURN_AMOUNT_SAL * 1e8)); // Convert to atomic units +const DRY_RUN = process.env.DRY_RUN !== 'false'; // Default to dry run for safety +const ASSET_TYPE = process.env.ASSET_TYPE || 'SAL'; +const FEE = 100000000n; // 0.001 SAL fee (standard) + +// ============================================================================ +// Main +// ============================================================================ + +async function runBurnIntegrationTest() { + console.log('╔════════════════════════════════════════════════════════════╗'); + console.log('║ BURN Transaction Integration Test ║'); + console.log('╚════════════════════════════════════════════════════════════╝\n'); + + // Validate input + if (!process.env.WALLET_SEED) { + console.error('ERROR: WALLET_SEED environment variable required.\n'); + console.log('Usage:'); + console.log(' WALLET_SEED="your 25 word mnemonic" bun test/burn-integration.test.js'); + console.log(''); + console.log('Options:'); + console.log(' BURN_AMOUNT=0.01 Amount to burn in SAL (default: 0.01)'); + console.log(' DRY_RUN=true Build tx but do not broadcast (default: true)'); + console.log(' ASSET_TYPE=SAL Asset to burn: SAL or SAL1 (default: SAL)'); + process.exit(1); + } + + // Parse mnemonic + const mnemonic = process.env.WALLET_SEED.trim(); + const result = mnemonicToSeed(mnemonic, { language: 'auto' }); + if (!result.valid) { + console.error('Invalid mnemonic:', result.error); + process.exit(1); + } + + const keys = deriveKeys(result.seed); + const carrotKeys = deriveCarrotKeys(keys.spendSecretKey); + + console.log('--- Configuration ---'); + console.log(`Daemon URL: ${DAEMON_URL}`); + console.log(`Burn amount: ${BURN_AMOUNT_SAL} ${ASSET_TYPE} (${BURN_AMOUNT} atomic)`); + console.log(`Fee: ${Number(FEE) / 1e8} SAL`); + console.log(`Mode: ${DRY_RUN ? 'DRY RUN (will NOT broadcast)' : 'LIVE (will broadcast!)'}`); + + // Generate wallet address for display + const mainAddress = createAddress({ + network: NETWORK.MAINNET, + format: ADDRESS_FORMAT.LEGACY, + type: ADDRESS_TYPE.STANDARD, + spendPublicKey: keys.spendPublicKey, + viewPublicKey: keys.viewPublicKey + }); + console.log(`Wallet: ${mainAddress.slice(0, 20)}...${mainAddress.slice(-10)}\n`); + + // Connect to daemon + console.log('Connecting to daemon...'); + const daemon = createDaemonRPC({ url: DAEMON_URL, timeout: 30000 }); + + const info = await daemon.getInfo(); + if (!info.success) { + console.error('ERROR: Failed to connect to daemon:', info.error?.message); + process.exit(1); + } + + const daemonHeight = info.result.height; + console.log(`Daemon height: ${daemonHeight}`); + console.log(`Network: ${info.result.nettype || 'mainnet'}\n`); + + // Create storage and sync + console.log('Syncing wallet to find UTXOs...'); + const storage = new MemoryStorage(); + await storage.open(); + + // Generate subaddress maps + const cnSubaddresses = generateCNSubaddressMap( + keys.spendPublicKey, + keys.viewSecretKey, + SUBADDRESS_LOOKAHEAD_MAJOR, + SUBADDRESS_LOOKAHEAD_MINOR + ); + + const carrotSubaddresses = generateCarrotSubaddressMap( + hexToBytes(carrotKeys.accountSpendPubkey), + hexToBytes(carrotKeys.accountViewPubkey), + hexToBytes(carrotKeys.generateAddressSecret), + SUBADDRESS_LOOKAHEAD_MAJOR, + SUBADDRESS_LOOKAHEAD_MINOR + ); + + const carrotKeysForSync = { + viewIncomingKey: hexToBytes(carrotKeys.viewIncomingKey), + accountSpendPubkey: hexToBytes(carrotKeys.accountSpendPubkey), + generateImageKey: hexToBytes(carrotKeys.generateImageKey), + generateAddressSecret: hexToBytes(carrotKeys.generateAddressSecret) + }; + + const sync = new WalletSync({ + storage, + daemon, + keys: { + viewSecretKey: keys.viewSecretKey, + spendPublicKey: keys.spendPublicKey, + spendSecretKey: keys.spendSecretKey + }, + carrotKeys: carrotKeysForSync, + subaddresses: cnSubaddresses, + carrotSubaddresses: carrotSubaddresses, + batchSize: 100 + }); + + // Track sync progress + let outputsFound = 0; + sync.on('outputFound', () => outputsFound++); + sync.on('syncProgress', (data) => { + if (data.currentHeight % 5000 === 0) { + console.log(` Height ${data.currentHeight} (${data.percentComplete.toFixed(1)}%) - ${outputsFound} outputs`); + } + }); + + const syncStart = Date.now(); + await sync.start(0); + const syncTime = ((Date.now() - syncStart) / 1000).toFixed(1); + + // Get wallet state + const outputs = await storage.getOutputs(); + const unspentOutputs = outputs.filter(o => !o.isSpent); + let balance = 0n; + for (const o of unspentOutputs) { + balance += o.amount; + } + + console.log(`\nSync complete in ${syncTime}s`); + console.log(`Total outputs: ${outputs.length}`); + console.log(`Unspent outputs: ${unspentOutputs.length}`); + console.log(`Balance: ${Number(balance) / 1e8} SAL\n`); + + // Check if we have enough funds + const requiredAmount = BURN_AMOUNT + FEE; + if (balance < requiredAmount) { + console.error(`ERROR: Insufficient funds. Need ${Number(requiredAmount) / 1e8} SAL, have ${Number(balance) / 1e8} SAL`); + await storage.close(); + process.exit(1); + } + + // Select inputs (simple selection - just use enough outputs) + console.log('Selecting inputs...'); + const selectedInputs = []; + let selectedAmount = 0n; + + for (const output of unspentOutputs) { + if (selectedAmount >= requiredAmount) break; + + // Fetch ring members from daemon + const ringSize = 11; + const globalIndex = output.globalIndex || 0; + + // Get ring members (decoys) from the daemon + const outsResponse = await daemon.getOuts({ + outputs: [{ amount: 0, index: globalIndex }], + get_txid: true + }); + + if (!outsResponse.success || !outsResponse.result.outs) { + console.warn(` Skipping output - couldn't fetch ring data`); + continue; + } + + // For a real implementation, we'd fetch proper decoys from the daemon + // This is simplified - real implementation needs proper decoy selection + const ring = []; + const ringCommitments = []; + const ringIndices = []; + + // Add the real output and generate fake decoys for testing + // In production, use daemon.getOutputDistribution and proper decoy selection + for (let i = 0; i < ringSize; i++) { + if (i === 0) { + // Real output + ring.push(output.outputPublicKey); + ringCommitments.push(output.commitment || output.outputPublicKey); + ringIndices.push(globalIndex); + } else { + // Placeholder - in production, fetch real decoys + ring.push(output.outputPublicKey); + ringCommitments.push(output.commitment || output.outputPublicKey); + ringIndices.push(globalIndex + i); + } + } + + selectedInputs.push({ + secretKey: output.outputSecretKey || keys.spendSecretKey, + publicKey: output.outputPublicKey, + amount: output.amount, + mask: output.mask || new Uint8Array(32), + ring, + ringCommitments, + ringIndices, + realIndex: 0 + }); + + selectedAmount += output.amount; + console.log(` Selected output: ${Number(output.amount) / 1e8} SAL`); + } + + console.log(`Total selected: ${Number(selectedAmount) / 1e8} SAL\n`); + + if (selectedInputs.length === 0) { + console.error('ERROR: Could not select any valid inputs'); + await storage.close(); + process.exit(1); + } + + // Build the BURN transaction + console.log('Building BURN transaction...'); + + try { + const tx = buildBurnTransaction( + { + inputs: selectedInputs, + burnAmount: BURN_AMOUNT, + changeAddress: { + viewPublicKey: keys.viewPublicKey, + spendPublicKey: keys.spendPublicKey, + isSubaddress: false + }, + fee: FEE + }, + { + assetType: ASSET_TYPE + } + ); + + console.log('\n--- Transaction Built ---'); + console.log(`TX Type: ${tx.prefix.txType} (BURN)`); + console.log(`Amount Burnt: ${Number(tx.prefix.amount_burnt) / 1e8} ${ASSET_TYPE}`); + console.log(`Source Asset: ${tx.prefix.source_asset_type}`); + console.log(`Destination Asset: ${tx.prefix.destination_asset_type}`); + console.log(`Inputs: ${tx.prefix.inputs.length}`); + console.log(`Outputs: ${tx.prefix.outputs.length} (change only)`); + console.log(`CLSAG Signatures: ${tx.rct?.CLSAGs?.length || 0}`); + + // Serialize for broadcasting + const txBlob = serializeTransaction(tx); + const txHex = bytesToHex(txBlob); + console.log(`Serialized size: ${txBlob.length} bytes`); + console.log(`TX Hex (first 100): ${txHex.slice(0, 100)}...`); + + if (DRY_RUN) { + console.log('\n[DRY RUN] Transaction NOT broadcast.'); + console.log('To broadcast for real, run with DRY_RUN=false'); + } else { + console.log('\nBroadcasting transaction...'); + const submitResult = await daemon.sendRawTransaction(txHex); + + if (submitResult.success && !submitResult.result.not_relayed) { + console.log('\n✓ Transaction submitted successfully!'); + console.log(`TX Hash: ${submitResult.result.tx_hash || 'pending'}`); + } else { + console.error('\n✗ Transaction failed to submit'); + console.error('Reason:', submitResult.result?.reason || submitResult.error?.message || 'Unknown'); + } + } + + } catch (error) { + console.error('\nERROR building transaction:', error.message); + if (error.stack) { + console.error(error.stack); + } + process.exit(1); + } + + await storage.close(); + console.log('\n✓ Integration test completed!'); +} + +// Run +runBurnIntegrationTest().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/test/burn-transaction.test.js b/test/burn-transaction.test.js new file mode 100644 index 0000000..dce42a8 --- /dev/null +++ b/test/burn-transaction.test.js @@ -0,0 +1,293 @@ +#!/usr/bin/env bun +/** + * BURN Transaction Tests + * + * Tests for Salvium BURN transaction creation and serialization. + */ + +import { describe, test, expect } from 'bun:test'; +import { + buildBurnTransaction, + 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('BURN Transaction', () => { + + describe('buildBurnTransaction', () => { + + test('creates valid BURN transaction structure', () => { + const input = generateMockInput(50000000000n); // 500 SAL + const burnAmount = 100000000n; // 1 SAL + const fee = 100000000n; // 0.001 SAL + + const { publicKey: viewPubKey } = generateTestKeys(); + const { publicKey: spendPubKey } = generateTestKeys(); + + const tx = buildBurnTransaction( + { + inputs: [input], + burnAmount, + changeAddress: { + viewPublicKey: viewPubKey, + spendPublicKey: spendPubKey, + isSubaddress: false + }, + fee + }, + { + assetType: 'SAL' + } + ); + + expect(tx).toBeDefined(); + expect(tx.prefix).toBeDefined(); + expect(tx.prefix.txType).toBe(TX_TYPE.BURN); + expect(tx.prefix.amount_burnt).toBe(burnAmount); + expect(tx.prefix.source_asset_type).toBe('SAL'); + expect(tx.prefix.destination_asset_type).toBe('BURN'); + expect(tx.prefix.unlockTime).toBe(0); // BURN has no lock period + }); + + test('sets destination_asset_type to BURN', () => { + const input = generateMockInput(); + const burnAmount = 100000000n; + const fee = 100000000n; + + const { publicKey: viewPubKey } = generateTestKeys(); + const { publicKey: spendPubKey } = generateTestKeys(); + + const tx = buildBurnTransaction( + { + inputs: [input], + burnAmount, + changeAddress: { + viewPublicKey: viewPubKey, + spendPublicKey: spendPubKey, + isSubaddress: false + }, + fee + }, + { assetType: 'SAL' } + ); + + expect(tx.prefix.destination_asset_type).toBe('BURN'); + }); + + test('throws error for zero burn amount', () => { + const input = generateMockInput(); + const { publicKey: viewPubKey } = generateTestKeys(); + const { publicKey: spendPubKey } = generateTestKeys(); + + expect(() => { + buildBurnTransaction( + { + inputs: [input], + burnAmount: 0n, + changeAddress: { + viewPublicKey: viewPubKey, + spendPublicKey: spendPubKey, + isSubaddress: false + }, + fee: 100000000n + }, + { assetType: 'SAL' } + ); + }).toThrow('Burn amount must be positive'); + }); + + test('throws error for insufficient funds', () => { + const input = generateMockInput(100000000n); // 1 SAL + const { publicKey: viewPubKey } = generateTestKeys(); + const { publicKey: spendPubKey } = generateTestKeys(); + + expect(() => { + buildBurnTransaction( + { + inputs: [input], + burnAmount: 200000000n, // 2 SAL - more than input + changeAddress: { + viewPublicKey: viewPubKey, + spendPublicKey: spendPubKey, + isSubaddress: false + }, + fee: 100000000n + }, + { assetType: 'SAL' } + ); + }).toThrow('Insufficient funds'); + }); + + test('throws error for missing inputs', () => { + const { publicKey: viewPubKey } = generateTestKeys(); + const { publicKey: spendPubKey } = generateTestKeys(); + + expect(() => { + buildBurnTransaction( + { + inputs: [], + burnAmount: 100000000n, + changeAddress: { + viewPublicKey: viewPubKey, + spendPublicKey: spendPubKey, + isSubaddress: false + }, + fee: 100000000n + }, + { assetType: 'SAL' } + ); + }).toThrow('At least one input is required'); + }); + + test('throws error for missing change address', () => { + const input = generateMockInput(); + + expect(() => { + buildBurnTransaction( + { + inputs: [input], + burnAmount: 100000000n, + changeAddress: null, + fee: 100000000n + }, + { assetType: 'SAL' } + ); + }).toThrow('Change address is required'); + }); + + test('supports SAL1 asset type', () => { + const input = generateMockInput(); + const burnAmount = 100000000n; + const fee = 100000000n; + + const { publicKey: viewPubKey } = generateTestKeys(); + const { publicKey: spendPubKey } = generateTestKeys(); + + const tx = buildBurnTransaction( + { + inputs: [input], + burnAmount, + changeAddress: { + viewPublicKey: viewPubKey, + spendPublicKey: spendPubKey, + isSubaddress: false + }, + fee + }, + { + assetType: 'SAL1' + } + ); + + expect(tx.prefix.source_asset_type).toBe('SAL1'); + expect(tx.prefix.destination_asset_type).toBe('BURN'); + }); + + test('includes CLSAG signatures', () => { + const input = generateMockInput(); + const burnAmount = 100000000n; + const fee = 100000000n; + + const { publicKey: viewPubKey } = generateTestKeys(); + const { publicKey: spendPubKey } = generateTestKeys(); + + const tx = buildBurnTransaction( + { + inputs: [input], + burnAmount, + changeAddress: { + viewPublicKey: viewPubKey, + spendPublicKey: spendPubKey, + isSubaddress: false + }, + fee + }, + { assetType: 'SAL' } + ); + + 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 BURN fields', () => { + + test('serializes BURN transaction type', () => { + const prefix = { + version: 4, + unlockTime: 0, + inputs: [], + outputs: [], + extra: {}, + txType: TX_TYPE.BURN, + amount_burnt: 100000000n, + source_asset_type: 'SAL', + destination_asset_type: 'BURN', + return_address: null, + return_pubkey: null, + amount_slippage_limit: 0n + }; + + const serialized = serializeTxPrefix(prefix); + expect(serialized).toBeInstanceOf(Uint8Array); + expect(serialized.length).toBeGreaterThan(0); + }); + + }); + +}); + +console.log('\n=== BURN Transaction Tests ===\n'); diff --git a/test/carrot-self-test.js b/test/carrot-self-test.js index 9f4eda4..170aef5 100644 --- a/test/carrot-self-test.js +++ b/test/carrot-self-test.js @@ -3,6 +3,10 @@ * CARROT Self-Test * Generates a CARROT output and verifies detection * This tests the entire CARROT scanning pipeline end-to-end + * + * Usage: + * WALLET_SEED="your 25 word mnemonic" bun test/carrot-self-test.js + * MASTER_KEY="64-char-hex" bun test/carrot-self-test.js */ import { blake2b } from '../src/blake2b.js'; @@ -20,10 +24,34 @@ import { console.log('=== CARROT Self-Test ===\n'); +// Get wallet seed from environment +if (!process.env.WALLET_SEED && !process.env.MASTER_KEY) { + console.error('ERROR: WALLET_SEED or MASTER_KEY environment variable required.\n'); + console.log('Usage:'); + console.log(' WALLET_SEED="your 25 word mnemonic" bun test/carrot-self-test.js'); + console.log(' MASTER_KEY="64-char-hex" bun test/carrot-self-test.js'); + process.exit(1); +} + // 1. Generate recipient wallet console.log('1. Generating recipient wallet...'); -const mnemonic = 'bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon'; -const seedResult = mnemonicToSeed(mnemonic, { language: 'auto' }); + +let seedResult; +if (process.env.WALLET_SEED) { + const mnemonic = process.env.WALLET_SEED.trim(); + seedResult = mnemonicToSeed(mnemonic, { language: 'auto' }); + if (!seedResult.valid) { + console.error('Invalid mnemonic:', seedResult.error); + process.exit(1); + } +} else { + const masterKey = process.env.MASTER_KEY.trim(); + if (masterKey.length !== 64) { + console.error('MASTER_KEY must be 64 hex characters'); + process.exit(1); + } + seedResult = { seed: hexToBytes(masterKey), valid: true }; +} const cnKeys = deriveKeys(seedResult.seed); const recipientCarrot = deriveCarrotKeys(cnKeys.spendSecretKey); diff --git a/test/quick-carrot-test.js b/test/quick-carrot-test.js index cdfcd3b..22e77d2 100644 --- a/test/quick-carrot-test.js +++ b/test/quick-carrot-test.js @@ -2,6 +2,10 @@ /** * Quick CARROT scanning test * Tests CARROT output detection on a specific block + * + * Usage: + * WALLET_SEED="your 25 word mnemonic" bun test/quick-carrot-test.js + * MASTER_KEY="64-char-hex" bun test/quick-carrot-test.js */ import { DaemonRPC } from '../src/rpc/daemon.js'; @@ -10,14 +14,36 @@ import { deriveKeys, deriveCarrotKeys } from '../src/carrot.js'; import { hexToBytes, bytesToHex } from '../src/address.js'; import { carrotEcdhKeyExchange, computeCarrotViewTag, makeInputContextCoinbase } from '../src/carrot-scanning.js'; -// Test mnemonic (25 words "bacon"... just for testing) -const mnemonic = 'bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon bacon'; +// Get wallet seed from environment +if (!process.env.WALLET_SEED && !process.env.MASTER_KEY) { + console.error('ERROR: WALLET_SEED or MASTER_KEY environment variable required.\n'); + console.log('Usage:'); + console.log(' WALLET_SEED="your 25 word mnemonic" bun test/quick-carrot-test.js'); + console.log(' MASTER_KEY="64-char-hex" bun test/quick-carrot-test.js'); + process.exit(1); +} console.log('=== Quick CARROT Scanning Test ===\n'); // 1. Generate wallet keys console.log('Generating wallet keys...'); -const seedResult = mnemonicToSeed(mnemonic, { language: 'auto' }); + +let seedResult; +if (process.env.WALLET_SEED) { + const mnemonic = process.env.WALLET_SEED.trim(); + seedResult = mnemonicToSeed(mnemonic, { language: 'auto' }); + if (!seedResult.valid) { + console.error('Invalid mnemonic:', seedResult.error); + process.exit(1); + } +} else { + const masterKey = process.env.MASTER_KEY.trim(); + if (masterKey.length !== 64) { + console.error('MASTER_KEY must be 64 hex characters'); + process.exit(1); + } + seedResult = { seed: hexToBytes(masterKey), valid: true }; +} const cnKeys = deriveKeys(seedResult.seed); // Derive CARROT keys - master secret is the spend secret key