Fix CARROT sync regression: early view tag rejection for multi-output txs

Multi-output CARROT transactions store per-output D_e in additional_pubkeys
  (tag 0x04) with no tx_pubkey (tag 0x01), so the single-D_e pre-computation
  path was skipped entirely — every output hit the full _scanCarrotOutput path
  (~40ms each via FFI). Added per-output X25519 + 3-byte view tag rejection
  that restores post-fork sync from ~10 blk/s back to ~900+ blk/s.

  Also fixed parseExtra to handle 0xDE (minergate tag) and gracefully skip
  unknown tags via varint-length instead of aborting all remaining extra bytes.
This commit is contained in:
Matt Hess
2026-02-13 00:01:18 +00:00
parent ddeb249b09
commit 070901f2dd
5 changed files with 1005 additions and 23 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ dsa = "0.6"
spki = "0.7"
signature = "2"
der = "0.7"
rusqlite = { version = "0.31", features = ["bundled-sqlcipher"] }
rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hex = "0.4"
+27 -12
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# Build libsalvium_crypto.a for iOS (device + simulator)
# Build libsalvium_crypto.a for iOS (device + simulator) as an xcframework.
#
# Prerequisites:
# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
#
# Produces: prebuilt/ios/libsalvium_crypto.a (universal fat binary via lipo)
# Produces: prebuilt/ios/SalviumCrypto.xcframework
set -euo pipefail
@@ -12,6 +12,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
CRATE_DIR="$ROOT_DIR/crates/salvium-crypto"
OUT_DIR="$ROOT_DIR/prebuilt/ios"
WORK_DIR="$OUT_DIR/build"
TARGETS=(
aarch64-apple-ios # Device (arm64)
@@ -26,16 +27,30 @@ for target in "${TARGETS[@]}"; do
cargo build --release --target "$target" --manifest-path "$CRATE_DIR/Cargo.toml"
done
echo "==> Creating universal binary with lipo..."
mkdir -p "$OUT_DIR"
echo "==> Creating xcframework..."
mkdir -p "$WORK_DIR"
# Collect all .a paths
LIBS=()
for target in "${TARGETS[@]}"; do
LIBS+=("$CRATE_DIR/target/$target/release/libsalvium_crypto.a")
done
# Device .a (single arch, no lipo needed)
DEVICE_LIB="$CRATE_DIR/target/aarch64-apple-ios/release/libsalvium_crypto.a"
lipo -create "${LIBS[@]}" -output "$OUT_DIR/libsalvium_crypto.a"
# Merge simulator slices (aarch64-sim + x86_64) into one fat .a
SIM_FAT="$WORK_DIR/libsalvium_crypto-sim.a"
lipo -create \
"$CRATE_DIR/target/aarch64-apple-ios-sim/release/libsalvium_crypto.a" \
"$CRATE_DIR/target/x86_64-apple-ios/release/libsalvium_crypto.a" \
-output "$SIM_FAT"
# Remove old xcframework if present
rm -rf "$OUT_DIR/SalviumCrypto.xcframework"
# Create xcframework from device .a + simulator fat .a
xcodebuild -create-xcframework \
-library "$DEVICE_LIB" \
-library "$SIM_FAT" \
-output "$OUT_DIR/SalviumCrypto.xcframework"
# Clean up work dir
rm -rf "$WORK_DIR"
echo "==> Done: $OUT_DIR/SalviumCrypto.xcframework"
echo "==> Done: $OUT_DIR/libsalvium_crypto.a"
lipo -info "$OUT_DIR/libsalvium_crypto.a"
+36 -6
View File
@@ -357,12 +357,42 @@ export function parseExtra(extraBytes) {
extra.push({ type: 0x04, tag: 'additional_pubkeys', keys: additionalPubkeys });
break;
default:
// Unknown tag - try to skip using varint length
// This is a best-effort attempt
extra.push({ type: tag, tag: 'unknown', offset: offset - 1 });
// Skip remaining bytes as we don't know the format
offset = extraBytes.length;
case 0xDE: { // TX_EXTRA_MYSTERIOUS_MINERGATE_TAG — varint size + data
if (offset < extraBytes.length) {
const { value: fieldLen, bytesRead: lenBytes } = decodeVarint(extraBytes, offset);
const skipLen = Number(fieldLen);
if (offset + lenBytes + skipLen <= extraBytes.length) {
extra.push({ type: 0xDE, tag: 'minergate', data: extraBytes.slice(offset + lenBytes, offset + lenBytes + skipLen) });
offset += lenBytes + skipLen;
} else {
offset = extraBytes.length;
}
}
break;
}
default: {
// Unknown tag — try varint-length skip (CryptoNote convention)
let skipped = false;
if (offset < extraBytes.length) {
try {
const { value: fieldLen, bytesRead: lenBytes } = decodeVarint(extraBytes, offset);
const skipLen = Number(fieldLen);
if (skipLen >= 0 && offset + lenBytes + skipLen <= extraBytes.length) {
extra.push({ type: tag, tag: 'unknown', data: extraBytes.slice(offset + lenBytes, offset + lenBytes + skipLen) });
offset += lenBytes + skipLen;
skipped = true;
}
} catch (_e) {
// Varint decode failed
}
}
if (!skipped) {
extra.push({ type: tag, tag: 'unknown', offset: offset - 1 });
offset = extraBytes.length;
}
break;
}
}
}
+144 -4
View File
@@ -12,7 +12,7 @@
import { WalletOutput, WalletTransaction } from './wallet-store.js';
import { cnSubaddressSecretKey, carrotIndexExtensionGenerator, carrotSubaddressScalar } from './subaddress.js';
import { scanCarrotOutput, scanCarrotInternalOutput, computeReturnAddress, makeInputContext, makeInputContextCoinbase, generateCarrotKeyImage } from './carrot-scanning.js';
import { scanCarrotOutput, scanCarrotInternalOutput, computeReturnAddress, makeInputContext, makeInputContextCoinbase, generateCarrotKeyImage, carrotEcdhKeyExchange, testCarrotViewTag } from './carrot-scanning.js';
import { parseTransaction, parseBlock, extractTxPubKey, extractPaymentId, extractAdditionalPubKeys, serializeTxPrefix } from './transaction.js';
import { bytesToHex, hexToBytes } from './address.js';
import { TX_TYPE } from './wallet.js';
@@ -1183,9 +1183,23 @@ export class WalletSync {
offset += 32;
}
parsed.push({ type: 0x04, keys });
} else if (tag === 0xDE) {
// TX_EXTRA_MYSTERIOUS_MINERGATE_TAG — varint size + data
if (offset >= extraBytes.length) break;
let len = 0, lenBytes = 0;
let b = extraBytes[offset];
while (b & 0x80) { len |= (b & 0x7f) << (7 * lenBytes); lenBytes++; b = extraBytes[offset + lenBytes]; }
len |= b << (7 * lenBytes); lenBytes++;
offset += lenBytes + len;
} else {
// Unknown tag, try to skip
break;
// Unknown tag try varint-length skip
if (offset >= extraBytes.length) break;
const b0 = extraBytes[offset];
if (!(b0 & 0x80) && offset + 1 + b0 <= extraBytes.length) {
offset += 1 + b0; // single-byte varint length + data
} else {
break;
}
}
}
@@ -1332,6 +1346,76 @@ export class WalletSync {
}
}
// ── CARROT per-TX pre-computation ──────────────────────────────────────
// Pre-compute X25519 shared secret for early view tag rejection.
// Single-output CARROT txs: D_e is in txPubKey (tag 0x01) — one shared secret for all outputs.
// Multi-output CARROT txs: D_e[i] is in additional_pubkeys (tag 0x04) — per-output shared secret.
// With a 3-byte view tag (~1:16M false positive), virtually all non-owned
// outputs are rejected with just a cheap blake2b + X25519, never entering
// the expensive _scanCarrotOutput path.
let carrotPrecomputed = null;
let carrotPerOutputKeys = null;
if (this.carrotKeys?.viewIncomingKey) {
// Build inputContext (shared across all outputs, doesn't need txPubKey)
const inputs = tx.prefix?.vin || tx.inputs || [];
const firstKiRaw = inputs.length > 0 ? (inputs[0].keyImage || inputs[0].key?.k_image) : null;
let inputContext;
if (firstKiRaw) {
const kiBytes = typeof firstKiRaw === 'string' ? hexToBytes(firstKiRaw) : firstKiRaw;
inputContext = makeInputContext(kiBytes);
} else {
inputContext = makeInputContextCoinbase(header.height);
}
// Convert viewIncomingKey once (hex string → Uint8Array)
const viewIncomingKeyBytes = typeof this.carrotKeys.viewIncomingKey === 'string'
? hexToBytes(this.carrotKeys.viewIncomingKey)
: this.carrotKeys.viewIncomingKey;
// Internal (self-send) view tag key: raw viewBalanceSecret bytes
let viewBalanceSecretBytes = null;
if (firstKiRaw && this.carrotKeys.viewBalanceSecret) {
viewBalanceSecretBytes = typeof this.carrotKeys.viewBalanceSecret === 'string'
? hexToBytes(this.carrotKeys.viewBalanceSecret)
: this.carrotKeys.viewBalanceSecret;
}
if (txPubKey) {
// ── Single D_e path: pre-compute one shared secret for ALL outputs ──
const rctType = tx.rct?.type ?? 0;
const additionalPubKeys = rctType < 8 ? extractAdditionalPubKeys(tx) : null;
const deIsTxPubKey = rctType >= 8 || !additionalPubKeys || additionalPubKeys.length === 0;
if (deIsTxPubKey) {
const De = typeof txPubKey === 'string' ? hexToBytes(txPubKey) : txPubKey;
try {
const sharedSecretExternal = carrotEcdhKeyExchange(viewIncomingKeyBytes, De);
carrotPrecomputed = {
inputContext,
sharedSecretExternal,
sharedSecretInternal: viewBalanceSecretBytes,
hasKeyImages: !!firstKiRaw
};
} catch (_e) {
// Invalid D_e — fall through to per-output path
}
}
} else {
// ── Per-output D_e path: multi-output CARROT txs store D_e[i] in additional_pubkeys ──
const additionalPubKeys = extractAdditionalPubKeys(tx);
if (additionalPubKeys.length > 0) {
carrotPerOutputKeys = {
keys: additionalPubKeys,
viewIncomingKeyBytes,
viewBalanceSecretBytes,
inputContext,
hasKeyImages: !!firstKiRaw
};
}
}
}
for (let i = 0; i < outputs.length; i++) {
const output = outputs[i];
const outputPubKey = this._extractOutputPubKey(output);
@@ -1345,7 +1429,63 @@ export class WalletSync {
const isCarrotOutput = this._isCarrotOutput(output);
if (isCarrotOutput && this.carrotKeys) {
// CARROT scanning - pass txPubKey (D_e) from tx_extra
// ── FAST PATH: early view tag rejection ──
// Check 3-byte CARROT view tag before entering the expensive _scanCarrotOutput.
if (output.viewTag instanceof Uint8Array) {
let viewTagMatch = false;
if (carrotPrecomputed) {
// Single D_e: use pre-computed shared secret (same for all outputs)
if (carrotPrecomputed.sharedSecretExternal) {
viewTagMatch = testCarrotViewTag(
carrotPrecomputed.sharedSecretExternal,
carrotPrecomputed.inputContext,
outputPubKey,
output.viewTag
);
}
if (!viewTagMatch && carrotPrecomputed.sharedSecretInternal) {
viewTagMatch = testCarrotViewTag(
carrotPrecomputed.sharedSecretInternal,
carrotPrecomputed.inputContext,
outputPubKey,
output.viewTag
);
}
if (!viewTagMatch && !carrotPrecomputed.hasKeyImages) {
const koHex = bytesToHex(outputPubKey);
if (this._returnOutputMap.has(koHex)) viewTagMatch = true;
}
} else if (carrotPerOutputKeys && carrotPerOutputKeys.keys[i]) {
// Per-output D_e: compute X25519 shared secret for this output's D_e
const perDe = carrotPerOutputKeys.keys[i];
const De = typeof perDe === 'string' ? hexToBytes(perDe) : perDe;
try {
const sharedSecret = carrotEcdhKeyExchange(carrotPerOutputKeys.viewIncomingKeyBytes, De);
viewTagMatch = testCarrotViewTag(
sharedSecret,
carrotPerOutputKeys.inputContext,
outputPubKey,
output.viewTag
);
// Internal (self-send) check
if (!viewTagMatch && carrotPerOutputKeys.viewBalanceSecretBytes) {
viewTagMatch = testCarrotViewTag(
carrotPerOutputKeys.viewBalanceSecretBytes,
carrotPerOutputKeys.inputContext,
outputPubKey,
output.viewTag
);
}
} catch (_e) {
viewTagMatch = true; // X25519 failed — don't reject, let full scan handle it
}
}
if ((carrotPrecomputed || carrotPerOutputKeys) && !viewTagMatch) continue;
}
// View tag matched (or pre-check unavailable) — full CARROT scan
scanResult = await this._scanCarrotOutput(output, i, tx, txHash, txPubKey, header);
} else if (cnDerivation) {
// CryptoNote (legacy) scanning with pre-computed derivation
+797
View File
@@ -0,0 +1,797 @@
#!/usr/bin/env bun
/**
* Comprehensive Stake Burn-In Test for Salvium JS
*
* Two wallets, mining from genesis, transfers to diversify inputs, multiple
* stakes of varied sizes, crossing the CARROT hard fork (height 1100),
* verifying stake returns after the 20-block testnet lock period, and full
* reconciliation.
*
* Prerequisites:
* - Fresh testnet chain (reset to height 0)
* - Mining to wallet A's CN address (pre-1100) then CARROT address (post-1100)
* - Wallet A json at ~/testnet-wallet/wallet-a.json
*
* Usage:
* bun test/stake-integration.test.js # Full run
* bun test/stake-integration.test.js --phase cn # CN only
* bun test/stake-integration.test.js --phase carrot # CARROT only (resumes sync)
*
* All transactions are BROADCAST (not dry-run). Wallet B is persisted to disk.
* Full TX log saved to ~/testnet-wallet/stake-test-log.json
*/
import { setCryptoBackend } from '../src/crypto/index.js';
import { DaemonRPC } from '../src/rpc/daemon.js';
import { Wallet } from '../src/wallet.js';
import { getHfVersionForHeight, NETWORK_ID } from '../src/consensus.js';
import { existsSync } from 'node:fs';
import { getHeight, waitForHeight, fmt, short, loadWalletFromFile } from './test-helpers.js';
await setCryptoBackend('wasm');
// =============================================================================
// Configuration
// =============================================================================
const DAEMON_URL = process.env.DAEMON_URL || 'http://web.whiskymine.io:29081';
const NETWORK = 'testnet';
const CARROT_FORK_HEIGHT = 1100;
const SPENDABLE_AGE = 10;
const DEFAULT_RING_SIZE = 16;
const COINBASE_MATURITY = 60;
const STAKE_LOCK_PERIOD = 20; // testnet — from src/consensus.js:194
const WALLET_A_FILE = process.env.WALLET_A || `${process.env.HOME}/testnet-wallet/wallet-a.json`;
const WALLET_B_FILE = process.env.WALLET_B || `${process.env.HOME}/testnet-wallet/wallet-b.json`;
const SYNC_CACHE_A = WALLET_A_FILE.replace(/\.json$/, '-sync.json');
const SYNC_CACHE_B = WALLET_B_FILE.replace(/\.json$/, '-sync.json');
const TX_LOG_FILE = `${process.env.HOME}/testnet-wallet/stake-test-log.json`;
// =============================================================================
// State
// =============================================================================
const txLog = [];
let totalFees = 0n;
let totalStaked = 0n;
let totalReturned = 0n;
let totalYield = 0n;
const stats = {
transfers: { attempted: 0, succeeded: 0, failed: 0 },
stakes: { attempted: 0, succeeded: 0, failed: 0 },
};
const daemon = new DaemonRPC({ url: DAEMON_URL });
/**
* Stake registry — tracks every stake issued.
* Map<txHash, { amount, height, unlockHeight, era, wallet }>
*/
const stakeRegistry = new Map();
/**
* Return registry — tracks protocol_tx outputs matched to stakes.
* Map<txHash, { amount, height, matchedStake, yield }>
*/
const returnRegistry = new Map();
/** Current era asset type — recomputed before each phase */
let assetType = 'SAL';
async function refreshAssetType() {
const h = await getHeight(daemon);
const hfVer = getHfVersionForHeight(h, NETWORK_ID.TESTNET);
assetType = hfVer >= 6 ? 'SAL1' : 'SAL';
return assetType;
}
// =============================================================================
// Helpers
// =============================================================================
function logTx(type, from, to, amount, fee, txHash, height) {
txLog.push({ type, from, to, amount: amount.toString(), fee: fee.toString(), txHash, height, time: Date.now() });
totalFees += fee;
}
async function syncAndReport(wallet, label, cacheFile = null) {
const currentHeight = await getHeight(daemon);
if (cacheFile && existsSync(cacheFile)) {
try {
const cached = JSON.parse(await Bun.file(cacheFile).text());
const cachedSyncHeight = cached.syncHeight || 0;
if (cachedSyncHeight > currentHeight) {
console.log(` ${label}: Cache stale (cached=${cachedSyncHeight}, chain=${currentHeight}), resetting`);
} else {
wallet.loadSyncCache(cached);
}
} catch { /* ignore bad cache */ }
}
await wallet.syncWithDaemon();
if (cacheFile) {
await Bun.write(cacheFile, wallet.dumpSyncCacheJSON());
}
const { balance, unlockedBalance } = await wallet.getStorageBalance({ assetType });
console.log(` ${label}: balance=${fmt(balance, assetType)}, spendable=${fmt(unlockedBalance, assetType)}`);
return { balance, unlockedBalance };
}
async function outputCount(wallet) {
if (!wallet._storage) return 0;
const all = await wallet._storage.getOutputs({ isSpent: false });
return all.length;
}
// =============================================================================
// Transaction Wrappers
// =============================================================================
async function doTransfer(wallet, fromLabel, toAddress, amount) {
stats.transfers.attempted++;
const h = await getHeight(daemon);
try {
const result = await wallet.transfer(
[{ address: toAddress, amount }],
{ priority: 'default', assetType }
);
stats.transfers.succeeded++;
logTx('transfer', fromLabel, toAddress.slice(0, 10), amount, result.fee, result.txHash, h);
return result;
} catch (e) {
stats.transfers.failed++;
console.log(` FAILED [${fromLabel}->${short(toAddress)}, ${fmt(amount, assetType)}]: ${e.message}`);
return null;
}
}
async function doStake(wallet, label, amount, era) {
stats.stakes.attempted++;
const h = await getHeight(daemon);
try {
const result = await wallet.stake(amount, { priority: 'default', assetType });
stats.stakes.succeeded++;
logTx('stake', label, 'protocol', amount, result.fee, result.txHash, h);
totalStaked += amount;
// Register in stake registry for later verification
const unlockHeight = h + STAKE_LOCK_PERIOD;
stakeRegistry.set(result.txHash, {
amount,
height: h,
unlockHeight,
era: era || (h < CARROT_FORK_HEIGHT ? 'CN' : 'CARROT'),
wallet: label,
});
console.log(` Staked ${fmt(amount, assetType)} at height ${h}, unlock at ${unlockHeight} [${result.txHash.slice(0, 16)}...]`);
return result;
} catch (e) {
stats.stakes.failed++;
console.log(` FAILED [stake ${fmt(amount, assetType)}]: ${e.message}`);
return null;
}
}
// =============================================================================
// Batch Helpers
// =============================================================================
async function batchTransfers(wallet, fromLabel, toAddress, targetCount, minAmt, maxAmt, cacheFile = null) {
console.log(` Sending up to ${targetCount} transfers ${fromLabel} -> ${short(toAddress)}`);
let success = 0;
let consecutiveFails = 0;
for (let i = 0; i < targetCount; i++) {
const amount = BigInt(Math.floor(Math.random() * Number(maxAmt - minAmt)) + Number(minAmt));
const result = await doTransfer(wallet, fromLabel, toAddress, amount);
if (result) {
success++;
consecutiveFails = 0;
if ((i + 1) % 10 === 0 || i === targetCount - 1) {
console.log(` ${i + 1}/${targetCount} sent (${success} ok, ${i + 1 - success} failed)`);
}
} else {
consecutiveFails++;
if (consecutiveFails >= 5) {
console.log(` Stopping after ${consecutiveFails} consecutive failures (likely out of spendable outputs)`);
break;
}
}
}
console.log(` Batch done: ${success}/${targetCount} succeeded`);
return success;
}
function banner(title) {
console.log('\n' + '='.repeat(72));
console.log(` ${title}`);
console.log('='.repeat(72));
}
function section(title) {
console.log(`\n--- ${title} ---`);
}
async function saveTxLog() {
const stakeEntries = {};
for (const [hash, entry] of stakeRegistry) {
stakeEntries[hash] = { ...entry, amount: entry.amount.toString() };
}
const returnEntries = {};
for (const [hash, entry] of returnRegistry) {
returnEntries[hash] = { ...entry, amount: entry.amount.toString(), yield: entry.yield.toString() };
}
const data = {
timestamp: new Date().toISOString(),
stats,
totalFees: totalFees.toString(),
totalStaked: totalStaked.toString(),
totalReturned: totalReturned.toString(),
totalYield: totalYield.toString(),
stakeRegistry: stakeEntries,
returnRegistry: returnEntries,
txCount: txLog.length,
txLog,
};
await Bun.write(TX_LOG_FILE, JSON.stringify(data, null, 2));
}
// =============================================================================
// Stake Return Verification
// =============================================================================
/**
* After mining past a stake's unlock height, scan wallet transactions for
* protocol_tx outputs that correspond to stake returns.
*
* A return is matched when:
* - The transaction is a protocol_tx
* - It appears at or shortly after the stake's unlockHeight
* - The return amount >= the staked amount (includes yield)
*/
async function verifyStakeReturns(wallet, label) {
if (!wallet._storage) {
console.log(` ${label}: No storage — cannot verify returns`);
return;
}
const transactions = await wallet._storage.getTransactions();
const currentHeight = await getHeight(daemon);
let matched = 0;
let unmatched = 0;
for (const [txHash, stake] of stakeRegistry) {
if (stake.wallet !== label) continue;
// Skip stakes whose unlock height hasn't been reached yet
if (currentHeight < stake.unlockHeight + SPENDABLE_AGE) {
console.log(` [${txHash.slice(0, 16)}...] Not yet unlocked (need height ${stake.unlockHeight + SPENDABLE_AGE}, at ${currentHeight})`);
continue;
}
// Already matched?
if (returnRegistry.has(txHash)) {
matched++;
continue;
}
// Look for protocol_tx outputs near unlockHeight
// The return typically arrives in a block at unlockHeight or within a few blocks after
const returnCandidates = transactions.filter(tx =>
tx.isProtocolTx &&
tx.blockHeight >= stake.unlockHeight &&
tx.blockHeight <= stake.unlockHeight + 10
);
if (returnCandidates.length > 0) {
// Find the best candidate: an output whose amount >= staked amount
let bestCandidate = null;
let bestAmount = 0n;
for (const candidate of returnCandidates) {
// Sum received outputs in this protocol_tx
const outputs = candidate.outputs || [];
let candidateAmount = 0n;
for (const out of outputs) {
candidateAmount += out.amount || 0n;
}
// Also check the transaction-level amount field
if (candidateAmount === 0n && candidate.amount) {
candidateAmount = candidate.amount;
}
if (candidateAmount >= stake.amount && candidateAmount > bestAmount) {
bestCandidate = candidate;
bestAmount = candidateAmount;
}
}
if (bestCandidate) {
const yieldAmount = bestAmount - stake.amount;
returnRegistry.set(txHash, {
amount: bestAmount,
height: bestCandidate.blockHeight,
matchedStake: txHash,
yield: yieldAmount,
});
totalReturned += bestAmount;
totalYield += yieldAmount;
matched++;
console.log(` [${txHash.slice(0, 16)}...] RETURN FOUND at height ${bestCandidate.blockHeight}: ` +
`staked=${fmt(stake.amount, assetType)}, returned=${fmt(bestAmount, assetType)}, yield=${fmt(yieldAmount, assetType)}`);
} else {
unmatched++;
console.log(` [${txHash.slice(0, 16)}...] UNMATCHED: ${returnCandidates.length} protocol_tx found near unlock height ${stake.unlockHeight} but none >= staked amount ${fmt(stake.amount, assetType)}`);
}
} else {
unmatched++;
console.log(` [${txHash.slice(0, 16)}...] NO RETURN: no protocol_tx found near unlock height ${stake.unlockHeight} (checked ${stake.unlockHeight}-${stake.unlockHeight + 10})`);
}
}
const total = matched + unmatched;
if (total > 0) {
console.log(` ${label} stake return verification: ${matched}/${total} matched`);
}
}
// =============================================================================
// Wallet Loading
// =============================================================================
async function loadOrCreateWalletB() {
if (existsSync(WALLET_B_FILE)) {
const wallet = await loadWalletFromFile(WALLET_B_FILE, NETWORK);
console.log(` Wallet B loaded from ${WALLET_B_FILE}`);
return wallet;
}
const wallet = Wallet.create({ network: NETWORK });
await Bun.write(WALLET_B_FILE, JSON.stringify(wallet.toJSON(), null, 2));
console.log(` Wallet B CREATED and saved to ${WALLET_B_FILE}`);
return wallet;
}
// =============================================================================
// PHASE 1: CN ERA
// =============================================================================
async function phaseCN(walletA, walletB) {
banner('PHASE 1: CN ERA — Stake Burn-In (pre-CARROT, height < 1100)');
await refreshAssetType();
let h = await getHeight(daemon);
const postCarrot = h >= CARROT_FORK_HEIGHT;
const addrA = postCarrot ? walletA.getCarrotAddress() : walletA.getLegacyAddress();
const addrB = postCarrot ? walletB.getCarrotAddress() : walletB.getLegacyAddress();
console.log(` A address: ${short(addrA)}`);
console.log(` B address: ${short(addrB)}`);
// ---- Wait for coinbase maturity + ring decoys ----
const MIN_HEIGHT_FOR_RING = COINBASE_MATURITY + DEFAULT_RING_SIZE + 5;
await waitForHeight(daemon, MIN_HEIGHT_FOR_RING, 'ring + coinbase maturity');
let syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
if (syncA.unlockedBalance === 0n) {
console.log(' Waiting for more blocks (no spendable balance)...');
await waitForHeight(daemon, MIN_HEIGHT_FOR_RING + 10, 'more coinbase');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
}
// ---- Transfers A->B (input diversification) ----
section('CN Transfers: A -> B (0.5-5 SAL) — input diversification');
await batchTransfers(walletA, 'A', addrB, 20, 50_000_000n, 500_000_000n, SYNC_CACHE_A);
h = await getHeight(daemon);
await waitForHeight(daemon, h + SPENDABLE_AGE + 2, 'A->B confirms');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
let syncB = await syncAndReport(walletB, 'B', SYNC_CACHE_B);
// ---- Transfers B->A (create change outputs in both) ----
if (syncB.unlockedBalance > 0n) {
section('CN Transfers: B -> A (0.1-1 SAL) — change diversification');
await batchTransfers(walletB, 'B', addrA, 10, 10_000_000n, 100_000_000n, SYNC_CACHE_B);
} else {
section('CN Transfers: B -> A');
console.log(' SKIPPED: wallet B has no spendable outputs');
}
h = await getHeight(daemon);
await waitForHeight(daemon, h + 3, 'B->A settle');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
// ---- CN Stakes — Round 1 (wallet A, 3 varied sizes) ----
section('CN Stakes — Round 1 (wallet A: small, medium, large)');
h = await getHeight(daemon);
await waitForHeight(daemon, h + 3, 'pre-stake settle');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
const cnStakeAmounts1 = [
// Small: 1-3 SAL
BigInt(Math.floor(Math.random() * 200_000_000) + 100_000_000),
// Medium: 5-10 SAL
BigInt(Math.floor(Math.random() * 500_000_000) + 500_000_000),
// Large: 15-25 SAL
BigInt(Math.floor(Math.random() * 1_000_000_000) + 1_500_000_000),
];
for (let i = 0; i < cnStakeAmounts1.length; i++) {
const labels = ['small', 'medium', 'large'];
console.log(` Stake ${i + 1}/3 (${labels[i]}): ${fmt(cnStakeAmounts1[i], assetType)}`);
await doStake(walletA, 'A', cnStakeAmounts1[i], 'CN');
if (i < cnStakeAmounts1.length - 1) {
const sh = await getHeight(daemon);
await waitForHeight(daemon, sh + 2, 'stake spacing');
}
}
// ---- Mine past lock period, verify Round 1 returns ----
section('CN Stake Returns — Round 1 Verification');
h = await getHeight(daemon);
const round1UnlockTarget = h + STAKE_LOCK_PERIOD + SPENDABLE_AGE + 5;
await waitForHeight(daemon, round1UnlockTarget, 'Round 1 unlock + spendable age');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
await verifyStakeReturns(walletA, 'A');
// ---- CN Stakes — Round 2 (wallet B, if it has balance) ----
section('CN Stakes — Round 2 (wallet B)');
syncB = await syncAndReport(walletB, 'B', SYNC_CACHE_B);
if (syncB.unlockedBalance > 200_000_000n) {
const cnStakeAmountsB = [
BigInt(Math.floor(Math.random() * 100_000_000) + 50_000_000),
BigInt(Math.floor(Math.random() * 200_000_000) + 100_000_000),
];
for (let i = 0; i < cnStakeAmountsB.length; i++) {
console.log(` B Stake ${i + 1}/2: ${fmt(cnStakeAmountsB[i], assetType)}`);
await doStake(walletB, 'B', cnStakeAmountsB[i], 'CN');
if (i < cnStakeAmountsB.length - 1) {
const sh = await getHeight(daemon);
await waitForHeight(daemon, sh + 2, 'B stake spacing');
}
}
} else {
console.log(' SKIPPED: wallet B has insufficient balance for staking');
}
// ---- More transfers to keep inputs flowing ----
section('CN Transfers: A -> B (keep inputs flowing)');
h = await getHeight(daemon);
await waitForHeight(daemon, h + 3, 'pre-transfer settle');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
await batchTransfers(walletA, 'A', addrB, 10, 50_000_000n, 300_000_000n, SYNC_CACHE_A);
// ---- Mine past Round 2 lock, verify B's returns ----
section('CN Stake Returns — Round 2 Verification');
h = await getHeight(daemon);
const round2UnlockTarget = h + STAKE_LOCK_PERIOD + SPENDABLE_AGE + 5;
await waitForHeight(daemon, round2UnlockTarget, 'Round 2 unlock + spendable age');
syncB = await syncAndReport(walletB, 'B', SYNC_CACHE_B);
await verifyStakeReturns(walletB, 'B');
// Also re-verify A in case more returns arrived
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
await verifyStakeReturns(walletA, 'A');
// ---- CN Phase Accounting ----
section('CN Phase Accounting');
console.log(` A outputs: ${await outputCount(walletA)}`);
console.log(` B outputs: ${await outputCount(walletB)}`);
console.log(` Total fees: ${fmt(totalFees, assetType)}`);
console.log(` Total staked: ${fmt(totalStaked, assetType)}`);
console.log(` Total returned: ${fmt(totalReturned, assetType)}`);
console.log(` Total yield: ${fmt(totalYield, assetType)}`);
console.log(` Stakes issued: ${stakeRegistry.size}`);
console.log(` Returns found: ${returnRegistry.size}`);
console.log(` TX count: ${txLog.length}`);
await saveTxLog();
}
// =============================================================================
// PHASE 2: CARROT ERA
// =============================================================================
async function phaseCARROT(walletA, walletB) {
banner('PHASE 2: CARROT ERA — Stake Burn-In (height >= 1100)');
await refreshAssetType();
const addrA = walletA.getCarrotAddress() || walletA.getLegacyAddress();
const addrB = walletB.getCarrotAddress() || walletB.getLegacyAddress();
console.log(` A CARROT: ${short(addrA)}`);
console.log(` B CARROT: ${short(addrB)}`);
console.log(` Waiting for CARROT fork + coinbase maturity...`);
console.log(` (Switch mining to wallet A CARROT address after height 1100)`);
console.log(` CARROT address: ${walletA.getCarrotAddress()}`);
await waitForHeight(daemon, CARROT_FORK_HEIGHT + SPENDABLE_AGE + 5, 'CARROT maturity');
let syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
let syncB = await syncAndReport(walletB, 'B', SYNC_CACHE_B);
// ---- CARROT Transfers A->B (input diversification) ----
section('CARROT Transfers: A -> B (0.5-5 SAL) — input diversification');
await batchTransfers(walletA, 'A', addrB, 20, 50_000_000n, 500_000_000n, SYNC_CACHE_A);
let h = await getHeight(daemon);
await waitForHeight(daemon, h + SPENDABLE_AGE + 2, 'CARROT A->B confirms');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
syncB = await syncAndReport(walletB, 'B', SYNC_CACHE_B);
// ---- CARROT Transfers B->A ----
if (syncB.unlockedBalance > 0n) {
section('CARROT Transfers: B -> A (0.1-1 SAL) — change diversification');
await batchTransfers(walletB, 'B', addrA, 10, 10_000_000n, 100_000_000n, SYNC_CACHE_B);
} else {
section('CARROT Transfers: B -> A');
console.log(' SKIPPED: wallet B has no spendable outputs');
}
h = await getHeight(daemon);
await waitForHeight(daemon, h + 3, 'B->A settle');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
// ---- CARROT Stakes — Round 1 (wallet A, 3 varied sizes) ----
section('CARROT Stakes — Round 1 (wallet A: small, medium, large)');
h = await getHeight(daemon);
await waitForHeight(daemon, h + 3, 'pre-stake settle');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
const carrotStakeAmounts1 = [
// Small: 1-3 SAL
BigInt(Math.floor(Math.random() * 200_000_000) + 100_000_000),
// Medium: 5-10 SAL
BigInt(Math.floor(Math.random() * 500_000_000) + 500_000_000),
// Large: 15-25 SAL
BigInt(Math.floor(Math.random() * 1_000_000_000) + 1_500_000_000),
];
for (let i = 0; i < carrotStakeAmounts1.length; i++) {
const labels = ['small', 'medium', 'large'];
console.log(` Stake ${i + 1}/3 (${labels[i]}): ${fmt(carrotStakeAmounts1[i], assetType)}`);
await doStake(walletA, 'A', carrotStakeAmounts1[i], 'CARROT');
if (i < carrotStakeAmounts1.length - 1) {
const sh = await getHeight(daemon);
await waitForHeight(daemon, sh + 2, 'stake spacing');
}
}
// ---- Mine past lock period, verify CARROT Round 1 returns ----
section('CARROT Stake Returns — Round 1 Verification');
h = await getHeight(daemon);
const carrotR1Unlock = h + STAKE_LOCK_PERIOD + SPENDABLE_AGE + 5;
await waitForHeight(daemon, carrotR1Unlock, 'CARROT Round 1 unlock + spendable age');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
await verifyStakeReturns(walletA, 'A');
// ---- CARROT Stakes — Round 2 (wallet B) ----
section('CARROT Stakes — Round 2 (wallet B)');
syncB = await syncAndReport(walletB, 'B', SYNC_CACHE_B);
if (syncB.unlockedBalance > 200_000_000n) {
const carrotStakeAmountsB = [
BigInt(Math.floor(Math.random() * 100_000_000) + 50_000_000),
BigInt(Math.floor(Math.random() * 200_000_000) + 100_000_000),
];
for (let i = 0; i < carrotStakeAmountsB.length; i++) {
console.log(` B Stake ${i + 1}/2: ${fmt(carrotStakeAmountsB[i], assetType)}`);
await doStake(walletB, 'B', carrotStakeAmountsB[i], 'CARROT');
if (i < carrotStakeAmountsB.length - 1) {
const sh = await getHeight(daemon);
await waitForHeight(daemon, sh + 2, 'B stake spacing');
}
}
} else {
console.log(' SKIPPED: wallet B has insufficient balance for staking');
}
// ---- More transfers to diversify further ----
section('CARROT Transfers: A -> B (diversify further)');
h = await getHeight(daemon);
await waitForHeight(daemon, h + 3, 'pre-transfer settle');
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
await batchTransfers(walletA, 'A', addrB, 10, 50_000_000n, 300_000_000n, SYNC_CACHE_A);
// ---- Mine past Round 2 lock, verify B's CARROT returns ----
section('CARROT Stake Returns — Round 2 Verification');
h = await getHeight(daemon);
const carrotR2Unlock = h + STAKE_LOCK_PERIOD + SPENDABLE_AGE + 5;
await waitForHeight(daemon, carrotR2Unlock, 'CARROT Round 2 unlock + spendable age');
syncB = await syncAndReport(walletB, 'B', SYNC_CACHE_B);
await verifyStakeReturns(walletB, 'B');
// Also re-verify A in case more returns arrived
syncA = await syncAndReport(walletA, 'A', SYNC_CACHE_A);
await verifyStakeReturns(walletA, 'A');
// ---- CARROT Phase Accounting ----
section('CARROT Phase Accounting');
console.log(` A outputs: ${await outputCount(walletA)}`);
console.log(` B outputs: ${await outputCount(walletB)}`);
console.log(` Total fees: ${fmt(totalFees, assetType)}`);
console.log(` Total staked: ${fmt(totalStaked, assetType)}`);
console.log(` Total returned: ${fmt(totalReturned, assetType)}`);
console.log(` Total yield: ${fmt(totalYield, assetType)}`);
console.log(` Stakes issued: ${stakeRegistry.size}`);
console.log(` Returns found: ${returnRegistry.size}`);
console.log(` TX count: ${txLog.length}`);
await saveTxLog();
}
// =============================================================================
// FINAL RECONCILIATION
// =============================================================================
async function reconcile(walletA, walletB) {
banner('FINAL RECONCILIATION');
await refreshAssetType();
const h = await getHeight(daemon);
await waitForHeight(daemon, h + 5, 'final settle');
const syncA = await syncAndReport(walletA, 'A (final)', SYNC_CACHE_A);
const syncB = await syncAndReport(walletB, 'B (final)', SYNC_CACHE_B);
// Final return verification pass
section('Final Stake Return Verification');
await verifyStakeReturns(walletA, 'A');
await verifyStakeReturns(walletB, 'B');
// Sum transfer amounts by direction
let totalA2B = 0n, totalB2A = 0n;
let cnTransfers = 0, carrotTransfers = 0;
for (const tx of txLog) {
const amt = BigInt(tx.amount);
if (tx.type === 'transfer') {
if (tx.from === 'A') totalA2B += amt;
if (tx.from === 'B') totalB2A += amt;
if (tx.height < CARROT_FORK_HEIGHT) cnTransfers++;
else carrotTransfers++;
}
}
const cnStakes = txLog.filter(t => t.type === 'stake' && t.height < CARROT_FORK_HEIGHT).length;
const carrotStakes = txLog.filter(t => t.type === 'stake' && t.height >= CARROT_FORK_HEIGHT).length;
console.log('\n Transaction Summary');
console.log(' ' + '-'.repeat(50));
console.log(` Total transactions: ${txLog.length}`);
console.log(` CN-era transfers: ${cnTransfers}`);
console.log(` CARROT-era transfers: ${carrotTransfers}`);
console.log(` CN-era stakes: ${cnStakes}`);
console.log(` CARROT-era stakes: ${carrotStakes}`);
console.log(` Transfers: ${stats.transfers.succeeded}/${stats.transfers.attempted} ok (${stats.transfers.failed} failed)`);
console.log(` Stakes: ${stats.stakes.succeeded}/${stats.stakes.attempted} ok (${stats.stakes.failed} failed)`);
// Per-stake verification
console.log('\n Per-Stake Verification');
console.log(' ' + '-'.repeat(50));
let stakesVerified = 0;
let stakesUnverified = 0;
for (const [txHash, stake] of stakeRegistry) {
const returnEntry = returnRegistry.get(txHash);
if (returnEntry) {
stakesVerified++;
console.log(` [${stake.era}] ${stake.wallet} ${fmt(stake.amount, assetType)} @ h${stake.height} -> ` +
`returned ${fmt(returnEntry.amount, assetType)} @ h${returnEntry.height} (yield: ${fmt(returnEntry.yield, assetType)})`);
} else {
stakesUnverified++;
const reason = (await getHeight(daemon)) < stake.unlockHeight + SPENDABLE_AGE
? 'not yet unlocked'
: 'no return detected';
console.log(` [${stake.era}] ${stake.wallet} ${fmt(stake.amount, assetType)} @ h${stake.height} -> ${reason}`);
}
}
console.log(`\n Verified: ${stakesVerified}/${stakeRegistry.size}`);
if (stakesUnverified > 0) {
console.log(` Unverified: ${stakesUnverified} (may need more blocks)`);
}
// Balance sheet
console.log('\n Balance Sheet');
console.log(' ' + '-'.repeat(50));
console.log(` Wallet A balance: ${fmt(syncA.balance, assetType)} (${await outputCount(walletA)} outputs)`);
console.log(` Wallet B balance: ${fmt(syncB.balance, assetType)} (${await outputCount(walletB)} outputs)`);
console.log(` Total A -> B: ${fmt(totalA2B, assetType)}`);
console.log(` Total B -> A: ${fmt(totalB2A, assetType)}`);
console.log(` Total fees: ${fmt(totalFees, assetType)}`);
console.log(` Total staked: ${fmt(totalStaked, assetType)}`);
console.log(` Total returned: ${fmt(totalReturned, assetType)}`);
console.log(` Total yield: ${fmt(totalYield, assetType)}`);
// Success rate
const totalAttempted = stats.transfers.attempted + stats.stakes.attempted;
const totalSucceeded = stats.transfers.succeeded + stats.stakes.succeeded;
const totalFailed = totalAttempted - totalSucceeded;
const successRate = totalAttempted > 0 ? ((totalSucceeded / totalAttempted) * 100).toFixed(1) : '0.0';
console.log('\n Result');
console.log(' ' + '-'.repeat(50));
console.log(` Success rate: ${totalSucceeded}/${totalAttempted} (${successRate}%)`);
console.log(` Transfer success: ${stats.transfers.succeeded}/${stats.transfers.attempted}`);
console.log(` Stake success: ${stats.stakes.succeeded}/${stats.stakes.attempted}`);
console.log(` Stake returns: ${returnRegistry.size}/${stakeRegistry.size} verified`);
if (totalFailed === 0 && returnRegistry.size === stakeRegistry.size) {
console.log(' ALL TRANSACTIONS AND STAKE RETURNS VERIFIED');
} else if (totalFailed === 0) {
console.log(' ALL TRANSACTIONS SUCCEEDED (some stake returns may still be pending)');
} else {
console.log(` WARNING: ${totalFailed} transactions failed`);
}
await saveTxLog();
console.log(`\n TX log saved to ${TX_LOG_FILE}`);
}
// =============================================================================
// MAIN
// =============================================================================
async function main() {
console.log();
console.log('+----------------------------------------------------------------------+');
console.log('| SALVIUM-JS STAKE BURN-IN TEST |');
console.log('+----------------------------------------------------------------------+');
console.log();
const h = await getHeight(daemon);
console.log(` Daemon: ${DAEMON_URL}`);
console.log(` Network: ${NETWORK}`);
console.log(` Height: ${h}`);
console.log(` CARROT fork: ${CARROT_FORK_HEIGHT}`);
console.log(` Stake lock: ${STAKE_LOCK_PERIOD} blocks`);
console.log(` Spendable age: ${SPENDABLE_AGE} blocks`);
console.log();
// Load wallets
section('Loading Wallets');
const walletA = await loadWalletFromFile(WALLET_A_FILE, NETWORK);
walletA.setDaemon(daemon);
console.log(` A CN addr: ${short(walletA.getLegacyAddress())}`);
console.log(` A CARROT addr: ${short(walletA.getCarrotAddress())}`);
const walletB = await loadOrCreateWalletB();
walletB.setDaemon(daemon);
console.log(` B CN addr: ${short(walletB.getLegacyAddress())}`);
console.log(` B CARROT addr: ${short(walletB.getCarrotAddress())}`);
// Parse --phase argument
const phaseArg = process.argv.find(a => a.startsWith('--phase='))?.split('=')[1]
|| (process.argv.includes('--phase') ? process.argv[process.argv.indexOf('--phase') + 1] : null)
|| 'all';
console.log(`\n Phase: ${phaseArg}`);
if (phaseArg === 'all' || phaseArg === 'cn') {
await phaseCN(walletA, walletB);
}
if (phaseArg === 'all' || phaseArg === 'carrot') {
await phaseCARROT(walletA, walletB);
}
await reconcile(walletA, walletB);
console.log('\nStake burn-in test complete.\n');
}
main().catch(e => {
console.error('\nFATAL:', e);
process.exit(1);
});