Support pre-CARROT STAKE↔RETURN matching

The return pubkey is explicit in the tx prefix for all STAKE types:
  CARROT (v>=4) stores it in protocol_tx_data.return_pubkey, pre-CARROT
  stores it as a top-level prefix field. Use it as the primary matching
  key instead of relying solely on the CARROT _returnOutputMap path.

  - Extract return_pubkey from prefix when recording StakeRecords
  - Match PROTOCOL outputs by publicKey against stored return_pubkey
    as fallback when CARROT returnOriginKey isn't set
  - Pass return_address/return_pubkey/protocol_tx_data through
    _convertJsonToTx so JSON fallback path has the same fields
This commit is contained in:
Matt Hess
2026-02-13 23:37:58 +00:00
parent 5acb8ff993
commit 09a558eaf0
+56 -6
View File
@@ -869,7 +869,7 @@ 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);
await this._recordStakeLifecycle(txType, isProtocolTx, txHash, header, ownedOutputs, spentOutputs, walletTx, tx);
if (ownedOutputs.length > 0) {
this._emit('outputReceived', {
@@ -974,7 +974,7 @@ 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);
await this._recordStakeLifecycle(txType, isProtocolTx, txHash, header, ownedOutputs, spentOutputs, walletTx, tx);
// Emit events
if (ownedOutputs.length > 0) {
@@ -1110,7 +1110,17 @@ export class WalletSync {
vout,
extra,
type: txJson.type || 0,
amount_burnt: this._safeBigInt(txJson.amount_burnt)
amount_burnt: this._safeBigInt(txJson.amount_burnt),
// Salvium staking/return fields
return_address: txJson.return_address ? hexToBytes(txJson.return_address) : null,
return_pubkey: txJson.return_pubkey ? hexToBytes(txJson.return_pubkey) : null,
protocol_tx_data: txJson.protocol_tx_data ? {
version: txJson.protocol_tx_data.version || 0,
return_address: txJson.protocol_tx_data.return_address ? hexToBytes(txJson.protocol_tx_data.return_address) : null,
return_pubkey: txJson.protocol_tx_data.return_pubkey ? hexToBytes(txJson.protocol_tx_data.return_pubkey) : null,
return_view_tag: txJson.protocol_tx_data.return_view_tag ? hexToBytes(txJson.protocol_tx_data.return_view_tag) : null,
return_anchor_enc: txJson.protocol_tx_data.return_anchor_enc ? hexToBytes(txJson.protocol_tx_data.return_anchor_enc) : null
} : null
};
// RCT signatures (minimal for coinbase)
@@ -1313,7 +1323,7 @@ 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);
await this._recordStakeLifecycle(txType, isProtocolTx, txHash, header, ownedOutputs, spentOutputs, walletTx, tx);
// Emit events
if (ownedOutputs.length > 0) {
@@ -2123,6 +2133,28 @@ export class WalletSync {
return null;
}
/**
* Extract the return pubkey from a STAKE transaction prefix.
* CARROT (v>=4): protocol_tx_data.return_pubkey
* Pre-CARROT: prefix.return_pubkey (top-level)
* @private
* @returns {string|null} Return pubkey as hex, or null
*/
_extractReturnPubkey(tx) {
const prefix = tx.prefix || tx;
// CARROT STAKE (version >= 4): protocol_tx_data.return_pubkey
if (prefix.protocol_tx_data?.return_pubkey) {
const rp = prefix.protocol_tx_data.return_pubkey;
return typeof rp === 'string' ? rp : bytesToHex(rp);
}
// Pre-CARROT: top-level return_pubkey
if (prefix.return_pubkey) {
const rp = prefix.return_pubkey;
return typeof rp === 'string' ? rp : bytesToHex(rp);
}
return null;
}
/**
* Record stake lifecycle events (STAKE creation + PROTOCOL return matching)
* @private
@@ -2133,10 +2165,14 @@ export class WalletSync {
* @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
* @param {Object} tx - The parsed transaction (for prefix field access)
*/
async _recordStakeLifecycle(txType, isProtocolTx, txHash, header, ownedOutputs, spentOutputs, walletTx) {
async _recordStakeLifecycle(txType, isProtocolTx, txHash, header, ownedOutputs, spentOutputs, walletTx, tx) {
// Record new STAKE
if (txType === TX_TYPE.STAKE && spentOutputs.length > 0) {
// Prefer explicit return_pubkey from prefix (works for both CARROT and pre-CARROT).
// Fall back to CARROT change output key for self-send-based matching.
const returnPubkey = this._extractReturnPubkey(tx);
const changeOutput = ownedOutputs.find(o => o);
await this.storage.putStake(new StakeRecord({
stakeTxHash: txHash,
@@ -2145,13 +2181,14 @@ export class WalletSync {
amountStaked: walletTx.amountBurnt,
fee: walletTx.fee,
assetType: changeOutput?.assetType || 'SAL',
changeOutputKey: changeOutput?.publicKey || null
changeOutputKey: returnPubkey || changeOutput?.publicKey || null
}));
}
// Match PROTOCOL returns to their originating STAKE
if (isProtocolTx && ownedOutputs.length > 0) {
for (const output of ownedOutputs) {
// Path 1: CARROT — returnOriginKey set by _scanCarrotOutput via _returnOutputMap
if (output.returnOriginKey) {
const stake = await this.storage.getStakeByOutputKey(output.returnOriginKey);
if (stake) {
@@ -2161,6 +2198,19 @@ export class WalletSync {
returnTimestamp: header.timestamp,
returnAmount: output.amount
});
continue;
}
}
// Path 2: Pre-CARROT — match output public key against stored return_pubkey
if (output.publicKey) {
const stake = await this.storage.getStakeByOutputKey(output.publicKey);
if (stake && stake.status === 'locked') {
await this.storage.markStakeReturned(stake.stakeTxHash, {
returnTxHash: txHash,
returnHeight: header.height,
returnTimestamp: header.timestamp,
returnAmount: output.amount
});
}
}
}