From b83a3a1063c366c6a1ef8bdad5ea91634e6ab995 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. --- 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 615d44124ef7a5fbd981d4ba915acc2699a4a816 Mon Sep 17 00:00:00 2001 From: maykonmichel Date: Fri, 4 Sep 2026 10:31:16 -0700 Subject: [PATCH 2/5] feat(transactions): serve execution history from the indexer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The history hook re-fetched every transaction from the fullnode via getTransactionByVersion just to read timestamp and sender: heavy (one full transaction per row) and broken for aged vaults whose versions the fullnode has pruned (HTTP 410), which left the whole page blank. Everything the row renders is already indexed: payload, executor (== the on-chain sender), status and approval counts. With the new multisig_transactions.timestamp column the last missing field is covered too, so the hook now builds execution events directly from the indexer rows — no fullnode calls — collapsing each page to the single indexer query it already made and removing the pruning dependency entirely. TransactionRow renders from the execution event alone; rejected executions carry no payload, so they fall back to a "Rejected transaction" label. The unused fetchCachedTransaction action is removed. Deploy ordering: the indexer processors must be redeployed with the timestamp column before the web app is built, since graphql-codegen validates the query against the live indexer schema. --- apps/web/actions/fetchCachedTransaction.ts | 14 --- .../(dashboard)/transactions/page.tsx | 19 ++--- apps/web/components/TransactionRow.tsx | 83 ++++++++---------- apps/web/hooks/useMultisigExecutionEvents.ts | 85 ++++++++++--------- apps/web/operations/schemas/multisig.graphql | 1 + 5 files changed, 88 insertions(+), 114 deletions(-) delete mode 100644 apps/web/actions/fetchCachedTransaction.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..482ac83 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 >, @@ -75,7 +96,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 +108,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 +137,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 +148,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 } From b7004fb4c8376da652eb823732d829164b25b5bb Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Fri, 25 Sep 2026 14:02:24 -0300 Subject: [PATCH 3/5] fix(vault): version history cache and add discovery probe coverage --- 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 482ac83..8605bb9 100644 --- a/apps/web/hooks/useMultisigExecutionEvents.ts +++ b/apps/web/hooks/useMultisigExecutionEvents.ts @@ -85,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; 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 9b9353a2cd0afb68b1c4d46ad39eb18ce6537942 Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Fri, 25 Sep 2026 14:15:28 -0300 Subject: [PATCH 4/5] fix(indexer): promote validated initial-owner discovery mapping --- 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/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 - 17 files changed, 516 insertions(+), 1099 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 create mode 100644 apps/web/actions/fetchCachedTransaction.ts 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/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 From efa092aa213a7e61ed7c3b6c56fe6528c34bd713 Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Fri, 25 Sep 2026 14:19:06 -0300 Subject: [PATCH 5/5] fix(transactions): restore indexer-backed history with cache protection --- 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