Add STAKE↔RETURN lifecycle tracking with StakeRecord entity

Thread isReturn/returnOriginKey from CARROT scan pipeline into
  WalletOutput storage, then use it to link incoming PROTOCOL payouts
  back to their originating STAKE transactions.

  - Add StakeRecord class (stakeTxHash → return info lifecycle)
  - Add stake storage to MemoryStorage + IndexedDBStorage (v2 schema)
  - Create StakeRecords on outgoing STAKE txs, match returns on
    PROTOCOL txs via CARROT _returnOutputMap → changeOutputKey lookup
  - stakedBalance now reflects only currently locked stakes (not
    total ever staked) in getBalance() and getStorageBalance()
  - getStakeHistory() returns full lifecycle with status, returnAmount,
    yieldEarned
  - Handle reorg rollback for stakes (deleteStakesAbove)
  - Fix stake-tracker.js tx hash display, add --tx lookup mode
This commit is contained in:
Matt Hess
2026-02-13 23:31:56 +00:00
parent 6bfa6a183f
commit 5acb8ff993
4 changed files with 719 additions and 20 deletions
+269 -4
View File
@@ -81,6 +81,14 @@ export class WalletStorage {
// Asset types
async getAssetTypes() { throw new Error('Not implemented'); }
// Stake operations
async putStake(record) { throw new Error('Not implemented'); }
async getStake(stakeTxHash) { throw new Error('Not implemented'); }
async getStakes(query) { throw new Error('Not implemented'); }
async getStakeByOutputKey(changeOutputKey) { throw new Error('Not implemented'); }
async markStakeReturned(stakeTxHash, returnInfo) { throw new Error('Not implemented'); }
async deleteStakesAbove(height) { throw new Error('Not implemented'); }
// Reorg rollback operations
async deleteOutputsAbove(height) { throw new Error('Not implemented'); }
async deleteTransactionsAbove(height) { throw new Error('Not implemented'); }
@@ -141,6 +149,10 @@ export class WalletOutput {
// TX public key (needed to derive output secret key for spending)
this.txPubKey = data.txPubKey || null;
// Stake return detection (CARROT)
this.isReturn = data.isReturn || false; // True if this output is a stake return from protocol_tx
this.returnOriginKey = data.returnOriginKey || null; // The STAKE change output's Ko (hex) that this return matches
// Frozen (user-controlled)
this.isFrozen = data.isFrozen || false;
@@ -223,6 +235,8 @@ export class WalletOutput {
carrotEphemeralPubkey: this.carrotEphemeralPubkey,
carrotSharedSecret: this.carrotSharedSecret,
carrotEnoteType: this.carrotEnoteType,
isReturn: this.isReturn,
returnOriginKey: this.returnOriginKey,
isFrozen: this.isFrozen,
createdAt: this.createdAt,
updatedAt: this.updatedAt
@@ -364,6 +378,64 @@ export class WalletTransaction {
}
}
// ============================================================================
// STAKE RECORD MODEL
// ============================================================================
/**
* Represents a stake lifecycle record (STAKE → RETURN)
*/
export class StakeRecord {
constructor(data = {}) {
// Stake info
this.stakeTxHash = data.stakeTxHash || null;
this.stakeHeight = data.stakeHeight || null;
this.stakeTimestamp = data.stakeTimestamp || null;
this.amountStaked = data.amountStaked !== undefined ? BigInt(data.amountStaked) : 0n;
this.fee = data.fee !== undefined ? BigInt(data.fee) : 0n;
this.assetType = data.assetType || 'SAL';
this.changeOutputKey = data.changeOutputKey || null;
// Return info (populated when PROTOCOL payout is detected)
this.status = data.status || 'locked'; // 'locked' | 'returned'
this.returnTxHash = data.returnTxHash || null;
this.returnHeight = data.returnHeight || null;
this.returnTimestamp = data.returnTimestamp || null;
this.returnAmount = data.returnAmount !== undefined ? BigInt(data.returnAmount) : 0n;
this.createdAt = data.createdAt || Date.now();
this.updatedAt = data.updatedAt || Date.now();
}
toJSON() {
return {
stakeTxHash: this.stakeTxHash,
stakeHeight: this.stakeHeight,
stakeTimestamp: this.stakeTimestamp,
amountStaked: this.amountStaked.toString(),
fee: this.fee.toString(),
assetType: this.assetType,
changeOutputKey: this.changeOutputKey,
status: this.status,
returnTxHash: this.returnTxHash,
returnHeight: this.returnHeight,
returnTimestamp: this.returnTimestamp,
returnAmount: this.returnAmount.toString(),
createdAt: this.createdAt,
updatedAt: this.updatedAt
};
}
static fromJSON(data) {
return new StakeRecord({
...data,
amountStaked: BigInt(data.amountStaked || 0),
fee: BigInt(data.fee || 0),
returnAmount: BigInt(data.returnAmount || 0)
});
}
}
// ============================================================================
// MEMORY STORAGE (Default)
// ============================================================================
@@ -382,6 +454,8 @@ export class MemoryStorage extends WalletStorage {
this._state = new Map(); // Generic key-value state
this._blockHashes = new Map(); // height -> blockHash
this._blockHashRetention = 200; // Only keep last N block hashes (for reorg detection)
this._stakes = new Map(); // stakeTxHash -> StakeRecord
this._stakeOutputKeys = new Map(); // changeOutputKey -> stakeTxHash (for return matching)
this._syncHeight = 0;
this._isOpen = false;
}
@@ -401,6 +475,8 @@ export class MemoryStorage extends WalletStorage {
this._spentKeyImages.clear();
this._state.clear();
this._blockHashes.clear();
this._stakes.clear();
this._stakeOutputKeys.clear();
this._syncHeight = 0;
}
@@ -455,6 +531,66 @@ export class MemoryStorage extends WalletStorage {
return Array.from(types).sort();
}
// Stake operations
async putStake(record) {
const sr = record instanceof StakeRecord ? record : new StakeRecord(record);
sr.updatedAt = Date.now();
this._stakes.set(sr.stakeTxHash, sr);
if (sr.changeOutputKey) {
this._stakeOutputKeys.set(sr.changeOutputKey, sr.stakeTxHash);
}
return sr;
}
async getStake(stakeTxHash) {
const sr = this._stakes.get(stakeTxHash);
return sr ? StakeRecord.fromJSON(sr.toJSON()) : null;
}
async getStakes(query = {}) {
const results = [];
for (const sr of this._stakes.values()) {
if (query.status !== undefined && sr.status !== query.status) continue;
if (query.assetType && sr.assetType !== query.assetType) continue;
results.push(StakeRecord.fromJSON(sr.toJSON()));
}
return results;
}
async getStakeByOutputKey(changeOutputKey) {
const stakeTxHash = this._stakeOutputKeys.get(changeOutputKey);
if (!stakeTxHash) return null;
return this.getStake(stakeTxHash);
}
async markStakeReturned(stakeTxHash, returnInfo) {
const sr = this._stakes.get(stakeTxHash);
if (!sr) return;
sr.status = 'returned';
sr.returnTxHash = returnInfo.returnTxHash;
sr.returnHeight = returnInfo.returnHeight;
sr.returnTimestamp = returnInfo.returnTimestamp;
sr.returnAmount = returnInfo.returnAmount !== undefined ? BigInt(returnInfo.returnAmount) : 0n;
sr.updatedAt = Date.now();
}
async deleteStakesAbove(height) {
for (const [key, sr] of this._stakes) {
if (sr.stakeHeight !== null && sr.stakeHeight > height) {
if (sr.changeOutputKey) this._stakeOutputKeys.delete(sr.changeOutputKey);
this._stakes.delete(key);
} else if (sr.returnHeight !== null && sr.returnHeight > height) {
// Undo the return, keep the stake
sr.status = 'locked';
sr.returnTxHash = null;
sr.returnHeight = null;
sr.returnTimestamp = null;
sr.returnAmount = 0n;
sr.updatedAt = Date.now();
}
}
}
// Transaction operations
async putTransaction(tx) {
const wt = tx instanceof WalletTransaction ? tx : new WalletTransaction(tx);
@@ -596,10 +732,11 @@ export class MemoryStorage extends WalletStorage {
*/
dump() {
return {
version: 1,
version: 2,
syncHeight: this._syncHeight,
outputs: Array.from(this._outputs.values()).map(o => o.toJSON()),
transactions: Array.from(this._transactions.values()).map(t => t.toJSON()),
stakes: Array.from(this._stakes.values()).map(s => s.toJSON()),
spentKeyImages: Array.from(this._spentKeyImages),
blockHashes: Object.fromEntries(this._blockHashes),
state: Object.fromEntries(this._state)
@@ -613,7 +750,7 @@ export class MemoryStorage extends WalletStorage {
*/
dumpJSON() {
const parts = [];
parts.push('{"version":1');
parts.push('{"version":2');
parts.push(`,"syncHeight":${this._syncHeight}`);
// Outputs - stringify one at a time
@@ -636,6 +773,16 @@ export class MemoryStorage extends WalletStorage {
}
parts.push(']');
// Stakes - stringify one at a time
parts.push(',"stakes":[');
first = true;
for (const s of this._stakes.values()) {
if (!first) parts.push(',');
parts.push(JSON.stringify(s.toJSON()));
first = false;
}
parts.push(']');
// Spent key images
parts.push(',"spentKeyImages":');
parts.push(JSON.stringify(Array.from(this._spentKeyImages)));
@@ -657,7 +804,7 @@ export class MemoryStorage extends WalletStorage {
* @param {Object} data - Previously dumped state
*/
load(data) {
if (!data || data.version !== 1) return;
if (!data || (data.version !== 1 && data.version !== 2)) return;
this._syncHeight = data.syncHeight || 0;
@@ -692,6 +839,16 @@ export class MemoryStorage extends WalletStorage {
}
}
if (data.stakes) {
for (const s of data.stakes) {
const sr = StakeRecord.fromJSON(s);
this._stakes.set(sr.stakeTxHash, sr);
if (sr.changeOutputKey) {
this._stakeOutputKeys.set(sr.changeOutputKey, sr.stakeTxHash);
}
}
}
if (data.blockHashes) {
// Only load the most recent block hashes (prune old ones on load)
const entries = Object.entries(data.blockHashes).map(([h, hash]) => [parseInt(h), hash]);
@@ -719,7 +876,7 @@ export class IndexedDBStorage extends WalletStorage {
constructor(options = {}) {
super();
this._dbName = options.dbName || 'salvium-wallet';
this._version = options.version || 1;
this._version = options.version || 2;
this._db = null;
}
@@ -771,6 +928,13 @@ export class IndexedDBStorage extends WalletStorage {
if (!db.objectStoreNames.contains('blockHashes')) {
db.createObjectStore('blockHashes', { keyPath: 'height' });
}
// Stakes store (stake lifecycle tracking)
if (!db.objectStoreNames.contains('stakes')) {
const stakeStore = db.createObjectStore('stakes', { keyPath: 'stakeTxHash' });
stakeStore.createIndex('status', 'status', { unique: false });
stakeStore.createIndex('changeOutputKey', 'changeOutputKey', { unique: true });
}
};
});
}
@@ -785,6 +949,7 @@ export class IndexedDBStorage extends WalletStorage {
async clear() {
const stores = ['outputs', 'transactions', 'keyImages', 'state'];
if (this._db.objectStoreNames.contains('blockHashes')) stores.push('blockHashes');
if (this._db.objectStoreNames.contains('stakes')) stores.push('stakes');
const tx = this._db.transaction(stores, 'readwrite');
for (const name of stores) tx.objectStore(name).clear();
return new Promise((resolve, reject) => {
@@ -950,6 +1115,105 @@ export class IndexedDBStorage extends WalletStorage {
});
}
// Stake operations
async putStake(record) {
const sr = record instanceof StakeRecord ? record : new StakeRecord(record);
sr.updatedAt = Date.now();
const data = sr.toJSON();
return new Promise((resolve, reject) => {
const tx = this._db.transaction('stakes', 'readwrite');
tx.objectStore('stakes').put(data);
tx.oncomplete = () => resolve(sr);
tx.onerror = () => reject(tx.error);
});
}
async getStake(stakeTxHash) {
return new Promise((resolve, reject) => {
const tx = this._db.transaction('stakes', 'readonly');
const request = tx.objectStore('stakes').get(stakeTxHash);
request.onsuccess = () => {
resolve(request.result ? StakeRecord.fromJSON(request.result) : null);
};
request.onerror = () => reject(request.error);
});
}
async getStakes(query = {}) {
return new Promise((resolve, reject) => {
const tx = this._db.transaction('stakes', 'readonly');
const store = tx.objectStore('stakes');
const results = [];
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const sr = StakeRecord.fromJSON(cursor.value);
let match = true;
if (query.status !== undefined && sr.status !== query.status) match = false;
if (query.assetType && sr.assetType !== query.assetType) match = false;
if (match) results.push(sr);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
async getStakeByOutputKey(changeOutputKey) {
return new Promise((resolve, reject) => {
const tx = this._db.transaction('stakes', 'readonly');
const index = tx.objectStore('stakes').index('changeOutputKey');
const request = index.get(changeOutputKey);
request.onsuccess = () => {
resolve(request.result ? StakeRecord.fromJSON(request.result) : null);
};
request.onerror = () => reject(request.error);
});
}
async markStakeReturned(stakeTxHash, returnInfo) {
const sr = await this.getStake(stakeTxHash);
if (!sr) return;
sr.status = 'returned';
sr.returnTxHash = returnInfo.returnTxHash;
sr.returnHeight = returnInfo.returnHeight;
sr.returnTimestamp = returnInfo.returnTimestamp;
sr.returnAmount = returnInfo.returnAmount !== undefined ? BigInt(returnInfo.returnAmount) : 0n;
sr.updatedAt = Date.now();
await this.putStake(sr);
}
async deleteStakesAbove(height) {
return new Promise((resolve, reject) => {
const tx = this._db.transaction('stakes', 'readwrite');
const store = tx.objectStore('stakes');
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const data = cursor.value;
if (data.stakeHeight !== null && data.stakeHeight > height) {
cursor.delete();
} else if (data.returnHeight !== null && data.returnHeight > height) {
data.status = 'locked';
data.returnTxHash = null;
data.returnHeight = null;
data.returnTimestamp = null;
data.returnAmount = '0';
data.updatedAt = Date.now();
cursor.update(data);
}
cursor.continue();
}
};
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
// Sync state
async getSyncHeight() {
const state = await this.getState('syncHeight');
@@ -1145,6 +1409,7 @@ export default {
WalletStorage,
WalletOutput,
WalletTransaction,
StakeRecord,
MemoryStorage,
IndexedDBStorage,
createStorage
+79 -2
View File
@@ -10,7 +10,7 @@
* @module wallet-sync
*/
import { WalletOutput, WalletTransaction } from './wallet-store.js';
import { WalletOutput, WalletTransaction, StakeRecord } from './wallet-store.js';
import { cnSubaddressSecretKey, carrotIndexExtensionGenerator, carrotSubaddressScalar } from './subaddress.js';
import { scanCarrotOutput, scanCarrotInternalOutput, computeReturnAddress, makeInputContext, makeInputContextCoinbase, generateCarrotKeyImage, carrotEcdhKeyExchange, testCarrotViewTag } from './carrot-scanning.js';
import { parseTransaction, parseBlock, extractTxPubKey, extractPaymentId, extractAdditionalPubKeys, serializeTxPrefix } from './transaction.js';
@@ -393,6 +393,9 @@ export class WalletSync {
await this.storage.deleteTransactionsAbove(commonHeight);
await this.storage.unspendOutputsAbove(commonHeight);
await this.storage.deleteBlockHashesAbove(commonHeight);
if (typeof this.storage.deleteStakesAbove === 'function') {
await this.storage.deleteStakesAbove(commonHeight);
}
// Reset sync height to rescan from common ancestor
this.startHeight = commonHeight + 1;
@@ -865,6 +868,9 @@ export class WalletSync {
await this.storage.putTransaction(walletTx);
// Record stake lifecycle (STAKE creation / PROTOCOL return matching)
await this._recordStakeLifecycle(txType, isProtocolTx, txHash, header, ownedOutputs, spentOutputs, walletTx);
if (ownedOutputs.length > 0) {
this._emit('outputReceived', {
txHash, outputs: ownedOutputs, blockHeight: header.height
@@ -967,6 +973,9 @@ export class WalletSync {
await this.storage.putTransaction(walletTx);
// Record stake lifecycle (STAKE creation / PROTOCOL return matching)
await this._recordStakeLifecycle(txType, isProtocolTx, txHash, header, ownedOutputs, spentOutputs, walletTx);
// Emit events
if (ownedOutputs.length > 0) {
this._emit('outputReceived', {
@@ -1303,6 +1312,9 @@ export class WalletSync {
await this.storage.putTransaction(walletTx);
// Record stake lifecycle (STAKE creation / PROTOCOL return matching)
await this._recordStakeLifecycle(txType, isProtocolTx, txHash, header, ownedOutputs, spentOutputs, walletTx);
// Emit events
if (ownedOutputs.length > 0) {
this._emit('outputReceived', {
@@ -1540,7 +1552,9 @@ export class WalletSync {
carrotEphemeralPubkey: scanResult.carrotEphemeralPubkey || null,
carrotSharedSecret: scanResult.carrotSharedSecret || null,
carrotEnoteType: scanResult.enoteType ?? null,
assetType: output.assetType || 'SAL'
assetType: output.assetType || 'SAL',
isReturn: scanResult.isReturn || false,
returnOriginKey: scanResult.returnOriginKey || null
});
await this.storage.putOutput(walletOutput);
@@ -1897,6 +1911,23 @@ export class WalletSync {
: BigInt(output.amount || 0);
}
// Resolve returnOriginKey: the STAKE change output Ko that this return matches
let returnOriginKey = null;
if (isReturnOutput) {
const outputKey = this._extractOutputPubKey(output);
if (outputKey) {
const koHex = bytesToHex(outputKey);
const mapEntry = this._returnOutputMap.get(koHex);
if (mapEntry?.originalKo) {
returnOriginKey = bytesToHex(
mapEntry.originalKo instanceof Uint8Array
? mapEntry.originalKo
: hexToBytes(mapEntry.originalKo)
);
}
}
}
return {
amount,
mask: result.mask,
@@ -1905,6 +1936,8 @@ export class WalletSync {
subaddressIndex: result.subaddressIndex,
keyImage,
isCarrot: true,
isReturn: isReturnOutput,
returnOriginKey,
// CARROT-specific data needed for spending
carrotEphemeralPubkey: enoteEphemeralPubkey ? bytesToHex(enoteEphemeralPubkey) : null,
carrotSharedSecret: result.sharedSecret // Already hex from scanCarrotOutput
@@ -2090,6 +2123,50 @@ export class WalletSync {
return null;
}
/**
* Record stake lifecycle events (STAKE creation + PROTOCOL return matching)
* @private
* @param {number} txType - TX_TYPE value
* @param {boolean} isProtocolTx - Whether this is a protocol transaction
* @param {string} txHash - Transaction hash
* @param {Object} header - Block header { height, timestamp }
* @param {Array} ownedOutputs - WalletOutput instances we own in this tx
* @param {Array} spentOutputs - Outputs spent by this tx
* @param {WalletTransaction} walletTx - The wallet transaction record
*/
async _recordStakeLifecycle(txType, isProtocolTx, txHash, header, ownedOutputs, spentOutputs, walletTx) {
// Record new STAKE
if (txType === TX_TYPE.STAKE && spentOutputs.length > 0) {
const changeOutput = ownedOutputs.find(o => o);
await this.storage.putStake(new StakeRecord({
stakeTxHash: txHash,
stakeHeight: header.height,
stakeTimestamp: header.timestamp,
amountStaked: walletTx.amountBurnt,
fee: walletTx.fee,
assetType: changeOutput?.assetType || 'SAL',
changeOutputKey: changeOutput?.publicKey || null
}));
}
// Match PROTOCOL returns to their originating STAKE
if (isProtocolTx && ownedOutputs.length > 0) {
for (const output of ownedOutputs) {
if (output.returnOriginKey) {
const stake = await this.storage.getStakeByOutputKey(output.returnOriginKey);
if (stake) {
await this.storage.markStakeReturned(stake.stakeTxHash, {
returnTxHash: txHash,
returnHeight: header.height,
returnTimestamp: header.timestamp,
returnAmount: output.amount
});
}
}
}
}
}
/**
* Determine Salvium transaction type
* @private
+41 -14
View File
@@ -954,11 +954,13 @@ export class Wallet {
balance += amount;
if (output.isUnlocked(this._syncHeight)) unlockedBalance += amount;
}
// Sum staked amounts from STAKE transactions (type 6)
// Sum staked amounts from StakeRecords (only currently locked stakes)
let stakedBalance = 0n;
for (const tx of this._storage._transactions.values()) {
if (tx.txType === TX_TYPE.STAKE && tx.isOutgoing) {
stakedBalance += typeof tx.amountBurnt === 'bigint' ? tx.amountBurnt : BigInt(tx.amountBurnt || 0);
if (this._storage._stakes) {
for (const sr of this._storage._stakes.values()) {
if (sr.status === 'locked') {
stakedBalance += typeof sr.amountStaked === 'bigint' ? sr.amountStaked : BigInt(sr.amountStaked || 0);
}
}
}
return { balance, unlockedBalance, lockedBalance: balance - unlockedBalance, stakedBalance };
@@ -1990,16 +1992,39 @@ export class Wallet {
*/
async getStakeHistory() {
if (!this._storage) return [];
// Prefer StakeRecord-based history (full lifecycle with return info)
if (typeof this._storage.getStakes === 'function') {
const stakes = await this._storage.getStakes({});
return stakes.map(s => ({
stakeTxHash: s.stakeTxHash,
stakeHeight: s.stakeHeight,
stakeTimestamp: s.stakeTimestamp,
amountStaked: s.amountStaked,
fee: s.fee,
assetType: s.assetType,
status: s.status,
returnTxHash: s.returnTxHash,
returnHeight: s.returnHeight,
returnTimestamp: s.returnTimestamp,
returnAmount: s.returnAmount,
yieldEarned: s.status === 'returned' ? s.returnAmount - s.amountStaked : 0n
}));
}
// Fallback: tx-based history (no return info)
const stakeTxs = await this._storage.getTransactions({ txType: TX_TYPE.STAKE });
return stakeTxs.map(tx => ({
txHash: tx.txHash,
blockHeight: tx.blockHeight,
blockTimestamp: tx.blockTimestamp,
stakeTxHash: tx.txHash,
stakeHeight: tx.blockHeight,
stakeTimestamp: tx.blockTimestamp,
amountStaked: typeof tx.amountBurnt === 'bigint' ? tx.amountBurnt : BigInt(tx.amountBurnt || 0),
fee: typeof tx.fee === 'bigint' ? tx.fee : BigInt(tx.fee || 0),
changeAmount: typeof tx.changeAmount === 'bigint' ? tx.changeAmount : BigInt(tx.changeAmount || 0),
isOutgoing: tx.isOutgoing,
isIncoming: tx.isIncoming
assetType: 'SAL',
status: 'locked',
returnTxHash: null,
returnHeight: null,
returnTimestamp: null,
returnAmount: 0n,
yieldEarned: 0n
}));
}
@@ -2191,11 +2216,13 @@ export class Wallet {
balance += o.amount;
if (o.isSpendable(this._syncHeight)) unlockedBalance += o.amount;
}
// Sum staked amounts from STAKE transactions (type 6)
const stakeTxs = await this._storage.getTransactions({ txType: TX_TYPE.STAKE, isOutgoing: true });
// Sum staked amounts from StakeRecords (only currently locked stakes)
let stakedBalance = 0n;
for (const tx of stakeTxs) {
stakedBalance += typeof tx.amountBurnt === 'bigint' ? tx.amountBurnt : BigInt(tx.amountBurnt || 0);
if (typeof this._storage.getStakes === 'function') {
const lockedStakes = await this._storage.getStakes({ status: 'locked' });
for (const s of lockedStakes) {
stakedBalance += typeof s.amountStaked === 'bigint' ? s.amountStaked : BigInt(s.amountStaked || 0);
}
}
return { balance, unlockedBalance, lockedBalance: balance - unlockedBalance, stakedBalance };
}
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env bun
/**
* Salvium Stake Tracker
*
* Demonstrates stake + return tracking by scanning the chain for STAKE (type 6)
* and PROTOCOL (type 2) transactions, showing how amount_burnt flows through
* the staking lifecycle.
*
* Usage:
* bun tools/stake-tracker.js [--daemon URL] [--height N] [--range N] [--tx HASH]
*
* Examples:
* # Track the 130,130 SAL1 stake at block 417082
* bun tools/stake-tracker.js --height 417082
*
* # Scan 500 blocks from a starting height
* bun tools/stake-tracker.js --height 417000 --range 500
*
* # Look up a specific transaction by hash
* bun tools/stake-tracker.js --tx 1563a8c7...
*/
import { DaemonRPC } from '../src/rpc/daemon.js';
import { parseTransaction, parseBlock } from '../src/transaction/parsing.js';
import { hexToBytes, bytesToHex } from '../src/address.js';
import { TX_TYPE, RCT_TYPE } from '../src/transaction/constants.js';
// ─── CLI ─────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
function getArg(name, fallback) {
const idx = args.indexOf(name);
return idx >= 0 && args[idx + 1] ? args[idx + 1] : fallback;
}
const DAEMON_URL = getArg('--daemon', 'http://seed01.salvium.io:19081');
const START_HEIGHT = parseInt(getArg('--height', '417080'), 10);
const RANGE = parseInt(getArg('--range', '200'), 10);
const TX_HASH_LOOKUP = getArg('--tx', null);
// ─── Formatting ──────────────────────────────────────────────────────────────
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const CYAN = '\x1b[36m';
const RED = '\x1b[31m';
const RESET = '\x1b[0m';
function fmtSAL(atomic) {
const n = typeof atomic === 'bigint' ? atomic : BigInt(atomic || 0);
const whole = n / 100000000000n;
const frac = n % 100000000000n;
const fracStr = frac.toString().padStart(11, '0').replace(/0+$/, '') || '0';
return `${whole}.${fracStr}`;
}
function truncHash(h, len = 16) {
if (!h || h.length <= len * 2) return h;
return `${h.slice(0, len)}...${h.slice(-8)}`;
}
const TX_TYPE_NAMES = {
0: 'UNSET', 1: 'MINER', 2: 'PROTOCOL', 3: 'TRANSFER',
4: 'CONVERT', 5: 'BURN', 6: 'STAKE', 7: 'RETURN', 8: 'AUDIT'
};
// ─── TX Lookup Mode ─────────────────────────────────────────────────────────
async function lookupTx(daemon, txHash) {
console.log(`${BOLD}Salvium Stake Tracker${RESET} — TX Lookup`);
console.log(`Daemon: ${DAEMON_URL}\n`);
const resp = await daemon.getTransactions([txHash], { decode_as_json: true });
if (!resp.success || !resp.result.txs?.length) {
console.error(`Transaction not found: ${txHash}`);
process.exit(1);
}
const txData = resp.result.txs[0];
const txBytes = hexToBytes(txData.as_hex);
const tx = parseTransaction(txBytes);
const txType = tx.prefix?.txType || 0;
const amountBurnt = tx.prefix?.amount_burnt || 0n;
const fee = tx.rct?.txnFee || 0n;
const inputs = tx.prefix?.vin || [];
const outputs = tx.prefix?.vout || [];
console.log(`${'═'.repeat(78)}`);
console.log(` ${BOLD}TRANSACTION DETAILS${RESET}`);
console.log(`${'═'.repeat(78)}\n`);
console.log(` Hash: ${CYAN}${txData.tx_hash}${RESET}`);
console.log(` Type: ${BOLD}${TX_TYPE_NAMES[txType] || txType}${RESET} (${txType})`);
console.log(` Block: ${txData.block_height ?? 'mempool'}`);
console.log(` Confirmations: ${txData.block_height != null ? '(in block)' : 'unconfirmed'}`);
console.log(` Amount Burnt: ${BOLD}${fmtSAL(amountBurnt)} SAL1${RESET}`);
console.log(` Fee: ${fmtSAL(fee)} SAL1`);
console.log(` Inputs: ${inputs.length}`);
console.log(` Outputs: ${outputs.length}`);
if (inputs.length > 0) {
console.log(`\n ${DIM}Key Images:${RESET}`);
for (const inp of inputs) {
if (inp.keyImage) {
console.log(` ${DIM}${bytesToHex(inp.keyImage)}${RESET}`);
}
}
}
if (outputs.length > 0) {
console.log(`\n ${DIM}Output Keys:${RESET}`);
for (let i = 0; i < outputs.length; i++) {
const out = outputs[i];
const key = out.key ? bytesToHex(out.key) : '(unknown)';
const amount = out.amount || 0n;
const amountStr = amount > 0n ? ` amount=${fmtSAL(amount)}` : '';
console.log(` [${i}] ${DIM}${truncHash(key, 24)}${amountStr}${RESET}`);
}
}
console.log();
}
// ─── Scan Mode ──────────────────────────────────────────────────────────────
async function scanBlocks(daemon) {
const infoResp = await daemon.getInfo();
if (!infoResp.success) {
console.error('Failed to connect to daemon:', DAEMON_URL);
process.exit(1);
}
const chainHeight = infoResp.result.height;
console.log(`${BOLD}Salvium Stake Tracker${RESET}`);
console.log(`Daemon: ${DAEMON_URL} Chain height: ${chainHeight}`);
console.log(`Scanning blocks ${START_HEIGHT} to ${START_HEIGHT + RANGE - 1}...\n`);
const stakes = [];
const protocols = [];
const burns = [];
const converts = [];
const endHeight = Math.min(START_HEIGHT + RANGE, chainHeight);
const batchSize = 20;
for (let h = START_HEIGHT; h < endHeight; h += batchSize) {
const batchEnd = Math.min(h + batchSize, endHeight);
const heights = [];
for (let i = h; i < batchEnd; i++) heights.push(i);
process.stdout.write(`\r Scanning ${h} - ${batchEnd - 1}...`);
const resp = await daemon.getBlocksByHeight(heights);
if (!resp.success) {
console.error(`\n Failed at height ${h}:`, resp.error?.message);
continue;
}
for (let bi = 0; bi < resp.result.blocks.length; bi++) {
const block = resp.result.blocks[bi];
const blockHeight = h + bi;
// Parse block blob to get tx hashes
let parsedBlock = null;
try {
const blockBlob = block.block instanceof Uint8Array
? block.block : new Uint8Array(block.block);
parsedBlock = parseBlock(blockBlob);
} catch (_e) {
// Fall through — txHashes unavailable
}
// Parse user transactions
const txBlobs = block.txs || [];
for (let ti = 0; ti < txBlobs.length; ti++) {
try {
const txBytes = txBlobs[ti] instanceof Uint8Array ? txBlobs[ti] : hexToBytes(txBlobs[ti]);
const tx = parseTransaction(txBytes);
const txType = tx.prefix?.txType || 0;
// Get tx hash from parsed block's txHashes array
let txHash = '?';
if (parsedBlock?.txHashes?.[ti]) {
txHash = bytesToHex(parsedBlock.txHashes[ti]);
}
const amountBurnt = tx.prefix?.amount_burnt || 0n;
const fee = tx.rct?.txnFee || 0n;
const inputs = tx.prefix?.vin || [];
const outputs = tx.prefix?.vout || [];
const keyImages = inputs
.filter(inp => inp.keyImage)
.map(inp => bytesToHex(inp.keyImage));
if (txType === TX_TYPE.STAKE) {
stakes.push({
txHash, height: blockHeight, amountBurnt, fee,
inputCount: inputs.length, outputCount: outputs.length,
keyImages
});
} else if (txType === TX_TYPE.PROTOCOL) {
const amounts = outputs.map(o => o.amount || 0n);
protocols.push({
txHash, height: blockHeight,
outputCount: outputs.length, amounts, amountBurnt
});
} else if (txType === TX_TYPE.BURN) {
burns.push({ txHash, height: blockHeight, amountBurnt, fee });
} else if (txType === TX_TYPE.CONVERT) {
converts.push({ txHash, height: blockHeight, amountBurnt, fee });
}
} catch (e) {
// Skip unparseable txs
}
}
}
}
process.stdout.write('\r' + ' '.repeat(60) + '\r');
// ─── Display Results ─────────────────────────────────────────────────────
console.log(`${'═'.repeat(78)}`);
console.log(` ${BOLD}SCAN RESULTS${RESET} blocks ${START_HEIGHT}${endHeight - 1} (${endHeight - START_HEIGHT} blocks)`);
console.log(`${'═'.repeat(78)}\n`);
// Stakes
if (stakes.length > 0) {
console.log(`${GREEN}${BOLD} STAKE TRANSACTIONS (${stakes.length})${RESET}`);
console.log(` ${'─'.repeat(74)}`);
for (const s of stakes) {
console.log(` ${YELLOW}Block ${s.height}${RESET} tx: ${CYAN}${truncHash(s.txHash, 20)}${RESET}`);
console.log(` Amount Staked: ${BOLD}${fmtSAL(s.amountBurnt)} SAL1${RESET} (locked in amount_burnt)`);
console.log(` Fee: ${fmtSAL(s.fee)} SAL1`);
console.log(` Inputs: ${s.inputCount} (spent to fund stake + change)`);
console.log(` Outputs: ${s.outputCount} (change only — staked amount has no output)`);
console.log(` Key Images: ${DIM}${s.keyImages.slice(0, 3).map(k => truncHash(k, 12)).join(', ')}${s.keyImages.length > 3 ? ` ... +${s.keyImages.length - 3} more` : ''}${RESET}`);
console.log();
}
} else {
console.log(` ${DIM}No STAKE transactions found in range${RESET}\n`);
}
// Protocol (returns/yields)
if (protocols.length > 0) {
console.log(`${CYAN}${BOLD} PROTOCOL TRANSACTIONS (${protocols.length})${RESET} ${DIM}(yield payouts / stake returns)${RESET}`);
console.log(` ${'─'.repeat(74)}`);
for (const p of protocols) {
console.log(` ${YELLOW}Block ${p.height}${RESET} tx: ${CYAN}${truncHash(p.txHash, 20)}${RESET}`);
console.log(` Outputs: ${p.outputCount}`);
if (p.amountBurnt > 0n) {
console.log(` Amount Burnt: ${fmtSAL(p.amountBurnt)} SAL1`);
}
console.log();
}
} else {
console.log(` ${DIM}No PROTOCOL transactions found in range${RESET}\n`);
}
// Burns
if (burns.length > 0) {
console.log(`${RED}${BOLD} BURN TRANSACTIONS (${burns.length})${RESET}`);
console.log(` ${'─'.repeat(74)}`);
for (const b of burns) {
console.log(` ${YELLOW}Block ${b.height}${RESET} tx: ${CYAN}${truncHash(b.txHash, 20)}${RESET}`);
console.log(` Amount Burned: ${BOLD}${fmtSAL(b.amountBurnt)} SAL1${RESET}`);
console.log(` Fee: ${fmtSAL(b.fee)} SAL1`);
console.log();
}
}
// Converts
if (converts.length > 0) {
console.log(`${BOLD} CONVERT TRANSACTIONS (${converts.length})${RESET}`);
console.log(` ${'─'.repeat(74)}`);
for (const c of converts) {
console.log(` ${YELLOW}Block ${c.height}${RESET} tx: ${CYAN}${truncHash(c.txHash, 20)}${RESET}`);
console.log(` Amount Converted: ${BOLD}${fmtSAL(c.amountBurnt)} SAL1${RESET}`);
console.log(` Fee: ${fmtSAL(c.fee)} SAL1`);
console.log();
}
}
// ─── Staking Lifecycle Summary ───────────────────────────────────────────
if (stakes.length > 0 || protocols.length > 0) {
console.log(`${'═'.repeat(78)}`);
console.log(` ${BOLD}STAKING LIFECYCLE${RESET}`);
console.log(`${'═'.repeat(78)}\n`);
console.log(` ${DIM}How Salvium staking works on-chain:${RESET}`);
console.log(` 1. ${GREEN}STAKE TX${RESET} — User sends inputs; staked amount goes to ${BOLD}amount_burnt${RESET}`);
console.log(` (not to any output). Only change is returned.`);
console.log(` 2. ${DIM}Lock period${RESET} — Staked coins earn yield proportional to`);
console.log(` (your_stake / total_locked) * block_slippage`);
console.log(` 3. ${CYAN}PROTOCOL TX${RESET} — Daemon generates a payout TX returning`);
console.log(` the original stake + accrued yield\n`);
const totalStaked = stakes.reduce((sum, s) => sum + s.amountBurnt, 0n);
const totalFees = stakes.reduce((sum, s) => sum + s.fee, 0n);
console.log(` ${BOLD}Summary for this range:${RESET}`);
console.log(` Total staked: ${GREEN}${fmtSAL(totalStaked)} SAL1${RESET} across ${stakes.length} tx(s)`);
console.log(` Staking fees: ${fmtSAL(totalFees)} SAL1`);
console.log(` Protocol returns: ${CYAN}${protocols.length}${RESET} tx(s) in range`);
if (protocols.length === 0 && stakes.length > 0) {
console.log(`\n ${DIM}Tip: Stake returns may be beyond this range. Try --range 1000 or higher.${RESET}`);
}
}
console.log();
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
const daemon = new DaemonRPC({ url: DAEMON_URL });
if (TX_HASH_LOOKUP) {
await lookupTx(daemon, TX_HASH_LOOKUP);
} else {
await scanBlocks(daemon);
}
}
main().catch(e => {
console.error('Fatal:', e.message);
process.exit(1);
});