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