diff --git a/.changelog/820.feature.md b/.changelog/820.feature.md new file mode 100644 index 000000000..0448c0923 --- /dev/null +++ b/.changelog/820.feature.md @@ -0,0 +1,16 @@ +Improve Status Reporting for Consensus-Accounts Transactions + +The handling of multi-step runtime transactions (`consensus.Deposit`, +`consensus.Withdraw`, `consensus.Delegate`, and `consensus.Undelegate`) +has been improved. Previously, these transactions were marked as successful +once they were included in a block, even though the second step (executed in +the next runtime block) could fail. This could result in misleading statuses +for users. + +To address this: + +- These transactions will now be reported without a status until the second +step is completed + +- Once the relevant event is received, the transaction status will be updated +to either successful or failed diff --git a/analyzer/queries/queries.go b/analyzer/queries/queries.go index de5473a93..fb0364b88 100644 --- a/analyzer/queries/queries.go +++ b/analyzer/queries/queries.go @@ -515,10 +515,10 @@ var ( SELECT epochs.id, epochs.start_height, prev_epoch.validators FROM chain.epochs as epochs LEFT JOIN history.validators as history - ON epochs.id = history.epoch + ON epochs.id = history.epoch LEFT JOIN chain.epochs as prev_epoch ON epochs.id = prev_epoch.id + 1 - WHERE + WHERE history.epoch IS NULL AND epochs.id >= $1 ORDER BY epochs.id @@ -531,7 +531,7 @@ var ( ValidatorStakingRewardUpdate = ` UPDATE history.validators SET staking_rewards = $3 - WHERE + WHERE id = $1 AND epoch = $2` @@ -545,7 +545,7 @@ var ( FROM chain.epochs AS epochs LEFT JOIN history.validators AS history ON epochs.id = history.epoch - WHERE + WHERE history.epoch IS NULL AND epochs.id >= $1` @@ -1147,4 +1147,108 @@ var ( WHERE (evs.abi_parsed_at IS NULL OR evs.abi_parsed_at < abi_contracts.verification_info_downloaded_at) LIMIT $2` + + RuntimeConsensusAccountTransactionStatusUpdate = ` + -- First, find the round in which the transaction was submitted. + -- This should be the first previous successful round. We currently don't + -- have the runtime-block status field in the DB, so we find the previous + -- successful round by taking the first round which has at least one transaction. + WITH tx_round AS ( + SELECT + MAX(rt.round) AS round + FROM chain.runtime_transactions AS rt + WHERE + rt.runtime = $1 AND + rt.round < $2 + ), + matched_tx AS ( + SELECT + rt.runtime, + rt.round, + rt.tx_index + FROM chain.runtime_transactions AS rt + JOIN chain.runtime_transaction_signers AS rts ON + rt.runtime = rts.runtime AND + rt.round = rts.round AND + rt.tx_index = rts.tx_index + WHERE + rt.runtime = $1 AND + rt.round = (SELECT round FROM tx_round) AND + rt.method = $3 AND + rts.signer_address = $4 AND + rts.signer_index = 0 AND + rts.nonce = $5 + LIMIT 1 + ) + UPDATE chain.runtime_transactions rt + SET + success = $6, + error_module = $7, + error_code = $8, + error_message = $9 + FROM matched_tx + WHERE + rt.runtime = matched_tx.runtime AND + rt.round = matched_tx.round AND + rt.tx_index = matched_tx.tx_index` + + RuntimeConsensusAccountTransactionStatusUpdateFastSync = ` + INSERT INTO todo_updates.transaction_status_updates (runtime, round, method, sender, nonce, success, error_module, error_code, error_message) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)` + + RuntimeTransactionStatusUpdatesRecompute = ` + -- Find the previous successful round for each record in 'todo_updates.transaction_status_updates'. + WITH transaction_status_updates_round AS ( + SELECT + tsu.runtime, + tsu.round, + tsu.method, + tsu.sender, + tsu.nonce, + tsu.success, + tsu.error_module, + tsu.error_code, + tsu.error_message, + ( + SELECT MAX(rt.round) + FROM chain.runtime_transactions AS rt + WHERE rt.runtime = tsu.runtime + AND rt.round < tsu.round + ) AS prev_round + FROM todo_updates.transaction_status_updates AS tsu + ), + matched_txs AS ( + SELECT + rt.runtime, + rt.round, + rt.tx_index, + tsu.success, + tsu.error_module, + tsu.error_code, + tsu.error_message + FROM transaction_status_updates_round AS tsu + JOIN chain.runtime_transactions AS rt + ON rt.runtime = tsu.runtime AND + rt.method = tsu.method AND + rt.round = tsu.prev_round + JOIN chain.runtime_transaction_signers AS rts + ON rt.runtime = rts.runtime AND + rt.round = rts.round AND + rt.tx_index = rts.tx_index + WHERE + rt.runtime = $1 AND + rts.signer_address = tsu.sender AND + rts.nonce = tsu.nonce + ) + UPDATE chain.runtime_transactions rt + SET + success = matched_txs.success, + error_module = matched_txs.error_module, + error_code = matched_txs.error_code, + error_message = matched_txs.error_message + FROM matched_txs + WHERE + rt.runtime = matched_txs.runtime + AND rt.round = matched_txs.round + AND rt.tx_index = matched_txs.tx_index` ) diff --git a/analyzer/runtime/accounts.go b/analyzer/runtime/accounts.go index bfa27e926..644656ab8 100644 --- a/analyzer/runtime/accounts.go +++ b/analyzer/runtime/accounts.go @@ -4,11 +4,13 @@ import ( "math/big" "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/accounts" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/consensusaccounts" sdkTypes "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" "golang.org/x/exp/slices" "github.com/oasisprotocol/nexus/analyzer" "github.com/oasisprotocol/nexus/analyzer/queries" + "github.com/oasisprotocol/nexus/common" "github.com/oasisprotocol/nexus/storage" ) @@ -146,3 +148,79 @@ func (m *processor) queueTransfer(batch *storage.QueryBatch, round uint64, e acc ) } } + +func (m *processor) queueConsensusAccountsEvents(batch *storage.QueryBatch, blockData *BlockData) { + for _, event := range blockData.EventData { + if event.WithScope.ConsensusAccounts == nil { + continue + } + if e := event.WithScope.ConsensusAccounts.Deposit; e != nil { + m.queueTransactionStatusUpdate(batch, blockData.Header.Round, "consensus.Deposit", e.From, e.Nonce, e.Error) + } + if e := event.WithScope.ConsensusAccounts.Withdraw; e != nil { + m.queueTransactionStatusUpdate(batch, blockData.Header.Round, "consensus.Withdraw", e.From, e.Nonce, e.Error) + } + if e := event.WithScope.ConsensusAccounts.Delegate; e != nil { + m.queueTransactionStatusUpdate(batch, blockData.Header.Round, "consensus.Delegate", e.From, e.Nonce, e.Error) + } + if e := event.WithScope.ConsensusAccounts.UndelegateStart; e != nil { + m.queueTransactionStatusUpdate(batch, blockData.Header.Round, "consensus.Undelegate", e.From, e.Nonce, e.Error) + } + // Nothing to do for 'UndelegateEnd'. + } +} + +// Updates the status of a transaction based on the event result. +// These transactions are special in the way that the actual action is executed in the next round. +func (m *processor) queueTransactionStatusUpdate( + batch *storage.QueryBatch, + round uint64, + methodName string, + sender sdkTypes.Address, + nonce uint64, + e *consensusaccounts.ConsensusError, +) { + // We can only do this in slow-sync, because the event affects a transaction in previous round. + // For fast-sync, this is handled in the FinalizeFastSync step. + var errorModule *string + var errorCode *uint32 + var errorMessage *string + success := true + if e != nil { + errorModule = &e.Module + errorCode = &e.Code + // The event doesn't contain the error message, so construct a human readable one here. + // TODO: We could try loading the error message, but Nexus currently doesn't have a mapping + // from error codes to error messages. This can also be done on the frontend. + errorMessage = common.Ptr("Consensus error: " + e.Module) + success = false + } + switch m.mode { + case analyzer.FastSyncMode: + batch.Queue( + queries.RuntimeConsensusAccountTransactionStatusUpdateFastSync, + m.runtime, + round, + methodName, + sender, + nonce, + success, + errorModule, + errorCode, + errorMessage, + ) + case analyzer.SlowSyncMode: + batch.Queue( + queries.RuntimeConsensusAccountTransactionStatusUpdate, + m.runtime, + round, + methodName, + sender, + nonce, + success, + errorModule, + errorCode, + errorMessage, + ) + } +} diff --git a/analyzer/runtime/extract.go b/analyzer/runtime/extract.go index db70adaea..38441ae2a 100644 --- a/analyzer/runtime/extract.go +++ b/analyzer/runtime/extract.go @@ -342,6 +342,9 @@ func ExtractRound(blockHeader nodeapi.RuntimeBlockHeader, txrs []nodeapi.Runtime // Ref: https://github.com/oasisprotocol/oasis-sdk/blob/runtime-sdk/v0.8.4/runtime-sdk/src/modules/consensus_accounts/mod.rs#L418 to = blockTransactionData.SignerData[0].Address } + // Set the 'Success' field to 'Pending' for deposits. This is because the outcome of the Deposit tx is only known in the next block. + blockTransactionData.Success = nil + return nil }, ConsensusAccountsWithdraw: func(body *consensusaccounts.Withdraw) error { @@ -359,6 +362,9 @@ func ExtractRound(blockHeader nodeapi.RuntimeBlockHeader, txrs []nodeapi.Runtime to = blockTransactionData.SignerData[0].Address } blockTransactionData.RelatedAccountAddresses[to] = struct{}{} + // Set the 'Success' field to 'Pending' for withdrawals. This is because the outcome of the Withdraw tx is only known in the next block. + blockTransactionData.Success = nil + return nil }, ConsensusAccountsDelegate: func(body *consensusaccounts.Delegate) error { @@ -392,6 +398,8 @@ func ExtractRound(blockHeader nodeapi.RuntimeBlockHeader, txrs []nodeapi.Runtime return fmt.Errorf("to: %w", err) } blockTransactionData.RelatedAccountAddresses[to] = struct{}{} + // Set the 'Success' field to 'Pending' for delegations. This is because the outcome of the Delegate tx is only known in the next block. + blockTransactionData.Success = nil return nil }, ConsensusAccountsUndelegate: func(body *consensusaccounts.Undelegate) error { @@ -408,6 +416,9 @@ func ExtractRound(blockHeader nodeapi.RuntimeBlockHeader, txrs []nodeapi.Runtime // to convert `shares` to `amount` until the undelegation actually happens (= UndelegateDone event); in the meantime, // the validator's token pool might change, e.g. because of slashing. // Do not store `body.Shares` in DB's `amount` to avoid confusion. Clients can still look up the shares in the tx body if they really need it. + + // Set the 'Success' field to 'Pending' for undelegations. This is because the outcome of the Undelegate tx is only known in the next block. + blockTransactionData.Success = nil return nil }, EVMCreate: func(body *sdkEVM.Create, ok *[]byte) error { @@ -583,6 +594,7 @@ func ExtractRound(blockHeader nodeapi.RuntimeBlockHeader, txrs []nodeapi.Runtime blockData.GasUsed += txGasUsed blockData.Size += blockTransactionData.Size } + return &blockData, nil } diff --git a/analyzer/runtime/runtime.go b/analyzer/runtime/runtime.go index 25c457447..62511ccfc 100644 --- a/analyzer/runtime/runtime.go +++ b/analyzer/runtime/runtime.go @@ -175,6 +175,10 @@ func (m *processor) FinalizeFastSync(ctx context.Context, lastFastSyncHeight int batch.Queue(queries.RuntimeEVMTokenRecompute, m.runtime) batch.Queue("DELETE FROM todo_updates.evm_tokens WHERE runtime = $1", m.runtime) + m.logger.Info("recomputing transaction statuses from transaction status updates") + batch.Queue(queries.RuntimeTransactionStatusUpdatesRecompute, m.runtime) + batch.Queue("DELETE from todo_updates.transaction_status_updates WHERE runtime = $1", m.runtime) + if err := m.target.SendBatch(ctx, batch); err != nil { return err } @@ -213,6 +217,7 @@ func (m *processor) ProcessBlock(ctx context.Context, round uint64) error { batch := &storage.QueryBatch{} m.queueDbUpdates(batch, blockData) m.queueAccountsEvents(batch, blockData) + m.queueConsensusAccountsEvents(batch, blockData) analysisTimer.ObserveDuration() // Update indexing progress. diff --git a/storage/migrations/01_runtimes.up.sql b/storage/migrations/01_runtimes.up.sql index 10bc2fea6..dc9763702 100644 --- a/storage/migrations/01_runtimes.up.sql +++ b/storage/migrations/01_runtimes.up.sql @@ -77,7 +77,7 @@ CREATE TABLE chain.runtime_transactions oasis_encrypted_result_data BYTEA, -- Error information. - success BOOLEAN, -- NULL means success is unknown (can happen in confidential runtimes) + success BOOLEAN, -- NULL means success is unknown (can happen in confidential runtimes, or for 'consensusaccounts' transactions which whose action is known only in the next round) error_module TEXT, error_code UINT63, error_message TEXT, diff --git a/storage/migrations/04_fast_sync_temp_tables.up.sql b/storage/migrations/04_fast_sync_temp_tables.up.sql index d7cdfda38..4852eb63e 100644 --- a/storage/migrations/04_fast_sync_temp_tables.up.sql +++ b/storage/migrations/04_fast_sync_temp_tables.up.sql @@ -28,5 +28,18 @@ CREATE TABLE todo_updates.evm_tokens( -- Tracks updates to chain.evm_tokens(last num_transfers UINT63 NOT NULL DEFAULT 0, last_mutate_round UINT63 NOT NULL ); +-- Added in 09_fast_sync_temp_transaction_status_updates.up.sql. +-- CREATE TABLE todo_updates.transaction_status_updates( -- Tracks transaction status updates for consensus-accounts transactions. +-- runtime runtime NOT NULL, +-- round UINT63 NOT NULL, +-- method TEXT NOT NULL, +-- sender oasis_addr NOT NULL, +-- nonce UINT63 NOT NULL, + +-- success BOOLEAN NOT NULL, +-- error_module TEXT, +-- error_code UINT63, +-- error_message TEXT +-- ); COMMIT; diff --git a/storage/migrations/09_fast_sync_temp_transaction_status_updates.up.sql b/storage/migrations/09_fast_sync_temp_transaction_status_updates.up.sql new file mode 100644 index 000000000..185a193b5 --- /dev/null +++ b/storage/migrations/09_fast_sync_temp_transaction_status_updates.up.sql @@ -0,0 +1,16 @@ +BEGIN; + +CREATE TABLE todo_updates.transaction_status_updates( -- Tracks transaction status updates for consensus-accounts transactions. + runtime runtime NOT NULL, + round UINT63 NOT NULL, + method TEXT NOT NULL, + sender oasis_addr NOT NULL, + nonce UINT63 NOT NULL, + + success BOOLEAN NOT NULL, + error_module TEXT, + error_code UINT63, + error_message TEXT +); + +COMMIT;