From 85acc875f766bb5c55372f22786a09243cc1bda9 Mon Sep 17 00:00:00 2001 From: maykonmichel Date: Fri, 4 Sep 2026 10:31:02 -0700 Subject: [PATCH 1/5] fix(indexer): declare multisig_account as MoveType and index transaction timestamp remapping-processor v0.0.21 validates that every event_fields ($.x) target column is a MoveType. Both multisig_account columns were declared EventMetadata(AccountAddress) yet are fed by the $.multisig_account payload field on the modern module events (Vote, AddOwners, RemoveOwners and the execution events), so the processor crash-looped on boot with "Column multisig_owner_activities.multisig_account should have a MoveType column_type" and stopped indexing entirely. Declare both multisig_account columns as MoveType(Address). The legacy *Event variants that populate them from account_address metadata stay compatible, since that metadata is the same underlying address. Also map the previously-idle transaction_metadata.timestamp hook into a new multisig_transactions.timestamp column, so execution history can be rendered straight from the indexer, and bump spec_version to 0.0.21 to match the running processor. (cherry picked from commit b83a3a1063c366c6a1ef8bdad5ea91634e6ab995) --- apps/indexer/multisig-mainnet.yaml | 305 +++++++++++++++-------------- apps/indexer/multisig-testnet.yaml | 295 +++++++++++++++------------- 2 files changed, 313 insertions(+), 287 deletions(-) diff --git a/apps/indexer/multisig-mainnet.yaml b/apps/indexer/multisig-mainnet.yaml index 1c535be..e226b74 100644 --- a/apps/indexer/multisig-mainnet.yaml +++ b/apps/indexer/multisig-mainnet.yaml @@ -1,272 +1,285 @@ spec_identifier: - spec_creator: shepherd@aptoslabs.com - spec_name: remapping-processor - spec_version: 0.0.10 + spec_creator: "shepherd@aptoslabs.com" + spec_name: "remapping-processor" + spec_version: "0.0.21" common_config: - network: mainnet + network: "mainnet" starting_version: 0 starting_version_override: null custom_config: transaction_metadata: - timestamp: [] + timestamp: + - table: "multisig_transactions" + column: "timestamp" version: - - table: multisig_transactions - column: version - - table: multisig_owner_activities - column: version + - table: "multisig_transactions" + column: "version" + - table: "multisig_owner_activities" + column: "version" epoch: [] block_height: [] + hash: [] events: 0x1::multisig_account::AddOwners: event_fields: $.multisig_account: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" $.owners_added: - - table: multisig_owner_activities - column: owners_added + - table: "multisig_owner_activities" + column: "owners_added" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::AddOwnersEvent: event_fields: $.owners_added: - - table: multisig_owner_activities - column: owners_added + - table: "multisig_owner_activities" + column: "owners_added" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::ExecuteRejectedTransaction: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.multisig_account: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" $.num_rejections: - - table: multisig_transactions - column: num_rejections + - table: "multisig_transactions" + column: "num_rejections" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::ExecuteRejectedTransactionEvent: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.num_rejections: - - table: multisig_transactions - column: num_rejections + - table: "multisig_transactions" + column: "num_rejections" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::RemoveOwners: event_fields: $.multisig_account: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" $.owners_removed: - - table: multisig_owner_activities - column: owners_removed + - table: "multisig_owner_activities" + column: "owners_removed" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::RemoveOwnersEvent: event_fields: $.owners_removed: - - table: multisig_owner_activities - column: owners_removed + - table: "multisig_owner_activities" + column: "owners_removed" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionFailed: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.multisig_account: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" $.num_approvals: - - table: multisig_transactions - column: num_approvals + - table: "multisig_transactions" + column: "num_approvals" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" $.transaction_payload: - - table: multisig_transactions - column: transaction_payload + - table: "multisig_transactions" + column: "transaction_payload" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionFailedEvent: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.num_approvals: - - table: multisig_transactions - column: num_approvals + - table: "multisig_transactions" + column: "num_approvals" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" $.transaction_payload: - - table: multisig_transactions - column: transaction_payload + - table: "multisig_transactions" + column: "transaction_payload" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionSucceeded: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.multisig_account: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" $.num_approvals: - - table: multisig_transactions - column: num_approvals + - table: "multisig_transactions" + column: "num_approvals" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" $.transaction_payload: - - table: multisig_transactions - column: transaction_payload + - table: "multisig_transactions" + column: "transaction_payload" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionSucceededEvent: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.num_approvals: - - table: multisig_transactions - column: num_approvals + - table: "multisig_transactions" + column: "num_approvals" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" $.transaction_payload: - - table: multisig_transactions - column: transaction_payload + - table: "multisig_transactions" + column: "transaction_payload" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::Vote: event_fields: $.multisig_account: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" $.owner: - - table: multisig_owner_activities - column: owner_vote + - table: "multisig_owner_activities" + column: "owner_vote" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::VoteEvent: event_fields: $.owner: - - table: multisig_owner_activities - column: owner_vote + - table: "multisig_owner_activities" + column: "owner_vote" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] payload: {} db_schema: multisig_transactions: version: + is_index: true + is_nullable: false + is_option: false + is_primary_key: false + is_vec: false + default_value: null + column_type: + type: "transaction_metadata" + column_type: "version" + timestamp: is_index: false is_nullable: false is_option: false @@ -274,8 +287,8 @@ custom_config: is_vec: false default_value: null column_type: - type: transaction_metadata - column_type: version + type: "transaction_metadata" + column_type: "timestamp" executor: is_index: false is_nullable: false @@ -284,18 +297,18 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: address + type: "move_type" + column_type: "address" multisig_account: - is_index: false + is_index: true is_nullable: false is_option: false is_primary_key: true is_vec: false default_value: null column_type: - type: event_metadata - column_type: account_address + type: "move_type" + column_type: "address" num_rejections: is_index: false is_nullable: true @@ -304,8 +317,8 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: u64 + type: "move_type" + column_type: "u64" sequence_number: is_index: false is_nullable: false @@ -314,8 +327,8 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: u64 + type: "move_type" + column_type: "u64" event_type: is_index: false is_nullable: false @@ -324,8 +337,8 @@ custom_config: is_vec: false default_value: null column_type: - type: event_metadata - column_type: event_type + type: "event_metadata" + column_type: "event_type" num_approvals: is_index: false is_nullable: true @@ -334,8 +347,8 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: u64 + type: "move_type" + column_type: "u64" transaction_payload: is_index: false is_nullable: true @@ -344,19 +357,19 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: string + type: "move_type" + column_type: "string" multisig_owner_activities: version: - is_index: false + is_index: true is_nullable: false is_option: false is_primary_key: true is_vec: false default_value: null column_type: - type: transaction_metadata - column_type: version + type: "transaction_metadata" + column_type: "version" multisig_account: is_index: false is_nullable: false @@ -365,18 +378,18 @@ custom_config: is_vec: false default_value: null column_type: - type: event_metadata - column_type: account_address + type: "move_type" + column_type: "address" owners_added: - is_index: false + is_index: true is_nullable: true is_option: false is_primary_key: false is_vec: true default_value: null column_type: - type: move_type - column_type: address + type: "move_type" + column_type: "address" event_type: is_index: false is_nullable: false @@ -385,25 +398,25 @@ custom_config: is_vec: false default_value: null column_type: - type: event_metadata - column_type: event_type + type: "event_metadata" + column_type: "event_type" owners_removed: - is_index: false + is_index: true is_nullable: true is_option: false is_primary_key: false is_vec: true default_value: null column_type: - type: move_type - column_type: address + type: "move_type" + column_type: "address" owner_vote: - is_index: false + is_index: true is_nullable: true is_option: false is_primary_key: false is_vec: false default_value: null column_type: - type: move_type - column_type: address + type: "move_type" + column_type: "address" diff --git a/apps/indexer/multisig-testnet.yaml b/apps/indexer/multisig-testnet.yaml index 0fa74d9..261cf31 100644 --- a/apps/indexer/multisig-testnet.yaml +++ b/apps/indexer/multisig-testnet.yaml @@ -1,266 +1,269 @@ spec_identifier: - spec_creator: shepherd@aptoslabs.com - spec_name: remapping-processor - spec_version: 0.0.10 + spec_creator: "shepherd@aptoslabs.com" + spec_name: "remapping-processor" + spec_version: "0.0.21" common_config: - network: testnet + network: "testnet" starting_version: 4500665800 starting_version_override: null custom_config: transaction_metadata: - timestamp: [] + timestamp: + - table: "multisig_transactions" + column: "timestamp" version: - - table: multisig_transactions - column: version - - table: multisig_owner_activities - column: version + - table: "multisig_transactions" + column: "version" + - table: "multisig_owner_activities" + column: "version" epoch: [] block_height: [] + hash: [] events: 0x1::multisig_account::AddOwners: event_fields: $.multisig_account: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" $.owners_added: - - table: multisig_owner_activities - column: owners_added + - table: "multisig_owner_activities" + column: "owners_added" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::AddOwnersEvent: event_fields: $.owners_added: - - table: multisig_owner_activities - column: owners_added + - table: "multisig_owner_activities" + column: "owners_added" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::ExecuteRejectedTransaction: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.multisig_account: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" $.num_rejections: - - table: multisig_transactions - column: num_rejections + - table: "multisig_transactions" + column: "num_rejections" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::ExecuteRejectedTransactionEvent: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.num_rejections: - - table: multisig_transactions - column: num_rejections + - table: "multisig_transactions" + column: "num_rejections" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::RemoveOwners: event_fields: $.multisig_account: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" $.owners_removed: - - table: multisig_owner_activities - column: owners_removed + - table: "multisig_owner_activities" + column: "owners_removed" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::RemoveOwnersEvent: event_fields: $.owners_removed: - - table: multisig_owner_activities - column: owners_removed + - table: "multisig_owner_activities" + column: "owners_removed" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionFailed: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.multisig_account: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" $.num_approvals: - - table: multisig_transactions - column: num_approvals + - table: "multisig_transactions" + column: "num_approvals" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" $.transaction_payload: - - table: multisig_transactions - column: transaction_payload + - table: "multisig_transactions" + column: "transaction_payload" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionFailedEvent: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.num_approvals: - - table: multisig_transactions - column: num_approvals + - table: "multisig_transactions" + column: "num_approvals" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" $.transaction_payload: - - table: multisig_transactions - column: transaction_payload + - table: "multisig_transactions" + column: "transaction_payload" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionSucceeded: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.multisig_account: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" $.num_approvals: - - table: multisig_transactions - column: num_approvals + - table: "multisig_transactions" + column: "num_approvals" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" $.transaction_payload: - - table: multisig_transactions - column: transaction_payload + - table: "multisig_transactions" + column: "transaction_payload" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionSucceededEvent: event_fields: $.executor: - - table: multisig_transactions - column: executor + - table: "multisig_transactions" + column: "executor" $.num_approvals: - - table: multisig_transactions - column: num_approvals + - table: "multisig_transactions" + column: "num_approvals" $.sequence_number: - - table: multisig_transactions - column: sequence_number + - table: "multisig_transactions" + column: "sequence_number" $.transaction_payload: - - table: multisig_transactions - column: transaction_payload + - table: "multisig_transactions" + column: "transaction_payload" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_transactions - column: multisig_account + - table: "multisig_transactions" + column: "multisig_account" event_type: - - table: multisig_transactions - column: event_type + - table: "multisig_transactions" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::Vote: event_fields: $.multisig_account: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" $.owner: - - table: multisig_owner_activities - column: owner_vote + - table: "multisig_owner_activities" + column: "owner_vote" event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] 0x1::multisig_account::VoteEvent: event_fields: $.owner: - - table: multisig_owner_activities - column: owner_vote + - table: "multisig_owner_activities" + column: "owner_vote" event_metadata: sequence_number: [] creation_number: [] account_address: - - table: multisig_owner_activities - column: multisig_account + - table: "multisig_owner_activities" + column: "multisig_account" event_type: - - table: multisig_owner_activities - column: event_type + - table: "multisig_owner_activities" + column: "event_type" event_index: [] constant_values: [] payload: {} @@ -274,8 +277,18 @@ custom_config: is_vec: false default_value: null column_type: - type: transaction_metadata - column_type: version + type: "transaction_metadata" + column_type: "version" + timestamp: + is_index: false + is_nullable: false + is_option: false + is_primary_key: false + is_vec: false + default_value: null + column_type: + type: "transaction_metadata" + column_type: "timestamp" executor: is_index: false is_nullable: false @@ -284,8 +297,8 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: address + type: "move_type" + column_type: "address" multisig_account: is_index: false is_nullable: false @@ -294,8 +307,8 @@ custom_config: is_vec: false default_value: null column_type: - type: event_metadata - column_type: account_address + type: "move_type" + column_type: "address" num_rejections: is_index: false is_nullable: true @@ -304,8 +317,8 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: u64 + type: "move_type" + column_type: "u64" sequence_number: is_index: false is_nullable: false @@ -314,8 +327,8 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: u64 + type: "move_type" + column_type: "u64" event_type: is_index: false is_nullable: false @@ -324,8 +337,8 @@ custom_config: is_vec: false default_value: null column_type: - type: event_metadata - column_type: event_type + type: "event_metadata" + column_type: "event_type" num_approvals: is_index: false is_nullable: true @@ -334,8 +347,8 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: u64 + type: "move_type" + column_type: "u64" transaction_payload: is_index: false is_nullable: true @@ -344,8 +357,8 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: string + type: "move_type" + column_type: "string" multisig_owner_activities: version: is_index: false @@ -355,8 +368,8 @@ custom_config: is_vec: false default_value: null column_type: - type: transaction_metadata - column_type: version + type: "transaction_metadata" + column_type: "version" multisig_account: is_index: false is_nullable: false @@ -365,8 +378,8 @@ custom_config: is_vec: false default_value: null column_type: - type: event_metadata - column_type: account_address + type: "move_type" + column_type: "address" owners_added: is_index: false is_nullable: true @@ -375,8 +388,8 @@ custom_config: is_vec: true default_value: null column_type: - type: move_type - column_type: address + type: "move_type" + column_type: "address" event_type: is_index: false is_nullable: false @@ -385,8 +398,8 @@ custom_config: is_vec: false default_value: null column_type: - type: event_metadata - column_type: event_type + type: "event_metadata" + column_type: "event_type" owners_removed: is_index: false is_nullable: true @@ -395,8 +408,8 @@ custom_config: is_vec: true default_value: null column_type: - type: move_type - column_type: address + type: "move_type" + column_type: "address" owner_vote: is_index: false is_nullable: false @@ -405,5 +418,5 @@ custom_config: is_vec: false default_value: null column_type: - type: move_type - column_type: address + type: "move_type" + column_type: "address" From 760d46cac09a9d285375f9031be9327264590576 Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Fri, 25 Sep 2026 14:02:24 -0300 Subject: [PATCH 2/5] fix(vault): version history cache and add discovery probe coverage (cherry picked from commit b7004fb4c8376da652eb823732d829164b25b5bb) --- apps/indexer/experiments/README.md | 226 ++++++++++++++++ .../multisig-creation-probe-long-address.yaml | 83 ++++++ .../multisig-creation-probe-schema.graphql | 14 + .../multisig-creation-probe.graphql | 33 +++ .../experiments/multisig-creation-probe.yaml | 83 ++++++ .../vault-owner-discovery-status.graphql | 15 ++ .../experiments/vault-owner-discovery.graphql | 20 ++ apps/web/hooks/useMultisigExecutionEvents.ts | 3 +- apps/web/tests/lib/executionHistory.test.ts | 247 ++++++++++++++++++ apps/web/vitest.config.ts | 1 + 10 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 apps/indexer/experiments/README.md create mode 100644 apps/indexer/experiments/multisig-creation-probe-long-address.yaml create mode 100644 apps/indexer/experiments/multisig-creation-probe-schema.graphql create mode 100644 apps/indexer/experiments/multisig-creation-probe.graphql create mode 100644 apps/indexer/experiments/multisig-creation-probe.yaml create mode 100644 apps/indexer/experiments/vault-owner-discovery-status.graphql create mode 100644 apps/indexer/experiments/vault-owner-discovery.graphql create mode 100644 apps/web/tests/lib/executionHistory.test.ts diff --git a/apps/indexer/experiments/README.md b/apps/indexer/experiments/README.md new file mode 100644 index 0000000..2706e03 --- /dev/null +++ b/apps/indexer/experiments/README.md @@ -0,0 +1,226 @@ +# Initial-owner discovery probe + +**Status: the long-address probe passed for the known Mainnet creation.** The +user's Hasura response was compared with a fresh public fullnode sample: all +17 window rows matched, and the initial co-owner lookup returned `7345859301`. +This verifies the probe's sample, not complete vault discovery. Production +processors and mobile queries remain unchanged. + +Use [multisig-creation-probe-long-address.yaml](./multisig-creation-probe-long-address.yaml). +Its only semantic change from the original probe is the payload function key: + +```yaml +custom_config: + payload: + 0x0000000000000000000000000000000000000000000000000000000000000001::multisig_account::create_with_owners: + '0': + - table: multisig_creation_probe + column: owners +``` + +The original [short-address YAML](./multisig-creation-probe.yaml) remains as the +bug reproduction. It is not the file to import for this retry. + +## Verified result + +The user supplied the deployed GraphQL response after tracking the new table +in Hasura. Comparison with a fresh fetch of versions `7345859291`–`7345859311` +passed: + +- `creation` returned `7345859301`, the expected initial co-owner, and gas units + `11873`. +- `owner_lookup` returned that same version for the co-owner, who differs from + the transaction sender. The successful transaction's written multisig resource + contains both the co-owner and creator. +- All 17 `window` rows matched the fullnode data, including transaction versions, + owners, standardized event types, and gas values. The 16 unrelated fee rows + had null `owners`, as expected. + +This confirms that the full-address workaround allows payload enrichment and +owner-array lookup for the tested creation. It also confirms that the payload +mapping does not exclude unrelated fee transactions in this sample. Stop the +probe after collecting the result; further fee ingestion is unnecessary for +this verification. + +The comparison used the user's pasted Hasura result, not a direct authenticated +request to the processor. Failed-call handling, other creation paths, historical +coverage, ingestion cost, and mobile integration remain to be validated. + +## Verify another vault and discover owner candidates + +[vault-owner-discovery.graphql](./vault-owner-discovery.graphql) runs without +variables in Hasura and defaults to owner +`0xc476707d8a2d5c00242096332855ab0968225cbdc1cce0e25532b1df33f98eb0`. +It removes the earlier sample's upper version bound. + +- `target_creation` checks version `7350673760` for this owner. A public + [fullnode fetch](https://api.mainnet.aptoslabs.com/v1/transactions/by_version/7350673760) + confirmed that this successful `create_with_owners` call by + `0xc8afd0a0a7fc403d4785f1458d4a465993425fa339938ac3ac24396f4a877282` + created vault + `0xe4f95233928a4957db9988417d6d35318f88769804625a960298fb179f04d5f7`, + with the queried owner in argument `0`. The resource currently contains both + owners. The processor must run through that version before testing its row. +- `creation_candidates` returns up to 100 matching versions in ascending order. + For subsequent pages, set `afterVersion` to the last returned version and + repeat until empty. Override `owner` for the active wallet. The fixed + `target_creation` alias is only a diagnostic and should be omitted from the + mobile query. + +The probe has no vault-address column: each returned version must be fetched +from the fullnode, checked for success, and resolved through its multisig +resource writes. Deduplicate addresses and verify current ownership before +display. Combine these candidates with creator history and later owner events; +the query alone cannot list every owned vault or recover history before the +processor's starting version. + +The user's first result for this query returned an empty `target_creation`, +with candidate versions `7345859301`, `7347901015`, and `7347937978`. The target +was ahead of the processor's reported version `7349077670`. + +On 2026-09-25, after processing reached `7352113486`, the user supplied a new +response with `7350673760` and the invited owner in both `target_creation` and +`creation_candidates`. This second vault test passed after catch-up. The response +contains seven candidate versions; that count is not a verified count of current +owned vaults. Each candidate still needs transaction resolution and a current +ownership check before display. + +The newest matching candidate alone does not establish processing progress. + +Run [vault-owner-discovery-status.graphql](./vault-owner-discovery-status.graphql) +to inspect the newest fee row across all owners and look up the target version +without an owner filter. Also check Geomi's latest processed version against +`7350673760`. If the probe was stopped after the first experiment, resume it +and let it reach the new target before assessing the mapping. A present target +with missing owners indicates an enrichment issue; an absent target after +processing has passed it requires investigating missing ingestion/history. + +## Geomi team's findings + +The following comes from the team's source review shared in this conversation; +we have not independently run their UI or processor implementation locally. + +- Payload mappings support `vector
` and the existing column shape + (`move_type` / `address`, `is_vec: true`). +- Both import and direct YAML entry pass through the graph editor. Its function + lookup uses the supplied account address, while fetched ABIs use the full + address. The short `0x1` key misses the lookup and loses its source edge. + Review then reports an unmapped column, and serialization omits that column. + The full address is the team's workaround. Event keys already normalize + addresses, so the event mapping does not need the same change. +- Events create rows; payload arguments and transaction metadata only enrich + those rows. A function mapping does not filter ingestion. This FeeStatement + probe therefore stores unrelated transactions with null `owners`. +- Failed calls can also have FeeStatement and payload arguments. A non-null + `owners` value does not prove creation succeeded. Success and VM status are + not available as metadata mappings. +- Resource-write indexing is not implemented in this remapping processor. + A resource-based solution would require a different processor or a new feature. + +## Import and verify the retry + +1. Create a separate processor named `multisig-creation-probe-v2`, on Mainnet, + starting at `7345859291`. Import the **long-address YAML** above. It contains + only a probe table; do not replace the production processor's mappings. +2. In Review Config, confirm `custom_config.payload` still maps argument `0` to + `owners`, and `db_schema.multisig_creation_probe.owners` still exists as a + nullable address vector. If either disappears or validation fails, stop and + report that result to Geomi instead of deploying another stripped config. +3. Create the processor, wait for Running and a processed version of at least + `7345859311`, then open its own Hasura console. Track only + `public.multisig_creation_probe` if it is untracked. Confirm `owners` exists. +4. Run [multisig-creation-probe.graphql](./multisig-creation-probe.graphql). + The [schema query](./multisig-creation-probe-schema.graphql) can inspect + exposed GraphQL roots and columns if needed. +5. Record the reviewed config, processed version, and query response, then stop + the test processor. The YAML has **no automatic end version**; a query range + does not limit ingestion. Keep the old fee-only test stopped as well. + +Expected results for the inclusive window `7345859291`–`7345859311`: + +- `creation` contains version `7345859301`, co-owner + `0xc476707d8a2d5c00242096332855ab0968225cbdc1cce0e25532b1df33f98eb0`, + and `total_charge_gas_units` of `11873`. +- `owner_lookup` returns `7345859301`. The creator is not expected in `owners`. +- `window` contains 17 fee rows: the creation plus 16 unrelated transactions + with null `owners`. Four system transactions in the sample have no fee event. + These extra rows are expected, not evidence that the mapping failed. + +A missing or null owner on the known successful creation means the workaround +has not passed. If unrelated fee rows are absent, investigate the deployment +and processing range rather than concluding that payload mapping filters them. +The [public transaction sample](https://api.mainnet.aptoslabs.com/v1/transactions?start=7345859291&limit=21) +provides the comparison data. The created vault is +`0xafb026bab856645f9906ae85ce8cbd1300b70ee86fc1384a89393ec5ea274162`. + +## What the passing probe enables + +This table supplies candidate creation versions for initial additional owners. +It is not a complete owner registry. Mobile would need a new query for those +versions, alongside its existing creator-history and owner-event queries. + +The existing mobile `readCreationAddresses` already fetches the transaction, +rejects unsuccessful transactions, and extracts written multisig resource +addresses. Its `snapshot` checks current on-chain ownership. Reuse those checks +for this new candidate source; do not treat payload addresses as proof that a +vault exists or that the wallet is still an owner. Add a failed-creation case to +integration validation before connecting the source to production. + +Creator omission does not by itself block the reported `create_with_owners` +fix: mobile already has a creator-history query for that function. However, +plain `create()`, bootstrapper-removal, existing-account creation, and custom +wrappers need separate coverage. The retry deliberately adds no extra function +mappings, so the address-format change is tested in isolation. + +Before production use, resolve the cost of indexing every FeeStatement and +plan historical coverage/backfill. A GraphQL filter does not reduce ingestion. +Sender metadata would help creator coverage, but would not by itself provide +success filtering or support every creation path. Resource indexing remains a +separate implementation option outside this no-code processor. + +## Earlier deployment + +The original short-address probe was accepted despite the review error: + +> Column 'owners' in table 'multisig_creation_probe' has no source mappings. All table columns must have at least one source mapping. + +Review showed `payload: {}` and omitted `owners`. The processor reached Running +and passed the sample range. Its table was initially untracked in Hasura; after +tracking, Browse Rows showed only `version`, `fee_event_type`, and +`total_charge_gas_units`. This verified fee ingestion, not owner discovery. +The team subsequently identified the ABI lookup issue described above. + +## Source verification + +Checked on 2026-09-24 against Aptos source commit +[`cc09f4c`](https://github.com/aptos-labs/aptos-core/blob/cc09f4c5f6d96acacc3e2637d84265111d5d6cc1/aptos-move/framework/aptos-framework/sources/multisig_account.move). + +- [`create_with_owners`](https://github.com/aptos-labs/aptos-core/blob/cc09f4c5f6d96acacc3e2637d84265111d5d6cc1/aptos-move/framework/aptos-framework/sources/multisig_account.move#L784-L802) + adds the creator to `additional_owners`. Payload argument `0` alone therefore + omits the creator. +- [`create_with_owners_internal`](https://github.com/aptos-labs/aptos-core/blob/cc09f4c5f6d96acacc3e2637d84265111d5d6cc1/aptos-move/framework/aptos-framework/sources/multisig_account.move#L877-L916) + writes `MultisigAccount.owners` directly. It initializes event handles without + emitting `AddOwners` or `AddOwnersEvent`, and disables the metadata event. +- Later additions use + [`update_owner_schema`](https://github.com/aptos-labs/aptos-core/blob/cc09f4c5f6d96acacc3e2637d84265111d5d6cc1/aptos-move/framework/aptos-framework/sources/multisig_account.move#L1611-L1658), + which emits `AddOwners`. The demonstrated gap concerns initial co-owners; + post-creation additions have an event source already mapped by the indexer. +- [`owners` and `is_owner`](https://github.com/aptos-labs/aptos-core/blob/cc09f4c5f6d96acacc3e2637d84265111d5d6cc1/aptos-move/framework/aptos-framework/sources/multisig_account.move#L418-L428) + require a known vault address; they do not discover vaults from an owner. + +The historical Mainnet transaction `7345859301` was fetched again: it succeeds, +emits only `FeeStatement`, and writes both owners into the multisig resource. +This independently verifies the repro; current `main` is not evidence of the +exact framework revision deployed at that historical version. + +Inference: indexing resource writes would capture the ownership state directly. +This Move source does not establish Geomi payload or resource-indexing support. + +## Validation limits + +Local checks verify YAML shape, the single semantic change, and the expected +sample data. A published Rust config-type round-trip verifies serialization +only; its package version is not the deployed processor version. The subsequent +user-provided Hasura response supplies runtime evidence for the known creation +and sample window. It does not establish production completeness or correctness +for failed transactions and other creation paths. diff --git a/apps/indexer/experiments/multisig-creation-probe-long-address.yaml b/apps/indexer/experiments/multisig-creation-probe-long-address.yaml new file mode 100644 index 0000000..19ecd38 --- /dev/null +++ b/apps/indexer/experiments/multisig-creation-probe-long-address.yaml @@ -0,0 +1,83 @@ +# Probe only: every FeeStatement creates a row, including failed transactions. +# Stop after checking the sample; starting_version does not set an end version. +spec_identifier: + spec_creator: 'shepherd@aptoslabs.com' + spec_name: 'remapping-processor' + spec_version: '0.0.21' +common_config: + network: 'mainnet' + starting_version: 7345859291 + starting_version_override: null +custom_config: + transaction_metadata: + timestamp: [] + version: + - table: 'multisig_creation_probe' + column: 'version' + epoch: [] + block_height: [] + hash: [] + events: + 0x1::transaction_fee::FeeStatement: + event_fields: + $.total_charge_gas_units: + - table: 'multisig_creation_probe' + column: 'total_charge_gas_units' + event_metadata: + sequence_number: [] + creation_number: [] + account_address: [] + event_type: + - table: 'multisig_creation_probe' + column: 'fee_event_type' + event_index: [] + constant_values: [] + payload: + # Geomi's graph editor requires the full address for its function ABI lookup. + 0x0000000000000000000000000000000000000000000000000000000000000001::multisig_account::create_with_owners: + '0': + - table: 'multisig_creation_probe' + column: 'owners' + db_schema: + multisig_creation_probe: + version: + is_index: true + is_nullable: false + is_option: false + is_primary_key: true + is_vec: false + default_value: null + column_type: + type: 'transaction_metadata' + column_type: 'version' + fee_event_type: + is_index: false + is_nullable: false + is_option: false + is_primary_key: false + is_vec: false + default_value: null + column_type: + type: 'event_metadata' + column_type: 'event_type' + total_charge_gas_units: + is_index: false + is_nullable: false + is_option: false + is_primary_key: false + is_vec: false + default_value: null + column_type: + type: 'move_type' + column_type: 'u64' + owners: + is_index: true + # Unrelated fee rows have no matching payload argument. + is_nullable: true + is_option: false + is_primary_key: false + is_vec: true + default_value: null + column_type: + type: 'move_type' + column_type: 'address' diff --git a/apps/indexer/experiments/multisig-creation-probe-schema.graphql b/apps/indexer/experiments/multisig-creation-probe-schema.graphql new file mode 100644 index 0000000..bd8282d --- /dev/null +++ b/apps/indexer/experiments/multisig-creation-probe-schema.graphql @@ -0,0 +1,14 @@ +query InspectProbeSchema { + __schema { + queryType { + fields { + name + } + } + } + __type(name: "multisig_creation_probe") { + fields { + name + } + } +} diff --git a/apps/indexer/experiments/multisig-creation-probe.graphql b/apps/indexer/experiments/multisig-creation-probe.graphql new file mode 100644 index 0000000..789753e --- /dev/null +++ b/apps/indexer/experiments/multisig-creation-probe.graphql @@ -0,0 +1,33 @@ +query MultisigCreationProbe { + creation: multisig_creation_probe(where: { version: { _eq: "7345859301" } }) { + version + owners + fee_event_type + total_charge_gas_units + } + owner_lookup: multisig_creation_probe( + where: { + version: { _gte: "7345859291", _lte: "7345859311" } + owners: { + _contains: [ + "0xc476707d8a2d5c00242096332855ab0968225cbdc1cce0e25532b1df33f98eb0" + ] + } + } + order_by: { version: asc } + limit: 100 + ) { + version + owners + } + window: multisig_creation_probe( + where: { version: { _gte: "7345859291", _lte: "7345859311" } } + order_by: { version: asc } + limit: 100 + ) { + version + owners + fee_event_type + total_charge_gas_units + } +} diff --git a/apps/indexer/experiments/multisig-creation-probe.yaml b/apps/indexer/experiments/multisig-creation-probe.yaml new file mode 100644 index 0000000..1ab416e --- /dev/null +++ b/apps/indexer/experiments/multisig-creation-probe.yaml @@ -0,0 +1,83 @@ +# Reproduction only: Geomi review drops payload and owners and reports an error. +# The accepted deployment has fee data but no owners column. See README.md. +# This has no automatic stop version. Stop the probe after inspecting the results. +spec_identifier: + spec_creator: 'shepherd@aptoslabs.com' + spec_name: 'remapping-processor' + spec_version: '0.0.21' +common_config: + network: 'mainnet' + starting_version: 7345859291 + starting_version_override: null +custom_config: + transaction_metadata: + timestamp: [] + version: + - table: 'multisig_creation_probe' + column: 'version' + epoch: [] + block_height: [] + hash: [] + events: + 0x1::transaction_fee::FeeStatement: + event_fields: + $.total_charge_gas_units: + - table: 'multisig_creation_probe' + column: 'total_charge_gas_units' + event_metadata: + sequence_number: [] + creation_number: [] + account_address: [] + event_type: + - table: 'multisig_creation_probe' + column: 'fee_event_type' + event_index: [] + constant_values: [] + payload: + 0x1::multisig_account::create_with_owners: + '0': + - table: 'multisig_creation_probe' + column: 'owners' + db_schema: + multisig_creation_probe: + version: + is_index: true + is_nullable: false + is_option: false + is_primary_key: true + is_vec: false + default_value: null + column_type: + type: 'transaction_metadata' + column_type: 'version' + fee_event_type: + is_index: false + is_nullable: false + is_option: false + is_primary_key: false + is_vec: false + default_value: null + column_type: + type: 'event_metadata' + column_type: 'event_type' + total_charge_gas_units: + is_index: false + is_nullable: false + is_option: false + is_primary_key: false + is_vec: false + default_value: null + column_type: + type: 'move_type' + column_type: 'u64' + owners: + is_index: true + # Preserve unmatched rows so the probe reveals whether FeeStatement is global. + is_nullable: true + is_option: false + is_primary_key: false + is_vec: true + default_value: null + column_type: + type: 'move_type' + column_type: 'address' diff --git a/apps/indexer/experiments/vault-owner-discovery-status.graphql b/apps/indexer/experiments/vault-owner-discovery-status.graphql new file mode 100644 index 0000000..d4bfe31 --- /dev/null +++ b/apps/indexer/experiments/vault-owner-discovery-status.graphql @@ -0,0 +1,15 @@ +query CheckVaultDiscoveryProgress { + latest_indexed_fee: multisig_creation_probe( + order_by: { version: desc } + limit: 1 + ) { + version + } + target_without_owner_filter: multisig_creation_probe( + where: { version: { _eq: "7350673760" } } + ) { + version + owners + total_charge_gas_units + } +} diff --git a/apps/indexer/experiments/vault-owner-discovery.graphql b/apps/indexer/experiments/vault-owner-discovery.graphql new file mode 100644 index 0000000..a7e8a77 --- /dev/null +++ b/apps/indexer/experiments/vault-owner-discovery.graphql @@ -0,0 +1,20 @@ +query VaultDiscoveryForOwner( + $owner: String! = "0xc476707d8a2d5c00242096332855ab0968225cbdc1cce0e25532b1df33f98eb0" + $afterVersion: bigint! = "0" + $limit: Int! = 100 +) { + target_creation: multisig_creation_probe( + where: { version: { _eq: "7350673760" }, owners: { _contains: [$owner] } } + ) { + version + owners + } + creation_candidates: multisig_creation_probe( + where: { owners: { _contains: [$owner] }, version: { _gt: $afterVersion } } + order_by: { version: asc } + limit: $limit + ) { + version + owners + } +} diff --git a/apps/web/hooks/useMultisigExecutionEvents.ts b/apps/web/hooks/useMultisigExecutionEvents.ts index e769a25..fbd0434 100644 --- a/apps/web/hooks/useMultisigExecutionEvents.ts +++ b/apps/web/hooks/useMultisigExecutionEvents.ts @@ -64,7 +64,8 @@ export default function useMultisigExecutionEvents({ return useInfiniteQuery({ ...options, enabled, - queryKey: ['multisig-execution-events', address, network], + // Persisted array pages cannot be read as { events, rawCount } pages. + queryKey: ['multisig-execution-events', address, network, 'v2'], initialPageParam: 0, queryFn: async ({ pageParam = 0 }) => { const resolvedNetwork = network?.network ?? aptos.config.network; diff --git a/apps/web/tests/lib/executionHistory.test.ts b/apps/web/tests/lib/executionHistory.test.ts new file mode 100644 index 0000000..6d58a84 --- /dev/null +++ b/apps/web/tests/lib/executionHistory.test.ts @@ -0,0 +1,247 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + dehydrate, + hydrate, + QueryClient, + QueryClientProvider +} from '@tanstack/react-query'; +import { + AccountAddress, + EntryFunction, + MultiSigTransactionPayload, + Network +} from '@aptos-labs/ts-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import useMultisigExecutionEvents, { + type ExecutionEvent +} from '@/hooks/useMultisigExecutionEvents'; +import TransactionRow from '@/components/TransactionRow'; +import { LONG_FRAMEWORK_ADDRESS } from '@/lib/constants'; +import { storageOptionsSerializers } from '@/lib/storage'; + +const { getMultisigTransactions, getTransactionByVersion } = vi.hoisted(() => ({ + getMultisigTransactions: vi.fn(), + getTransactionByVersion: vi.fn() +})); + +vi.mock('@/operations', () => ({ + getMultisigIndexerClient: () => ({ getMultisigTransactions }) +})); + +vi.mock('@aptos-labs/react', () => ({ + useClients: () => ({ + aptos: { + config: { network: 'mainnet' }, + getTransactionByVersion + } + }), + useNameFromAddress: () => ({ data: undefined }) +})); + +const address = '0x1'; +const network = { network: Network.MAINNET }; +const timestamp = Date.parse('2026-08-20T00:33:24.335Z'); +const payload = new MultiSigTransactionPayload( + EntryFunction.build('0x1::aptos_account', 'transfer', [], []) +) + .bcsToHex() + .toString(); +const row = { + version: '7350673760', + event_type: `${LONG_FRAMEWORK_ADDRESS}::multisig_account::TransactionExecutionSucceeded`, + timestamp: '2026-08-20T00:33:24.335000', + transaction_payload: payload, + executor: '0x2', + sequence_number: '7', + num_approvals: '2', + num_rejections: '0' +}; + +let client: QueryClient; + +beforeEach(() => { + vi.resetAllMocks(); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + getMultisigTransactions.mockResolvedValue({ multisig_transactions: [row] }); + getTransactionByVersion.mockRejectedValue(new Error('HTTP 410: pruned')); +}); + +afterEach(() => { + client.clear(); + vi.restoreAllMocks(); +}); + +function renderHistory() { + let result: ReturnType | undefined; + function Probe() { + result = useMultisigExecutionEvents({ address, network }); + return null; + } + renderToStaticMarkup( + createElement(QueryClientProvider, { client }, createElement(Probe)) + ); + if (!result) throw new Error('History hook did not render'); + return result; +} + +describe('execution history', () => { + it.each([ + { name: 'empty', page: [] }, + { + name: 'populated', + page: [ + { version: row.version, transaction: { timestamp: '1787186004335000' } } + ] + } + ])( + 'ignores restored $name array pages from the previous cache format', + async ({ page }) => { + const oldKey = ['multisig-execution-events', address, network]; + const oldData = { pages: [page], pageParams: [0] }; + client.setQueryData(oldKey, oldData); + const stored = JSON.stringify(dehydrate(client)); + client.clear(); + hydrate(client, JSON.parse(stored)); + expect(client.getQueryData(oldKey)).toEqual(oldData); + + const history = renderHistory(); + expect(history.data).toBeUndefined(); + const refreshed = await history.refetch(); + expect(refreshed.data?.pages[0]).toMatchObject({ + rawCount: 1, + events: [{ version: row.version, timestamp }] + }); + } + ); + + it('restores the current cache format with usable executor addresses', async () => { + await renderHistory().refetch(); + const stored = JSON.stringify( + dehydrate(client), + storageOptionsSerializers.replacer + ); + client.clear(); + hydrate(client, JSON.parse(stored, storageOptionsSerializers.reviver)); + + const event = renderHistory().data?.pages[0]?.events[0]; + expect(event?.timestamp).toBe(timestamp); + expect(event?.executor.toString()).toBe(row.executor); + }); + + it.each([ + ['1787186004335000', timestamp], + [1787186004335000, timestamp], + ['2026-08-20T00:33:24.335000', timestamp], + ['2026-08-20T00:33:24.335Z', timestamp], + ['2026-08-19T21:33:24.335-03:00', timestamp], + [null, undefined], + ['invalid', undefined] + ])( + 'converts indexer timestamp %j to milliseconds', + async (value, expected) => { + getMultisigTransactions.mockResolvedValue({ + multisig_transactions: [{ ...row, timestamp: value }] + }); + const result = await renderHistory().refetch(); + expect(result.isSuccess).toBe(true); + expect(result.data?.pages[0]?.events[0]).toMatchObject({ + timestamp: expected + }); + } + ); + + it.each([ + ['TransactionExecutionSucceeded', 'success'], + ['TransactionExecutionSucceededEvent', 'success'], + ['TransactionExecutionFailed', 'failed'], + ['TransactionExecutionFailedEvent', 'failed'], + ['ExecuteRejectedTransaction', 'rejected'], + ['ExecuteRejectedTransactionEvent', 'rejected'] + ])( + 'reads %s entirely from the indexer, even when the fullnode has pruned it', + async (event, type) => { + getMultisigTransactions.mockResolvedValue({ + multisig_transactions: [ + { + ...row, + event_type: `${LONG_FRAMEWORK_ADDRESS}::multisig_account::${event}`, + transaction_payload: type === 'rejected' ? null : payload + } + ] + }); + const result = await renderHistory().refetch(); + expect(result.isSuccess).toBe(true); + expect(result.data?.pages[0]?.events[0]).toMatchObject({ + type, + version: row.version, + timestamp, + executor: AccountAddress.from('0x2'), + sequenceNumber: 7, + approvals: 2, + rejections: 0 + }); + expect(getTransactionByVersion).not.toHaveBeenCalled(); + } + ); + + it('continues after a full page containing an unknown event and stops after a short page', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + getMultisigTransactions + .mockResolvedValueOnce({ + multisig_transactions: Array.from({ length: 15 }, (_, index) => ({ + ...row, + version: String(100 - index), + event_type: index === 0 ? 'unknown' : row.event_type + })) + }) + .mockResolvedValueOnce({ multisig_transactions: [row] }); + const first = await renderHistory().fetchNextPage(); + expect(first.data?.pages[0]?.events).toHaveLength(14); + expect(first.hasNextPage).toBe(true); + expect(error).toHaveBeenCalledWith('Unknown event type: unknown'); + + const second = await first.fetchNextPage(); + expect(getMultisigTransactions).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + multisigAccount: address, + offset: 15, + limit: 15 + }) + ); + expect(second.data?.pages).toHaveLength(2); + expect(second.data?.pages[1]?.events[0]?.version).toBe(row.version); + expect(second.hasNextPage).toBe(false); + }); +}); + +describe('transaction rows', () => { + it.each(['success', 'failed', 'rejected'])( + 'renders a %s execution using only indexer fields', + (type) => { + const html = renderToStaticMarkup( + createElement(TransactionRow, { + network: Network.MAINNET, + executionEvent: { + type, + version: row.version, + timestamp, + payload: type === 'rejected' ? undefined : payload, + executor: AccountAddress.from(row.executor), + sequenceNumber: 7, + approvals: 2, + rejections: 0 + } + }) + ); + expect(html).toContain( + type === 'rejected' ? 'Rejected transaction' : 'Transfer APT' + ); + expect(html).toContain(`txn/${row.version}`); + expect(html).toContain(new Date(timestamp).toLocaleString()); + expect(html).toContain('0x2'); + expect(getTransactionByVersion).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 3294b91..8622256 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ + esbuild: { jsx: 'automatic' }, resolve: { alias: { '@': import.meta.dirname From 9e87db3de08eacaf9e927feb5a1b83d9feee8585 Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Fri, 25 Sep 2026 14:15:28 -0300 Subject: [PATCH 3/5] fix(indexer): promote validated initial-owner discovery mapping (cherry picked from commit 9b9353a2cd0afb68b1c4d46ad39eb18ce6537942) --- apps/indexer/README.md | 50 +++ apps/indexer/experiments/README.md | 226 ----------- .../multisig-creation-probe-long-address.yaml | 83 ----- .../multisig-creation-probe-schema.graphql | 14 - .../multisig-creation-probe.graphql | 33 -- .../experiments/multisig-creation-probe.yaml | 83 ----- .../vault-owner-discovery-status.graphql | 15 - .../experiments/vault-owner-discovery.graphql | 20 - apps/indexer/multisig-mainnet.yaml | 352 +++++++++++------- apps/indexer/multisig-testnet.yaml | 286 +++++++------- apps/web/hooks/useMultisigExecutionEvents.ts | 3 +- apps/web/tests/lib/executionHistory.test.ts | 247 ------------ apps/web/vitest.config.ts | 1 - 13 files changed, 402 insertions(+), 1011 deletions(-) delete mode 100644 apps/indexer/experiments/README.md delete mode 100644 apps/indexer/experiments/multisig-creation-probe-long-address.yaml delete mode 100644 apps/indexer/experiments/multisig-creation-probe-schema.graphql delete mode 100644 apps/indexer/experiments/multisig-creation-probe.graphql delete mode 100644 apps/indexer/experiments/multisig-creation-probe.yaml delete mode 100644 apps/indexer/experiments/vault-owner-discovery-status.graphql delete mode 100644 apps/indexer/experiments/vault-owner-discovery.graphql delete mode 100644 apps/web/tests/lib/executionHistory.test.ts diff --git a/apps/indexer/README.md b/apps/indexer/README.md index 3c9031f..b49c392 100644 --- a/apps/indexer/README.md +++ b/apps/indexer/README.md @@ -6,6 +6,7 @@ Petra Vault uses the No-Code Indexing (NCI) service from [Geomi](https://geomi.d - [Setup Instructions](#setup-instructions) - [Environment Configuration](#environment-configuration) +- [Initial-owner Discovery on Mainnet](#initial-owner-discovery-on-mainnet) ## Setup Instructions @@ -63,3 +64,52 @@ NEXT_PUBLIC_MULTISIG_INDEXER_TESTNET_ENDPOINT="https://api.testnet.aptoslabs.com | `NEXT_PUBLIC_MULTISIG_INDEXER_MAINNET_ENDPOINT` | GraphQL endpoint for mainnet | `https://api.mainnet.aptoslabs.com/nocode/v1/api/[id]/v1/graphql` | | `NEXT_PUBLIC_MULTISIG_INDEXER_TESTNET_API_KEY` | API key for testnet processor | `AG-...` | | `NEXT_PUBLIC_MULTISIG_INDEXER_TESTNET_ENDPOINT` | GraphQL endpoint for testnet | `https://api.testnet.aptoslabs.com/nocode/v1/api/[id]/v1/graphql` | + +## Initial-owner Discovery on Mainnet + +`multisig-mainnet.yaml` adds `multisig_creation_candidates` alongside the existing +transaction and owner-activity tables. It maps argument `0` of +`0x1::multisig_account::create_with_owners` into a nullable `owners` address array. +The YAML uses the full framework address because Geomi's editor otherwise drops +the payload mapping during its function ABI lookup. + +The mapping was tested in `multisig-creation-probe-v2` with Mainnet creation +versions `7345859301` and `7350673760`. Both were returned for the invited owner +once indexing reached their versions. The production table uses the same mappings +under the name `multisig_creation_candidates`. + +After deploying, indexing the required history, and exposing the new table to the +mobile API key, query candidate versions for the active wallet: + +```graphql +query VaultCreationCandidates( + $owner: String! + $afterVersion: bigint! = "0" + $limit: Int! = 100 +) { + multisig_creation_candidates( + where: { owners: { _contains: [$owner] }, version: { _gt: $afterVersion } } + order_by: { version: asc } + limit: $limit + ) { + version + owners + } +} +``` + +For each subsequent page, use the last returned version as `afterVersion` until +the result is empty. Mobile integration is still required: merge these versions +with existing creator-history and owner-event discovery, resolve successful +transactions into vault addresses, deduplicate them, and verify current ownership. +The query returns creation candidates, not vault addresses or current membership. +Argument `0` contains additional owners only; it excludes the creator and does +not cover other creation functions or custom wrappers. + +**Deployment remains a separate step.** The processor needs `FeeStatement` events +to create rows before payload enrichment. This stores a row for every matching +fee event, including unrelated transactions with null owners and failed creation +calls. Filtering the GraphQL query does not reduce ingestion. Plan ingestion cost +and backfill before deployment; the YAML's `starting_version: 0` requests history +from genesis. The promoted config has been checked locally, but has not been +deployed or verified using the mobile API key. diff --git a/apps/indexer/experiments/README.md b/apps/indexer/experiments/README.md deleted file mode 100644 index 2706e03..0000000 --- a/apps/indexer/experiments/README.md +++ /dev/null @@ -1,226 +0,0 @@ -# Initial-owner discovery probe - -**Status: the long-address probe passed for the known Mainnet creation.** The -user's Hasura response was compared with a fresh public fullnode sample: all -17 window rows matched, and the initial co-owner lookup returned `7345859301`. -This verifies the probe's sample, not complete vault discovery. Production -processors and mobile queries remain unchanged. - -Use [multisig-creation-probe-long-address.yaml](./multisig-creation-probe-long-address.yaml). -Its only semantic change from the original probe is the payload function key: - -```yaml -custom_config: - payload: - 0x0000000000000000000000000000000000000000000000000000000000000001::multisig_account::create_with_owners: - '0': - - table: multisig_creation_probe - column: owners -``` - -The original [short-address YAML](./multisig-creation-probe.yaml) remains as the -bug reproduction. It is not the file to import for this retry. - -## Verified result - -The user supplied the deployed GraphQL response after tracking the new table -in Hasura. Comparison with a fresh fetch of versions `7345859291`–`7345859311` -passed: - -- `creation` returned `7345859301`, the expected initial co-owner, and gas units - `11873`. -- `owner_lookup` returned that same version for the co-owner, who differs from - the transaction sender. The successful transaction's written multisig resource - contains both the co-owner and creator. -- All 17 `window` rows matched the fullnode data, including transaction versions, - owners, standardized event types, and gas values. The 16 unrelated fee rows - had null `owners`, as expected. - -This confirms that the full-address workaround allows payload enrichment and -owner-array lookup for the tested creation. It also confirms that the payload -mapping does not exclude unrelated fee transactions in this sample. Stop the -probe after collecting the result; further fee ingestion is unnecessary for -this verification. - -The comparison used the user's pasted Hasura result, not a direct authenticated -request to the processor. Failed-call handling, other creation paths, historical -coverage, ingestion cost, and mobile integration remain to be validated. - -## Verify another vault and discover owner candidates - -[vault-owner-discovery.graphql](./vault-owner-discovery.graphql) runs without -variables in Hasura and defaults to owner -`0xc476707d8a2d5c00242096332855ab0968225cbdc1cce0e25532b1df33f98eb0`. -It removes the earlier sample's upper version bound. - -- `target_creation` checks version `7350673760` for this owner. A public - [fullnode fetch](https://api.mainnet.aptoslabs.com/v1/transactions/by_version/7350673760) - confirmed that this successful `create_with_owners` call by - `0xc8afd0a0a7fc403d4785f1458d4a465993425fa339938ac3ac24396f4a877282` - created vault - `0xe4f95233928a4957db9988417d6d35318f88769804625a960298fb179f04d5f7`, - with the queried owner in argument `0`. The resource currently contains both - owners. The processor must run through that version before testing its row. -- `creation_candidates` returns up to 100 matching versions in ascending order. - For subsequent pages, set `afterVersion` to the last returned version and - repeat until empty. Override `owner` for the active wallet. The fixed - `target_creation` alias is only a diagnostic and should be omitted from the - mobile query. - -The probe has no vault-address column: each returned version must be fetched -from the fullnode, checked for success, and resolved through its multisig -resource writes. Deduplicate addresses and verify current ownership before -display. Combine these candidates with creator history and later owner events; -the query alone cannot list every owned vault or recover history before the -processor's starting version. - -The user's first result for this query returned an empty `target_creation`, -with candidate versions `7345859301`, `7347901015`, and `7347937978`. The target -was ahead of the processor's reported version `7349077670`. - -On 2026-09-25, after processing reached `7352113486`, the user supplied a new -response with `7350673760` and the invited owner in both `target_creation` and -`creation_candidates`. This second vault test passed after catch-up. The response -contains seven candidate versions; that count is not a verified count of current -owned vaults. Each candidate still needs transaction resolution and a current -ownership check before display. - -The newest matching candidate alone does not establish processing progress. - -Run [vault-owner-discovery-status.graphql](./vault-owner-discovery-status.graphql) -to inspect the newest fee row across all owners and look up the target version -without an owner filter. Also check Geomi's latest processed version against -`7350673760`. If the probe was stopped after the first experiment, resume it -and let it reach the new target before assessing the mapping. A present target -with missing owners indicates an enrichment issue; an absent target after -processing has passed it requires investigating missing ingestion/history. - -## Geomi team's findings - -The following comes from the team's source review shared in this conversation; -we have not independently run their UI or processor implementation locally. - -- Payload mappings support `vector
` and the existing column shape - (`move_type` / `address`, `is_vec: true`). -- Both import and direct YAML entry pass through the graph editor. Its function - lookup uses the supplied account address, while fetched ABIs use the full - address. The short `0x1` key misses the lookup and loses its source edge. - Review then reports an unmapped column, and serialization omits that column. - The full address is the team's workaround. Event keys already normalize - addresses, so the event mapping does not need the same change. -- Events create rows; payload arguments and transaction metadata only enrich - those rows. A function mapping does not filter ingestion. This FeeStatement - probe therefore stores unrelated transactions with null `owners`. -- Failed calls can also have FeeStatement and payload arguments. A non-null - `owners` value does not prove creation succeeded. Success and VM status are - not available as metadata mappings. -- Resource-write indexing is not implemented in this remapping processor. - A resource-based solution would require a different processor or a new feature. - -## Import and verify the retry - -1. Create a separate processor named `multisig-creation-probe-v2`, on Mainnet, - starting at `7345859291`. Import the **long-address YAML** above. It contains - only a probe table; do not replace the production processor's mappings. -2. In Review Config, confirm `custom_config.payload` still maps argument `0` to - `owners`, and `db_schema.multisig_creation_probe.owners` still exists as a - nullable address vector. If either disappears or validation fails, stop and - report that result to Geomi instead of deploying another stripped config. -3. Create the processor, wait for Running and a processed version of at least - `7345859311`, then open its own Hasura console. Track only - `public.multisig_creation_probe` if it is untracked. Confirm `owners` exists. -4. Run [multisig-creation-probe.graphql](./multisig-creation-probe.graphql). - The [schema query](./multisig-creation-probe-schema.graphql) can inspect - exposed GraphQL roots and columns if needed. -5. Record the reviewed config, processed version, and query response, then stop - the test processor. The YAML has **no automatic end version**; a query range - does not limit ingestion. Keep the old fee-only test stopped as well. - -Expected results for the inclusive window `7345859291`–`7345859311`: - -- `creation` contains version `7345859301`, co-owner - `0xc476707d8a2d5c00242096332855ab0968225cbdc1cce0e25532b1df33f98eb0`, - and `total_charge_gas_units` of `11873`. -- `owner_lookup` returns `7345859301`. The creator is not expected in `owners`. -- `window` contains 17 fee rows: the creation plus 16 unrelated transactions - with null `owners`. Four system transactions in the sample have no fee event. - These extra rows are expected, not evidence that the mapping failed. - -A missing or null owner on the known successful creation means the workaround -has not passed. If unrelated fee rows are absent, investigate the deployment -and processing range rather than concluding that payload mapping filters them. -The [public transaction sample](https://api.mainnet.aptoslabs.com/v1/transactions?start=7345859291&limit=21) -provides the comparison data. The created vault is -`0xafb026bab856645f9906ae85ce8cbd1300b70ee86fc1384a89393ec5ea274162`. - -## What the passing probe enables - -This table supplies candidate creation versions for initial additional owners. -It is not a complete owner registry. Mobile would need a new query for those -versions, alongside its existing creator-history and owner-event queries. - -The existing mobile `readCreationAddresses` already fetches the transaction, -rejects unsuccessful transactions, and extracts written multisig resource -addresses. Its `snapshot` checks current on-chain ownership. Reuse those checks -for this new candidate source; do not treat payload addresses as proof that a -vault exists or that the wallet is still an owner. Add a failed-creation case to -integration validation before connecting the source to production. - -Creator omission does not by itself block the reported `create_with_owners` -fix: mobile already has a creator-history query for that function. However, -plain `create()`, bootstrapper-removal, existing-account creation, and custom -wrappers need separate coverage. The retry deliberately adds no extra function -mappings, so the address-format change is tested in isolation. - -Before production use, resolve the cost of indexing every FeeStatement and -plan historical coverage/backfill. A GraphQL filter does not reduce ingestion. -Sender metadata would help creator coverage, but would not by itself provide -success filtering or support every creation path. Resource indexing remains a -separate implementation option outside this no-code processor. - -## Earlier deployment - -The original short-address probe was accepted despite the review error: - -> Column 'owners' in table 'multisig_creation_probe' has no source mappings. All table columns must have at least one source mapping. - -Review showed `payload: {}` and omitted `owners`. The processor reached Running -and passed the sample range. Its table was initially untracked in Hasura; after -tracking, Browse Rows showed only `version`, `fee_event_type`, and -`total_charge_gas_units`. This verified fee ingestion, not owner discovery. -The team subsequently identified the ABI lookup issue described above. - -## Source verification - -Checked on 2026-09-24 against Aptos source commit -[`cc09f4c`](https://github.com/aptos-labs/aptos-core/blob/cc09f4c5f6d96acacc3e2637d84265111d5d6cc1/aptos-move/framework/aptos-framework/sources/multisig_account.move). - -- [`create_with_owners`](https://github.com/aptos-labs/aptos-core/blob/cc09f4c5f6d96acacc3e2637d84265111d5d6cc1/aptos-move/framework/aptos-framework/sources/multisig_account.move#L784-L802) - adds the creator to `additional_owners`. Payload argument `0` alone therefore - omits the creator. -- [`create_with_owners_internal`](https://github.com/aptos-labs/aptos-core/blob/cc09f4c5f6d96acacc3e2637d84265111d5d6cc1/aptos-move/framework/aptos-framework/sources/multisig_account.move#L877-L916) - writes `MultisigAccount.owners` directly. It initializes event handles without - emitting `AddOwners` or `AddOwnersEvent`, and disables the metadata event. -- Later additions use - [`update_owner_schema`](https://github.com/aptos-labs/aptos-core/blob/cc09f4c5f6d96acacc3e2637d84265111d5d6cc1/aptos-move/framework/aptos-framework/sources/multisig_account.move#L1611-L1658), - which emits `AddOwners`. The demonstrated gap concerns initial co-owners; - post-creation additions have an event source already mapped by the indexer. -- [`owners` and `is_owner`](https://github.com/aptos-labs/aptos-core/blob/cc09f4c5f6d96acacc3e2637d84265111d5d6cc1/aptos-move/framework/aptos-framework/sources/multisig_account.move#L418-L428) - require a known vault address; they do not discover vaults from an owner. - -The historical Mainnet transaction `7345859301` was fetched again: it succeeds, -emits only `FeeStatement`, and writes both owners into the multisig resource. -This independently verifies the repro; current `main` is not evidence of the -exact framework revision deployed at that historical version. - -Inference: indexing resource writes would capture the ownership state directly. -This Move source does not establish Geomi payload or resource-indexing support. - -## Validation limits - -Local checks verify YAML shape, the single semantic change, and the expected -sample data. A published Rust config-type round-trip verifies serialization -only; its package version is not the deployed processor version. The subsequent -user-provided Hasura response supplies runtime evidence for the known creation -and sample window. It does not establish production completeness or correctness -for failed transactions and other creation paths. diff --git a/apps/indexer/experiments/multisig-creation-probe-long-address.yaml b/apps/indexer/experiments/multisig-creation-probe-long-address.yaml deleted file mode 100644 index 19ecd38..0000000 --- a/apps/indexer/experiments/multisig-creation-probe-long-address.yaml +++ /dev/null @@ -1,83 +0,0 @@ -# Probe only: every FeeStatement creates a row, including failed transactions. -# Stop after checking the sample; starting_version does not set an end version. -spec_identifier: - spec_creator: 'shepherd@aptoslabs.com' - spec_name: 'remapping-processor' - spec_version: '0.0.21' -common_config: - network: 'mainnet' - starting_version: 7345859291 - starting_version_override: null -custom_config: - transaction_metadata: - timestamp: [] - version: - - table: 'multisig_creation_probe' - column: 'version' - epoch: [] - block_height: [] - hash: [] - events: - 0x1::transaction_fee::FeeStatement: - event_fields: - $.total_charge_gas_units: - - table: 'multisig_creation_probe' - column: 'total_charge_gas_units' - event_metadata: - sequence_number: [] - creation_number: [] - account_address: [] - event_type: - - table: 'multisig_creation_probe' - column: 'fee_event_type' - event_index: [] - constant_values: [] - payload: - # Geomi's graph editor requires the full address for its function ABI lookup. - 0x0000000000000000000000000000000000000000000000000000000000000001::multisig_account::create_with_owners: - '0': - - table: 'multisig_creation_probe' - column: 'owners' - db_schema: - multisig_creation_probe: - version: - is_index: true - is_nullable: false - is_option: false - is_primary_key: true - is_vec: false - default_value: null - column_type: - type: 'transaction_metadata' - column_type: 'version' - fee_event_type: - is_index: false - is_nullable: false - is_option: false - is_primary_key: false - is_vec: false - default_value: null - column_type: - type: 'event_metadata' - column_type: 'event_type' - total_charge_gas_units: - is_index: false - is_nullable: false - is_option: false - is_primary_key: false - is_vec: false - default_value: null - column_type: - type: 'move_type' - column_type: 'u64' - owners: - is_index: true - # Unrelated fee rows have no matching payload argument. - is_nullable: true - is_option: false - is_primary_key: false - is_vec: true - default_value: null - column_type: - type: 'move_type' - column_type: 'address' diff --git a/apps/indexer/experiments/multisig-creation-probe-schema.graphql b/apps/indexer/experiments/multisig-creation-probe-schema.graphql deleted file mode 100644 index bd8282d..0000000 --- a/apps/indexer/experiments/multisig-creation-probe-schema.graphql +++ /dev/null @@ -1,14 +0,0 @@ -query InspectProbeSchema { - __schema { - queryType { - fields { - name - } - } - } - __type(name: "multisig_creation_probe") { - fields { - name - } - } -} diff --git a/apps/indexer/experiments/multisig-creation-probe.graphql b/apps/indexer/experiments/multisig-creation-probe.graphql deleted file mode 100644 index 789753e..0000000 --- a/apps/indexer/experiments/multisig-creation-probe.graphql +++ /dev/null @@ -1,33 +0,0 @@ -query MultisigCreationProbe { - creation: multisig_creation_probe(where: { version: { _eq: "7345859301" } }) { - version - owners - fee_event_type - total_charge_gas_units - } - owner_lookup: multisig_creation_probe( - where: { - version: { _gte: "7345859291", _lte: "7345859311" } - owners: { - _contains: [ - "0xc476707d8a2d5c00242096332855ab0968225cbdc1cce0e25532b1df33f98eb0" - ] - } - } - order_by: { version: asc } - limit: 100 - ) { - version - owners - } - window: multisig_creation_probe( - where: { version: { _gte: "7345859291", _lte: "7345859311" } } - order_by: { version: asc } - limit: 100 - ) { - version - owners - fee_event_type - total_charge_gas_units - } -} diff --git a/apps/indexer/experiments/multisig-creation-probe.yaml b/apps/indexer/experiments/multisig-creation-probe.yaml deleted file mode 100644 index 1ab416e..0000000 --- a/apps/indexer/experiments/multisig-creation-probe.yaml +++ /dev/null @@ -1,83 +0,0 @@ -# Reproduction only: Geomi review drops payload and owners and reports an error. -# The accepted deployment has fee data but no owners column. See README.md. -# This has no automatic stop version. Stop the probe after inspecting the results. -spec_identifier: - spec_creator: 'shepherd@aptoslabs.com' - spec_name: 'remapping-processor' - spec_version: '0.0.21' -common_config: - network: 'mainnet' - starting_version: 7345859291 - starting_version_override: null -custom_config: - transaction_metadata: - timestamp: [] - version: - - table: 'multisig_creation_probe' - column: 'version' - epoch: [] - block_height: [] - hash: [] - events: - 0x1::transaction_fee::FeeStatement: - event_fields: - $.total_charge_gas_units: - - table: 'multisig_creation_probe' - column: 'total_charge_gas_units' - event_metadata: - sequence_number: [] - creation_number: [] - account_address: [] - event_type: - - table: 'multisig_creation_probe' - column: 'fee_event_type' - event_index: [] - constant_values: [] - payload: - 0x1::multisig_account::create_with_owners: - '0': - - table: 'multisig_creation_probe' - column: 'owners' - db_schema: - multisig_creation_probe: - version: - is_index: true - is_nullable: false - is_option: false - is_primary_key: true - is_vec: false - default_value: null - column_type: - type: 'transaction_metadata' - column_type: 'version' - fee_event_type: - is_index: false - is_nullable: false - is_option: false - is_primary_key: false - is_vec: false - default_value: null - column_type: - type: 'event_metadata' - column_type: 'event_type' - total_charge_gas_units: - is_index: false - is_nullable: false - is_option: false - is_primary_key: false - is_vec: false - default_value: null - column_type: - type: 'move_type' - column_type: 'u64' - owners: - is_index: true - # Preserve unmatched rows so the probe reveals whether FeeStatement is global. - is_nullable: true - is_option: false - is_primary_key: false - is_vec: true - default_value: null - column_type: - type: 'move_type' - column_type: 'address' diff --git a/apps/indexer/experiments/vault-owner-discovery-status.graphql b/apps/indexer/experiments/vault-owner-discovery-status.graphql deleted file mode 100644 index d4bfe31..0000000 --- a/apps/indexer/experiments/vault-owner-discovery-status.graphql +++ /dev/null @@ -1,15 +0,0 @@ -query CheckVaultDiscoveryProgress { - latest_indexed_fee: multisig_creation_probe( - order_by: { version: desc } - limit: 1 - ) { - version - } - target_without_owner_filter: multisig_creation_probe( - where: { version: { _eq: "7350673760" } } - ) { - version - owners - total_charge_gas_units - } -} diff --git a/apps/indexer/experiments/vault-owner-discovery.graphql b/apps/indexer/experiments/vault-owner-discovery.graphql deleted file mode 100644 index a7e8a77..0000000 --- a/apps/indexer/experiments/vault-owner-discovery.graphql +++ /dev/null @@ -1,20 +0,0 @@ -query VaultDiscoveryForOwner( - $owner: String! = "0xc476707d8a2d5c00242096332855ab0968225cbdc1cce0e25532b1df33f98eb0" - $afterVersion: bigint! = "0" - $limit: Int! = 100 -) { - target_creation: multisig_creation_probe( - where: { version: { _eq: "7350673760" }, owners: { _contains: [$owner] } } - ) { - version - owners - } - creation_candidates: multisig_creation_probe( - where: { owners: { _contains: [$owner] }, version: { _gt: $afterVersion } } - order_by: { version: asc } - limit: $limit - ) { - version - owners - } -} diff --git a/apps/indexer/multisig-mainnet.yaml b/apps/indexer/multisig-mainnet.yaml index e226b74..4dd2ffb 100644 --- a/apps/indexer/multisig-mainnet.yaml +++ b/apps/indexer/multisig-mainnet.yaml @@ -1,272 +1,294 @@ spec_identifier: - spec_creator: "shepherd@aptoslabs.com" - spec_name: "remapping-processor" + spec_creator: shepherd@aptoslabs.com + spec_name: remapping-processor spec_version: "0.0.21" common_config: - network: "mainnet" + network: mainnet starting_version: 0 starting_version_override: null custom_config: transaction_metadata: timestamp: - - table: "multisig_transactions" - column: "timestamp" + - table: multisig_transactions + column: timestamp version: - - table: "multisig_transactions" - column: "version" - - table: "multisig_owner_activities" - column: "version" + - table: multisig_transactions + column: version + - table: multisig_owner_activities + column: version + - table: multisig_creation_candidates + column: version epoch: [] block_height: [] hash: [] events: + # Payload mappings enrich event-created rows; they do not filter ingestion. + 0x1::transaction_fee::FeeStatement: + event_fields: + $.total_charge_gas_units: + - table: multisig_creation_candidates + column: total_charge_gas_units + event_metadata: + sequence_number: [] + creation_number: [] + account_address: [] + event_type: + - table: multisig_creation_candidates + column: fee_event_type + event_index: [] + constant_values: [] 0x1::multisig_account::AddOwners: event_fields: $.multisig_account: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account $.owners_added: - - table: "multisig_owner_activities" - column: "owners_added" + - table: multisig_owner_activities + column: owners_added event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::AddOwnersEvent: event_fields: $.owners_added: - - table: "multisig_owner_activities" - column: "owners_added" + - table: multisig_owner_activities + column: owners_added event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::ExecuteRejectedTransaction: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.multisig_account: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account $.num_rejections: - - table: "multisig_transactions" - column: "num_rejections" + - table: multisig_transactions + column: num_rejections $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::ExecuteRejectedTransactionEvent: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.num_rejections: - - table: "multisig_transactions" - column: "num_rejections" + - table: multisig_transactions + column: num_rejections $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::RemoveOwners: event_fields: $.multisig_account: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account $.owners_removed: - - table: "multisig_owner_activities" - column: "owners_removed" + - table: multisig_owner_activities + column: owners_removed event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::RemoveOwnersEvent: event_fields: $.owners_removed: - - table: "multisig_owner_activities" - column: "owners_removed" + - table: multisig_owner_activities + column: owners_removed event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionFailed: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.multisig_account: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account $.num_approvals: - - table: "multisig_transactions" - column: "num_approvals" + - table: multisig_transactions + column: num_approvals $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number $.transaction_payload: - - table: "multisig_transactions" - column: "transaction_payload" + - table: multisig_transactions + column: transaction_payload event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionFailedEvent: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.num_approvals: - - table: "multisig_transactions" - column: "num_approvals" + - table: multisig_transactions + column: num_approvals $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number $.transaction_payload: - - table: "multisig_transactions" - column: "transaction_payload" + - table: multisig_transactions + column: transaction_payload event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionSucceeded: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.multisig_account: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account $.num_approvals: - - table: "multisig_transactions" - column: "num_approvals" + - table: multisig_transactions + column: num_approvals $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number $.transaction_payload: - - table: "multisig_transactions" - column: "transaction_payload" + - table: multisig_transactions + column: transaction_payload event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionSucceededEvent: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.num_approvals: - - table: "multisig_transactions" - column: "num_approvals" + - table: multisig_transactions + column: num_approvals $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number $.transaction_payload: - - table: "multisig_transactions" - column: "transaction_payload" + - table: multisig_transactions + column: transaction_payload event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::Vote: event_fields: $.multisig_account: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account $.owner: - - table: "multisig_owner_activities" - column: "owner_vote" + - table: multisig_owner_activities + column: owner_vote event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::VoteEvent: event_fields: $.owner: - - table: "multisig_owner_activities" - column: "owner_vote" + - table: multisig_owner_activities + column: owner_vote event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] - payload: {} + payload: + # Geomi's function ABI lookup requires the full address. + 0x0000000000000000000000000000000000000000000000000000000000000001::multisig_account::create_with_owners: + '0': + - table: multisig_creation_candidates + column: owners db_schema: multisig_transactions: version: @@ -277,8 +299,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "transaction_metadata" - column_type: "version" + type: transaction_metadata + column_type: version timestamp: is_index: false is_nullable: false @@ -287,8 +309,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "transaction_metadata" - column_type: "timestamp" + type: transaction_metadata + column_type: timestamp executor: is_index: false is_nullable: false @@ -297,8 +319,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address multisig_account: is_index: true is_nullable: false @@ -307,8 +329,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address num_rejections: is_index: false is_nullable: true @@ -317,8 +339,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "u64" + type: move_type + column_type: u64 sequence_number: is_index: false is_nullable: false @@ -327,8 +349,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "u64" + type: move_type + column_type: u64 event_type: is_index: false is_nullable: false @@ -337,8 +359,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "event_metadata" - column_type: "event_type" + type: event_metadata + column_type: event_type num_approvals: is_index: false is_nullable: true @@ -347,8 +369,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "u64" + type: move_type + column_type: u64 transaction_payload: is_index: false is_nullable: true @@ -357,8 +379,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "string" + type: move_type + column_type: string multisig_owner_activities: version: is_index: true @@ -368,8 +390,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "transaction_metadata" - column_type: "version" + type: transaction_metadata + column_type: version multisig_account: is_index: false is_nullable: false @@ -378,8 +400,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address owners_added: is_index: true is_nullable: true @@ -388,8 +410,8 @@ custom_config: is_vec: true default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address event_type: is_index: false is_nullable: false @@ -398,8 +420,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "event_metadata" - column_type: "event_type" + type: event_metadata + column_type: event_type owners_removed: is_index: true is_nullable: true @@ -408,8 +430,8 @@ custom_config: is_vec: true default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address owner_vote: is_index: true is_nullable: true @@ -418,5 +440,47 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address + multisig_creation_candidates: + version: + is_index: true + is_nullable: false + is_option: false + is_primary_key: true + is_vec: false + default_value: null + column_type: + type: transaction_metadata + column_type: version + fee_event_type: + is_index: false + is_nullable: false + is_option: false + is_primary_key: false + is_vec: false + default_value: null + column_type: + type: event_metadata + column_type: event_type + total_charge_gas_units: + is_index: false + is_nullable: false + is_option: false + is_primary_key: false + is_vec: false + default_value: null + column_type: + type: move_type + column_type: u64 + owners: + is_index: true + # Unrelated fee rows have no matching payload argument. + is_nullable: true + is_option: false + is_primary_key: false + is_vec: true + default_value: null + column_type: + type: move_type + column_type: address diff --git a/apps/indexer/multisig-testnet.yaml b/apps/indexer/multisig-testnet.yaml index 261cf31..d956a6a 100644 --- a/apps/indexer/multisig-testnet.yaml +++ b/apps/indexer/multisig-testnet.yaml @@ -1,21 +1,21 @@ spec_identifier: - spec_creator: "shepherd@aptoslabs.com" - spec_name: "remapping-processor" + spec_creator: shepherd@aptoslabs.com + spec_name: remapping-processor spec_version: "0.0.21" common_config: - network: "testnet" + network: testnet starting_version: 4500665800 starting_version_override: null custom_config: transaction_metadata: timestamp: - - table: "multisig_transactions" - column: "timestamp" + - table: multisig_transactions + column: timestamp version: - - table: "multisig_transactions" - column: "version" - - table: "multisig_owner_activities" - column: "version" + - table: multisig_transactions + column: version + - table: multisig_owner_activities + column: version epoch: [] block_height: [] hash: [] @@ -23,247 +23,247 @@ custom_config: 0x1::multisig_account::AddOwners: event_fields: $.multisig_account: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account $.owners_added: - - table: "multisig_owner_activities" - column: "owners_added" + - table: multisig_owner_activities + column: owners_added event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::AddOwnersEvent: event_fields: $.owners_added: - - table: "multisig_owner_activities" - column: "owners_added" + - table: multisig_owner_activities + column: owners_added event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::ExecuteRejectedTransaction: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.multisig_account: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account $.num_rejections: - - table: "multisig_transactions" - column: "num_rejections" + - table: multisig_transactions + column: num_rejections $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::ExecuteRejectedTransactionEvent: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.num_rejections: - - table: "multisig_transactions" - column: "num_rejections" + - table: multisig_transactions + column: num_rejections $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::RemoveOwners: event_fields: $.multisig_account: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account $.owners_removed: - - table: "multisig_owner_activities" - column: "owners_removed" + - table: multisig_owner_activities + column: owners_removed event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::RemoveOwnersEvent: event_fields: $.owners_removed: - - table: "multisig_owner_activities" - column: "owners_removed" + - table: multisig_owner_activities + column: owners_removed event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionFailed: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.multisig_account: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account $.num_approvals: - - table: "multisig_transactions" - column: "num_approvals" + - table: multisig_transactions + column: num_approvals $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number $.transaction_payload: - - table: "multisig_transactions" - column: "transaction_payload" + - table: multisig_transactions + column: transaction_payload event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionFailedEvent: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.num_approvals: - - table: "multisig_transactions" - column: "num_approvals" + - table: multisig_transactions + column: num_approvals $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number $.transaction_payload: - - table: "multisig_transactions" - column: "transaction_payload" + - table: multisig_transactions + column: transaction_payload event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionSucceeded: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.multisig_account: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account $.num_approvals: - - table: "multisig_transactions" - column: "num_approvals" + - table: multisig_transactions + column: num_approvals $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number $.transaction_payload: - - table: "multisig_transactions" - column: "transaction_payload" + - table: multisig_transactions + column: transaction_payload event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::TransactionExecutionSucceededEvent: event_fields: $.executor: - - table: "multisig_transactions" - column: "executor" + - table: multisig_transactions + column: executor $.num_approvals: - - table: "multisig_transactions" - column: "num_approvals" + - table: multisig_transactions + column: num_approvals $.sequence_number: - - table: "multisig_transactions" - column: "sequence_number" + - table: multisig_transactions + column: sequence_number $.transaction_payload: - - table: "multisig_transactions" - column: "transaction_payload" + - table: multisig_transactions + column: transaction_payload event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_transactions" - column: "multisig_account" + - table: multisig_transactions + column: multisig_account event_type: - - table: "multisig_transactions" - column: "event_type" + - table: multisig_transactions + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::Vote: event_fields: $.multisig_account: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account $.owner: - - table: "multisig_owner_activities" - column: "owner_vote" + - table: multisig_owner_activities + column: owner_vote event_metadata: sequence_number: [] creation_number: [] account_address: [] event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] 0x1::multisig_account::VoteEvent: event_fields: $.owner: - - table: "multisig_owner_activities" - column: "owner_vote" + - table: multisig_owner_activities + column: owner_vote event_metadata: sequence_number: [] creation_number: [] account_address: - - table: "multisig_owner_activities" - column: "multisig_account" + - table: multisig_owner_activities + column: multisig_account event_type: - - table: "multisig_owner_activities" - column: "event_type" + - table: multisig_owner_activities + column: event_type event_index: [] constant_values: [] payload: {} @@ -277,8 +277,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "transaction_metadata" - column_type: "version" + type: transaction_metadata + column_type: version timestamp: is_index: false is_nullable: false @@ -287,8 +287,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "transaction_metadata" - column_type: "timestamp" + type: transaction_metadata + column_type: timestamp executor: is_index: false is_nullable: false @@ -297,8 +297,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address multisig_account: is_index: false is_nullable: false @@ -307,8 +307,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address num_rejections: is_index: false is_nullable: true @@ -317,8 +317,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "u64" + type: move_type + column_type: u64 sequence_number: is_index: false is_nullable: false @@ -327,8 +327,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "u64" + type: move_type + column_type: u64 event_type: is_index: false is_nullable: false @@ -337,8 +337,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "event_metadata" - column_type: "event_type" + type: event_metadata + column_type: event_type num_approvals: is_index: false is_nullable: true @@ -347,8 +347,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "u64" + type: move_type + column_type: u64 transaction_payload: is_index: false is_nullable: true @@ -357,8 +357,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "string" + type: move_type + column_type: string multisig_owner_activities: version: is_index: false @@ -368,8 +368,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "transaction_metadata" - column_type: "version" + type: transaction_metadata + column_type: version multisig_account: is_index: false is_nullable: false @@ -378,8 +378,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address owners_added: is_index: false is_nullable: true @@ -388,8 +388,8 @@ custom_config: is_vec: true default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address event_type: is_index: false is_nullable: false @@ -398,8 +398,8 @@ custom_config: is_vec: false default_value: null column_type: - type: "event_metadata" - column_type: "event_type" + type: event_metadata + column_type: event_type owners_removed: is_index: false is_nullable: true @@ -408,8 +408,8 @@ custom_config: is_vec: true default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address owner_vote: is_index: false is_nullable: false @@ -418,5 +418,5 @@ custom_config: is_vec: false default_value: null column_type: - type: "move_type" - column_type: "address" + type: move_type + column_type: address diff --git a/apps/web/hooks/useMultisigExecutionEvents.ts b/apps/web/hooks/useMultisigExecutionEvents.ts index fbd0434..e769a25 100644 --- a/apps/web/hooks/useMultisigExecutionEvents.ts +++ b/apps/web/hooks/useMultisigExecutionEvents.ts @@ -64,8 +64,7 @@ export default function useMultisigExecutionEvents({ return useInfiniteQuery({ ...options, enabled, - // Persisted array pages cannot be read as { events, rawCount } pages. - queryKey: ['multisig-execution-events', address, network, 'v2'], + queryKey: ['multisig-execution-events', address, network], initialPageParam: 0, queryFn: async ({ pageParam = 0 }) => { const resolvedNetwork = network?.network ?? aptos.config.network; diff --git a/apps/web/tests/lib/executionHistory.test.ts b/apps/web/tests/lib/executionHistory.test.ts deleted file mode 100644 index 6d58a84..0000000 --- a/apps/web/tests/lib/executionHistory.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { createElement } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { - dehydrate, - hydrate, - QueryClient, - QueryClientProvider -} from '@tanstack/react-query'; -import { - AccountAddress, - EntryFunction, - MultiSigTransactionPayload, - Network -} from '@aptos-labs/ts-sdk'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import useMultisigExecutionEvents, { - type ExecutionEvent -} from '@/hooks/useMultisigExecutionEvents'; -import TransactionRow from '@/components/TransactionRow'; -import { LONG_FRAMEWORK_ADDRESS } from '@/lib/constants'; -import { storageOptionsSerializers } from '@/lib/storage'; - -const { getMultisigTransactions, getTransactionByVersion } = vi.hoisted(() => ({ - getMultisigTransactions: vi.fn(), - getTransactionByVersion: vi.fn() -})); - -vi.mock('@/operations', () => ({ - getMultisigIndexerClient: () => ({ getMultisigTransactions }) -})); - -vi.mock('@aptos-labs/react', () => ({ - useClients: () => ({ - aptos: { - config: { network: 'mainnet' }, - getTransactionByVersion - } - }), - useNameFromAddress: () => ({ data: undefined }) -})); - -const address = '0x1'; -const network = { network: Network.MAINNET }; -const timestamp = Date.parse('2026-08-20T00:33:24.335Z'); -const payload = new MultiSigTransactionPayload( - EntryFunction.build('0x1::aptos_account', 'transfer', [], []) -) - .bcsToHex() - .toString(); -const row = { - version: '7350673760', - event_type: `${LONG_FRAMEWORK_ADDRESS}::multisig_account::TransactionExecutionSucceeded`, - timestamp: '2026-08-20T00:33:24.335000', - transaction_payload: payload, - executor: '0x2', - sequence_number: '7', - num_approvals: '2', - num_rejections: '0' -}; - -let client: QueryClient; - -beforeEach(() => { - vi.resetAllMocks(); - client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - getMultisigTransactions.mockResolvedValue({ multisig_transactions: [row] }); - getTransactionByVersion.mockRejectedValue(new Error('HTTP 410: pruned')); -}); - -afterEach(() => { - client.clear(); - vi.restoreAllMocks(); -}); - -function renderHistory() { - let result: ReturnType | undefined; - function Probe() { - result = useMultisigExecutionEvents({ address, network }); - return null; - } - renderToStaticMarkup( - createElement(QueryClientProvider, { client }, createElement(Probe)) - ); - if (!result) throw new Error('History hook did not render'); - return result; -} - -describe('execution history', () => { - it.each([ - { name: 'empty', page: [] }, - { - name: 'populated', - page: [ - { version: row.version, transaction: { timestamp: '1787186004335000' } } - ] - } - ])( - 'ignores restored $name array pages from the previous cache format', - async ({ page }) => { - const oldKey = ['multisig-execution-events', address, network]; - const oldData = { pages: [page], pageParams: [0] }; - client.setQueryData(oldKey, oldData); - const stored = JSON.stringify(dehydrate(client)); - client.clear(); - hydrate(client, JSON.parse(stored)); - expect(client.getQueryData(oldKey)).toEqual(oldData); - - const history = renderHistory(); - expect(history.data).toBeUndefined(); - const refreshed = await history.refetch(); - expect(refreshed.data?.pages[0]).toMatchObject({ - rawCount: 1, - events: [{ version: row.version, timestamp }] - }); - } - ); - - it('restores the current cache format with usable executor addresses', async () => { - await renderHistory().refetch(); - const stored = JSON.stringify( - dehydrate(client), - storageOptionsSerializers.replacer - ); - client.clear(); - hydrate(client, JSON.parse(stored, storageOptionsSerializers.reviver)); - - const event = renderHistory().data?.pages[0]?.events[0]; - expect(event?.timestamp).toBe(timestamp); - expect(event?.executor.toString()).toBe(row.executor); - }); - - it.each([ - ['1787186004335000', timestamp], - [1787186004335000, timestamp], - ['2026-08-20T00:33:24.335000', timestamp], - ['2026-08-20T00:33:24.335Z', timestamp], - ['2026-08-19T21:33:24.335-03:00', timestamp], - [null, undefined], - ['invalid', undefined] - ])( - 'converts indexer timestamp %j to milliseconds', - async (value, expected) => { - getMultisigTransactions.mockResolvedValue({ - multisig_transactions: [{ ...row, timestamp: value }] - }); - const result = await renderHistory().refetch(); - expect(result.isSuccess).toBe(true); - expect(result.data?.pages[0]?.events[0]).toMatchObject({ - timestamp: expected - }); - } - ); - - it.each([ - ['TransactionExecutionSucceeded', 'success'], - ['TransactionExecutionSucceededEvent', 'success'], - ['TransactionExecutionFailed', 'failed'], - ['TransactionExecutionFailedEvent', 'failed'], - ['ExecuteRejectedTransaction', 'rejected'], - ['ExecuteRejectedTransactionEvent', 'rejected'] - ])( - 'reads %s entirely from the indexer, even when the fullnode has pruned it', - async (event, type) => { - getMultisigTransactions.mockResolvedValue({ - multisig_transactions: [ - { - ...row, - event_type: `${LONG_FRAMEWORK_ADDRESS}::multisig_account::${event}`, - transaction_payload: type === 'rejected' ? null : payload - } - ] - }); - const result = await renderHistory().refetch(); - expect(result.isSuccess).toBe(true); - expect(result.data?.pages[0]?.events[0]).toMatchObject({ - type, - version: row.version, - timestamp, - executor: AccountAddress.from('0x2'), - sequenceNumber: 7, - approvals: 2, - rejections: 0 - }); - expect(getTransactionByVersion).not.toHaveBeenCalled(); - } - ); - - it('continues after a full page containing an unknown event and stops after a short page', async () => { - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - getMultisigTransactions - .mockResolvedValueOnce({ - multisig_transactions: Array.from({ length: 15 }, (_, index) => ({ - ...row, - version: String(100 - index), - event_type: index === 0 ? 'unknown' : row.event_type - })) - }) - .mockResolvedValueOnce({ multisig_transactions: [row] }); - const first = await renderHistory().fetchNextPage(); - expect(first.data?.pages[0]?.events).toHaveLength(14); - expect(first.hasNextPage).toBe(true); - expect(error).toHaveBeenCalledWith('Unknown event type: unknown'); - - const second = await first.fetchNextPage(); - expect(getMultisigTransactions).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - multisigAccount: address, - offset: 15, - limit: 15 - }) - ); - expect(second.data?.pages).toHaveLength(2); - expect(second.data?.pages[1]?.events[0]?.version).toBe(row.version); - expect(second.hasNextPage).toBe(false); - }); -}); - -describe('transaction rows', () => { - it.each(['success', 'failed', 'rejected'])( - 'renders a %s execution using only indexer fields', - (type) => { - const html = renderToStaticMarkup( - createElement(TransactionRow, { - network: Network.MAINNET, - executionEvent: { - type, - version: row.version, - timestamp, - payload: type === 'rejected' ? undefined : payload, - executor: AccountAddress.from(row.executor), - sequenceNumber: 7, - approvals: 2, - rejections: 0 - } - }) - ); - expect(html).toContain( - type === 'rejected' ? 'Rejected transaction' : 'Transfer APT' - ); - expect(html).toContain(`txn/${row.version}`); - expect(html).toContain(new Date(timestamp).toLocaleString()); - expect(html).toContain('0x2'); - expect(getTransactionByVersion).not.toHaveBeenCalled(); - } - ); -}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 8622256..3294b91 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,7 +1,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ - esbuild: { jsx: 'automatic' }, resolve: { alias: { '@': import.meta.dirname From 56f1c21ebed7c783cc31e722fc805aad248d7614 Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Fri, 25 Sep 2026 14:19:06 -0300 Subject: [PATCH 4/5] fix(transactions): restore indexer-backed history with cache protection (cherry picked from commit efa092aa213a7e61ed7c3b6c56fe6528c34bd713) --- apps/web/actions/fetchCachedTransaction.ts | 14 - .../(dashboard)/transactions/page.tsx | 19 +- apps/web/components/TransactionRow.tsx | 83 +++--- apps/web/hooks/useMultisigExecutionEvents.ts | 88 ++++--- apps/web/operations/schemas/multisig.graphql | 1 + apps/web/tests/lib/executionHistory.test.ts | 247 ++++++++++++++++++ apps/web/vitest.config.ts | 1 + 7 files changed, 338 insertions(+), 115 deletions(-) delete mode 100644 apps/web/actions/fetchCachedTransaction.ts create mode 100644 apps/web/tests/lib/executionHistory.test.ts diff --git a/apps/web/actions/fetchCachedTransaction.ts b/apps/web/actions/fetchCachedTransaction.ts deleted file mode 100644 index 1d83bfd..0000000 --- a/apps/web/actions/fetchCachedTransaction.ts +++ /dev/null @@ -1,14 +0,0 @@ -'use server'; - -import { unstable_cache } from 'next/cache'; -import { createAptosServerClient } from '@/lib/aptos'; -import { Network, TransactionResponse } from '@aptos-labs/ts-sdk'; - -export const fetchCachedTransaction = unstable_cache( - async (version: number, network: Network): Promise => { - const aptos = createAptosServerClient(network); - return aptos.getTransactionByVersion({ ledgerVersion: version }); - }, - ['aptos-transaction'], - { revalidate: false } -); diff --git a/apps/web/app/(authenticated)/vault/[vaultId]/(dashboard)/transactions/page.tsx b/apps/web/app/(authenticated)/vault/[vaultId]/(dashboard)/transactions/page.tsx index dd9b87a..ec1725b 100644 --- a/apps/web/app/(authenticated)/vault/[vaultId]/(dashboard)/transactions/page.tsx +++ b/apps/web/app/(authenticated)/vault/[vaultId]/(dashboard)/transactions/page.tsx @@ -36,25 +36,17 @@ export default function VaultTransactionsPage() { if (!executionEvents) return undefined; // Sort transactions by timestamp in descending order (newest first) - const sortedEvents = executionEvents.pages.flat().sort((a, b) => { - const timestampA = a.transaction?.timestamp - ? Number(a.transaction.timestamp) / 1000 - : 0; - const timestampB = b.transaction?.timestamp - ? Number(b.transaction.timestamp) / 1000 - : 0; - return timestampB - timestampA; - }); + const sortedEvents = executionEvents.pages + .flatMap((page) => page.events) + .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); // Group by month const groupedByMonth: Record = {}; sortedEvents.forEach((event) => { - if (!event.transaction?.timestamp) return; + if (!event.timestamp) return; - const date = new Date( - Math.floor(Number(event.transaction.timestamp) / 1000) - ); + const date = new Date(event.timestamp); const monthYear = `${date.toLocaleString('default', { month: 'long' })} ${date.getFullYear()}`; if (!groupedByMonth[monthYear]) { @@ -153,7 +145,6 @@ export default function VaultTransactionsPage() { }} > diff --git a/apps/web/components/TransactionRow.tsx b/apps/web/components/TransactionRow.tsx index 622da48..ac7c2db 100644 --- a/apps/web/components/TransactionRow.tsx +++ b/apps/web/components/TransactionRow.tsx @@ -2,12 +2,7 @@ import { ExecutionEvent } from '@/hooks/useMultisigExecutionEvents'; import { getEntryFunctionDisplayName } from '@/lib/displayNames'; -import { - EntryFunctionPayloadResponse, - MultisigPayloadResponse, - Network, - UserTransactionResponse -} from '@aptos-labs/ts-sdk'; +import { EntryFunctionPayloadResponse, Network } from '@aptos-labs/ts-sdk'; import { CheckCircledIcon, CheckIcon, @@ -25,67 +20,63 @@ import AddressDisplay from './AddressDisplay'; import { deserializeMultisigTransactionPayload } from '@/lib/payloads'; interface TransactionRowProps { - transaction: UserTransactionResponse; + executionEvent: ExecutionEvent; network?: Network; - executionEvent?: ExecutionEvent; } export default function TransactionRow({ - transaction, - network, - executionEvent + executionEvent, + network }: TransactionRowProps) { - let transactionPayload: - | Pick - | undefined; + const sender = executionEvent.executor.toString(); - if (transaction.payload.type === 'entry_function_payload') { - transactionPayload = transaction.payload as EntryFunctionPayloadResponse; - } + const title = useMemo(() => { + if (executionEvent.payload) { + const payload = deserializeMultisigTransactionPayload( + executionEvent.payload + ) as Pick; + if (payload?.function) + return getEntryFunctionDisplayName(payload.function); + } - if (transaction.payload.type === 'multisig_payload') { - transactionPayload = (transaction.payload as MultisigPayloadResponse) - .transaction_payload; - } + // Rejected executions carry no payload, so fall back to a status label. + if (executionEvent.type === 'rejected') return 'Rejected transaction'; - if (!transactionPayload && executionEvent?.payload) { - transactionPayload = deserializeMultisigTransactionPayload( - executionEvent.payload - ) as Pick; - } + return undefined; + }, [executionEvent]); const statusTextColor = useMemo(() => { - if (executionEvent?.type === 'success') return 'text-green-700'; - if (executionEvent?.type === 'failed') return 'text-yellow-700'; - if (executionEvent?.type === 'rejected') + if (executionEvent.type === 'success') return 'text-green-700'; + if (executionEvent.type === 'failed') return 'text-yellow-700'; + if (executionEvent.type === 'rejected') return 'text-destructive-foreground'; return 'text-muted-foreground'; }, [executionEvent]); const statusBackgroundColor = useMemo(() => { - if (executionEvent?.type === 'success') return 'bg-green-500/20'; - if (executionEvent?.type === 'failed') return 'bg-yellow-500/20'; - if (executionEvent?.type === 'rejected') return 'bg-destructive/20'; + if (executionEvent.type === 'success') return 'bg-green-500/20'; + if (executionEvent.type === 'failed') return 'bg-yellow-500/20'; + if (executionEvent.type === 'rejected') return 'bg-destructive/20'; return 'bg-accent'; }, [executionEvent]); const statusIcon = useMemo(() => { - if (executionEvent?.type === 'success') + if (executionEvent.type === 'success') return ; - if (executionEvent?.type === 'failed') + if (executionEvent.type === 'failed') return ; - if (executionEvent?.type === 'rejected') + if (executionEvent.type === 'rejected') return ; return ; }, [executionEvent]); - if (!transactionPayload) return null; + if (!title) return null; return (

- {getEntryFunctionDisplayName(transactionPayload.function)} + {title}

- {transaction.timestamp && ( + {executionEvent.timestamp && ( - {new Date(Number(transaction.timestamp) / 1000).toLocaleString()} + {new Date(executionEvent.timestamp).toLocaleString()} )}

@@ -117,23 +108,23 @@ export default function TransactionRow({
- +

- +

- {executionEvent?.approvals !== undefined ? ( + {executionEvent.approvals !== undefined ? (
-

{executionEvent?.approvals}

+

{executionEvent.approvals}

) : null} - {executionEvent?.rejections !== undefined ? ( + {executionEvent.rejections !== undefined ? (
-

{executionEvent?.rejections}

+

{executionEvent.rejections}

) : null} diff --git a/apps/web/hooks/useMultisigExecutionEvents.ts b/apps/web/hooks/useMultisigExecutionEvents.ts index e769a25..8605bb9 100644 --- a/apps/web/hooks/useMultisigExecutionEvents.ts +++ b/apps/web/hooks/useMultisigExecutionEvents.ts @@ -1,13 +1,7 @@ -import { fetchCachedTransaction } from '@/actions/fetchCachedTransaction'; import { getMultisigIndexerClient } from '@/operations'; import { NetworkInfo, Order_By } from '@aptos-labs/js-pro'; import { useClients } from '@aptos-labs/react'; -import { - AccountAddress, - Network, - TransactionResponseType, - UserTransactionResponse -} from '@aptos-labs/ts-sdk'; +import { AccountAddress, Network } from '@aptos-labs/ts-sdk'; import { DefaultError, InfiniteData, @@ -22,20 +16,47 @@ const MULTISIG_EXECUTION_EVENTS_PAGE_SIZE = 15; export interface ExecutionEvent { type: 'success' | 'failed' | 'rejected'; version: string; + /** Execution time in milliseconds since epoch, from the indexer. */ + timestamp?: number; payload?: string; approvals?: number; rejections?: number; + /** The owner that executed the transaction, also its on-chain sender. */ executor: AccountAddress; sequenceNumber: number; - transaction: UserTransactionResponse; +} + +/** + * A single page of execution events. `rawCount` is the number of rows the + * indexer returned before per-row filtering, so pagination can decide whether + * more pages exist independently of how many rows survived. + */ +export interface MultisigExecutionEventsPage { + events: ExecutionEvent[]; + rawCount: number; +} + +/** + * Normalizes the indexer's transaction timestamp into milliseconds since epoch. + * The column may surface either raw microseconds (e.g. "1692491604335271") or an + * ISO-8601 string without a timezone (e.g. "2026-08-20T00:33:24.335271", which + * is UTC), so both are handled. + */ +function timestampToMillis(timestamp: unknown): number | undefined { + if (timestamp === null || timestamp === undefined) return undefined; + const value = String(timestamp); + if (/^\d+$/.test(value)) return Number(value) / 1000; + const normalized = /[zZ]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value}Z`; + const millis = Date.parse(normalized); + return Number.isNaN(millis) ? undefined : millis; } interface UseMultisigExecutionEventsParameters extends Omit< UseInfiniteQueryOptions< - ExecutionEvent[], + MultisigExecutionEventsPage, DefaultError, - InfiniteData, + InfiniteData, QueryKey, number >, @@ -64,7 +85,8 @@ export default function useMultisigExecutionEvents({ return useInfiniteQuery({ ...options, enabled, - queryKey: ['multisig-execution-events', address, network], + // Persisted array pages cannot be read as { events, rawCount } pages. + queryKey: ['multisig-execution-events', address, network, 'v2'], initialPageParam: 0, queryFn: async ({ pageParam = 0 }) => { const resolvedNetwork = network?.network ?? aptos.config.network; @@ -75,7 +97,7 @@ export default function useMultisigExecutionEvents({ console.error( `Multisig indexer client is unavailable for this network: ${network}.` ); - return []; + return { events: [], rawCount: 0 }; } const { multisig_transactions: multisigTransactions } = @@ -87,31 +109,11 @@ export default function useMultisigExecutionEvents({ offset: pageParam }); - const userTransactions = await Promise.all( - multisigTransactions.map((e) => - fetchCachedTransaction(Number(e.version), resolvedNetwork) - ) - ); - - return multisigTransactions.reduce((acc, multisigTransaction) => { - const userTransaction = userTransactions.find( - (t) => - t.type === TransactionResponseType.User && - t.version === multisigTransaction.version - ); - - if ( - !userTransaction || - userTransaction.type !== TransactionResponseType.User - ) { - return acc; - } - - // Normalize the sender address to fix zero prefixed addresses - userTransaction.sender = AccountAddress.from( - userTransaction.sender - ).toString(); - + // Everything the UI renders (payload, timestamp, executor/sender, status) + // is already on the indexer row, so build the events directly instead of + // re-fetching each transaction from the fullnode — which is heavier and + // fails once old versions get pruned (HTTP 410). + const events = multisigTransactions.reduce((acc, multisigTransaction) => { let status: 'success' | 'failed' | 'rejected'; switch (multisigTransaction.event_type) { case `${LONG_FRAMEWORK_ADDRESS}::multisig_account::TransactionExecutionSucceeded`: @@ -136,6 +138,7 @@ export default function useMultisigExecutionEvents({ acc.push({ type: status, version: multisigTransaction.version, + timestamp: timestampToMillis(multisigTransaction.timestamp), payload: multisigTransaction.transaction_payload as | string | undefined, @@ -146,17 +149,20 @@ export default function useMultisigExecutionEvents({ ? Number(multisigTransaction.num_rejections) : undefined, executor: AccountAddress.from(multisigTransaction.executor!), - sequenceNumber: Number(multisigTransaction.sequence_number), - transaction: userTransaction + sequenceNumber: Number(multisigTransaction.sequence_number) }); return acc; }, [] as ExecutionEvent[]); + + return { events, rawCount: multisigTransactions.length }; }, getPreviousPageParam: (_, __, ___, allPageParams) => allPageParams.at(-1), + // Decide whether more pages exist from the raw indexer row count, not the + // filtered `events` length. Rows can drop (unknown event types), and using + // the filtered length here would halt pagination early and hide history. getNextPageParam: (lastPage, _, lastPageParam) => - lastPage.length === 0 || - lastPage.length !== MULTISIG_EXECUTION_EVENTS_PAGE_SIZE + lastPage.rawCount < MULTISIG_EXECUTION_EVENTS_PAGE_SIZE ? undefined : lastPageParam + MULTISIG_EXECUTION_EVENTS_PAGE_SIZE }); diff --git a/apps/web/operations/schemas/multisig.graphql b/apps/web/operations/schemas/multisig.graphql index d622499..50dc110 100644 --- a/apps/web/operations/schemas/multisig.graphql +++ b/apps/web/operations/schemas/multisig.graphql @@ -17,6 +17,7 @@ query getMultisigTransactions( num_approvals num_rejections sequence_number + timestamp transaction_payload version } diff --git a/apps/web/tests/lib/executionHistory.test.ts b/apps/web/tests/lib/executionHistory.test.ts new file mode 100644 index 0000000..6d58a84 --- /dev/null +++ b/apps/web/tests/lib/executionHistory.test.ts @@ -0,0 +1,247 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + dehydrate, + hydrate, + QueryClient, + QueryClientProvider +} from '@tanstack/react-query'; +import { + AccountAddress, + EntryFunction, + MultiSigTransactionPayload, + Network +} from '@aptos-labs/ts-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import useMultisigExecutionEvents, { + type ExecutionEvent +} from '@/hooks/useMultisigExecutionEvents'; +import TransactionRow from '@/components/TransactionRow'; +import { LONG_FRAMEWORK_ADDRESS } from '@/lib/constants'; +import { storageOptionsSerializers } from '@/lib/storage'; + +const { getMultisigTransactions, getTransactionByVersion } = vi.hoisted(() => ({ + getMultisigTransactions: vi.fn(), + getTransactionByVersion: vi.fn() +})); + +vi.mock('@/operations', () => ({ + getMultisigIndexerClient: () => ({ getMultisigTransactions }) +})); + +vi.mock('@aptos-labs/react', () => ({ + useClients: () => ({ + aptos: { + config: { network: 'mainnet' }, + getTransactionByVersion + } + }), + useNameFromAddress: () => ({ data: undefined }) +})); + +const address = '0x1'; +const network = { network: Network.MAINNET }; +const timestamp = Date.parse('2026-08-20T00:33:24.335Z'); +const payload = new MultiSigTransactionPayload( + EntryFunction.build('0x1::aptos_account', 'transfer', [], []) +) + .bcsToHex() + .toString(); +const row = { + version: '7350673760', + event_type: `${LONG_FRAMEWORK_ADDRESS}::multisig_account::TransactionExecutionSucceeded`, + timestamp: '2026-08-20T00:33:24.335000', + transaction_payload: payload, + executor: '0x2', + sequence_number: '7', + num_approvals: '2', + num_rejections: '0' +}; + +let client: QueryClient; + +beforeEach(() => { + vi.resetAllMocks(); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + getMultisigTransactions.mockResolvedValue({ multisig_transactions: [row] }); + getTransactionByVersion.mockRejectedValue(new Error('HTTP 410: pruned')); +}); + +afterEach(() => { + client.clear(); + vi.restoreAllMocks(); +}); + +function renderHistory() { + let result: ReturnType | undefined; + function Probe() { + result = useMultisigExecutionEvents({ address, network }); + return null; + } + renderToStaticMarkup( + createElement(QueryClientProvider, { client }, createElement(Probe)) + ); + if (!result) throw new Error('History hook did not render'); + return result; +} + +describe('execution history', () => { + it.each([ + { name: 'empty', page: [] }, + { + name: 'populated', + page: [ + { version: row.version, transaction: { timestamp: '1787186004335000' } } + ] + } + ])( + 'ignores restored $name array pages from the previous cache format', + async ({ page }) => { + const oldKey = ['multisig-execution-events', address, network]; + const oldData = { pages: [page], pageParams: [0] }; + client.setQueryData(oldKey, oldData); + const stored = JSON.stringify(dehydrate(client)); + client.clear(); + hydrate(client, JSON.parse(stored)); + expect(client.getQueryData(oldKey)).toEqual(oldData); + + const history = renderHistory(); + expect(history.data).toBeUndefined(); + const refreshed = await history.refetch(); + expect(refreshed.data?.pages[0]).toMatchObject({ + rawCount: 1, + events: [{ version: row.version, timestamp }] + }); + } + ); + + it('restores the current cache format with usable executor addresses', async () => { + await renderHistory().refetch(); + const stored = JSON.stringify( + dehydrate(client), + storageOptionsSerializers.replacer + ); + client.clear(); + hydrate(client, JSON.parse(stored, storageOptionsSerializers.reviver)); + + const event = renderHistory().data?.pages[0]?.events[0]; + expect(event?.timestamp).toBe(timestamp); + expect(event?.executor.toString()).toBe(row.executor); + }); + + it.each([ + ['1787186004335000', timestamp], + [1787186004335000, timestamp], + ['2026-08-20T00:33:24.335000', timestamp], + ['2026-08-20T00:33:24.335Z', timestamp], + ['2026-08-19T21:33:24.335-03:00', timestamp], + [null, undefined], + ['invalid', undefined] + ])( + 'converts indexer timestamp %j to milliseconds', + async (value, expected) => { + getMultisigTransactions.mockResolvedValue({ + multisig_transactions: [{ ...row, timestamp: value }] + }); + const result = await renderHistory().refetch(); + expect(result.isSuccess).toBe(true); + expect(result.data?.pages[0]?.events[0]).toMatchObject({ + timestamp: expected + }); + } + ); + + it.each([ + ['TransactionExecutionSucceeded', 'success'], + ['TransactionExecutionSucceededEvent', 'success'], + ['TransactionExecutionFailed', 'failed'], + ['TransactionExecutionFailedEvent', 'failed'], + ['ExecuteRejectedTransaction', 'rejected'], + ['ExecuteRejectedTransactionEvent', 'rejected'] + ])( + 'reads %s entirely from the indexer, even when the fullnode has pruned it', + async (event, type) => { + getMultisigTransactions.mockResolvedValue({ + multisig_transactions: [ + { + ...row, + event_type: `${LONG_FRAMEWORK_ADDRESS}::multisig_account::${event}`, + transaction_payload: type === 'rejected' ? null : payload + } + ] + }); + const result = await renderHistory().refetch(); + expect(result.isSuccess).toBe(true); + expect(result.data?.pages[0]?.events[0]).toMatchObject({ + type, + version: row.version, + timestamp, + executor: AccountAddress.from('0x2'), + sequenceNumber: 7, + approvals: 2, + rejections: 0 + }); + expect(getTransactionByVersion).not.toHaveBeenCalled(); + } + ); + + it('continues after a full page containing an unknown event and stops after a short page', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + getMultisigTransactions + .mockResolvedValueOnce({ + multisig_transactions: Array.from({ length: 15 }, (_, index) => ({ + ...row, + version: String(100 - index), + event_type: index === 0 ? 'unknown' : row.event_type + })) + }) + .mockResolvedValueOnce({ multisig_transactions: [row] }); + const first = await renderHistory().fetchNextPage(); + expect(first.data?.pages[0]?.events).toHaveLength(14); + expect(first.hasNextPage).toBe(true); + expect(error).toHaveBeenCalledWith('Unknown event type: unknown'); + + const second = await first.fetchNextPage(); + expect(getMultisigTransactions).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + multisigAccount: address, + offset: 15, + limit: 15 + }) + ); + expect(second.data?.pages).toHaveLength(2); + expect(second.data?.pages[1]?.events[0]?.version).toBe(row.version); + expect(second.hasNextPage).toBe(false); + }); +}); + +describe('transaction rows', () => { + it.each(['success', 'failed', 'rejected'])( + 'renders a %s execution using only indexer fields', + (type) => { + const html = renderToStaticMarkup( + createElement(TransactionRow, { + network: Network.MAINNET, + executionEvent: { + type, + version: row.version, + timestamp, + payload: type === 'rejected' ? undefined : payload, + executor: AccountAddress.from(row.executor), + sequenceNumber: 7, + approvals: 2, + rejections: 0 + } + }) + ); + expect(html).toContain( + type === 'rejected' ? 'Rejected transaction' : 'Transfer APT' + ); + expect(html).toContain(`txn/${row.version}`); + expect(html).toContain(new Date(timestamp).toLocaleString()); + expect(html).toContain('0x2'); + expect(getTransactionByVersion).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 3294b91..8622256 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ + esbuild: { jsx: 'automatic' }, resolve: { alias: { '@': import.meta.dirname From 0ba827fbf2b6d55ab7160e21b3ac40120051cbdc Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Fri, 25 Sep 2026 15:28:11 -0300 Subject: [PATCH 5/5] chore: limit multisig changes to indexer configuration --- apps/web/actions/fetchCachedTransaction.ts | 14 + .../(dashboard)/transactions/page.tsx | 19 +- apps/web/components/TransactionRow.tsx | 83 +++--- apps/web/hooks/useMultisigExecutionEvents.ts | 88 +++---- apps/web/operations/schemas/multisig.graphql | 1 - apps/web/tests/lib/executionHistory.test.ts | 247 ------------------ apps/web/vitest.config.ts | 1 - 7 files changed, 115 insertions(+), 338 deletions(-) create mode 100644 apps/web/actions/fetchCachedTransaction.ts delete mode 100644 apps/web/tests/lib/executionHistory.test.ts diff --git a/apps/web/actions/fetchCachedTransaction.ts b/apps/web/actions/fetchCachedTransaction.ts new file mode 100644 index 0000000..1d83bfd --- /dev/null +++ b/apps/web/actions/fetchCachedTransaction.ts @@ -0,0 +1,14 @@ +'use server'; + +import { unstable_cache } from 'next/cache'; +import { createAptosServerClient } from '@/lib/aptos'; +import { Network, TransactionResponse } from '@aptos-labs/ts-sdk'; + +export const fetchCachedTransaction = unstable_cache( + async (version: number, network: Network): Promise => { + const aptos = createAptosServerClient(network); + return aptos.getTransactionByVersion({ ledgerVersion: version }); + }, + ['aptos-transaction'], + { revalidate: false } +); diff --git a/apps/web/app/(authenticated)/vault/[vaultId]/(dashboard)/transactions/page.tsx b/apps/web/app/(authenticated)/vault/[vaultId]/(dashboard)/transactions/page.tsx index ec1725b..dd9b87a 100644 --- a/apps/web/app/(authenticated)/vault/[vaultId]/(dashboard)/transactions/page.tsx +++ b/apps/web/app/(authenticated)/vault/[vaultId]/(dashboard)/transactions/page.tsx @@ -36,17 +36,25 @@ export default function VaultTransactionsPage() { if (!executionEvents) return undefined; // Sort transactions by timestamp in descending order (newest first) - const sortedEvents = executionEvents.pages - .flatMap((page) => page.events) - .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + const sortedEvents = executionEvents.pages.flat().sort((a, b) => { + const timestampA = a.transaction?.timestamp + ? Number(a.transaction.timestamp) / 1000 + : 0; + const timestampB = b.transaction?.timestamp + ? Number(b.transaction.timestamp) / 1000 + : 0; + return timestampB - timestampA; + }); // Group by month const groupedByMonth: Record = {}; sortedEvents.forEach((event) => { - if (!event.timestamp) return; + if (!event.transaction?.timestamp) return; - const date = new Date(event.timestamp); + const date = new Date( + Math.floor(Number(event.transaction.timestamp) / 1000) + ); const monthYear = `${date.toLocaleString('default', { month: 'long' })} ${date.getFullYear()}`; if (!groupedByMonth[monthYear]) { @@ -145,6 +153,7 @@ export default function VaultTransactionsPage() { }} > diff --git a/apps/web/components/TransactionRow.tsx b/apps/web/components/TransactionRow.tsx index ac7c2db..622da48 100644 --- a/apps/web/components/TransactionRow.tsx +++ b/apps/web/components/TransactionRow.tsx @@ -2,7 +2,12 @@ import { ExecutionEvent } from '@/hooks/useMultisigExecutionEvents'; import { getEntryFunctionDisplayName } from '@/lib/displayNames'; -import { EntryFunctionPayloadResponse, Network } from '@aptos-labs/ts-sdk'; +import { + EntryFunctionPayloadResponse, + MultisigPayloadResponse, + Network, + UserTransactionResponse +} from '@aptos-labs/ts-sdk'; import { CheckCircledIcon, CheckIcon, @@ -20,63 +25,67 @@ import AddressDisplay from './AddressDisplay'; import { deserializeMultisigTransactionPayload } from '@/lib/payloads'; interface TransactionRowProps { - executionEvent: ExecutionEvent; + transaction: UserTransactionResponse; network?: Network; + executionEvent?: ExecutionEvent; } export default function TransactionRow({ - executionEvent, - network + transaction, + network, + executionEvent }: TransactionRowProps) { - const sender = executionEvent.executor.toString(); + let transactionPayload: + | Pick + | undefined; - const title = useMemo(() => { - if (executionEvent.payload) { - const payload = deserializeMultisigTransactionPayload( - executionEvent.payload - ) as Pick; - if (payload?.function) - return getEntryFunctionDisplayName(payload.function); - } + if (transaction.payload.type === 'entry_function_payload') { + transactionPayload = transaction.payload as EntryFunctionPayloadResponse; + } - // Rejected executions carry no payload, so fall back to a status label. - if (executionEvent.type === 'rejected') return 'Rejected transaction'; + if (transaction.payload.type === 'multisig_payload') { + transactionPayload = (transaction.payload as MultisigPayloadResponse) + .transaction_payload; + } - return undefined; - }, [executionEvent]); + if (!transactionPayload && executionEvent?.payload) { + transactionPayload = deserializeMultisigTransactionPayload( + executionEvent.payload + ) as Pick; + } const statusTextColor = useMemo(() => { - if (executionEvent.type === 'success') return 'text-green-700'; - if (executionEvent.type === 'failed') return 'text-yellow-700'; - if (executionEvent.type === 'rejected') + if (executionEvent?.type === 'success') return 'text-green-700'; + if (executionEvent?.type === 'failed') return 'text-yellow-700'; + if (executionEvent?.type === 'rejected') return 'text-destructive-foreground'; return 'text-muted-foreground'; }, [executionEvent]); const statusBackgroundColor = useMemo(() => { - if (executionEvent.type === 'success') return 'bg-green-500/20'; - if (executionEvent.type === 'failed') return 'bg-yellow-500/20'; - if (executionEvent.type === 'rejected') return 'bg-destructive/20'; + if (executionEvent?.type === 'success') return 'bg-green-500/20'; + if (executionEvent?.type === 'failed') return 'bg-yellow-500/20'; + if (executionEvent?.type === 'rejected') return 'bg-destructive/20'; return 'bg-accent'; }, [executionEvent]); const statusIcon = useMemo(() => { - if (executionEvent.type === 'success') + if (executionEvent?.type === 'success') return ; - if (executionEvent.type === 'failed') + if (executionEvent?.type === 'failed') return ; - if (executionEvent.type === 'rejected') + if (executionEvent?.type === 'rejected') return ; return ; }, [executionEvent]); - if (!title) return null; + if (!transactionPayload) return null; return (

- {title} + {getEntryFunctionDisplayName(transactionPayload.function)}

- {executionEvent.timestamp && ( + {transaction.timestamp && ( - {new Date(executionEvent.timestamp).toLocaleString()} + {new Date(Number(transaction.timestamp) / 1000).toLocaleString()} )}

@@ -108,23 +117,23 @@ export default function TransactionRow({
- +

- +

- {executionEvent.approvals !== undefined ? ( + {executionEvent?.approvals !== undefined ? (
-

{executionEvent.approvals}

+

{executionEvent?.approvals}

) : null} - {executionEvent.rejections !== undefined ? ( + {executionEvent?.rejections !== undefined ? (
-

{executionEvent.rejections}

+

{executionEvent?.rejections}

) : null} diff --git a/apps/web/hooks/useMultisigExecutionEvents.ts b/apps/web/hooks/useMultisigExecutionEvents.ts index 8605bb9..e769a25 100644 --- a/apps/web/hooks/useMultisigExecutionEvents.ts +++ b/apps/web/hooks/useMultisigExecutionEvents.ts @@ -1,7 +1,13 @@ +import { fetchCachedTransaction } from '@/actions/fetchCachedTransaction'; import { getMultisigIndexerClient } from '@/operations'; import { NetworkInfo, Order_By } from '@aptos-labs/js-pro'; import { useClients } from '@aptos-labs/react'; -import { AccountAddress, Network } from '@aptos-labs/ts-sdk'; +import { + AccountAddress, + Network, + TransactionResponseType, + UserTransactionResponse +} from '@aptos-labs/ts-sdk'; import { DefaultError, InfiniteData, @@ -16,47 +22,20 @@ const MULTISIG_EXECUTION_EVENTS_PAGE_SIZE = 15; export interface ExecutionEvent { type: 'success' | 'failed' | 'rejected'; version: string; - /** Execution time in milliseconds since epoch, from the indexer. */ - timestamp?: number; payload?: string; approvals?: number; rejections?: number; - /** The owner that executed the transaction, also its on-chain sender. */ executor: AccountAddress; sequenceNumber: number; -} - -/** - * A single page of execution events. `rawCount` is the number of rows the - * indexer returned before per-row filtering, so pagination can decide whether - * more pages exist independently of how many rows survived. - */ -export interface MultisigExecutionEventsPage { - events: ExecutionEvent[]; - rawCount: number; -} - -/** - * Normalizes the indexer's transaction timestamp into milliseconds since epoch. - * The column may surface either raw microseconds (e.g. "1692491604335271") or an - * ISO-8601 string without a timezone (e.g. "2026-08-20T00:33:24.335271", which - * is UTC), so both are handled. - */ -function timestampToMillis(timestamp: unknown): number | undefined { - if (timestamp === null || timestamp === undefined) return undefined; - const value = String(timestamp); - if (/^\d+$/.test(value)) return Number(value) / 1000; - const normalized = /[zZ]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value}Z`; - const millis = Date.parse(normalized); - return Number.isNaN(millis) ? undefined : millis; + transaction: UserTransactionResponse; } interface UseMultisigExecutionEventsParameters extends Omit< UseInfiniteQueryOptions< - MultisigExecutionEventsPage, + ExecutionEvent[], DefaultError, - InfiniteData, + InfiniteData, QueryKey, number >, @@ -85,8 +64,7 @@ export default function useMultisigExecutionEvents({ return useInfiniteQuery({ ...options, enabled, - // Persisted array pages cannot be read as { events, rawCount } pages. - queryKey: ['multisig-execution-events', address, network, 'v2'], + queryKey: ['multisig-execution-events', address, network], initialPageParam: 0, queryFn: async ({ pageParam = 0 }) => { const resolvedNetwork = network?.network ?? aptos.config.network; @@ -97,7 +75,7 @@ export default function useMultisigExecutionEvents({ console.error( `Multisig indexer client is unavailable for this network: ${network}.` ); - return { events: [], rawCount: 0 }; + return []; } const { multisig_transactions: multisigTransactions } = @@ -109,11 +87,31 @@ export default function useMultisigExecutionEvents({ offset: pageParam }); - // Everything the UI renders (payload, timestamp, executor/sender, status) - // is already on the indexer row, so build the events directly instead of - // re-fetching each transaction from the fullnode — which is heavier and - // fails once old versions get pruned (HTTP 410). - const events = multisigTransactions.reduce((acc, multisigTransaction) => { + const userTransactions = await Promise.all( + multisigTransactions.map((e) => + fetchCachedTransaction(Number(e.version), resolvedNetwork) + ) + ); + + return multisigTransactions.reduce((acc, multisigTransaction) => { + const userTransaction = userTransactions.find( + (t) => + t.type === TransactionResponseType.User && + t.version === multisigTransaction.version + ); + + if ( + !userTransaction || + userTransaction.type !== TransactionResponseType.User + ) { + return acc; + } + + // Normalize the sender address to fix zero prefixed addresses + userTransaction.sender = AccountAddress.from( + userTransaction.sender + ).toString(); + let status: 'success' | 'failed' | 'rejected'; switch (multisigTransaction.event_type) { case `${LONG_FRAMEWORK_ADDRESS}::multisig_account::TransactionExecutionSucceeded`: @@ -138,7 +136,6 @@ export default function useMultisigExecutionEvents({ acc.push({ type: status, version: multisigTransaction.version, - timestamp: timestampToMillis(multisigTransaction.timestamp), payload: multisigTransaction.transaction_payload as | string | undefined, @@ -149,20 +146,17 @@ export default function useMultisigExecutionEvents({ ? Number(multisigTransaction.num_rejections) : undefined, executor: AccountAddress.from(multisigTransaction.executor!), - sequenceNumber: Number(multisigTransaction.sequence_number) + sequenceNumber: Number(multisigTransaction.sequence_number), + transaction: userTransaction }); return acc; }, [] as ExecutionEvent[]); - - return { events, rawCount: multisigTransactions.length }; }, getPreviousPageParam: (_, __, ___, allPageParams) => allPageParams.at(-1), - // Decide whether more pages exist from the raw indexer row count, not the - // filtered `events` length. Rows can drop (unknown event types), and using - // the filtered length here would halt pagination early and hide history. getNextPageParam: (lastPage, _, lastPageParam) => - lastPage.rawCount < MULTISIG_EXECUTION_EVENTS_PAGE_SIZE + lastPage.length === 0 || + lastPage.length !== MULTISIG_EXECUTION_EVENTS_PAGE_SIZE ? undefined : lastPageParam + MULTISIG_EXECUTION_EVENTS_PAGE_SIZE }); diff --git a/apps/web/operations/schemas/multisig.graphql b/apps/web/operations/schemas/multisig.graphql index 50dc110..d622499 100644 --- a/apps/web/operations/schemas/multisig.graphql +++ b/apps/web/operations/schemas/multisig.graphql @@ -17,7 +17,6 @@ query getMultisigTransactions( num_approvals num_rejections sequence_number - timestamp transaction_payload version } diff --git a/apps/web/tests/lib/executionHistory.test.ts b/apps/web/tests/lib/executionHistory.test.ts deleted file mode 100644 index 6d58a84..0000000 --- a/apps/web/tests/lib/executionHistory.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { createElement } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { - dehydrate, - hydrate, - QueryClient, - QueryClientProvider -} from '@tanstack/react-query'; -import { - AccountAddress, - EntryFunction, - MultiSigTransactionPayload, - Network -} from '@aptos-labs/ts-sdk'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import useMultisigExecutionEvents, { - type ExecutionEvent -} from '@/hooks/useMultisigExecutionEvents'; -import TransactionRow from '@/components/TransactionRow'; -import { LONG_FRAMEWORK_ADDRESS } from '@/lib/constants'; -import { storageOptionsSerializers } from '@/lib/storage'; - -const { getMultisigTransactions, getTransactionByVersion } = vi.hoisted(() => ({ - getMultisigTransactions: vi.fn(), - getTransactionByVersion: vi.fn() -})); - -vi.mock('@/operations', () => ({ - getMultisigIndexerClient: () => ({ getMultisigTransactions }) -})); - -vi.mock('@aptos-labs/react', () => ({ - useClients: () => ({ - aptos: { - config: { network: 'mainnet' }, - getTransactionByVersion - } - }), - useNameFromAddress: () => ({ data: undefined }) -})); - -const address = '0x1'; -const network = { network: Network.MAINNET }; -const timestamp = Date.parse('2026-08-20T00:33:24.335Z'); -const payload = new MultiSigTransactionPayload( - EntryFunction.build('0x1::aptos_account', 'transfer', [], []) -) - .bcsToHex() - .toString(); -const row = { - version: '7350673760', - event_type: `${LONG_FRAMEWORK_ADDRESS}::multisig_account::TransactionExecutionSucceeded`, - timestamp: '2026-08-20T00:33:24.335000', - transaction_payload: payload, - executor: '0x2', - sequence_number: '7', - num_approvals: '2', - num_rejections: '0' -}; - -let client: QueryClient; - -beforeEach(() => { - vi.resetAllMocks(); - client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - getMultisigTransactions.mockResolvedValue({ multisig_transactions: [row] }); - getTransactionByVersion.mockRejectedValue(new Error('HTTP 410: pruned')); -}); - -afterEach(() => { - client.clear(); - vi.restoreAllMocks(); -}); - -function renderHistory() { - let result: ReturnType | undefined; - function Probe() { - result = useMultisigExecutionEvents({ address, network }); - return null; - } - renderToStaticMarkup( - createElement(QueryClientProvider, { client }, createElement(Probe)) - ); - if (!result) throw new Error('History hook did not render'); - return result; -} - -describe('execution history', () => { - it.each([ - { name: 'empty', page: [] }, - { - name: 'populated', - page: [ - { version: row.version, transaction: { timestamp: '1787186004335000' } } - ] - } - ])( - 'ignores restored $name array pages from the previous cache format', - async ({ page }) => { - const oldKey = ['multisig-execution-events', address, network]; - const oldData = { pages: [page], pageParams: [0] }; - client.setQueryData(oldKey, oldData); - const stored = JSON.stringify(dehydrate(client)); - client.clear(); - hydrate(client, JSON.parse(stored)); - expect(client.getQueryData(oldKey)).toEqual(oldData); - - const history = renderHistory(); - expect(history.data).toBeUndefined(); - const refreshed = await history.refetch(); - expect(refreshed.data?.pages[0]).toMatchObject({ - rawCount: 1, - events: [{ version: row.version, timestamp }] - }); - } - ); - - it('restores the current cache format with usable executor addresses', async () => { - await renderHistory().refetch(); - const stored = JSON.stringify( - dehydrate(client), - storageOptionsSerializers.replacer - ); - client.clear(); - hydrate(client, JSON.parse(stored, storageOptionsSerializers.reviver)); - - const event = renderHistory().data?.pages[0]?.events[0]; - expect(event?.timestamp).toBe(timestamp); - expect(event?.executor.toString()).toBe(row.executor); - }); - - it.each([ - ['1787186004335000', timestamp], - [1787186004335000, timestamp], - ['2026-08-20T00:33:24.335000', timestamp], - ['2026-08-20T00:33:24.335Z', timestamp], - ['2026-08-19T21:33:24.335-03:00', timestamp], - [null, undefined], - ['invalid', undefined] - ])( - 'converts indexer timestamp %j to milliseconds', - async (value, expected) => { - getMultisigTransactions.mockResolvedValue({ - multisig_transactions: [{ ...row, timestamp: value }] - }); - const result = await renderHistory().refetch(); - expect(result.isSuccess).toBe(true); - expect(result.data?.pages[0]?.events[0]).toMatchObject({ - timestamp: expected - }); - } - ); - - it.each([ - ['TransactionExecutionSucceeded', 'success'], - ['TransactionExecutionSucceededEvent', 'success'], - ['TransactionExecutionFailed', 'failed'], - ['TransactionExecutionFailedEvent', 'failed'], - ['ExecuteRejectedTransaction', 'rejected'], - ['ExecuteRejectedTransactionEvent', 'rejected'] - ])( - 'reads %s entirely from the indexer, even when the fullnode has pruned it', - async (event, type) => { - getMultisigTransactions.mockResolvedValue({ - multisig_transactions: [ - { - ...row, - event_type: `${LONG_FRAMEWORK_ADDRESS}::multisig_account::${event}`, - transaction_payload: type === 'rejected' ? null : payload - } - ] - }); - const result = await renderHistory().refetch(); - expect(result.isSuccess).toBe(true); - expect(result.data?.pages[0]?.events[0]).toMatchObject({ - type, - version: row.version, - timestamp, - executor: AccountAddress.from('0x2'), - sequenceNumber: 7, - approvals: 2, - rejections: 0 - }); - expect(getTransactionByVersion).not.toHaveBeenCalled(); - } - ); - - it('continues after a full page containing an unknown event and stops after a short page', async () => { - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - getMultisigTransactions - .mockResolvedValueOnce({ - multisig_transactions: Array.from({ length: 15 }, (_, index) => ({ - ...row, - version: String(100 - index), - event_type: index === 0 ? 'unknown' : row.event_type - })) - }) - .mockResolvedValueOnce({ multisig_transactions: [row] }); - const first = await renderHistory().fetchNextPage(); - expect(first.data?.pages[0]?.events).toHaveLength(14); - expect(first.hasNextPage).toBe(true); - expect(error).toHaveBeenCalledWith('Unknown event type: unknown'); - - const second = await first.fetchNextPage(); - expect(getMultisigTransactions).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - multisigAccount: address, - offset: 15, - limit: 15 - }) - ); - expect(second.data?.pages).toHaveLength(2); - expect(second.data?.pages[1]?.events[0]?.version).toBe(row.version); - expect(second.hasNextPage).toBe(false); - }); -}); - -describe('transaction rows', () => { - it.each(['success', 'failed', 'rejected'])( - 'renders a %s execution using only indexer fields', - (type) => { - const html = renderToStaticMarkup( - createElement(TransactionRow, { - network: Network.MAINNET, - executionEvent: { - type, - version: row.version, - timestamp, - payload: type === 'rejected' ? undefined : payload, - executor: AccountAddress.from(row.executor), - sequenceNumber: 7, - approvals: 2, - rejections: 0 - } - }) - ); - expect(html).toContain( - type === 'rejected' ? 'Rejected transaction' : 'Transfer APT' - ); - expect(html).toContain(`txn/${row.version}`); - expect(html).toContain(new Date(timestamp).toLocaleString()); - expect(html).toContain('0x2'); - expect(getTransactionByVersion).not.toHaveBeenCalled(); - } - ); -}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 8622256..3294b91 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,7 +1,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ - esbuild: { jsx: 'automatic' }, resolve: { alias: { '@': import.meta.dirname