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