From 9b72b08c2d8a756aab746df047918835e9e67b77 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 20:18:09 -0300 Subject: [PATCH] feat(wallet): humanize shared wallet experience --- Cargo.lock | 11 + Cargo.toml | 1 + apps/amm/CMakeLists.txt | 35 + apps/amm/config/idl/amm-idl.json | 791 ++++++++++++++++++ apps/amm/config/idl/token-idl.json | 398 +++++++++ apps/amm/config/networks.json | 18 + apps/amm/flake.lock | 36 +- apps/amm/flake.nix | 13 +- apps/amm/metadata.json | 6 +- apps/amm/src/ActiveNetwork.cpp | 139 +++ apps/amm/src/ActiveNetwork.h | 36 + apps/amm/src/AmmUiBackend.cpp | 374 ++++++++- apps/amm/src/AmmUiBackend.h | 29 + apps/amm/src/AmmUiBackend.rep | 15 + apps/amm/src/WalletIdlDecoder.cpp | 100 +++ apps/amm/src/WalletIdlDecoder.h | 54 ++ apps/amm/tests/cpp/ActiveNetworkTest.cpp | 47 ++ apps/shared/wallet/CMakeLists.txt | 4 + apps/shared/wallet/qml/WalletControl.qml | 689 +++++++++++---- .../wallet/qml/internal/AccountDelegate.qml | 101 ++- .../shared/wallet/src/LogosWalletProvider.cpp | 367 +++++++- apps/shared/wallet/src/LogosWalletProvider.h | 6 + apps/shared/wallet/src/WalletAccountId.cpp | 56 ++ apps/shared/wallet/src/WalletAccountId.h | 5 + apps/shared/wallet/src/WalletAccountModel.cpp | 197 ++++- apps/shared/wallet/src/WalletAccountModel.h | 51 +- apps/shared/wallet/src/WalletController.cpp | 278 +++++- apps/shared/wallet/src/WalletController.h | 28 + apps/shared/wallet/src/WalletProvider.h | 12 + .../tests/cpp/LogosWalletProviderTest.cpp | 186 +++- .../wallet/tests/cpp/fixtures/logos_sdk.h | 42 + .../wallet/tests/qml/tst_WalletControl.qml | 329 +++++++- .../wallet/tests/support/FakeWalletProvider.h | 32 + flake.lock | 44 + flake.nix | 45 + tools/wallet-idl-decoder/Cargo.toml | 18 + .../include/wallet_idl_decoder.h | 15 + tools/wallet-idl-decoder/src/lib.rs | 278 ++++++ 38 files changed, 4614 insertions(+), 272 deletions(-) create mode 100644 apps/amm/config/idl/amm-idl.json create mode 100644 apps/amm/config/idl/token-idl.json create mode 100644 apps/amm/config/networks.json create mode 100644 apps/amm/src/ActiveNetwork.cpp create mode 100644 apps/amm/src/ActiveNetwork.h create mode 100644 apps/amm/src/WalletIdlDecoder.cpp create mode 100644 apps/amm/src/WalletIdlDecoder.h create mode 100644 apps/amm/tests/cpp/ActiveNetworkTest.cpp create mode 100644 apps/shared/wallet/src/WalletAccountId.cpp create mode 100644 apps/shared/wallet/src/WalletAccountId.h create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 tools/wallet-idl-decoder/Cargo.toml create mode 100644 tools/wallet-idl-decoder/include/wallet_idl_decoder.h create mode 100644 tools/wallet-idl-decoder/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 7c3a140..e9c33a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4352,6 +4352,17 @@ dependencies = [ "libc", ] +[[package]] +name = "wallet-idl-decoder" +version = "0.1.0" +dependencies = [ + "base58", + "hex", + "serde", + "serde_json", + "spel-framework-core", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index c508ac9..be6b226 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "programs/integration_tests", "tools/idl-gen", "tools/risc0-packager", + "tools/wallet-idl-decoder", ] exclude = [ "programs/token/methods/guest", diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 38c8081..d96a7f1 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -31,6 +31,10 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp + src/ActiveNetwork.h + src/ActiveNetwork.cpp + src/WalletIdlDecoder.h + src/WalletIdlDecoder.cpp FIND_PACKAGES Qt6Gui Qt6Network @@ -39,4 +43,35 @@ logos_module( Qt6::Network LINK_TARGETS logos_wallet_access + EXTERNAL_LIBS + wallet_idl_decoder ) + +set_source_files_properties( + config/idl/token-idl.json + PROPERTIES QT_RESOURCE_ALIAS "idl/token-idl.json" +) +set_source_files_properties( + config/idl/amm-idl.json + PROPERTIES QT_RESOURCE_ALIAS "idl/amm-idl.json" +) +qt_add_resources(amm_ui_module_plugin amm_ui_wallet_data + PREFIX "/amm" + FILES + config/networks.json + config/idl/token-idl.json + config/idl/amm-idl.json +) + +include(CTest) +if(BUILD_TESTING) + find_package(Qt6 6.8 REQUIRED COMPONENTS Test) + add_executable(amm_active_network_test + tests/cpp/ActiveNetworkTest.cpp + src/ActiveNetwork.cpp + ) + set_target_properties(amm_active_network_test PROPERTIES AUTOMOC ON) + target_include_directories(amm_active_network_test PRIVATE src) + target_link_libraries(amm_active_network_test PRIVATE Qt6::Core Qt6::Test) + add_test(NAME amm_active_network COMMAND amm_active_network_test) +endif() diff --git a/apps/amm/config/idl/amm-idl.json b/apps/amm/config/idl/amm-idl.json new file mode 100644 index 0000000..dbe1260 --- /dev/null +++ b/apps/amm/config/idl/amm-idl.json @@ -0,0 +1,791 @@ +{ + "version": "0.1.0", + "name": "amm", + "instructions": [ + { + "name": "initialize", + "accounts": [ + { + "name": "config", + "writable": true, + "signer": false, + "init": true + } + ], + "args": [ + { + "name": "token_program_id", + "type": "program_id" + }, + { + "name": "twap_oracle_program_id", + "type": "program_id" + }, + { + "name": "authority", + "type": "account_id" + } + ] + }, + { + "name": "update_config", + "accounts": [ + { + "name": "config", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "authority", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { + "name": "token_program_id", + "type": { + "option": "program_id" + } + }, + { + "name": "twap_oracle_program_id", + "type": { + "option": "program_id" + } + }, + { + "name": "new_authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "create_price_observations", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "current_tick_account", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "price_observations", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "window_duration", + "type": "u64" + } + ] + }, + { + "name": "create_oracle_price_account", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "oracle_price_account", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "window_duration", + "type": "u64" + } + ] + }, + { + "name": "new_definition", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "vault_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "pool_definition_lp", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "lp_lock_holding", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "user_holding_a", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_b", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_lp", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": true + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "token_a_amount", + "type": "u128" + }, + { + "name": "token_b_amount", + "type": "u128" + }, + { + "name": "fees", + "type": "u128" + }, + { + "name": "deadline", + "type": "u64" + } + ] + }, + { + "name": "add_liquidity", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "pool_definition_lp", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_a", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_b", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_lp", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "min_amount_liquidity", + "type": "u128" + }, + { + "name": "max_amount_to_add_token_a", + "type": "u128" + }, + { + "name": "max_amount_to_add_token_b", + "type": "u128" + }, + { + "name": "deadline", + "type": "u64" + } + ] + }, + { + "name": "remove_liquidity", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "pool_definition_lp", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_lp", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "remove_liquidity_amount", + "type": "u128" + }, + { + "name": "min_amount_to_remove_token_a", + "type": "u128" + }, + { + "name": "min_amount_to_remove_token_b", + "type": "u128" + }, + { + "name": "deadline", + "type": "u64" + } + ] + }, + { + "name": "swap_exact_input", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_a", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_b", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "swap_amount_in", + "type": "u128" + }, + { + "name": "min_amount_out", + "type": "u128" + }, + { + "name": "token_definition_id_in", + "type": "account_id" + }, + { + "name": "deadline", + "type": "u64" + } + ] + }, + { + "name": "swap_exact_output", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_a", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_b", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "exact_amount_out", + "type": "u128" + }, + { + "name": "max_amount_in", + "type": "u128" + }, + { + "name": "token_definition_id_in", + "type": "account_id" + }, + { + "name": "deadline", + "type": "u64" + } + ] + }, + { + "name": "sync_reserves", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "pool", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "current_tick_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "PoolDefinition", + "type": { + "kind": "struct", + "fields": [ + { + "name": "definition_token_a_id", + "type": "account_id" + }, + { + "name": "definition_token_b_id", + "type": "account_id" + }, + { + "name": "vault_a_id", + "type": "account_id" + }, + { + "name": "vault_b_id", + "type": "account_id" + }, + { + "name": "liquidity_pool_id", + "type": "account_id" + }, + { + "name": "liquidity_pool_supply", + "type": "u128" + }, + { + "name": "reserve_a", + "type": "u128" + }, + { + "name": "reserve_b", + "type": "u128" + }, + { + "name": "fees", + "type": "u128" + } + ] + } + }, + { + "name": "AmmConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "token_program_id", + "type": "program_id" + }, + { + "name": "twap_oracle_program_id", + "type": "program_id" + }, + { + "name": "authority", + "type": "account_id" + } + ] + } + }, + { + "name": "TokenDefinition", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Fungible", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "total_supply", + "type": "u128" + }, + { + "name": "metadata_id", + "type": { + "option": "account_id" + } + }, + { + "name": "authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "NonFungible", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "printable_supply", + "type": "u128" + }, + { + "name": "metadata_id", + "type": "account_id" + } + ] + } + ] + } + }, + { + "name": "TokenHolding", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Fungible", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "balance", + "type": "u128" + } + ] + }, + { + "name": "NftMaster", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "print_balance", + "type": "u128" + } + ] + }, + { + "name": "NftPrintedCopy", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "owned", + "type": "bool" + } + ] + } + ] + } + }, + { + "name": "TokenMetadata", + "type": { + "kind": "struct", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "standard", + "type": { + "defined": "MetadataStandard" + } + }, + { + "name": "uri", + "type": "string" + }, + { + "name": "creators", + "type": "string" + }, + { + "name": "primary_sale_date", + "type": "u64" + } + ] + } + } + ], + "types": [ + { + "name": "MetadataStandard", + "kind": "enum", + "variants": [ + { + "name": "Simple" + }, + { + "name": "Expanded" + } + ] + } + ], + "instruction_type": "amm_core::Instruction" +} diff --git a/apps/amm/config/idl/token-idl.json b/apps/amm/config/idl/token-idl.json new file mode 100644 index 0000000..18510e4 --- /dev/null +++ b/apps/amm/config/idl/token-idl.json @@ -0,0 +1,398 @@ +{ + "version": "0.1.0", + "name": "token", + "instructions": [ + { + "name": "transfer", + "accounts": [ + { + "name": "sender", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "recipient", + "writable": true, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount_to_transfer", + "type": "u128" + } + ] + }, + { + "name": "new_fungible_definition", + "accounts": [ + { + "name": "definition_target_account", + "writable": true, + "signer": true, + "init": true + }, + { + "name": "holding_target_account", + "writable": true, + "signer": true, + "init": true + } + ], + "args": [ + { + "name": "name", + "type": "string" + }, + { + "name": "total_supply", + "type": "u128" + }, + { + "name": "mint_authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "new_definition_with_metadata", + "accounts": [ + { + "name": "definition_target_account", + "writable": true, + "signer": true, + "init": true + }, + { + "name": "holding_target_account", + "writable": true, + "signer": true, + "init": true + }, + { + "name": "metadata_target_account", + "writable": true, + "signer": true, + "init": true + } + ], + "args": [ + { + "name": "new_definition", + "type": { + "defined": "NewTokenDefinition" + } + }, + { + "name": "metadata", + "type": { + "defined": "Box" + } + } + ] + }, + { + "name": "initialize_account", + "accounts": [ + { + "name": "definition_account", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "account_to_initialize", + "writable": true, + "signer": true, + "init": true + } + ], + "args": [] + }, + { + "name": "burn", + "accounts": [ + { + "name": "definition_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_account", + "writable": true, + "signer": true, + "init": false + } + ], + "args": [ + { + "name": "amount_to_burn", + "type": "u128" + } + ] + }, + { + "name": "mint", + "accounts": [ + { + "name": "definition_account", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "user_holding_account", + "writable": true, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount_to_mint", + "type": "u128" + } + ] + }, + { + "name": "mint_with_authority", + "accounts": [ + { + "name": "definition_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "authority_account", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { + "name": "amount_to_mint", + "type": "u128" + } + ] + }, + { + "name": "set_authority", + "accounts": [ + { + "name": "definition_account", + "writable": true, + "signer": true, + "init": false + } + ], + "args": [ + { + "name": "new_authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "set_authority_with_authority", + "accounts": [ + { + "name": "definition_account", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "authority_account", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + { + "name": "new_authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "print_nft", + "accounts": [ + { + "name": "master_account", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "printed_account", + "writable": true, + "signer": true, + "init": true + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "TokenDefinition", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Fungible", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "total_supply", + "type": "u128" + }, + { + "name": "metadata_id", + "type": { + "option": "account_id" + } + }, + { + "name": "authority", + "type": { + "option": "account_id" + } + } + ] + }, + { + "name": "NonFungible", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "printable_supply", + "type": "u128" + }, + { + "name": "metadata_id", + "type": "account_id" + } + ] + } + ] + } + }, + { + "name": "TokenHolding", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Fungible", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "balance", + "type": "u128" + } + ] + }, + { + "name": "NftMaster", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "print_balance", + "type": "u128" + } + ] + }, + { + "name": "NftPrintedCopy", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "owned", + "type": "bool" + } + ] + } + ] + } + }, + { + "name": "TokenMetadata", + "type": { + "kind": "struct", + "fields": [ + { + "name": "definition_id", + "type": "account_id" + }, + { + "name": "standard", + "type": { + "defined": "MetadataStandard" + } + }, + { + "name": "uri", + "type": "string" + }, + { + "name": "creators", + "type": "string" + }, + { + "name": "primary_sale_date", + "type": "u64" + } + ] + } + } + ], + "types": [ + { + "name": "MetadataStandard", + "kind": "enum", + "variants": [ + { + "name": "Simple" + }, + { + "name": "Expanded" + } + ] + } + ], + "instruction_type": "token_core::Instruction" +} diff --git a/apps/amm/config/networks.json b/apps/amm/config/networks.json new file mode 100644 index 0000000..75e03eb --- /dev/null +++ b/apps/amm/config/networks.json @@ -0,0 +1,18 @@ +{ + "testnet": { + "checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a", + "ammProgramId": "77eeaa23668ad2675fb768cd7ecb1893387be464b9a51f16756006c1d307db07", + "tokenDefinitionIds": [ + "7b464ff9dd0d3bc07f7e2e0b0667ccd066d85ad12be4c79fc55687a863910aa6", + "48c81cf032e601ca367fc9816b957dbf5c0e4c11cf7008e8f4581ec1a67aab42", + "159caef810ea545951b3bd913efe625ee45008c80865c330e72a72ed48b61649", + "75f33110b185717209e3955f228d4a4448801d0ce8ba438a4a268050eeff3f44", + "fbd107ca4bb66bc58f59ac2d32a759be3ee0fb453f8fecd1991c11837d9660c7", + "5547fcb72644d95a385d313b887a96be41ff263bce6150b49fd87276839822bf", + "fa43e74a97d79c5f907ff3edabda5ad89bfbd3b0922572e675d4ad3c7b6029c7", + "4f3231d8a01e1d79f163bc27fce0c860a4a2f6890280e9d135eafbde0d68ed79", + "fa32f354408857006f8ea396b0419823bd04436eadb2d273d2618a46b4793ed8", + "00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb" + ] + } +} diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index 8f79e67..9fd15fe 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -1,6 +1,22 @@ { "nodes": { "crane": { + "locked": { + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "ref": "v0.23.4", + "repo": "crane", + "type": "github" + } + }, + "crane_2": { "locked": { "lastModified": 1780532242, "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", @@ -2225,7 +2241,7 @@ }, "logos-execution-zone": { "inputs": { - "crane": "crane", + "crane": "crane_2", "logos-blockchain-circuits": "logos-blockchain-circuits", "logos-liblogos": "logos-liblogos_4", "nixpkgs": [ @@ -25408,6 +25424,22 @@ "type": "github" } }, + "nixpkgs_399": { + "locked": { + "lastModified": 1782841183, + "narHash": "sha256-Ndt/5R7UN4rBdhFR1lxHZZZ42cD6vGlnuxC2VvvsKE4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c9bfd86ed684d27e63b0ff9ebb18699f84f27a3b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11-small", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_4": { "locked": { "lastModified": 1759036355, @@ -26762,8 +26794,10 @@ }, "root": { "inputs": { + "crane": "crane", "logos-module-builder": "logos-module-builder", "logos_execution_zone": "logos_execution_zone", + "nixpkgs": "nixpkgs_399", "shared_wallet": "shared_wallet" } }, diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index f237473..d01e26b 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -3,6 +3,8 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11-small"; + crane.url = "github:ipetkov/crane/v0.23.4"; # Shared C++ wallet access and Logos.Wallet QML sources. shared_wallet = { @@ -28,14 +30,21 @@ }; }; - outputs = inputs@{ logos-module-builder, shared_wallet, ... }: - logos-module-builder.lib.mkLogosQmlModule { + outputs = inputs@{ logos-module-builder, shared_wallet, nixpkgs, crane, ... }: + let + walletDecoderInput = (import ../../flake.nix).outputs { + inherit nixpkgs crane; + }; + in logos-module-builder.lib.mkLogosQmlModule { src = ./.; configFile = ./metadata.json; flakeInputs = inputs; preConfigure = '' cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}") ''; + externalLibInputs = { + wallet_idl_decoder = walletDecoderInput; + }; postInstall = '' # The builder installs the view under lib/qml after this hook. Its # import descriptor points back to this compiled shared QML module. diff --git a/apps/amm/metadata.json b/apps/amm/metadata.json index 7250885..c670a3d 100644 --- a/apps/amm/metadata.json +++ b/apps/amm/metadata.json @@ -14,7 +14,11 @@ "build": [], "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"] }, - "external_libraries": [], + "external_libraries": [ + { + "name": "wallet_idl_decoder" + } + ], "cmake": { "find_packages": [], "extra_sources": [], diff --git a/apps/amm/src/ActiveNetwork.cpp b/apps/amm/src/ActiveNetwork.cpp new file mode 100644 index 0000000..9bb1e27 --- /dev/null +++ b/apps/amm/src/ActiveNetwork.cpp @@ -0,0 +1,139 @@ +#include "ActiveNetwork.h" + +#include +#include +#include +#include + +namespace { +const char NETWORK_ENV[] = "AMM_UI_NETWORK"; +const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE"; + +bool isLowerHex(const QString& value, int size) +{ + if (value.size() != size) + return false; + for (const QChar character : value) { + const bool digit = character >= QLatin1Char('0') + && character <= QLatin1Char('9'); + if (!digit && (character < QLatin1Char('a') || character > QLatin1Char('f'))) + return false; + } + return true; +} +} + +bool ActiveNetwork::load() +{ + m_network = {}; + m_network.status = QStringLiteral("config_missing"); + m_expectedIdentity.clear(); + const QByteArray selected = qgetenv(NETWORK_ENV); + m_network.id = selected.isEmpty() + ? QStringLiteral("testnet") + : QString::fromLocal8Bit(selected).trimmed(); + + QJsonObject entry; + if (isDevnet()) { + const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV)); + QFile file(path); + if (path.isEmpty() || !file.open(QIODevice::ReadOnly)) + return false; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return false; + entry = document.object(); + m_expectedIdentity = entry.value(QStringLiteral("channelId")).toString(); + } else { + QFile file(QStringLiteral(":/amm/config/networks.json")); + if (!file.open(QIODevice::ReadOnly)) + return false; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return false; + entry = document.object().value(m_network.id).toObject(); + m_expectedIdentity = entry.value(QStringLiteral("checkpointHash")).toString(); + } + + m_network.ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString(); + if (!isValidIdentity(m_expectedIdentity) + || !isLowerHex(m_network.ammProgramId, 64)) { + return false; + } + for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) { + const QString id = value.toString(); + if (!isLowerHex(id, 64)) { + m_network.tokenIds.clear(); + return false; + } + m_network.tokenIds.append(id); + } + if (m_network.tokenIds.isEmpty()) + return false; + m_network.status = QStringLiteral("network_unknown"); + return true; +} + +bool ActiveNetwork::isConfigured() const +{ + return m_network.status != QStringLiteral("config_missing"); +} + +bool ActiveNetwork::isDevnet() const +{ + return m_network.id == QStringLiteral("devnet"); +} + +bool ActiveNetwork::needsIdentityProbe() const +{ + return m_network.status == QStringLiteral("loading") + || m_network.status == QStringLiteral("network_unknown"); +} + +void ActiveNetwork::sequencerChanged(bool available) +{ + if (isConfigured()) + clearIdentity(available ? QStringLiteral("loading") + : QStringLiteral("network_unknown")); +} + +void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable) +{ + if (!isConfigured()) + return; + if (!reachable) + clearIdentity(QStringLiteral("network_unknown")); + else if (!wasReachable) + clearIdentity(QStringLiteral("loading")); +} + +void ActiveNetwork::beginIdentityProbe() +{ + if (isConfigured()) + clearIdentity(QStringLiteral("loading")); +} + +void ActiveNetwork::finishIdentityProbe(const QString& identity) +{ + if (identity.isEmpty()) + clearIdentity(QStringLiteral("network_unknown")); + else if (identity != m_expectedIdentity) + clearIdentity(QStringLiteral("network_mismatch")); + else { + m_network.status = QStringLiteral("ready"); + m_network.fingerprint = (isDevnet() ? QStringLiteral("channel:") + : QStringLiteral("block10:")) + + identity; + } +} + +bool ActiveNetwork::isValidIdentity(const QString& value) +{ + return isLowerHex(value, 64); +} + +void ActiveNetwork::clearIdentity(const QString& status) +{ + m_network.status = status; + m_network.fingerprint.clear(); +} diff --git a/apps/amm/src/ActiveNetwork.h b/apps/amm/src/ActiveNetwork.h new file mode 100644 index 0000000..9b1db9b --- /dev/null +++ b/apps/amm/src/ActiveNetwork.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +struct ActiveNetworkSnapshot { + QString id; + QString status; + QString fingerprint; + QString ammProgramId; + QStringList tokenIds; +}; + +class ActiveNetwork final { +public: + bool load(); + + const QString& status() const { return m_network.status; } + bool isConfigured() const; + bool isDevnet() const; + bool needsIdentityProbe() const; + ActiveNetworkSnapshot snapshot() const { return m_network; } + + void sequencerChanged(bool available); + void reachabilityChanged(bool reachable, bool wasReachable); + void beginIdentityProbe(); + void finishIdentityProbe(const QString& identity); + + static bool isValidIdentity(const QString& value); + +private: + void clearIdentity(const QString& status); + + ActiveNetworkSnapshot m_network; + QString m_expectedIdentity; +}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index fbe6a92..106d68f 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,18 +1,132 @@ #include "AmmUiBackend.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + #include "LogosWalletProvider.h" +#include "WalletAccountId.h" #include "WalletController.h" +#include "WalletIdlDecoder.h" #include "logos_api.h" +namespace { +constexpr int CHECKPOINT_BLOCK_ID = 10; +constexpr int BLOCK_HASH_OFFSET = 40; +constexpr int BLOCK_HASH_SIZE = 32; +const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0')); + +QByteArray resource(const QString& path) +{ + QFile file(path); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); +} + +QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) +{ + return QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("method"), method }, + { QStringLiteral("params"), params }, + }).toJson(QJsonDocument::Compact); +} + +QString blockHashFromResponse(const QByteArray& payload) +{ + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(payload, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QByteArray block = QByteArray::fromBase64( + document.object().value(QStringLiteral("result")).toString().toLatin1()); + if (block.size() < BLOCK_HASH_OFFSET + BLOCK_HASH_SIZE) + return {}; + return QString::fromLatin1(block.mid(BLOCK_HASH_OFFSET, BLOCK_HASH_SIZE).toHex()); +} + +QString channelIdFromResponse(const QByteArray& payload) +{ + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(payload, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QString channel = document.object().value(QStringLiteral("result")).toString(); + return ActiveNetwork::isValidIdentity(channel) ? channel : QString(); +} + +QString decimalAdd(const QString& left, const QString& right) +{ + if (left.isEmpty() || right.isEmpty()) + return {}; + if (!std::all_of(left.cbegin(), left.cend(), [](QChar value) { return value.isDigit(); }) + || !std::all_of(right.cbegin(), right.cend(), [](QChar value) { return value.isDigit(); })) { + return {}; + } + QString result; + result.reserve(std::max(left.size(), right.size()) + 1); + qsizetype leftIndex = left.size(); + qsizetype rightIndex = right.size(); + int carry = 0; + while (leftIndex > 0 || rightIndex > 0 || carry > 0) { + const int leftDigit = leftIndex > 0 + ? left.at(--leftIndex).digitValue() : 0; + const int rightDigit = rightIndex > 0 + ? right.at(--rightIndex).digitValue() : 0; + const int sum = leftDigit + rightDigit + carry; + result.prepend(QChar(QLatin1Char('0').unicode() + sum % 10)); + carry = sum / 10; + } + while (result.size() > 1 && result.startsWith(QLatin1Char('0'))) + result.remove(0, 1); + return result; +} + +QJsonObject enumFields(const QJsonValue& value, const QString& variant) +{ + return value.toObject().value(variant).toObject(); +} + +WalletAccountRead accountRead(const WalletAccount& account) +{ + WalletAccountRead read; + read.accountId = account.address; + read.status = account.readStatus; + read.programOwner = account.programOwner; + read.dataHex = account.dataHex; + return read; +} +} + AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), m_wallet(std::make_unique(m_logosAPI)), m_walletController(std::make_unique( - *m_wallet, QStringLiteral("AmmUI"))) + *m_wallet, QStringLiteral("AmmUI"))), + m_networkManager(new QNetworkAccessManager(this)), + m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))), + m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json"))) { + setAssets({}); + setAssetStatus(QStringLiteral("idle")); + setAssetError({}); + m_network.load(); + m_idlRegistry.registerProgram( + m_network.snapshot().ammProgramId, QStringLiteral("AMM"), m_ammIdl); + publishNetworkState(); connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); + connect(m_walletController.get(), &WalletController::snapshotChanged, + this, &AmmUiBackend::refreshPortfolio); syncWalletState(); m_walletController->start(); } @@ -71,10 +185,27 @@ void AmmUiBackend::disconnectWallet() m_walletController->disconnect(); } +bool AmmUiBackend::setAccountAlias(QString accountId, QString alias) +{ + return m_walletController->setAccountAlias(accountId, alias); +} + +bool AmmUiBackend::setPrimaryAccount(QString accountId) +{ + return m_walletController->setPrimaryAccount(accountId); +} + void AmmUiBackend::syncWalletState() { const WalletUiState& state = m_walletController->state(); + const QString previousAddress = sequencerAddr(); + const bool wasReachable = sequencerReachable(); setIsWalletOpen(state.isWalletOpen); + setWalletStateReady(state.syncStatus != QStringLiteral("opening") + && state.syncStatus != QStringLiteral("syncing")); + setWalletSyncStatus(state.syncStatus); + setWalletSyncError(state.syncError); + setWalletCanSubmit(state.canSubmit()); setWalletExists(state.walletExists); setConfigPath(state.configPath); setStoragePath(state.storagePath); @@ -83,4 +214,245 @@ void AmmUiBackend::syncWalletState() setCurrentBlockHeight(state.currentBlockHeight); setSequencerAddr(state.sequencerAddress); setSequencerReachable(state.sequencerReachable); + setPrimaryAccountAddress(state.primaryAccountAddress); + setPrimaryAccountName(state.primaryAccountName); + + const bool addressChanged = previousAddress != state.sequencerAddress; + if (addressChanged) + m_network.sequencerChanged(!state.sequencerAddress.isEmpty()); + if (addressChanged || wasReachable != state.sequencerReachable) + m_network.reachabilityChanged(state.sequencerReachable, wasReachable); + publishNetworkState(); + if (state.sequencerReachable && m_network.needsIdentityProbe()) + probeNetworkIdentity(); +} + +void AmmUiBackend::publishNetworkState() +{ + const ActiveNetworkSnapshot network = m_network.snapshot(); + setActiveNetwork(network.id); + setNetworkStatus(network.status); + setNetworkFingerprint(network.fingerprint); +} + +void AmmUiBackend::probeNetworkIdentity() +{ + if (m_identityProbeInFlight || !m_network.isConfigured() || sequencerAddr().isEmpty()) + return; + m_identityProbeInFlight = true; + m_network.beginIdentityProbe(); + publishNetworkState(); + const QString address = sequencerAddr(); + const bool devnet = m_network.isDevnet(); + const QString method = devnet ? QStringLiteral("getChannelId") + : QStringLiteral("getBlock"); + const QJsonArray params = devnet ? QJsonArray() + : QJsonArray { CHECKPOINT_BLOCK_ID }; + QNetworkRequest request{QUrl(address)}; + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("application/json")); + request.setTransferTimeout(4000); + QNetworkReply* reply = m_networkManager->post(request, jsonRpcBody(method, params)); + connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { + m_identityProbeInFlight = false; + if (address != sequencerAddr()) { + reply->deleteLater(); + probeNetworkIdentity(); + return; + } + const QByteArray payload = reply->readAll(); + const QString identity = devnet ? channelIdFromResponse(payload) + : blockHashFromResponse(payload); + m_network.finishIdentityProbe(identity); + reply->deleteLater(); + publishNetworkState(); + refreshPortfolio(); + }); +} + +void AmmUiBackend::refreshPortfolio() +{ + const quint64 generation = ++m_portfolioGeneration; + if (!m_walletController->state().isWalletOpen) { + setAssets({}); + setAssetStatus(QStringLiteral("idle")); + setAssetError({}); + return; + } + if (m_network.status() != QStringLiteral("ready")) { + setAssets({}); + setAssetStatus(QStringLiteral("blocked")); + setAssetError(m_network.status()); + return; + } + if (m_tokenIdl.isEmpty()) { + setAssetStatus(QStringLiteral("error")); + setAssetError(QStringLiteral("token_idl_missing")); + return; + } + setAssetStatus(QStringLiteral("loading")); + setAssetError({}); + m_wallet->readPublicAccountsAsync( + m_network.snapshot().tokenIds, + [this, generation](QVector reads) { + applyDefinitions(generation, reads); + }); +} + +void AmmUiBackend::applyDefinitions( + quint64 generation, + const QVector& reads) +{ + if (generation != m_portfolioGeneration) + return; + const ActiveNetworkSnapshot network = m_network.snapshot(); + const WalletDecodeResult decoded = WalletIdlDecoder::decode(m_tokenIdl, reads); + if (!decoded.ok() || reads.size() != network.tokenIds.size() + || decoded.accounts.size() != reads.size()) { + setAssetStatus(QStringLiteral("error")); + setAssetError(decoded.error.isEmpty() + ? QStringLiteral("definition_decode_failed") + : decoded.error); + return; + } + + m_tokens.clear(); + m_tokenProgramId.clear(); + int unavailable = 0; + for (qsizetype index = 0; index < reads.size(); ++index) { + const WalletAccountRead& read = reads.at(index); + const WalletDecodedAccount& account = decoded.accounts.at(index); + TokenInfo token; + token.id = network.tokenIds.at(index); + token.name = QStringLiteral("Unknown token"); + token.status = QStringLiteral("unavailable"); + const QJsonObject fungible = enumFields(account.value, QStringLiteral("Fungible")); + if (read.ok() && account.status == QStringLiteral("decoded") + && account.typeName == QStringLiteral("TokenDefinition") + && !fungible.isEmpty() && read.programOwner != DEFAULT_PROGRAM_OWNER) { + token.name = fungible.value(QStringLiteral("name")).toString().trimmed(); + if (token.name.isEmpty()) + token.name = QStringLiteral("Unnamed token"); + token.programOwner = read.programOwner; + token.status = QStringLiteral("ready"); + if (m_tokenProgramId.isEmpty()) + m_tokenProgramId = read.programOwner; + else if (m_tokenProgramId != read.programOwner) { + setAssets({}); + setAssetStatus(QStringLiteral("error")); + setAssetError(QStringLiteral("token_program_mismatch")); + return; + } + } else { + ++unavailable; + } + m_tokens.append(std::move(token)); + } + if (m_tokenProgramId.isEmpty()) { + setAssets({}); + setAssetStatus(QStringLiteral("error")); + setAssetError(QStringLiteral("definitions_unavailable")); + return; + } + m_idlRegistry.registerProgram( + m_tokenProgramId, QStringLiteral("Token"), m_tokenIdl); + setAssetError(unavailable > 0 + ? QStringLiteral("some_definitions_unavailable") + : QString()); + applyWalletPortfolio(generation); +} + +void AmmUiBackend::applyWalletPortfolio(quint64 generation) +{ + if (generation != m_portfolioGeneration) + return; + const WalletSnapshot snapshot = m_walletController->snapshot(); + QVector programReads; + for (const WalletAccount& account : snapshot.accounts) { + if (!account.isPublic || account.readStatus != QStringLiteral("ok")) + continue; + programReads.append(accountRead(account)); + } + + QHash balances; + QVector presentations; + const QVector programs = m_idlRegistry.decode(programReads); + for (const WalletDecodedProgram& program : programs) { + for (const WalletDecodedAccount& account : program.result.accounts) { + WalletAccountPresentation presentation; + presentation.address = account.id; + presentation.programName = program.programName; + presentation.accountType = account.typeName; + if (program.programId == m_tokenProgramId + && account.typeName == QStringLiteral("TokenHolding")) { + const QJsonObject fungible = enumFields( + account.value, QStringLiteral("Fungible")); + if (fungible.isEmpty()) + continue; + const QString encodedId = fungible + .value(QStringLiteral("definition_id")).toString(); + const QString definitionId = account.accountIds.value(encodedId); + const QString amount = fungible.value(QStringLiteral("balance")).toString(); + const QString current = balances.value(definitionId, QStringLiteral("0")); + const QString total = decimalAdd(current, amount); + if (!definitionId.isEmpty() && !total.isEmpty()) + balances.insert(definitionId, total); + presentation.kind = QStringLiteral("token_holding"); + presentation.definitionId = definitionId; + presentation.hiddenFromAccounts = true; + for (const TokenInfo& token : m_tokens) { + if (token.id == definitionId) { + presentation.semanticName = token.name + QStringLiteral(" holding"); + break; + } + } + } else if (program.programId == m_tokenProgramId + && account.typeName == QStringLiteral("TokenDefinition")) { + presentation.kind = QStringLiteral("token_definition"); + const QJsonObject fungible = enumFields( + account.value, QStringLiteral("Fungible")); + presentation.semanticName = fungible.value(QStringLiteral("name")).toString(); + } else if (program.programId == m_tokenProgramId + && account.typeName == QStringLiteral("TokenMetadata")) { + presentation.kind = QStringLiteral("token_metadata"); + } else { + presentation.kind = QStringLiteral("program"); + presentation.semanticName = account.typeName; + } + presentations.append(std::move(presentation)); + } + } + m_walletController->applyAccountPresentations(presentations); + + QVariantList assets; + QVariantList available; + int unavailableCount = 0; + for (const TokenInfo& token : m_tokens) { + const QString balance = balances.value(token.id, QStringLiteral("0")); + const bool positive = balance != QStringLiteral("0"); + QString displayDefinitionId = walletAccountIdToBase58(token.id); + if (displayDefinitionId.isEmpty()) + displayDefinitionId = token.id; + QVariantMap asset { + { QStringLiteral("name"), token.name }, + { QStringLiteral("symbol"), token.name }, + { QStringLiteral("balance"), balance }, + { QStringLiteral("definitionId"), token.id }, + { QStringLiteral("displayDefinitionId"), displayDefinitionId }, + { QStringLiteral("programOwner"), token.programOwner }, + { QStringLiteral("status"), token.status }, + { QStringLiteral("section"), positive ? QStringLiteral("assets") + : QStringLiteral("available") }, + }; + if (positive) + assets.append(std::move(asset)); + else + available.append(std::move(asset)); + if (token.status != QStringLiteral("ready")) + ++unavailableCount; + } + assets.append(available); + setAssets(assets); + setAssetStatus(unavailableCount > 0 ? QStringLiteral("partial") + : QStringLiteral("ready")); } diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 99fa7e0..9b6d84d 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -3,14 +3,19 @@ #include +#include #include +#include #include "rep_AmmUiBackend_source.h" +#include "ActiveNetwork.h" #include "WalletAccountModel.h" +#include "WalletIdlDecoder.h" class LogosAPI; class LogosWalletProvider; +class QNetworkAccessManager; class WalletController; class AmmUiBackend : public AmmUiBackendSimpleSource { @@ -33,13 +38,37 @@ public slots: QString createNew(QString configPath, QString storagePath, QString password) override; bool openExisting() override; void disconnectWallet() override; + bool setAccountAlias(QString accountId, QString alias) override; + bool setPrimaryAccount(QString accountId) override; private: + struct TokenInfo { + QString id; + QString name; + QString programOwner; + QString status; + }; + void syncWalletState(); + void publishNetworkState(); + void probeNetworkIdentity(); + void refreshPortfolio(); + void applyDefinitions(quint64 generation, + const QVector& reads); + void applyWalletPortfolio(quint64 generation); LogosAPI* m_logosAPI; std::unique_ptr m_wallet; std::unique_ptr m_walletController; + QNetworkAccessManager* m_networkManager; + ActiveNetwork m_network; + QByteArray m_tokenIdl; + QByteArray m_ammIdl; + WalletIdlRegistry m_idlRegistry; + QVector m_tokens; + QString m_tokenProgramId; + bool m_identityProbeInFlight = false; + quint64 m_portfolioGeneration = 0; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 6023dbb..d6031ce 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -5,6 +5,10 @@ class AmmUiBackend { PROP(bool isWalletOpen READONLY) + PROP(bool walletStateReady READONLY) + PROP(QString walletSyncStatus READONLY) + PROP(QString walletSyncError READONLY) + PROP(bool walletCanSubmit READONLY) PROP(bool walletExists READONLY) PROP(QString configPath READONLY) PROP(QString storagePath READONLY) @@ -15,6 +19,15 @@ class AmmUiBackend // Whether the configured sequencer answered the last reachability probe. // Defaults true so the UI doesn't flash a warning before the first check. PROP(bool sequencerReachable READONLY) + PROP(QString primaryAccountAddress READONLY) + PROP(QString primaryAccountName READONLY) + + PROP(QString activeNetwork READONLY) + PROP(QString networkStatus READONLY) + PROP(QString networkFingerprint READONLY) + PROP(QVariantList assets READONLY) + PROP(QString assetStatus READONLY) + PROP(QString assetError READONLY) // Account management SLOT(QString createAccountPublic()) @@ -22,6 +35,8 @@ class AmmUiBackend SLOT(void refreshAccounts()) SLOT(void refreshBalances()) SLOT(QString getBalance(QString accountIdHex, bool isPublic)) + SLOT(bool setAccountAlias(QString accountId, QString alias)) + SLOT(bool setPrimaryAccount(QString accountId)) // Wallet lifecycle. createNewDefault() is the happy path: it creates a // fresh per-app wallet at walletHome with no path picking. createNew() diff --git a/apps/amm/src/WalletIdlDecoder.cpp b/apps/amm/src/WalletIdlDecoder.cpp new file mode 100644 index 0000000..986046d --- /dev/null +++ b/apps/amm/src/WalletIdlDecoder.cpp @@ -0,0 +1,100 @@ +#include "WalletIdlDecoder.h" + +#include + +#include +#include +#include +#include + +#include + +WalletDecodeResult WalletIdlDecoder::decode( + const QByteArray& idlJson, + const QVector& accounts) +{ + WalletDecodeResult result; + QJsonParseError idlError; + const QJsonDocument idl = QJsonDocument::fromJson(idlJson, &idlError); + if (idlError.error != QJsonParseError::NoError || !idl.isObject()) { + result.status = QStringLiteral("error"); + result.error = QStringLiteral("invalid_idl"); + return result; + } + + QJsonArray inputs; + for (const WalletAccountRead& account : accounts) { + inputs.append(QJsonObject { + { QStringLiteral("id"), account.accountId }, + { QStringLiteral("dataHex"), account.dataHex }, + }); + } + const QByteArray request = QJsonDocument(QJsonObject { + { QStringLiteral("idl"), idl.object() }, + { QStringLiteral("accounts"), inputs }, + }).toJson(QJsonDocument::Compact); + + char* responsePointer = wallet_idl_decode_accounts(request.constData()); + if (!responsePointer) { + result.status = QStringLiteral("error"); + result.error = QStringLiteral("decoder_unavailable"); + return result; + } + const QByteArray response(responsePointer); + wallet_idl_decoder_free(responsePointer); + + QJsonParseError responseError; + const QJsonDocument document = QJsonDocument::fromJson(response, &responseError); + if (responseError.error != QJsonParseError::NoError || !document.isObject()) { + result.status = QStringLiteral("error"); + result.error = QStringLiteral("invalid_decoder_response"); + return result; + } + + const QJsonObject root = document.object(); + result.status = root.value(QStringLiteral("status")).toString(); + result.error = root.value(QStringLiteral("error")).toString(); + for (const QJsonValue& value : root.value(QStringLiteral("accounts")).toArray()) { + const QJsonObject decoded = value.toObject(); + WalletDecodedAccount account; + account.id = decoded.value(QStringLiteral("id")).toString(); + account.status = decoded.value(QStringLiteral("status")).toString(); + account.typeName = decoded.value(QStringLiteral("typeName")).toString(); + account.value = decoded.value(QStringLiteral("value")); + const QJsonObject ids = decoded.value(QStringLiteral("accountIds")).toObject(); + for (auto iterator = ids.begin(); iterator != ids.end(); ++iterator) + account.accountIds.insert(iterator.key(), iterator.value().toString()); + result.accounts.append(std::move(account)); + } + return result; +} + +void WalletIdlRegistry::registerProgram(const QString& programId, + const QString& programName, + const QByteArray& idlJson) +{ + if (!programId.isEmpty() && !programName.isEmpty() && !idlJson.isEmpty()) + m_programs.insert(programId, { programName, idlJson }); +} + +QVector WalletIdlRegistry::decode( + const QVector& accounts) const +{ + QHash> grouped; + for (const WalletAccountRead& account : accounts) { + if (m_programs.contains(account.programOwner)) + grouped[account.programOwner].append(account); + } + + QVector decoded; + decoded.reserve(grouped.size()); + for (auto iterator = grouped.cbegin(); iterator != grouped.cend(); ++iterator) { + const Program program = m_programs.value(iterator.key()); + decoded.append({ + iterator.key(), + program.name, + WalletIdlDecoder::decode(program.idl, iterator.value()), + }); + } + return decoded; +} diff --git a/apps/amm/src/WalletIdlDecoder.h b/apps/amm/src/WalletIdlDecoder.h new file mode 100644 index 0000000..0811226 --- /dev/null +++ b/apps/amm/src/WalletIdlDecoder.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "WalletProvider.h" + +struct WalletDecodedAccount { + QString id; + QString status; + QString typeName; + QJsonValue value; + QHash accountIds; +}; + +struct WalletDecodeResult { + QString status; + QString error; + QVector accounts; + + bool ok() const { return status == QStringLiteral("ok"); } +}; + +class WalletIdlDecoder final { +public: + static WalletDecodeResult decode(const QByteArray& idlJson, + const QVector& accounts); +}; + +struct WalletDecodedProgram { + QString programId; + QString programName; + WalletDecodeResult result; +}; + +class WalletIdlRegistry final { +public: + void registerProgram(const QString& programId, + const QString& programName, + const QByteArray& idlJson); + QVector decode( + const QVector& accounts) const; + +private: + struct Program { + QString name; + QByteArray idl; + }; + + QHash m_programs; +}; diff --git a/apps/amm/tests/cpp/ActiveNetworkTest.cpp b/apps/amm/tests/cpp/ActiveNetworkTest.cpp new file mode 100644 index 0000000..0f5250c --- /dev/null +++ b/apps/amm/tests/cpp/ActiveNetworkTest.cpp @@ -0,0 +1,47 @@ +#include "ActiveNetwork.h" + +#include +#include +#include +#include +#include + +class ActiveNetworkTest : public QObject { + Q_OBJECT + +private slots: + void validatesIdentityBeforeReadiness(); +}; + +void ActiveNetworkTest::validatesIdentityBeforeReadiness() +{ + const QString identity(64, QLatin1Char('a')); + const QString programId(64, QLatin1Char('b')); + const QString tokenId(64, QLatin1Char('c')); + QTemporaryFile config; + QVERIFY(config.open()); + config.write(QJsonDocument(QJsonObject { + { QStringLiteral("channelId"), identity }, + { QStringLiteral("ammProgramId"), programId }, + { QStringLiteral("tokenDefinitionIds"), QJsonArray { tokenId } }, + }).toJson(QJsonDocument::Compact)); + config.flush(); + qputenv("AMM_UI_NETWORK", "devnet"); + qputenv("AMM_UI_DEVNET_FILE", config.fileName().toLocal8Bit()); + + ActiveNetwork network; + QVERIFY(network.load()); + QCOMPARE(network.status(), QStringLiteral("network_unknown")); + network.sequencerChanged(true); + network.finishIdentityProbe(QString(64, QLatin1Char('d'))); + QCOMPARE(network.status(), QStringLiteral("network_mismatch")); + network.reachabilityChanged(false, true); + network.reachabilityChanged(true, false); + network.finishIdentityProbe(identity); + QCOMPARE(network.status(), QStringLiteral("ready")); + QCOMPARE(network.snapshot().fingerprint, QStringLiteral("channel:") + identity); + QCOMPARE(network.snapshot().tokenIds, QStringList { tokenId }); +} + +QTEST_GUILESS_MAIN(ActiveNetworkTest) +#include "ActiveNetworkTest.moc" diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 896716b..32adf6a 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -29,6 +29,8 @@ if(LOGOS_WALLET_BUILD_ACCESS) src/WalletProvider.cpp src/LogosWalletProvider.h src/LogosWalletProvider.cpp + src/WalletAccountId.h + src/WalletAccountId.cpp src/WalletAccountModel.h src/WalletAccountModel.cpp src/WalletController.h @@ -140,6 +142,8 @@ if(BUILD_TESTING) tests/cpp/LogosWalletProviderTest.cpp src/WalletProvider.cpp src/LogosWalletProvider.cpp + src/WalletAccountId.cpp + src/WalletAccountId.h src/WalletAccountModel.cpp src/WalletAccountModel.h src/WalletController.cpp diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml index 28d32a1..0136de7 100644 --- a/apps/shared/wallet/qml/WalletControl.qml +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -12,15 +12,29 @@ Item { property var watchCall: null property bool compact: false property real viewportWidth: width - property int selectedIndex: 0 + property int selectedIndex: -1 property bool busy: false + property bool openPending: false + property bool advancedExpanded: false + property bool availableExpanded: false + property string postCreationWarning: "" + property bool createdWalletAwaitingAcknowledgement: false + property string reportedOpenErrorKey: "" readonly property bool connected: root.wallet !== null && root.wallet.isWalletOpen readonly property bool compactLayout: root.compact || root.viewportWidth < 680 + readonly property bool walletOpening: root.wallet !== null + && (root.wallet.walletSyncStatus === "opening" + || root.wallet.walletSyncStatus === "syncing") readonly property string selectedAddress: root.accountAt(root.selectedIndex, "address") + readonly property string selectedDisplayAddress: root.accountAt(root.selectedIndex, "displayAddress") readonly property string selectedName: root.accountAt(root.selectedIndex, "name") readonly property string selectedBalance: root.accountAt(root.selectedIndex, "balance") readonly property bool selectedIsPublic: root.accountAt(root.selectedIndex, "isPublic") === true + readonly property var walletAssets: root.wallet && root.wallet.assets ? root.wallet.assets : [] + readonly property int availableAssetCount: root.assetCount("available") + readonly property string primaryName: root.wallet && root.wallet.primaryAccountName + ? root.wallet.primaryAccountName : root.selectedName implicitWidth: root.connected ? connectedButton.implicitWidth : connectButton.implicitWidth implicitHeight: 40 @@ -30,11 +44,15 @@ Item { model: root.accountModel delegate: QtObject { required property string address + required property string displayAddress required property string name required property string balance required property bool isPublic + required property bool isPrimary + required property bool canBePrimary + required property string kind } - onCountChanged: root.clampSelection() + onCountChanged: root.syncPrimarySelection() } function accountAt(index, field) { @@ -42,26 +60,50 @@ Item { return entry ? entry[field] : (field === "isPublic" ? false : "") } - function clampSelection() { - if (accounts.count === 0) { - root.selectedIndex = 0 - } else { - root.selectedIndex = Math.max(0, Math.min(root.selectedIndex, accounts.count - 1)) + function assetCount(sectionName) { + let count = 0 + for (const asset of root.walletAssets) { + if (asset.section === sectionName) + ++count } + return count + } + + function syncPrimarySelection() { + if (accounts.count === 0) { + root.selectedIndex = -1 + return + } + const requested = root.wallet && root.wallet.primaryAccountAddress + ? root.wallet.primaryAccountAddress : "" + for (let index = 0; index < accounts.count; ++index) { + const account = accounts.objectAt(index) + if ((requested.length > 0 && account.address === requested) || account.isPrimary) { + root.selectedIndex = index + return + } + } + for (let index = 0; index < accounts.count; ++index) { + const account = accounts.objectAt(index) + if (account.kind === "user" && account.canBePrimary) { + root.selectedIndex = index + return + } + } + root.selectedIndex = -1 } function shortAddress(address) { return address && address.length > 13 - ? address.substring(0, 6) + "..." + address.substring(address.length - 4) + ? address.substring(0, 6) + "…" + address.substring(address.length - 4) : address || "" } function watchResult(result, success, failure) { - if (root.watchCall) { + if (root.watchCall) root.watchCall(result, success, failure) - } else { + else success(result) - } } function showError(message) { @@ -69,22 +111,108 @@ Item { messageDialog.open() } + function walletRefreshWarning(subject) { + if (!root.wallet || root.wallet.walletSyncStatus !== "error") + return "" + return qsTr("%1 was created, but could not be refreshed. Reconnect the wallet to refresh it.") + .arg(subject) + } + + function openFailureKey() { + if (!root.wallet || root.wallet.walletSyncStatus !== "error") + return "" + return root.wallet.walletSyncError || "unknown" + } + + function openFailureMessage() { + const error = root.wallet ? root.wallet.walletSyncError : "" + return error + ? qsTr("Wallet could not be opened: %1").arg(error) + : qsTr("Wallet could not be opened.") + } + + function reportUnhandledOpenFailure() { + if (!root.wallet || root.openPending || root.wallet.isWalletOpen + || root.wallet.walletSyncStatus !== "error") { + return + } + const key = root.openFailureKey() + if (key === root.reportedOpenErrorKey) + return + root.reportedOpenErrorKey = key + root.showError(root.openFailureMessage()) + } + + function openAccepted(result) { + return result === true || result === "true" || result === 1 || result === "1" + } + + function finishOpen() { + root.openPending = false + root.busy = false + } + + function failOpen(message) { + if (!root.openPending) + return + root.reportedOpenErrorKey = root.openFailureKey() + root.finishOpen() + root.showError(message) + } + + function settleOpenFromWalletState() { + if (!root.openPending || !root.wallet) + return + const status = root.wallet.walletSyncStatus + if (status === undefined) { + root.finishOpen() + return + } + if (status === "ready" && root.wallet.isWalletOpen) { + root.finishOpen() + return + } + if (status === "error") { + root.failOpen(root.openFailureMessage()) + return + } + if (status === "closed" && root.wallet.walletExists === false) + root.failOpen(qsTr("Wallet could not be opened.")) + } + function openWallet() { - if (!root.wallet || root.busy) + if (!root.wallet || root.busy || root.walletOpening) return root.busy = true + root.openPending = true try { root.watchResult(root.wallet.openExisting(), function(ok) { - root.busy = false - if (!ok) - root.showError(qsTr("Wallet could not be opened.")) + if (!root.openAccepted(ok)) { + root.failOpen(qsTr("Wallet could not be opened.")) + return + } + Qt.callLater(root.settleOpenFromWalletState) }, function(error) { - root.busy = false - root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + root.failOpen(qsTr("Wallet could not be opened: %1").arg(error)) }) } catch (error) { - root.busy = false - root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + root.failOpen(qsTr("Wallet could not be opened: %1").arg(error)) + } + } + + function makePrimary(address) { + if (!root.wallet || !address) + return + try { + root.watchResult(root.wallet.setPrimaryAccount(address), function(ok) { + if (!ok) + root.showError(qsTr("This account cannot be primary.")) + root.syncPrimarySelection() + }, function(error) { + root.showError(qsTr("Primary account could not be changed: %1").arg(error)) + }) + } catch (error) { + root.showError(qsTr("Primary account could not be changed: %1").arg(error)) } } @@ -106,18 +234,40 @@ Item { Connections { target: root.accountModel ignoreUnknownSignals: true - function onModelReset() { root.clampSelection() } - function onRowsInserted() { root.clampSelection() } - function onRowsRemoved() { root.clampSelection() } + function onModelReset() { root.syncPrimarySelection() } + function onRowsInserted() { root.syncPrimarySelection() } + function onRowsRemoved() { root.syncPrimarySelection() } + function onDataChanged() { root.syncPrimarySelection() } } + Connections { + target: root.wallet + ignoreUnknownSignals: true + function onPrimaryAccountAddressChanged() { root.syncPrimarySelection() } + function onWalletSyncStatusChanged() { + if (!root.wallet || root.wallet.walletSyncStatus !== "error") + root.reportedOpenErrorKey = "" + Qt.callLater(root.settleOpenFromWalletState) + Qt.callLater(root.reportUnhandledOpenFailure) + } + function onWalletSyncErrorChanged() { + Qt.callLater(root.settleOpenFromWalletState) + Qt.callLater(root.reportUnhandledOpenFailure) + } + function onIsWalletOpenChanged() { Qt.callLater(root.settleOpenFromWalletState) } + function onWalletExistsChanged() { Qt.callLater(root.settleOpenFromWalletState) } + } + + Component.onCompleted: Qt.callLater(root.reportUnhandledOpenFailure) + onConnectedChanged: { if (!root.connected) { - root.selectedIndex = 0 + root.selectedIndex = -1 walletMenu.close() + } else { + root.syncPrimarySelection() } } - onViewportWidthChanged: { if (walletMenu.opened) Qt.callLater(walletMenu.updateAnchor) @@ -129,24 +279,20 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: !root.connected - enabled: root.wallet !== null && !root.busy + enabled: root.wallet !== null && !root.busy && !root.walletOpening implicitHeight: 40 implicitWidth: root.compactLayout ? 40 : 108 - text: root.compactLayout ? "" : root.busy ? qsTr("Connecting...") : qsTr("Connect") - display: root.compactLayout ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + text: root.compactLayout ? "" : (root.busy || root.walletOpening + ? qsTr("Connecting…") : qsTr("Connect")) icon.source: Qt.resolvedUrl("icons/account.svg") - icon.color: "#ffffff" - icon.width: 18 - icon.height: 18 - Accessible.name: qsTr("Connect wallet") + Accessible.name: qsTr("Connect AMM Wallet") ToolTip.text: Accessible.name ToolTip.visible: hovered && root.compactLayout background: Rectangle { - color: connectButton.pressed ? "#d95c1e" : "#f26a21" - radius: 6 + color: connectButton.pressed ? "#d97706" : "#f59e0b" + radius: 8 } - contentItem: RowLayout { spacing: 6 Image { @@ -159,12 +305,11 @@ Item { Layout.fillWidth: true visible: !root.compactLayout text: connectButton.text - color: "#ffffff" + color: "#18181b" font.bold: true horizontalAlignment: Text.AlignHCenter } } - onClicked: { if (root.wallet && root.wallet.walletExists) root.openWallet() @@ -181,42 +326,43 @@ Item { visible: root.connected enabled: !root.busy implicitHeight: 40 - implicitWidth: root.compactLayout ? 44 : Math.max(140, accountButtonLabel.implicitWidth + 58) - Accessible.name: qsTr("Wallet account %1").arg(root.selectedAddress) + implicitWidth: root.compactLayout ? 44 : Math.max(176, accountButtonLabel.implicitWidth + 54) + Accessible.name: qsTr("AMM Wallet, primary account %1").arg(root.primaryName) background: Rectangle { color: connectedButton.pressed ? "#3f3f46" : "#27272a" border.width: walletMenu.opened || connectedButton.activeFocus ? 1 : 0 - border.color: "#f26a21" - radius: 6 + border.color: "#f59e0b" + radius: 8 } - contentItem: RowLayout { spacing: 8 - Rectangle { Layout.preferredWidth: 8 Layout.preferredHeight: 8 radius: 4 - color: "#22c55e" + color: !root.wallet || root.wallet.networkStatus === undefined + || root.wallet.networkStatus === "ready" + ? "#22c55e" + : root.wallet.networkStatus === "loading" ? "#f59e0b" : "#ef4444" } - Label { id: accountButtonLabel Layout.fillWidth: true visible: !root.compactLayout - text: root.shortAddress(root.selectedAddress) || qsTr("Connected") - color: "#f4f4f5" - horizontalAlignment: Text.AlignHCenter + text: root.primaryName.length > 0 + ? qsTr("AMM Wallet · %1").arg(root.primaryName) + : qsTr("AMM Wallet") + color: "#fafafa" + font.bold: true + elide: Text.ElideRight } - Label { visible: !root.compactLayout - text: walletMenu.opened ? "\u25b4" : "\u25be" + text: walletMenu.opened ? "▴" : "▾" color: "#a1a1aa" } } - onClicked: { if (walletMenu.opened || Date.now() - walletMenu.lastClosedMs < 200) walletMenu.close() @@ -243,10 +389,8 @@ Item { Math.min(connectedButton.width - width, viewport.width - width - 12 - anchorPosition.x)) : connectedButton.width - width - y: opensAbove - ? -height - 8 - : connectedButton.height + 8 - width: Math.min(360, Math.max(0, Math.min(root.viewportWidth, + y: opensAbove ? -height - 8 : connectedButton.height + 8 + width: Math.min(400, Math.max(0, Math.min(root.viewportWidth, viewport ? viewport.width : root.viewportWidth) - 24)) height: Math.min(implicitHeight, availableMenuHeight) margins: 12 @@ -257,31 +401,28 @@ Item { if (viewport) anchorPosition = connectedButton.mapToItem(viewport, 0, 0) } - onAboutToShow: updateAnchor() - onClosed: { walletMenu.lastClosedMs = Date.now() if (walletStack.depth > 1) walletStack.pop(null, StackView.Immediate) } - Connections { target: walletMenu.opened ? walletMenu.viewport : null - function onWidthChanged() { Qt.callLater(walletMenu.updateAnchor) } function onHeightChanged() { Qt.callLater(walletMenu.updateAnchor) } } - background: Rectangle { color: "#18181b" border.color: "#3f3f46" border.width: 1 - radius: 8 + radius: 10 } contentItem: StackView { id: walletStack + objectName: "walletStack" + clip: true width: walletMenu.availableWidth height: walletMenu.availableHeight implicitWidth: walletMenu.availableWidth @@ -291,86 +432,214 @@ Item { Component { id: walletOverview + ScrollView { + implicitHeight: Math.min(overviewContent.implicitHeight, 520) + contentWidth: availableWidth + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - ColumnLayout { - spacing: 12 + ColumnLayout { + id: overviewContent + objectName: "walletOverviewContent" + width: parent.width + spacing: 12 - RowLayout { - Layout.fillWidth: true - - Item { Layout.fillWidth: true } - - WalletIconButton { - objectName: "walletAccountsButton" - iconSource: Qt.resolvedUrl("icons/account.svg") - accessibleName: qsTr("Accounts") - onClicked: walletStack.push(accountList) - } - - WalletIconButton { - objectName: "walletDisconnectButton" - iconSource: Qt.resolvedUrl("icons/power.svg") - accessibleName: qsTr("Disconnect") - onClicked: { - walletMenu.close() - if (root.wallet) - root.wallet.disconnectWallet() - } - } - } - - Rectangle { - Layout.fillWidth: true - implicitHeight: accountCard.implicitHeight + 24 - color: "#27272a" - radius: 6 - - ColumnLayout { - id: accountCard - anchors.fill: parent - anchors.margins: 12 - spacing: 8 - - RowLayout { + RowLayout { + Layout.fillWidth: true + ColumnLayout { Layout.fillWidth: true - + spacing: 1 Label { - text: root.selectedName || qsTr("Account") - color: "#f4f4f5" + text: qsTr("AMM Wallet") + color: "#fafafa" font.bold: true + font.pixelSize: 16 } - Label { - text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") + text: root.wallet && root.wallet.activeNetwork + ? root.wallet.activeNetwork : qsTr("Network unavailable") color: "#a1a1aa" font.pixelSize: 11 } - - Item { Layout.fillWidth: true } - - Label { - text: root.selectedBalance || "-" - color: "#f4f4f5" - font.bold: true + } + WalletIconButton { + objectName: "walletAccountsButton" + iconSource: Qt.resolvedUrl("icons/account.svg") + accessibleName: qsTr("Accounts") + onClicked: walletStack.push(accountList, StackView.Immediate) + } + WalletIconButton { + objectName: "walletDisconnectButton" + iconSource: Qt.resolvedUrl("icons/power.svg") + accessibleName: qsTr("Disconnect") + onClicked: { + walletMenu.close() + if (root.wallet) + root.wallet.disconnectWallet() } } + } - RowLayout { - Layout.fillWidth: true - spacing: 4 - - Label { + Rectangle { + Layout.fillWidth: true + implicitHeight: identityCard.implicitHeight + 24 + color: "#27272a" + radius: 8 + ColumnLayout { + id: identityCard + anchors.fill: parent + anchors.margins: 12 + spacing: 7 + RowLayout { Layout.fillWidth: true - text: root.selectedAddress - color: "#a1a1aa" - font.family: "monospace" - font.pixelSize: 11 - elide: Text.ElideMiddle + Label { + Layout.fillWidth: true + text: root.primaryName || qsTr("No primary account") + color: "#fafafa" + font.bold: true + elide: Text.ElideRight + } + Label { + text: qsTr("Primary") + color: "#fbbf24" + font.pixelSize: 11 + font.bold: true + } } + Label { + text: root.selectedIsPublic ? qsTr("Public user account") + : qsTr("Private account") + color: "#a1a1aa" + font.pixelSize: 11 + } + RowLayout { + Layout.fillWidth: true + spacing: 4 + Label { + Layout.fillWidth: true + text: root.selectedDisplayAddress + color: "#71717a" + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + CopyButton { + visible: root.selectedDisplayAddress.length > 0 + onCopyRequested: root.copyToClipboard(root.selectedDisplayAddress) + } + } + } + } - CopyButton { - visible: root.selectedAddress.length > 0 - onCopyRequested: root.copyToClipboard(root.selectedAddress) + Label { + text: qsTr("Assets") + color: "#fafafa" + font.bold: true + } + Label { + visible: root.wallet && root.wallet.assetStatus === "loading" + text: qsTr("Loading balances…") + color: "#a1a1aa" + } + Repeater { + model: root.walletAssets + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + visible: modelData.section === "assets" + implicitHeight: visible ? 62 : 0 + color: "#27272a" + radius: 8 + RowLayout { + anchors.fill: parent + anchors.margins: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: modelData.name + color: "#fafafa" + font.bold: true + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: root.shortAddress(modelData.displayDefinitionId + || modelData.definitionId) + color: "#71717a" + font.family: "monospace" + font.pixelSize: 10 + } + } + Label { + text: modelData.balance + color: "#fafafa" + font.family: "monospace" + font.bold: true + } + CopyButton { + onCopyRequested: root.copyToClipboard( + modelData.displayDefinitionId || modelData.definitionId) + } + } + } + } + Label { + visible: (!root.walletAssets || root.walletAssets.length === 0) + && (!root.wallet || root.wallet.assetStatus !== "loading") + text: root.wallet && root.wallet.assetError + ? qsTr("Assets unavailable: %1").arg(root.wallet.assetError) + : qsTr("No assets yet") + color: "#a1a1aa" + wrapMode: Text.Wrap + } + Button { + objectName: "walletAvailableAssetsButton" + Layout.fillWidth: true + visible: root.availableAssetCount > 0 + text: root.availableExpanded ? qsTr("Hide available tokens") + : qsTr("Available tokens") + flat: true + onClicked: root.availableExpanded = !root.availableExpanded + } + Repeater { + model: root.walletAssets + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + visible: root.availableExpanded && modelData.section === "available" + implicitHeight: visible ? 58 : 0 + color: "#202023" + radius: 8 + RowLayout { + anchors.fill: parent + anchors.margins: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: modelData.name + color: modelData.status === "ready" ? "#d4d4d8" : "#a1a1aa" + elide: Text.ElideRight + } + Label { + text: root.shortAddress(modelData.displayDefinitionId + || modelData.definitionId) + color: "#71717a" + font.family: "monospace" + font.pixelSize: 10 + } + } + Label { + text: modelData.status === "ready" ? "0" : qsTr("Unavailable") + color: "#71717a" + font.pixelSize: 11 + } + CopyButton { + onCopyRequested: root.copyToClipboard( + modelData.displayDefinitionId || modelData.definitionId) + } } } } @@ -380,77 +649,173 @@ Item { Component { id: accountList - ColumnLayout { - spacing: 12 - + spacing: 10 RowLayout { Layout.fillWidth: true - WalletIconButton { + objectName: "walletAccountsBackButton" iconSource: Qt.resolvedUrl("icons/back.svg") accessibleName: qsTr("Back") - onClicked: walletStack.pop() + onClicked: walletStack.pop(null, StackView.Immediate) } - Label { Layout.fillWidth: true text: qsTr("Accounts") - color: "#f4f4f5" + color: "#fafafa" font.bold: true } } - + Label { + Layout.fillWidth: true + visible: walletMenu.availableMenuHeight >= 280 + text: qsTr("Choose the account used as your wallet identity. Program records stay under Advanced.") + color: "#a1a1aa" + font.pixelSize: 11 + wrapMode: Text.Wrap + } ListView { id: accountListView objectName: "walletAccountList" Layout.fillWidth: true Layout.fillHeight: true - Layout.minimumHeight: 0 - Layout.preferredHeight: Math.min(contentHeight, 260) + Layout.minimumHeight: 48 + Layout.preferredHeight: Math.min(contentHeight, 300) clip: true - spacing: 6 + spacing: 0 model: root.accountModel ScrollIndicator.vertical: ScrollIndicator { } + delegate: Item { + id: accountWrapper + required property int index + required property string name + required property string alias + required property string address + required property string displayAddress + required property string balance + required property bool isPublic + required property string kind + required property string section + required property string programName + required property string accountType + required property string visibility + required property bool canBePrimary + required property bool isPrimary - delegate: AccountDelegate { + readonly property bool shown: section === "accounts" + || (root.advancedExpanded && section === "advanced") width: ListView.view.width - highlighted: index === root.selectedIndex - onClicked: { - root.selectedIndex = index - walletStack.pop() + height: shown ? accountDelegate.implicitHeight + 6 : 0 + visible: shown + + function clicked() { + if (canBePrimary && !isPrimary) + root.makePrimary(address) + } + + AccountDelegate { + id: accountDelegate + width: parent.width + index: accountWrapper.index + name: accountWrapper.name + alias: accountWrapper.alias + address: accountWrapper.address + displayAddress: accountWrapper.displayAddress + balance: accountWrapper.balance + isPublic: accountWrapper.isPublic + kind: accountWrapper.kind + section: accountWrapper.section + programName: accountWrapper.programName + accountType: accountWrapper.accountType + visibility: accountWrapper.visibility + canBePrimary: accountWrapper.canBePrimary + isPrimary: accountWrapper.isPrimary + onMakePrimaryRequested: function(address) { root.makePrimary(address) } + onRenameRequested: function(address, alias) { + renameDialog.accountAddress = address + renameField.text = alias + renameDialog.open() + } + onCopyRequested: function(text) { root.copyToClipboard(text) } } - onCopyRequested: function(text) { root.copyToClipboard(text) } } } - - Button { - objectName: "walletAddAccountButton" + RowLayout { Layout.fillWidth: true - text: qsTr("Add account") - enabled: !root.busy - onClicked: createAccountDialog.open() + spacing: 6 + Button { + objectName: "walletAdvancedAccountsButton" + Layout.fillWidth: true + text: root.advancedExpanded ? qsTr("Hide Advanced") : qsTr("Advanced") + flat: true + onClicked: root.advancedExpanded = !root.advancedExpanded + } + Button { + objectName: "walletAddAccountButton" + Layout.fillWidth: true + text: qsTr("Add account") + enabled: !root.busy + onClicked: createAccountDialog.open() + } } } } } + Dialog { + id: renameDialog + objectName: "walletRenameDialog" + property string accountAddress: "" + parent: Overlay.overlay + modal: true + anchors.centerIn: parent + width: Math.min(360, parent ? parent.width - 32 : 360) + title: qsTr("Rename account") + standardButtons: Dialog.Save | Dialog.Cancel + TextField { + id: renameField + objectName: "walletAliasField" + width: parent.width + maximumLength: 40 + placeholderText: qsTr("Account name") + Accessible.name: qsTr("Account name") + } + onAccepted: { + if (!root.wallet) + return + try { + root.watchResult(root.wallet.setAccountAlias(accountAddress, renameField.text), + function(ok) { + if (!ok) + root.showError(qsTr("Account name could not be saved.")) + }, function(error) { + root.showError(qsTr("Account name could not be saved: %1").arg(error)) + }) + } catch (error) { + root.showError(qsTr("Account name could not be saved: %1").arg(error)) + } + } + } + CreateWalletDialog { id: createWalletDialog objectName: "createWalletDialog" walletHome: root.wallet ? root.wallet.walletHome || "" : "" busy: root.busy - onCreateRequested: function(password) { if (!root.wallet || root.busy) return + root.postCreationWarning = "" + root.createdWalletAwaitingAcknowledgement = false root.busy = true try { root.watchResult(root.wallet.createNewDefault(password), function(mnemonic) { root.busy = false - if (mnemonic && mnemonic.length > 0) + if (mnemonic && mnemonic.length > 0) { createWalletDialog.mnemonic = mnemonic - else + root.createdWalletAwaitingAcknowledgement = true + root.postCreationWarning = root.walletRefreshWarning(qsTr("Wallet")) + } else createWalletDialog.errorText = qsTr("Wallet could not be created.") }, function(error) { root.busy = false @@ -461,30 +826,40 @@ Item { createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error) } } - onCopyRequested: function(text) { root.copyToClipboard(text) } + onClosed: { + const warning = root.postCreationWarning.length > 0 ? root.postCreationWarning + : root.createdWalletAwaitingAcknowledgement + ? root.walletRefreshWarning(qsTr("Wallet")) : "" + root.postCreationWarning = "" + root.createdWalletAwaitingAcknowledgement = false + if (warning.length > 0) + root.showError(warning) + } } CreateAccountDialog { id: createAccountDialog objectName: "createAccountDialog" busy: root.busy - onCreateRequested: function(isPublic) { if (!root.wallet || root.busy) return root.busy = true try { - const request = isPublic - ? root.wallet.createAccountPublic() - : root.wallet.createAccountPrivate() + const request = isPublic ? root.wallet.createAccountPublic() + : root.wallet.createAccountPrivate() root.watchResult(request, function(accountId) { root.busy = false if (accountId && accountId.length > 0) { createAccountDialog.close() - } else { + Qt.callLater(function() { + const warning = root.walletRefreshWarning(qsTr("Account")) + if (warning.length > 0) + root.showError(warning) + }) + } else root.showError(qsTr("Account could not be created.")) - } }, function(error) { root.busy = false root.showError(qsTr("Account could not be created: %1").arg(error)) diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml index b0ff529..b0918fc 100644 --- a/apps/shared/wallet/qml/internal/AccountDelegate.qml +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -7,71 +7,136 @@ ItemDelegate { required property int index required property string name + required property string alias required property string address + required property string displayAddress required property string balance required property bool isPublic + required property string kind + required property string section + required property string programName + required property string accountType + required property string visibility + required property bool canBePrimary + required property bool isPrimary signal copyRequested(string text) + signal makePrimaryRequested(string address) + signal renameRequested(string address, string alias) leftPadding: 12 rightPadding: 8 topPadding: 10 bottomPadding: 10 + enabled: root.section !== "hidden" + Accessible.name: root.isPrimary + ? qsTr("%1, primary account").arg(root.name) + : root.name - Accessible.name: qsTr("%1, balance %2").arg(root.name).arg(root.balance || "0") + function kindLabel() { + if (root.kind === "user") + return qsTr("User") + if (root.kind === "private") + return qsTr("Account") + if (root.accountType.length > 0) + return root.accountType + return root.kind === "unknown" ? qsTr("Unknown") : qsTr("Program") + } background: Rectangle { - color: root.highlighted || root.hovered ? "#27272a" : "#18181b" - radius: 6 - border.width: root.activeFocus ? 1 : 0 - border.color: "#f26a21" + color: root.isPrimary || root.hovered ? "#27272a" : "#18181b" + radius: 8 + border.width: root.activeFocus || root.isPrimary ? 1 : 0 + border.color: root.isPrimary ? "#f59e0b" : "#52525b" } contentItem: ColumnLayout { - spacing: 6 + spacing: 7 RowLayout { Layout.fillWidth: true - spacing: 8 + spacing: 7 Label { + Layout.fillWidth: true text: root.name - color: "#f4f4f5" + color: "#fafafa" + font.bold: true + elide: Text.ElideRight + } + + Label { + visible: root.isPrimary + text: qsTr("Primary") + color: "#fbbf24" + font.pixelSize: 11 font.bold: true } Label { - text: root.isPublic ? qsTr("Public") : qsTr("Private") + text: root.kindLabel() color: "#a1a1aa" font.pixelSize: 11 } - Item { Layout.fillWidth: true } - Label { - text: root.balance.length > 0 ? root.balance : "-" - color: "#f4f4f5" - font.bold: true + text: root.visibility === "private" ? qsTr("Private") : qsTr("Public") + color: root.visibility === "private" ? "#c4b5fd" : "#93c5fd" + font.pixelSize: 11 } } + Label { + visible: root.programName.length > 0 + Layout.fillWidth: true + text: qsTr("%1 program · wallet controlled").arg(root.programName) + color: "#a1a1aa" + font.pixelSize: 11 + elide: Text.ElideRight + } + RowLayout { Layout.fillWidth: true spacing: 4 Label { Layout.fillWidth: true - text: root.address - color: "#a1a1aa" + text: root.displayAddress + color: "#71717a" font.family: "monospace" font.pixelSize: 11 elide: Text.ElideMiddle } CopyButton { - visible: root.address.length > 0 - onCopyRequested: root.copyRequested(root.address) + visible: root.displayAddress.length > 0 + onCopyRequested: root.copyRequested(root.displayAddress) + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Button { + text: qsTr("Rename") + flat: true + onClicked: root.renameRequested(root.address, root.alias) + } + + Item { Layout.fillWidth: true } + + Button { + visible: root.canBePrimary && !root.isPrimary + text: qsTr("Make primary") + flat: true + onClicked: root.makePrimaryRequested(root.address) } } } + + onClicked: { + if (root.canBePrimary && !root.isPrimary) + root.makePrimaryRequested(root.address) + } } diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp index 3e56952..f23321f 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.cpp +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -73,6 +74,46 @@ WalletCreation failedCreation(WalletFailure failure) creation.snapshot.failure = failure; return creation; } + +WalletAccountRead parsePublicAccount(const QString& accountId, const QString& payload) +{ + WalletAccountRead read; + read.accountId = accountId; + if (!isHex(accountId, 64)) + return read; + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return read; + + const QJsonObject account = document.object(); + const QString owner = account.value(QStringLiteral("program_owner")).toString(); + const QString balance = account.value(QStringLiteral("balance")).toString(); + const QString nonce = account.value(QStringLiteral("nonce")).toString(); + const QString data = account.value(QStringLiteral("data")).toString(); + if (!isHex(owner, 64) + || !isHex(balance, 32) + || !isHex(nonce, 32) + || data.size() % 2 != 0 + || !isHex(data, data.size())) { + return read; + } + + read.status = QStringLiteral("ok"); + read.programOwner = owner; + read.balanceHex = balance; + read.nonceHex = nonce; + read.dataHex = data; + return read; +} + +void applyPublicRead(WalletAccount& account, const WalletAccountRead& read) +{ + account.readStatus = read.status; + account.programOwner = read.programOwner; + account.dataHex = read.dataHex; +} } struct LogosWalletProvider::Impl { @@ -130,6 +171,76 @@ WalletSession LogosWalletProvider::connect(const WalletPaths& paths) return session; } +void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback callback) +{ + clearSnapshot(); + const quint64 generation = ++m_generation; + if (!m_impl->logos) { + QTimer::singleShot(0, [callback = std::move(callback)]() mutable { + callback(failedSession(WalletFailure::WalletUnavailable)); + }); + return; + } + + auto finishOpen = [this, generation, callback = std::move(callback)]( + bool adopted, WalletFailure failure) mutable { + if (generation != m_generation) + return; + if (failure != WalletFailure::None) { + callback(failedSession(failure)); + return; + } + m_connected = true; + loadSnapshotAsync(generation, + [this, generation, adopted, callback = std::move(callback)]( + WalletSnapshot snapshot) mutable { + if (generation != m_generation) + return; + WalletSession session; + session.adopted = adopted; + session.failure = snapshot.failure; + session.snapshot = std::move(snapshot); + callback(std::move(session)); + }); + }; + + auto openStored = [this, generation, paths, finishOpen]() mutable { + if (generation != m_generation) + return; + if (!QFileInfo::exists(paths.storage)) { + finishOpen(false, WalletFailure::WalletMissing); + return; + } + m_impl->logos->logos_execution_zone.openAsync( + paths.config, paths.storage, + [this, generation, finishOpen](int result) mutable { + if (generation != m_generation) + return; + finishOpen(false, result == WALLET_FFI_SUCCESS + ? WalletFailure::None : WalletFailure::OpenFailed); + }); + }; + + m_impl->logos->logos_execution_zone.get_sequencer_addrAsync( + [this, generation, finishOpen, openStored](QString address) mutable { + if (generation != m_generation) + return; + if (!address.isEmpty()) { + finishOpen(true, WalletFailure::None); + return; + } + m_impl->logos->logos_execution_zone.list_accountsAsync( + [this, generation, finishOpen, openStored](QVariantList accounts) mutable { + if (generation != m_generation) + return; + if (!accounts.isEmpty()) + finishOpen(true, WalletFailure::None); + else + openStored(); + }); + }); +} + WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, const QString& password) { @@ -180,6 +291,26 @@ WalletSnapshot LogosWalletProvider::snapshot(bool forceRefresh) return result; } +void LogosWalletProvider::snapshotAsync(bool forceRefresh, SnapshotCallback callback) +{ + if (m_snapshotReady && !forceRefresh) { + const WalletSnapshot snapshot = m_snapshot; + QTimer::singleShot(0, [callback = std::move(callback), snapshot]() mutable { + callback(snapshot); + }); + return; + } + if (!m_connected) { + WalletSnapshot snapshot; + snapshot.failure = WalletFailure::WalletUnavailable; + QTimer::singleShot(0, [callback = std::move(callback), snapshot]() mutable { + callback(snapshot); + }); + return; + } + loadSnapshotAsync(++m_generation, std::move(callback)); +} + void LogosWalletProvider::clearSnapshot() { m_snapshot = {}; @@ -208,45 +339,57 @@ WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic) if (isPublic) creation.publicAccount = readPublicAccount(creation.accountId); - - clearSnapshot(); creation.snapshot = snapshot(true); return creation; } WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const { - WalletAccountRead read; - read.accountId = accountId; if (!m_impl->logos || !isHex(accountId, 64)) - return read; + return WalletAccountRead { accountId }; + return parsePublicAccount( + accountId, + m_impl->logos->logos_execution_zone.get_account_public(accountId)); +} - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson( - m_impl->logos->logos_execution_zone.get_account_public(accountId).toUtf8(), - &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) - return read; - - const QJsonObject account = document.object(); - const QString owner = account.value(QStringLiteral("program_owner")).toString(); - const QString balance = account.value(QStringLiteral("balance")).toString(); - const QString nonce = account.value(QStringLiteral("nonce")).toString(); - const QString data = account.value(QStringLiteral("data")).toString(); - if (!isHex(owner, 64) - || !isHex(balance, 32) - || !isHex(nonce, 32) - || data.size() % 2 != 0 - || !isHex(data, data.size())) { - return read; +void LogosWalletProvider::readPublicAccountsAsync( + const QStringList& accountIds, + AccountReadsCallback callback) +{ + if (!m_impl->logos || accountIds.isEmpty()) { + QTimer::singleShot(0, + [callback = std::move(callback)]() mutable { callback({}); }); + return; } - read.status = QStringLiteral("ok"); - read.programOwner = owner; - read.balanceHex = balance; - read.nonceHex = nonce; - read.dataHex = data; - return read; + struct BatchState { + QVector reads; + qsizetype remaining = 0; + AccountReadsCallback callback; + }; + const quint64 generation = m_generation; + auto state = std::make_shared(); + state->reads.resize(accountIds.size()); + state->remaining = accountIds.size(); + state->callback = std::move(callback); + for (qsizetype index = 0; index < accountIds.size(); ++index) { + const QString accountId = accountIds.at(index); + if (!isHex(accountId, 64)) { + state->reads[index] = WalletAccountRead { accountId }; + if (--state->remaining == 0) + state->callback(std::move(state->reads)); + continue; + } + m_impl->logos->logos_execution_zone.get_account_publicAsync( + accountId, + [this, generation, state, index, accountId](QString payload) mutable { + if (generation != m_generation) + return; + state->reads[index] = parsePublicAccount(accountId, payload); + if (--state->remaining == 0) + state->callback(std::move(state->reads)); + }); + } } WalletSubmission LogosWalletProvider::submitPublicTransaction( @@ -314,6 +457,7 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction( void LogosWalletProvider::disconnect() { + ++m_generation; if (m_connected) save(); clearSnapshot(); @@ -361,20 +505,181 @@ WalletSnapshot LogosWalletProvider::loadSnapshot() if (account.isPublic) { const WalletAccountRead read = readPublicAccount(address); result.publicAccountReads.append(read); + applyPublicRead(account, read); account.balance = read.ok() ? littleEndianU128ToDecimal(read.balanceHex) : m_impl->logos->logos_execution_zone.get_balance(address, true); } else { + account.readStatus = QStringLiteral("private"); account.balance = m_impl->logos->logos_execution_zone.get_balance(address, false); } result.accounts.append(account); } - if (!save()) - result.failure = WalletFailure::SaveFailed; return result; } +void LogosWalletProvider::loadSnapshotAsync(quint64 generation, SnapshotCallback callback) +{ + if (!m_impl->logos || generation != m_generation) + return; + + m_impl->logos->logos_execution_zone.get_current_block_heightAsync( + [this, generation, callback = std::move(callback)](int currentHeight) mutable { + if (generation != m_generation) + return; + + auto afterSync = [this, generation, currentHeight, + callback = std::move(callback)](int syncResult) mutable { + if (generation != m_generation) + return; + if (syncResult != WALLET_FFI_SUCCESS) { + WalletSnapshot failed; + failed.failure = WalletFailure::ReadFailed; + callback(std::move(failed)); + return; + } + + m_impl->logos->logos_execution_zone.get_last_synced_blockAsync( + [this, generation, currentHeight, + callback = std::move(callback)](int lastSynced) mutable { + if (generation != m_generation) + return; + m_impl->logos->logos_execution_zone.get_sequencer_addrAsync( + [this, generation, currentHeight, lastSynced, + callback = std::move(callback)](QString address) mutable { + if (generation != m_generation) + return; + m_impl->logos->logos_execution_zone.list_accountsAsync( + [this, generation, currentHeight, lastSynced, + address = std::move(address), + callback = std::move(callback)]( + QVariantList entries) mutable { + if (generation != m_generation) + return; + + struct SnapshotState { + WalletSnapshot snapshot; + QVector publicReads; + QVector publicFlags; + qsizetype remaining = 0; + SnapshotCallback callback; + }; + auto state = std::make_shared(); + state->snapshot.currentBlockHeight = static_cast( + qMax(0, currentHeight)); + state->snapshot.lastSyncedBlock = static_cast( + qMax(0, lastSynced)); + state->snapshot.sequencerAddress = std::move(address); + state->snapshot.accounts.resize(entries.size()); + state->publicReads.resize(entries.size()); + state->publicFlags.resize(entries.size()); + state->remaining = entries.size(); + state->callback = std::move(callback); + + for (qsizetype index = 0; index < entries.size(); ++index) { + const QVariantMap entry = entries.at(index).toMap(); + const QString accountId = entry + .value(QStringLiteral("account_id")).toString(); + if (entry.isEmpty() || !isHex(accountId, 64)) { + state->snapshot.failure = WalletFailure::ReadFailed; + state->callback(std::move(state->snapshot)); + return; + } + state->snapshot.accounts[index] = WalletAccount { + accountId, + {}, + entry.value(QStringLiteral("is_public"), true).toBool(), + }; + if (!state->snapshot.accounts.at(index).isPublic) { + state->snapshot.accounts[index].readStatus = + QStringLiteral("private"); + } + state->publicFlags[index] = + state->snapshot.accounts.at(index).isPublic; + } + + auto finishOne = std::make_shared>(); + *finishOne = [this, generation, state, finishOne]() mutable { + if (generation != m_generation || --state->remaining > 0) + return; + for (qsizetype index = 0; + index < state->publicReads.size(); ++index) { + if (state->publicFlags.at(index)) + state->snapshot.publicAccountReads.append( + state->publicReads.at(index)); + } + m_impl->logos->logos_execution_zone.saveAsync( + [this, generation, state](int result) mutable { + if (generation != m_generation) + return; + if (result != WALLET_FFI_SUCCESS) + state->snapshot.failure = WalletFailure::SaveFailed; + if (state->snapshot.ok()) { + m_snapshot = state->snapshot; + m_snapshotReady = true; + } + state->callback(std::move(state->snapshot)); + }); + }; + + if (entries.isEmpty()) { + state->remaining = 1; + (*finishOne)(); + return; + } + + for (qsizetype index = 0; index < entries.size(); ++index) { + const WalletAccount account = state->snapshot.accounts.at(index); + if (!account.isPublic) { + m_impl->logos->logos_execution_zone.get_balanceAsync( + account.address, false, + [state, finishOne, index](QString balance) { + state->snapshot.accounts[index].balance = + std::move(balance); + (*finishOne)(); + }); + continue; + } + + m_impl->logos->logos_execution_zone.get_account_publicAsync( + account.address, + [this, state, finishOne, index, + accountId = account.address](QString payload) { + const WalletAccountRead read = + parsePublicAccount(accountId, payload); + state->publicReads[index] = read; + applyPublicRead( + state->snapshot.accounts[index], read); + if (read.ok()) { + state->snapshot.accounts[index].balance = + littleEndianU128ToDecimal(read.balanceHex); + (*finishOne)(); + return; + } + m_impl->logos->logos_execution_zone.get_balanceAsync( + accountId, true, + [state, finishOne, index](QString balance) { + state->snapshot.accounts[index].balance = + std::move(balance); + (*finishOne)(); + }); + }); + } + }); + }); + }); + }; + + if (currentHeight > 0) { + m_impl->logos->logos_execution_zone.sync_to_blockAsync( + currentHeight, std::move(afterSync)); + } else { + afterSync(WALLET_FFI_SUCCESS); + } + }); +} + bool LogosWalletProvider::save() const { return m_impl->logos diff --git a/apps/shared/wallet/src/LogosWalletProvider.h b/apps/shared/wallet/src/LogosWalletProvider.h index d046290..a22aac8 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.h +++ b/apps/shared/wallet/src/LogosWalletProvider.h @@ -14,12 +14,16 @@ public: ~LogosWalletProvider() override; WalletSession connect(const WalletPaths& paths) override; + void connectAsync(const WalletPaths& paths, SessionCallback callback) override; WalletCreation createWallet(const WalletPaths& paths, const QString& password) override; WalletSnapshot snapshot(bool forceRefresh = false) override; + void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override; void clearSnapshot() override; WalletAccountCreation createAccount(bool isPublic) override; WalletAccountRead readPublicAccount(const QString& accountId) const override; + void readPublicAccountsAsync(const QStringList& accountIds, + AccountReadsCallback callback) override; WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) override; void disconnect() override; @@ -27,6 +31,7 @@ public: private: bool sharedWalletIsOpen() const; WalletSnapshot loadSnapshot(); + void loadSnapshotAsync(quint64 generation, SnapshotCallback callback); bool save() const; struct Impl; @@ -34,4 +39,5 @@ private: WalletSnapshot m_snapshot; bool m_snapshotReady = false; bool m_connected = false; + quint64 m_generation = 0; }; diff --git a/apps/shared/wallet/src/WalletAccountId.cpp b/apps/shared/wallet/src/WalletAccountId.cpp new file mode 100644 index 0000000..76903c7 --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountId.cpp @@ -0,0 +1,56 @@ +#include "WalletAccountId.h" + +#include +#include + +namespace { +constexpr char BASE58_ALPHABET[] = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +bool isHexCharacter(QChar character) +{ + const ushort value = character.unicode(); + return (value >= '0' && value <= '9') + || (value >= 'a' && value <= 'f') + || (value >= 'A' && value <= 'F'); +} +} + +QString walletAccountIdToBase58(const QString& accountId) +{ + if (accountId.size() != 64) + return {}; + for (const QChar character : accountId) { + if (!isHexCharacter(character)) + return {}; + } + + const QByteArray bytes = QByteArray::fromHex(accountId.toLatin1()); + if (bytes.size() != 32) + return {}; + + qsizetype leadingZeroes = 0; + while (leadingZeroes < bytes.size() && bytes.at(leadingZeroes) == 0) + ++leadingZeroes; + + QVector digits; + digits.reserve(45); + for (const char byte : bytes) { + int carry = static_cast(byte); + for (unsigned char& digit : digits) { + carry += static_cast(digit) * 256; + digit = static_cast(carry % 58); + carry /= 58; + } + while (carry > 0) { + digits.append(static_cast(carry % 58)); + carry /= 58; + } + } + + QString encoded(leadingZeroes, QLatin1Char('1')); + encoded.reserve(leadingZeroes + digits.size()); + for (auto digit = digits.crbegin(); digit != digits.crend(); ++digit) + encoded.append(QLatin1Char(BASE58_ALPHABET[*digit])); + return encoded; +} diff --git a/apps/shared/wallet/src/WalletAccountId.h b/apps/shared/wallet/src/WalletAccountId.h new file mode 100644 index 0000000..ad7cf3e --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountId.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +QString walletAccountIdToBase58(const QString& accountId); diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp index 06e94bf..615f4b7 100644 --- a/apps/shared/wallet/src/WalletAccountModel.cpp +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -1,5 +1,13 @@ #include "WalletAccountModel.h" +#include "WalletAccountId.h" + +#include + +namespace { +const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0')); +} + WalletAccountModel::WalletAccountModel(QObject* parent) : QAbstractListModel(parent) { @@ -21,10 +29,36 @@ QVariant WalletAccountModel::data(const QModelIndex& index, int role) const return account.name; case AddressRole: return account.address; + case DisplayAddressRole: + return account.displayAddress; case BalanceRole: return account.balance; case IsPublicRole: return account.isPublic; + case KindRole: + return account.kind; + case SectionRole: + return account.section; + case ProgramOwnerRole: + return account.programOwner; + case ReadStatusRole: + return account.readStatus; + case ProgramNameRole: + return account.programName; + case AccountTypeRole: + return account.accountType; + case VisibilityRole: + return account.isPublic ? QStringLiteral("public") : QStringLiteral("private"); + case ControlRole: + return QStringLiteral("wallet"); + case CanBePrimaryRole: + return account.canBePrimary; + case IsPrimaryRole: + return account.isPrimary; + case DefinitionIdRole: + return account.definitionId; + case AliasRole: + return account.alias; default: return {}; } @@ -37,25 +71,170 @@ QHash WalletAccountModel::roleNames() const { AddressRole, "address" }, { BalanceRole, "balance" }, { IsPublicRole, "isPublic" }, + { KindRole, "kind" }, + { SectionRole, "section" }, + { ProgramOwnerRole, "programOwner" }, + { ReadStatusRole, "readStatus" }, + { ProgramNameRole, "programName" }, + { AccountTypeRole, "accountType" }, + { VisibilityRole, "visibility" }, + { ControlRole, "control" }, + { CanBePrimaryRole, "canBePrimary" }, + { IsPrimaryRole, "isPrimary" }, + { DefinitionIdRole, "definitionId" }, + { AliasRole, "alias" }, + { DisplayAddressRole, "displayAddress" }, }; } -void WalletAccountModel::replaceAccounts(const QVector& accounts) +void WalletAccountModel::replaceAccounts(const QVector& accounts, + const QHash& aliases, + const QString& primaryAddress) { beginResetModel(); const qsizetype oldCount = m_accounts.size(); m_accounts.clear(); m_accounts.reserve(accounts.size()); - for (qsizetype index = 0; index < accounts.size(); ++index) { - const WalletAccount& account = accounts.at(index); - m_accounts.append({ - QStringLiteral("Account %1").arg(index + 1), - account.address, - account.balance, - account.isPublic, - }); + for (const WalletAccount& account : accounts) { + Entry entry; + entry.alias = aliases.value(account.address); + entry.address = account.address; + entry.displayAddress = walletAccountIdToBase58(account.address); + if (entry.displayAddress.isEmpty()) + entry.displayAddress = account.address; + entry.balance = account.balance; + entry.isPublic = account.isPublic; + entry.programOwner = account.programOwner; + entry.readStatus = account.readStatus; + if (!account.isPublic) { + entry.kind = QStringLiteral("private"); + entry.canBePrimary = true; + } else if (account.readStatus != QStringLiteral("ok")) { + entry.kind = QStringLiteral("unknown"); + } else if (account.programOwner == DEFAULT_PROGRAM_OWNER) { + entry.kind = QStringLiteral("user"); + entry.canBePrimary = true; + } else { + entry.kind = QStringLiteral("program"); + } + entry.section = sectionFor(entry); + entry.isPrimary = account.address == primaryAddress && entry.canBePrimary; + updateEntryName(entry); + m_accounts.append(std::move(entry)); } endResetModel(); if (oldCount != m_accounts.size()) emit countChanged(); } + +void WalletAccountModel::applyPresentations( + const QVector& presentations) +{ + for (const WalletAccountPresentation& presentation : presentations) { + const int row = indexOf(presentation.address); + if (row < 0) + continue; + Entry& entry = m_accounts[row]; + if (!presentation.kind.isEmpty()) + entry.kind = presentation.kind; + entry.programName = presentation.programName; + entry.accountType = presentation.accountType; + entry.definitionId = presentation.definitionId; + entry.semanticName = presentation.semanticName; + entry.section = sectionFor(entry, presentation.hiddenFromAccounts); + entry.canBePrimary = entry.kind == QStringLiteral("user") + || entry.kind == QStringLiteral("private"); + if (!entry.canBePrimary) + entry.isPrimary = false; + updateEntryName(entry); + const QModelIndex changed = index(row); + emit dataChanged(changed, changed); + } +} + +void WalletAccountModel::setAlias(const QString& address, const QString& alias) +{ + const int row = indexOf(address); + if (row < 0) + return; + Entry& entry = m_accounts[row]; + entry.alias = alias; + updateEntryName(entry); + emit dataChanged(index(row), index(row), { NameRole, AliasRole }); +} + +void WalletAccountModel::setPrimaryAddress(const QString& address) +{ + for (int row = 0; row < m_accounts.size(); ++row) { + Entry& entry = m_accounts[row]; + const bool next = entry.address == address && entry.canBePrimary; + if (entry.isPrimary == next) + continue; + entry.isPrimary = next; + emit dataChanged(index(row), index(row), { IsPrimaryRole }); + } +} + +bool WalletAccountModel::contains(const QString& address) const +{ + return indexOf(address) >= 0; +} + +bool WalletAccountModel::canBePrimary(const QString& address) const +{ + const int row = indexOf(address); + return row >= 0 && m_accounts.at(row).canBePrimary; +} + +QString WalletAccountModel::firstAutomaticPrimary() const +{ + for (const Entry& entry : m_accounts) { + if (entry.kind == QStringLiteral("user")) + return entry.address; + } + return {}; +} + +int WalletAccountModel::indexOf(const QString& address) const +{ + for (int row = 0; row < m_accounts.size(); ++row) { + if (m_accounts.at(row).address == address) + return row; + } + return -1; +} + +QString WalletAccountModel::defaultName(const Entry& entry) +{ + if (!entry.accountType.isEmpty()) { + QString name = entry.accountType; + for (qsizetype index = 1; index < name.size(); ++index) { + if (name.at(index).isUpper() && name.at(index - 1).isLower()) + name.insert(index++, QLatin1Char(' ')); + } + return name; + } + if (entry.kind == QStringLiteral("user")) + return QStringLiteral("User account"); + if (entry.kind == QStringLiteral("private")) + return QStringLiteral("Private account"); + if (entry.kind == QStringLiteral("unknown")) + return QStringLiteral("Unknown account"); + return QStringLiteral("Program account"); +} + +QString WalletAccountModel::sectionFor(const Entry& entry, bool hiddenFromAccounts) +{ + if (hiddenFromAccounts || entry.kind == QStringLiteral("token_holding")) + return QStringLiteral("hidden"); + if (entry.kind == QStringLiteral("user") || entry.kind == QStringLiteral("private")) + return QStringLiteral("accounts"); + return QStringLiteral("advanced"); +} + +void WalletAccountModel::updateEntryName(Entry& entry) +{ + entry.name = !entry.alias.isEmpty() + ? entry.alias + : (!entry.semanticName.isEmpty() ? entry.semanticName : defaultName(entry)); +} diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h index c00b0d6..86acf4f 100644 --- a/apps/shared/wallet/src/WalletAccountModel.h +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -1,10 +1,21 @@ #pragma once #include +#include #include #include "WalletProvider.h" +struct WalletAccountPresentation { + QString address; + QString kind; + QString semanticName; + QString programName; + QString accountType; + QString definitionId; + bool hiddenFromAccounts = false; +}; + class WalletAccountModel final : public QAbstractListModel { Q_OBJECT Q_PROPERTY(int count READ count NOTIFY countChanged) @@ -15,6 +26,19 @@ public: AddressRole, BalanceRole, IsPublicRole, + KindRole, + SectionRole, + ProgramOwnerRole, + ReadStatusRole, + ProgramNameRole, + AccountTypeRole, + VisibilityRole, + ControlRole, + CanBePrimaryRole, + IsPrimaryRole, + DefinitionIdRole, + AliasRole, + DisplayAddressRole, }; Q_ENUM(Role) @@ -24,7 +48,16 @@ public: QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - void replaceAccounts(const QVector& accounts); + void replaceAccounts(const QVector& accounts, + const QHash& aliases = {}, + const QString& primaryAddress = {}); + void applyPresentations(const QVector& presentations); + void setAlias(const QString& address, const QString& alias); + void setPrimaryAddress(const QString& address); + bool contains(const QString& address) const; + bool canBePrimary(const QString& address) const; + QString firstAutomaticPrimary() const; + int indexOf(const QString& address) const; int count() const { return m_accounts.size(); } signals: @@ -32,11 +65,27 @@ signals: private: struct Entry { + QString alias; + QString semanticName; QString name; QString address; + QString displayAddress; QString balance; bool isPublic = true; + QString kind; + QString section; + QString programOwner; + QString readStatus; + QString programName; + QString accountType; + QString definitionId; + bool canBePrimary = false; + bool isPrimary = false; }; + static QString defaultName(const Entry& entry); + static QString sectionFor(const Entry& entry, bool hiddenFromAccounts = false); + void updateEntryName(Entry& entry); + QVector m_accounts; }; diff --git a/apps/shared/wallet/src/WalletController.cpp b/apps/shared/wallet/src/WalletController.cpp index 5cffdfd..ddf915b 100644 --- a/apps/shared/wallet/src/WalletController.cpp +++ b/apps/shared/wallet/src/WalletController.cpp @@ -3,8 +3,12 @@ #include #include +#include #include #include +#include +#include +#include #include #include #include @@ -18,6 +22,10 @@ namespace { const char SETTINGS_ORG[] = "Logos"; const char DISCONNECTED_KEY[] = "disconnected"; const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; +const char WALLET_SETTINGS_GROUP[] = "wallets"; +const char ALIASES_KEY[] = "aliases"; +const char PRIMARY_ACCOUNT_KEY[] = "primaryAccount"; +constexpr qsizetype MAX_ALIAS_LENGTH = 40; QString toLocalPath(const QString& path) { @@ -25,6 +33,26 @@ QString toLocalPath(const QString& path) return QUrl::fromUserInput(path).toLocalFile(); return path; } + +QString configuredSequencer(const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return {}; + return document.object().value(QStringLiteral("sequencer_addr")).toString(); +} + +QString canonicalStoragePath(const QString& path) +{ + const QFileInfo info(path); + const QString canonical = info.canonicalFilePath(); + return canonical.isEmpty() + ? QDir::cleanPath(info.absoluteFilePath()) + : canonical; +} } WalletController::WalletController(WalletProvider& wallet, @@ -38,7 +66,10 @@ WalletController::WalletController(WalletProvider& wallet, m_reachabilityTimer(new QTimer(this)) { m_state.walletHome = defaultWalletHome(); + m_state.configPath = defaultConfigPath(); + m_state.storagePath = defaultStoragePath(); m_state.walletExists = QFileInfo::exists(defaultStoragePath()); + m_state.sequencerAddress = configuredSequencer(defaultConfigPath()); m_reachabilityTimer->setInterval(10000); connect(m_reachabilityTimer, &QTimer::timeout, @@ -83,20 +114,60 @@ void WalletController::openOnStartup() const QString config = defaultConfigPath(); const QString storage = defaultStoragePath(); - const WalletSession session = m_wallet.connect({ config, storage }); - if (session.failure == WalletFailure::WalletMissing) - return; - if (!session.ok()) { - qWarning() << "WalletController: wallet connection failed" - << walletFailureCode(session.failure); - return; + beginOpen(config, storage); +} + +bool WalletController::beginOpen(const QString& config, const QString& storage) +{ + if (m_state.syncStatus == QStringLiteral("opening") + || m_state.syncStatus == QStringLiteral("syncing")) { + return false; } + const quint64 generation = ++m_operationGeneration; m_state.configPath = config; m_state.storagePath = storage; - m_state.walletExists = QFileInfo::exists(storage) || session.adopted; - m_state.isWalletOpen = true; - applySnapshot(session.snapshot); + m_state.syncStatus = QStringLiteral("opening"); + m_state.syncError.clear(); + const QString endpoint = configuredSequencer(config); + if (!endpoint.isEmpty()) + m_state.sequencerAddress = endpoint; + emit stateChanged(); + + QTimer::singleShot(0, this, [this, generation]() { + if (generation == m_operationGeneration + && m_state.syncStatus == QStringLiteral("opening")) { + m_state.syncStatus = QStringLiteral("syncing"); + emit stateChanged(); + } + }); + m_wallet.connectAsync({ config, storage }, + [this, generation, config, storage](WalletSession session) { + if (generation != m_operationGeneration) + return; + if (session.failure == WalletFailure::WalletMissing) { + m_state.syncStatus = QStringLiteral("closed"); + m_state.walletExists = false; + emit stateChanged(); + return; + } + if (!session.ok()) { + qWarning() << "WalletController: wallet connection failed" + << walletFailureCode(session.failure); + m_state.syncStatus = QStringLiteral("error"); + m_state.syncError = walletFailureCode(session.failure); + emit stateChanged(); + return; + } + + m_state.configPath = config; + m_state.storagePath = storage; + m_state.walletExists = QFileInfo::exists(storage) || session.adopted; + m_state.isWalletOpen = true; + m_state.syncStatus = QStringLiteral("ready"); + applySnapshot(session.snapshot); + }); + return true; } QString WalletController::createDefaultWallet(const QString& password) @@ -112,7 +183,9 @@ QString WalletController::createWallet(const QString& configPath, const QString storage = toLocalPath(storagePath); const WalletCreation creation = m_wallet.createWallet( { config, storage }, password); - if (creation.mnemonic.isEmpty()) { + const bool createdButUnreadable = creation.failure == WalletFailure::ReadFailed; + if (creation.mnemonic.isEmpty() + || (!creation.ok() && !createdButUnreadable)) { qWarning() << "WalletController: wallet creation failed" << walletFailureCode(creation.failure); return {}; @@ -122,14 +195,18 @@ QString WalletController::createWallet(const QString& configPath, m_state.storagePath = storage; m_state.walletExists = true; QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); - if (!creation.ok()) { - qWarning() << "WalletController: wallet creation failed" - << walletFailureCode(creation.failure); + m_state.isWalletOpen = true; + if (!creation.snapshot.ok()) { + qWarning() << "WalletController: wallet creation refresh failed" + << walletFailureCode(creation.snapshot.failure); + m_state.syncStatus = QStringLiteral("error"); + m_state.syncError = walletFailureCode(creation.snapshot.failure); emit stateChanged(); return creation.mnemonic; } - m_state.isWalletOpen = true; + m_state.syncStatus = QStringLiteral("ready"); + m_state.syncError.clear(); applySnapshot(creation.snapshot); return creation.mnemonic; } @@ -140,29 +217,67 @@ bool WalletController::open() ? defaultConfigPath() : m_state.configPath; const QString storage = m_state.storagePath.isEmpty() ? defaultStoragePath() : m_state.storagePath; - const WalletSession session = m_wallet.connect({ config, storage }); - if (!session.ok()) { - qWarning() << "WalletController: wallet open failed" - << walletFailureCode(session.failure); - return false; - } - - m_state.configPath = config; - m_state.storagePath = storage; - m_state.walletExists = true; - m_state.isWalletOpen = true; QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); - applySnapshot(session.snapshot); - return true; + return beginOpen(config, storage); } void WalletController::disconnect() { + ++m_operationGeneration; m_wallet.disconnect(); m_state.isWalletOpen = false; + m_state.syncStatus = QStringLiteral("closed"); + m_state.syncError.clear(); + m_state.primaryAccountAddress.clear(); + m_state.primaryAccountName.clear(); + m_snapshot = {}; + m_aliases.clear(); m_accountModel->replaceAccounts({}); QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true); emit stateChanged(); + emit snapshotChanged(); +} + +bool WalletController::setAccountAlias(const QString& address, const QString& alias) +{ + if (!m_accountModel->contains(address)) + return false; + const QString normalized = alias.trimmed(); + if (normalized.size() > MAX_ALIAS_LENGTH) + return false; + if (normalized.isEmpty()) + m_aliases.remove(address); + else + m_aliases.insert(address, normalized); + m_accountModel->setAlias(address, normalized); + storeAliases(m_aliases); + updatePrimaryState(m_state.primaryAccountAddress); + emit stateChanged(); + return true; +} + +bool WalletController::setPrimaryAccount(const QString& address) +{ + if (!m_accountModel->canBePrimary(address)) + return false; + m_accountModel->setPrimaryAddress(address); + storePrimaryAccount(address); + updatePrimaryState(address); + emit stateChanged(); + return true; +} + +void WalletController::applyAccountPresentations( + const QVector& presentations) +{ + m_accountModel->applyPresentations(presentations); + QString primary = m_state.primaryAccountAddress; + if (!m_accountModel->canBePrimary(primary)) + primary = m_accountModel->firstAutomaticPrimary(); + m_accountModel->setPrimaryAddress(primary); + storePrimaryAccount(primary); + updatePrimaryState(primary); + emit stateChanged(); } QString WalletController::createAccount(bool isPublic) @@ -174,23 +289,41 @@ QString WalletController::createAccount(bool isPublic) return {}; } if (creation.snapshot.ok()) { + m_state.syncStatus = QStringLiteral("ready"); + m_state.syncError.clear(); applySnapshot(creation.snapshot); } else { qWarning() << "WalletController: account refresh failed" << walletFailureCode(creation.snapshot.failure); + m_state.syncStatus = QStringLiteral("error"); + m_state.syncError = walletFailureCode(creation.snapshot.failure); + emit stateChanged(); } return creation.accountId; } void WalletController::refresh() { - const WalletSnapshot next = m_wallet.snapshot(true); - if (next.ok()) { - applySnapshot(next); - } else { - qWarning() << "WalletController: wallet refresh failed" - << walletFailureCode(next.failure); - } + if (!m_state.isWalletOpen || m_state.syncStatus == QStringLiteral("syncing")) + return; + const quint64 generation = ++m_operationGeneration; + m_state.syncStatus = QStringLiteral("syncing"); + m_state.syncError.clear(); + emit stateChanged(); + m_wallet.snapshotAsync(true, [this, generation](WalletSnapshot next) { + if (generation != m_operationGeneration) + return; + if (next.ok()) { + m_state.syncStatus = QStringLiteral("ready"); + applySnapshot(next); + } else { + qWarning() << "WalletController: wallet refresh failed" + << walletFailureCode(next.failure); + m_state.syncStatus = QStringLiteral("error"); + m_state.syncError = walletFailureCode(next.failure); + emit stateChanged(); + } + }); } QString WalletController::balance(const QString& accountId, bool isPublic) @@ -205,14 +338,85 @@ QString WalletController::balance(const QString& accountId, bool isPublic) void WalletController::applySnapshot(const WalletSnapshot& snapshot) { - m_accountModel->replaceAccounts(snapshot.accounts); + m_snapshot = snapshot; + m_aliases = loadAliases(); + QString primary = loadPrimaryAccount(); + m_accountModel->replaceAccounts(snapshot.accounts, m_aliases, primary); + if (!m_accountModel->canBePrimary(primary)) + primary = m_accountModel->firstAutomaticPrimary(); + m_accountModel->setPrimaryAddress(primary); + storePrimaryAccount(primary); + updatePrimaryState(primary); m_state.lastSyncedBlock = static_cast(snapshot.lastSyncedBlock); m_state.currentBlockHeight = static_cast(snapshot.currentBlockHeight); - m_state.sequencerAddress = snapshot.sequencerAddress; + if (!snapshot.sequencerAddress.isEmpty()) + m_state.sequencerAddress = snapshot.sequencerAddress; + emit snapshotChanged(); emit stateChanged(); checkReachability(); } +QString WalletController::walletSettingsGroup() const +{ + const QByteArray hash = QCryptographicHash::hash( + canonicalStoragePath(m_state.storagePath).toUtf8(), + QCryptographicHash::Sha256).toHex(); + return QStringLiteral("%1/%2") + .arg(QString::fromLatin1(WALLET_SETTINGS_GROUP), QString::fromLatin1(hash)); +} + +QHash WalletController::loadAliases() const +{ + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + const QVariantMap stored = settings.value(ALIASES_KEY).toMap(); + QHash aliases; + for (auto iterator = stored.cbegin(); iterator != stored.cend(); ++iterator) { + const QString alias = iterator.value().toString().trimmed(); + if (!alias.isEmpty() && alias.size() <= MAX_ALIAS_LENGTH) + aliases.insert(iterator.key(), alias); + } + return aliases; +} + +QString WalletController::loadPrimaryAccount() const +{ + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + return settings.value(PRIMARY_ACCOUNT_KEY).toString(); +} + +void WalletController::storeAliases(const QHash& aliases) const +{ + QVariantMap stored; + for (auto iterator = aliases.cbegin(); iterator != aliases.cend(); ++iterator) + stored.insert(iterator.key(), iterator.value()); + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + settings.setValue(ALIASES_KEY, stored); +} + +void WalletController::storePrimaryAccount(const QString& address) const +{ + QSettings settings(SETTINGS_ORG, m_settingsApplication); + settings.beginGroup(walletSettingsGroup()); + if (address.isEmpty()) + settings.remove(PRIMARY_ACCOUNT_KEY); + else + settings.setValue(PRIMARY_ACCOUNT_KEY, address); +} + +void WalletController::updatePrimaryState(const QString& address) +{ + m_state.primaryAccountAddress = address; + m_state.primaryAccountName.clear(); + const int row = m_accountModel->indexOf(address); + if (row >= 0) { + m_state.primaryAccountName = m_accountModel->data( + m_accountModel->index(row), WalletAccountModel::NameRole).toString(); + } +} + void WalletController::checkReachability() { if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty()) diff --git a/apps/shared/wallet/src/WalletController.h b/apps/shared/wallet/src/WalletController.h index c27f9ce..6d8ae82 100644 --- a/apps/shared/wallet/src/WalletController.h +++ b/apps/shared/wallet/src/WalletController.h @@ -1,13 +1,16 @@ #pragma once #include +#include #include +#include #include "WalletProvider.h" class QNetworkAccessManager; class QTimer; class WalletAccountModel; +struct WalletAccountPresentation; struct WalletUiState { bool isWalletOpen = false; @@ -19,6 +22,15 @@ struct WalletUiState { int currentBlockHeight = 0; QString sequencerAddress; bool sequencerReachable = true; + QString syncStatus = QStringLiteral("closed"); + QString syncError; + QString primaryAccountAddress; + QString primaryAccountName; + + bool canSubmit() const + { + return isWalletOpen && syncStatus == QStringLiteral("ready"); + } }; class WalletController final : public QObject { @@ -33,6 +45,7 @@ public: WalletAccountModel* accountModel() const { return m_accountModel; } const WalletUiState& state() const { return m_state; } + const WalletSnapshot& snapshot() const { return m_snapshot; } void start(); QString createAccount(bool isPublic); @@ -44,9 +57,14 @@ public: const QString& password); bool open(); void disconnect(); + bool setAccountAlias(const QString& address, const QString& alias); + bool setPrimaryAccount(const QString& address); + void applyAccountPresentations( + const QVector& presentations); signals: void stateChanged(); + void snapshotChanged(); private: static QString defaultWalletHome(); @@ -54,14 +72,24 @@ private: QString defaultStoragePath() const; void openOnStartup(); + bool beginOpen(const QString& config, const QString& storage); void applySnapshot(const WalletSnapshot& snapshot); void checkReachability(); + QString walletSettingsGroup() const; + QHash loadAliases() const; + QString loadPrimaryAccount() const; + void storeAliases(const QHash& aliases) const; + void storePrimaryAccount(const QString& address) const; + void updatePrimaryState(const QString& address); WalletProvider& m_wallet; QString m_settingsApplication; WalletUiState m_state; + WalletSnapshot m_snapshot; + QHash m_aliases; WalletAccountModel* m_accountModel; QNetworkAccessManager* m_network; QTimer* m_reachabilityTimer; bool m_started = false; + quint64 m_operationGeneration = 0; }; diff --git a/apps/shared/wallet/src/WalletProvider.h b/apps/shared/wallet/src/WalletProvider.h index a7814ca..94fb225 100644 --- a/apps/shared/wallet/src/WalletProvider.h +++ b/apps/shared/wallet/src/WalletProvider.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -38,6 +39,9 @@ struct WalletAccount { QString address; QString balance; bool isPublic = true; + QString readStatus; + QString programOwner; + QString dataHex; }; struct WalletSnapshot { @@ -92,15 +96,23 @@ struct WalletSubmission { class WalletProvider { public: + using SessionCallback = std::function; + using SnapshotCallback = std::function; + using AccountReadsCallback = std::function)>; + virtual ~WalletProvider() = default; virtual WalletSession connect(const WalletPaths& paths) = 0; + virtual void connectAsync(const WalletPaths& paths, SessionCallback callback) = 0; virtual WalletCreation createWallet(const WalletPaths& paths, const QString& password) = 0; virtual WalletSnapshot snapshot(bool forceRefresh = false) = 0; + virtual void snapshotAsync(bool forceRefresh, SnapshotCallback callback) = 0; virtual void clearSnapshot() = 0; virtual WalletAccountCreation createAccount(bool isPublic) = 0; virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0; + virtual void readPublicAccountsAsync(const QStringList& accountIds, + AccountReadsCallback callback) = 0; virtual WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) = 0; virtual void disconnect() = 0; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp index 9701d6b..3be6565 100644 --- a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -10,6 +10,7 @@ #include "FakeWalletProvider.h" #include "LogosWalletProvider.h" +#include "WalletAccountId.h" #include "WalletAccountModel.h" #include "WalletController.h" #include "logos_sdk.h" @@ -17,7 +18,9 @@ namespace { const QString ACCOUNT_A(64, QLatin1Char('a')); const QString ACCOUNT_B(64, QLatin1Char('b')); +const QString ACCOUNT_C(64, QLatin1Char('d')); const QString PROGRAM_ID(64, QLatin1Char('c')); +const QString EOA_OWNER(64, QLatin1Char('0')); QString publicAccountJson(const QString& owner = PROGRAM_ID, const QString& balance = QStringLiteral("01000000000000000000000000000000"), @@ -39,6 +42,7 @@ QVariantMap accountEntry(const QString& id, bool isPublic) { QStringLiteral("is_public"), isPublic }, }; } + } class LogosWalletProviderTest : public QObject { @@ -47,6 +51,7 @@ class LogosWalletProviderTest : public QObject { private slots: void adoptsOpenWalletAndCachesSnapshots(); void opensConfiguredWalletWhenNoSharedSessionExists(); + void opensAndReadsAsynchronously(); void createsAndPersistsWallet(); void validatesCompletePublicAccountPayloads(); void fallsBackToBalanceWhenPublicReadFails(); @@ -55,9 +60,12 @@ private slots: void preservesCreatedAccountWhenSnapshotRefreshFails(); void dispatchesExactGenericTransaction(); void rejectsInvalidSubmissionResponses(); + void encodesAccountIdsForDisplay(); void exposesStableAccountModelRoles(); + void persistsHumanizedWalletPreferences(); void fakeProviderImplementsConsumerContract(); void controllerOwnsUiWalletFlow(); + void controllerReportsCreationPersistenceAndRefreshFailures(); void controllerStopsReachabilityChecksAfterDisconnect(); }; @@ -84,6 +92,10 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("1")); QCOMPARE(session.snapshot.accounts.at(1).balance, QStringLiteral("42")); QCOMPARE(session.snapshot.publicAccountReads.size(), 1); + QCOMPARE(session.snapshot.accounts.at(0).readStatus, QStringLiteral("ok")); + QCOMPARE(session.snapshot.accounts.at(0).programOwner, PROGRAM_ID); + QCOMPARE(session.snapshot.accounts.at(0).dataHex, QStringLiteral("00ff")); + QCOMPARE(session.snapshot.accounts.at(1).readStatus, QStringLiteral("private")); QCOMPARE(session.snapshot.currentBlockHeight, quint64(12)); QCOMPARE(session.snapshot.lastSyncedBlock, quint64(11)); @@ -138,6 +150,38 @@ void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists() WalletFailure::WalletMissing); } +void LogosWalletProviderTest::opensAndReadsAsynchronously() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.publicAccounts.insert( + ACCOUNT_A, publicAccountJson(EOA_OWNER)); + LogosWalletProvider provider(&modules); + + bool connected = false; + provider.connectAsync({}, [&connected](WalletSession session) { + connected = session.ok() && session.snapshot.accounts.size() == 1; + }); + QVERIFY(connected); + + bool refreshed = false; + provider.snapshotAsync(true, [&refreshed](WalletSnapshot snapshot) { + refreshed = snapshot.ok() && snapshot.accounts.at(0).programOwner == EOA_OWNER; + }); + QVERIFY(refreshed); + + bool batchRead = false; + provider.readPublicAccountsAsync( + { ACCOUNT_A, ACCOUNT_B }, + [&batchRead](QVector reads) { + batchRead = reads.size() == 2 + && reads.at(0).ok() + && !reads.at(1).ok(); + }); + QVERIFY(batchRead); +} + void LogosWalletProviderTest::createsAndPersistsWallet() { QTemporaryDir directory; @@ -338,19 +382,92 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles() WalletAccountModel model; QSignalSpy countChanged(&model, &WalletAccountModel::countChanged); model.replaceAccounts({ - { ACCOUNT_A, QStringLiteral("10"), true }, - { ACCOUNT_B, QStringLiteral("20"), false }, - }); + { ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} }, + { ACCOUNT_B, QStringLiteral("20"), false, QStringLiteral("private"), {}, {} }, + { ACCOUNT_C, QStringLiteral("30"), true, QStringLiteral("ok"), PROGRAM_ID, QStringLiteral("00") }, + }, { { ACCOUNT_A, QStringLiteral("Trading") } }, ACCOUNT_A); - QCOMPARE(model.count(), 2); + QCOMPARE(model.count(), 3); QCOMPARE(countChanged.count(), 1); QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name")); QCOMPARE(model.data(model.index(0), WalletAccountModel::NameRole).toString(), - QStringLiteral("Account 1")); + QStringLiteral("Trading")); + QCOMPARE(model.data(model.index(0), WalletAccountModel::KindRole).toString(), + QStringLiteral("user")); + QVERIFY(model.data(model.index(0), WalletAccountModel::CanBePrimaryRole).toBool()); + QVERIFY(model.data(model.index(0), WalletAccountModel::IsPrimaryRole).toBool()); QCOMPARE(model.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B); + QCOMPARE(model.roleNames().value(WalletAccountModel::DisplayAddressRole), + QByteArray("displayAddress")); + QCOMPARE(model.data(model.index(1), WalletAccountModel::DisplayAddressRole).toString(), + walletAccountIdToBase58(ACCOUNT_B)); QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(), QStringLiteral("20")); QVERIFY(!model.data(model.index(1), WalletAccountModel::IsPublicRole).toBool()); + QCOMPARE(model.data(model.index(2), WalletAccountModel::KindRole).toString(), + QStringLiteral("program")); + QVERIFY(!model.data(model.index(2), WalletAccountModel::CanBePrimaryRole).toBool()); + + model.applyPresentations({ { + ACCOUNT_C, + QStringLiteral("token_holding"), + QStringLiteral("TEST holding"), + QStringLiteral("Token"), + QStringLiteral("TokenHolding"), + ACCOUNT_A, + true, + } }); + QCOMPARE(model.data(model.index(2), WalletAccountModel::SectionRole).toString(), + QStringLiteral("hidden")); + QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(), + QStringLiteral("TEST holding")); + model.setAlias(ACCOUNT_C, QStringLiteral("Reserve")); + QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(), + QStringLiteral("Reserve")); + model.setAlias(ACCOUNT_C, {}); + QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(), + QStringLiteral("TEST holding")); +} + +void LogosWalletProviderTest::encodesAccountIdsForDisplay() +{ + QCOMPARE(walletAccountIdToBase58( + QStringLiteral("00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb")), + QStringLiteral("14tAtixMByFyJrcZVyWibitnijLgd59PfyrjdnYzo8La")); + QCOMPARE(walletAccountIdToBase58(QString(64, QLatin1Char('0'))), + QString(32, QLatin1Char('1'))); + QVERIFY(walletAccountIdToBase58(QStringLiteral("not-an-account-id")).isEmpty()); +} + +void LogosWalletProviderTest::persistsHumanizedWalletPreferences() +{ + const QString application = QStringLiteral("HumanizedWalletPreferencesTest"); + QSettings settings(QStringLiteral("Logos"), application); + settings.clear(); + FakeWalletProvider provider; + provider.connectResult.adopted = true; + provider.connectResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} }, + { ACCOUNT_B, QStringLiteral("20"), false, QStringLiteral("private"), {}, {} }, + { ACCOUNT_C, QStringLiteral("30"), true, QStringLiteral("ok"), PROGRAM_ID, {} }, + }; + + { + WalletController controller(provider, application); + QVERIFY(controller.open()); + QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A); + QVERIFY(!controller.setPrimaryAccount(ACCOUNT_C)); + QVERIFY(controller.setAccountAlias(ACCOUNT_B, QStringLiteral(" Private savings "))); + QVERIFY(controller.setPrimaryAccount(ACCOUNT_B)); + QCOMPARE(controller.state().primaryAccountName, QStringLiteral("Private savings")); + QVERIFY(!controller.setAccountAlias(ACCOUNT_A, QString(41, QLatin1Char('x')))); + } + + WalletController reopened(provider, application); + QVERIFY(reopened.open()); + QCOMPARE(reopened.state().primaryAccountAddress, ACCOUNT_B); + QCOMPARE(reopened.state().primaryAccountName, QStringLiteral("Private savings")); + settings.clear(); } void LogosWalletProviderTest::fakeProviderImplementsConsumerContract() @@ -419,6 +536,65 @@ void LogosWalletProviderTest::controllerOwnsUiWalletFlow() settings.clear(); } +void LogosWalletProviderTest::controllerReportsCreationPersistenceAndRefreshFailures() +{ + const QString settingsApplication = QStringLiteral("WalletCreationFailureTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + WalletController controller(provider, settingsApplication); + const WalletUiState baseline = controller.state(); + const int baselineAccountCount = controller.accountModel()->count(); + QSignalSpy snapshotChanged(&controller, &WalletController::snapshotChanged); + + provider.createWalletResult.mnemonic = QStringLiteral("alpha beta gamma"); + provider.createWalletResult.failure = WalletFailure::SaveFailed; + provider.createWalletResult.snapshot.failure = WalletFailure::SaveFailed; + QCOMPARE(controller.createWallet(QStringLiteral("config"), QStringLiteral("storage"), + QStringLiteral("secret")), + QString()); + QCOMPARE(controller.state().isWalletOpen, baseline.isWalletOpen); + QCOMPARE(controller.state().walletExists, baseline.walletExists); + QCOMPARE(controller.state().syncStatus, baseline.syncStatus); + QCOMPARE(controller.state().syncError, baseline.syncError); + QCOMPARE(controller.accountModel()->count(), baselineAccountCount); + QCOMPARE(snapshotChanged.count(), 0); + + provider.createWalletResult.failure = WalletFailure::ReadFailed; + provider.createWalletResult.snapshot.failure = WalletFailure::ReadFailed; + QCOMPARE(controller.createWallet(QStringLiteral("config"), QStringLiteral("storage"), + QStringLiteral("secret")), + QStringLiteral("alpha beta gamma")); + QVERIFY(controller.state().isWalletOpen); + QVERIFY(controller.state().walletExists); + QCOMPARE(controller.state().syncStatus, QStringLiteral("error")); + QCOMPARE(controller.state().syncError, QStringLiteral("read_failed")); + QCOMPARE(controller.accountModel()->count(), baselineAccountCount); + QCOMPARE(snapshotChanged.count(), 0); + + provider.createAccountResult.accountId = ACCOUNT_B; + provider.createAccountResult.snapshot.failure = WalletFailure::ReadFailed; + QCOMPARE(controller.createAccount(true), ACCOUNT_B); + QCOMPARE(controller.state().syncStatus, QStringLiteral("error")); + QCOMPARE(controller.state().syncError, QStringLiteral("read_failed")); + QCOMPARE(controller.accountModel()->count(), baselineAccountCount); + QCOMPARE(snapshotChanged.count(), 0); + + provider.createAccountResult.snapshot = {}; + provider.createAccountResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("5"), true, QStringLiteral("ok"), EOA_OWNER, {} }, + { ACCOUNT_B, QStringLiteral("3"), true, QStringLiteral("ok"), EOA_OWNER, {} }, + }; + QCOMPARE(controller.createAccount(true), ACCOUNT_B); + QCOMPARE(controller.state().syncStatus, QStringLiteral("ready")); + QVERIFY(controller.state().syncError.isEmpty()); + QCOMPARE(controller.accountModel()->count(), 2); + QCOMPARE(snapshotChanged.count(), 1); + + settings.clear(); +} + void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect() { const QString settingsApplication = QStringLiteral("WalletReachabilityTest"); diff --git a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h index c1927f4..13ca5d7 100644 --- a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h +++ b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h @@ -6,6 +6,8 @@ #include #include +#include + class LogosAPI; class FakeExecutionZone { @@ -48,6 +50,13 @@ public: return openResult; } + void openAsync(const QString& config, + const QString& storage, + std::function callback) + { + callback(open(config, storage)); + } + QString create_new(const QString& config, const QString& storage, const QString& password) @@ -64,36 +73,69 @@ public: return saveResult; } + void saveAsync(std::function callback) { callback(save()); } + QString create_account_public() { return publicAccountId; } QString create_account_private() { return privateAccountId; } int get_last_synced_block() const { return lastSyncedBlock; } int get_current_block_height() const { return currentBlockHeight; } + void get_last_synced_blockAsync(std::function callback) + { + callback(get_last_synced_block()); + } + void get_current_block_heightAsync(std::function callback) + { + callback(get_current_block_height()); + } int sync_to_block(quint64) { ++syncCalls; return syncResult; } + void sync_to_blockAsync(int blockId, std::function callback) + { + callback(sync_to_block(static_cast(blockId))); + } QString get_sequencer_addr() const { return sequencerAddress; } + void get_sequencer_addrAsync(std::function callback) + { + callback(get_sequencer_addr()); + } QVariantList list_accounts() { ++listCalls; return accounts; } + void list_accountsAsync(std::function callback) + { + callback(list_accounts()); + } QString get_account_public(const QString& accountId) { ++publicReadCalls; return publicAccounts.value(accountId); } + void get_account_publicAsync(const QString& accountId, + std::function callback) + { + callback(get_account_public(accountId)); + } QString get_balance(const QString& accountId, bool) const { return balances.value(accountId); } + void get_balanceAsync(const QString& accountId, + bool isPublic, + std::function callback) + { + callback(get_balance(accountId, isPublic)); + } QString send_generic_public_transaction( const QStringList& accountIds, diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml index 157a0bc..3a07e34 100644 --- a/apps/shared/wallet/tests/qml/tst_WalletControl.qml +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -13,32 +13,62 @@ Item { QtObject { property bool isWalletOpen: false property bool walletExists: true + property bool completeOpenImmediately: true + property bool createWalletFails: false + property bool createWalletRefreshFails: false + property bool accountRefreshFails: false property string walletHome: "/wallet" + property string walletSyncStatus: "closed" + property string walletSyncError: "" property int openCalls: 0 property int createCalls: 0 property int publicAccountCalls: 0 property int privateAccountCalls: 0 property int disconnectCalls: 0 + property int primaryAccountCalls: 0 + property int aliasCalls: 0 + property string primaryAccountAddress: "" + property string primaryAccountName: "" + property string activeNetwork: "testnet" + property string networkStatus: "ready" + property string assetStatus: "ready" + property string assetError: "" + property var assets: [] function openExisting() { openCalls++ - isWalletOpen = true + if (completeOpenImmediately) { + walletSyncStatus = "ready" + isWalletOpen = true + } return true } function createNewDefault(_password) { createCalls++ + if (createWalletFails) + return "" isWalletOpen = true + walletSyncStatus = createWalletRefreshFails ? "error" : "ready" + walletSyncError = createWalletRefreshFails ? "read_failed" : "" return "alpha beta gamma" } function createAccountPublic() { publicAccountCalls++ + if (accountRefreshFails) { + walletSyncStatus = "error" + walletSyncError = "read_failed" + } return "a".repeat(64) } function createAccountPrivate() { privateAccountCalls++ + if (accountRefreshFails) { + walletSyncStatus = "error" + walletSyncError = "read_failed" + } return "b".repeat(64) } @@ -46,6 +76,17 @@ Item { disconnectCalls++ isWalletOpen = false } + + function setPrimaryAccount(address) { + primaryAccountCalls++ + primaryAccountAddress = address + return true + } + + function setAccountAlias(_address, _alias) { + aliasCalls++ + return true + } } } @@ -115,7 +156,7 @@ Item { const model = createTemporaryObject(modelComponent, root) verify(model, "Account model exists") for (const account of accounts || []) - model.append(account) + model.append(accountData(account)) const control = createTemporaryObject(controlComponent, root, { wallet: backend, accountModel: model @@ -124,6 +165,24 @@ Item { return { backend, model, control } } + function accountData(account) { + return { + name: account.name || "Account", + alias: account.alias || "", + address: account.address || "", + displayAddress: account.displayAddress || account.address || "", + balance: account.balance || "0", + isPublic: account.isPublic === true, + kind: account.kind || (account.isPublic === false ? "private" : "user"), + section: account.section || "accounts", + programName: account.programName || "", + accountType: account.accountType || "", + visibility: account.visibility || (account.isPublic === false ? "private" : "public"), + canBePrimary: account.canBePrimary === undefined ? true : account.canBePrimary, + isPrimary: account.isPrimary === true + } + } + function test_opensExistingWallet() { const fixture = createControl({ walletExists: true }, []) const connectButton = findChild(fixture.control, "walletConnectButton") @@ -133,6 +192,81 @@ Item { tryCompare(fixture.control, "connected", true) } + function test_disablesConnectWhileWalletIsOpening() { + const fixture = createControl({ + walletExists: true, + walletSyncStatus: "syncing" + }, []) + const connectButton = findChild(fixture.control, "walletConnectButton") + verify(!connectButton.enabled) + compare(connectButton.text, "Connecting…") + mouseClick(connectButton) + compare(fixture.backend.openCalls, 0) + } + + function test_showsAsyncOpenFailure() { + const fixture = createControl({ + walletExists: true, + completeOpenImmediately: false + }, []) + const connectButton = findChild(fixture.control, "walletConnectButton") + mouseClick(connectButton) + compare(fixture.backend.openCalls, 1) + verify(fixture.control.busy) + + fixture.backend.walletSyncStatus = "error" + fixture.backend.walletSyncError = "open_failed" + + const dialog = findChild(fixture.control, "walletMessageDialog") + tryCompare(dialog, "opened", true) + compare(fixture.control.busy, false) + compare(dialog.message, "Wallet could not be opened: open_failed") + } + + function test_showsAsyncMissingWallet() { + const fixture = createControl({ + walletExists: true, + completeOpenImmediately: false + }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + verify(fixture.control.busy) + + fixture.backend.walletExists = false + + const dialog = findChild(fixture.control, "walletMessageDialog") + tryCompare(dialog, "opened", true) + compare(fixture.control.busy, false) + compare(dialog.message, "Wallet could not be opened.") + } + + function test_showsStartupOpenFailure() { + const fixture = createControl({ + walletExists: true, + walletSyncStatus: "error", + walletSyncError: "open_failed" + }, []) + const dialog = findChild(fixture.control, "walletMessageDialog") + tryCompare(dialog, "opened", true) + compare(dialog.message, "Wallet could not be opened: open_failed") + } + + function test_cancellingWalletCreationDoesNotClaimSuccess() { + const fixture = createControl({ walletExists: false }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + const creation = findChild(fixture.control, "createWalletDialog") + tryCompare(creation, "opened", true) + + fixture.backend.walletSyncStatus = "error" + fixture.backend.walletSyncError = "wallet_unavailable" + const message = findChild(fixture.control, "walletMessageDialog") + tryCompare(message, "opened", true) + compare(message.message, "Wallet could not be opened: wallet_unavailable") + + creation.close() + wait(0) + compare(message.message, "Wallet could not be opened: wallet_unavailable") + } + function test_requiresSeedBackupAcknowledgement() { const fixture = createControl({ walletExists: false }, []) mouseClick(findChild(fixture.control, "walletConnectButton")) @@ -165,6 +299,42 @@ Item { tryCompare(dialog, "opened", false) } + function test_showsWalletCreationFailure() { + const fixture = createControl({ walletExists: false, createWalletFails: true }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + const dialog = findChild(fixture.control, "createWalletDialog") + tryCompare(dialog, "opened", true) + findChild(dialog, "walletPasswordField").text = "secret" + findChild(dialog, "walletConfirmPasswordField").text = "secret" + findChild(dialog, "createWalletButton").clicked() + compare(fixture.backend.createCalls, 1) + compare(dialog.mnemonic, "") + compare(dialog.errorText, "Wallet could not be created.") + verify(dialog.opened) + } + + function test_warnsWhenCreatedWalletCannotRefresh() { + const fixture = createControl({ + walletExists: false, + createWalletRefreshFails: true + }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + const dialog = findChild(fixture.control, "createWalletDialog") + tryCompare(dialog, "opened", true) + findChild(dialog, "walletPasswordField").text = "secret" + findChild(dialog, "walletConfirmPasswordField").text = "secret" + findChild(dialog, "createWalletButton").clicked() + tryCompare(dialog, "mnemonic", "alpha beta gamma") + const message = findChild(fixture.control, "walletMessageDialog") + verify(!message.opened) + mouseClick(findChild(dialog, "walletBackupAcknowledgement")) + mouseClick(findChild(dialog, "walletContinueButton")) + + tryCompare(message, "opened", true) + compare(message.message, + "Wallet was created, but could not be refreshed. Reconnect the wallet to refresh it.") + } + function test_clampsSelectionAndDisconnectsLocally() { const fixture = createControl({ isWalletOpen: true }, [ { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, @@ -173,12 +343,12 @@ Item { fixture.control.selectedIndex = 1 compare(fixture.control.selectedAddress, "b".repeat(64)) fixture.model.clear() - tryCompare(fixture.control, "selectedIndex", 0) + tryCompare(fixture.control, "selectedIndex", -1) compare(fixture.control.selectedAddress, "") - fixture.model.append({ + fixture.model.append(accountData({ name: "One", address: "a".repeat(64), balance: "10", isPublic: true - }) + })) mouseClick(findChild(fixture.control, "walletAccountButton")) const disconnectButton = findChild(fixture.control, "walletDisconnectButton") tryVerify(function() { return disconnectButton.visible }) @@ -226,8 +396,20 @@ Item { function test_selectsAccount() { const fixture = createControl({ isWalletOpen: true }, [ - { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, - { name: "Two", address: "b".repeat(64), balance: "20", isPublic: false } + { + name: "One", + address: "a".repeat(64), + displayAddress: "base58-one", + balance: "10", + isPublic: true + }, + { + name: "Two", + address: "b".repeat(64), + displayAddress: "base58-two", + balance: "20", + isPublic: false + } ]) mouseClick(findChild(fixture.control, "walletAccountButton")) const accountsButton = findChild(fixture.control, "walletAccountsButton") @@ -240,7 +422,95 @@ Item { const secondAccount = accountList.itemAtIndex(1) secondAccount.clicked() tryCompare(fixture.control, "selectedIndex", 1) + compare(fixture.backend.primaryAccountAddress, "b".repeat(64)) compare(fixture.control.selectedAddress, "b".repeat(64)) + compare(fixture.control.selectedDisplayAddress, "base58-two") + } + + function test_accountNavigationKeepsOverviewInsidePopup() { + const assets = [] + for (let index = 0; index < 10; ++index) { + assets.push({ + name: "Token " + index, + balance: "100", + definitionId: "c".repeat(64), + displayDefinitionId: "base58-token-" + index, + status: "ready", + section: "assets" + }) + } + const fixture = createControl({ isWalletOpen: true, assets: assets }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const stack = findChild(fixture.control, "walletStack") + verify(stack, "Wallet stack exists") + verify(stack.clip, "Wallet pages are clipped to the popup") + mouseClick(findChild(fixture.control, "walletAccountsButton")) + tryCompare(stack, "busy", false) + compare(stack.depth, 2) + + mouseClick(findChild(fixture.control, "walletAccountsBackButton")) + tryCompare(stack, "busy", false) + compare(stack.depth, 1) + compare(stack.currentItem.x, 0) + const overviewContent = findChild(fixture.control, "walletOverviewContent") + verify(overviewContent, "Wallet overview content exists") + compare(overviewContent.mapToItem(stack, 0, 0).x, 0) + } + + function test_programRecordCannotBecomePrimary() { + const userAddress = "a".repeat(64) + const programAddress = "c".repeat(64) + const fixture = createControl({ + isWalletOpen: true, + primaryAccountAddress: userAddress, + primaryAccountName: "Trading" + }, [ + { + name: "Trading", + address: userAddress, + balance: "10", + isPublic: true, + kind: "user", + isPrimary: true + }, + { + name: "Token definition", + address: programAddress, + balance: "0", + isPublic: true, + kind: "token_definition", + section: "advanced", + programName: "Token", + accountType: "TokenDefinition", + canBePrimary: false + } + ]) + compare(fixture.control.selectedAddress, userAddress) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + mouseClick(findChild(fixture.control, "walletAdvancedAccountsButton")) + const list = findChild(fixture.control, "walletAccountList") + tryVerify(function() { return list.itemAtIndex(1) !== null }) + list.itemAtIndex(1).clicked() + compare(fixture.backend.primaryAccountCalls, 0) + compare(fixture.control.selectedAddress, userAddress) + } + + function test_onlyProgramRecordsLeavesPrimaryEmpty() { + const fixture = createControl({ isWalletOpen: true }, [{ + name: "Token definition", + address: "c".repeat(64), + balance: "0", + isPublic: true, + kind: "token_definition", + section: "advanced", + canBePrimary: false + }]) + compare(fixture.control.selectedIndex, -1) + compare(fixture.control.selectedAddress, "") + compare(fixture.control.primaryName, "") } function test_createsAccount() { @@ -262,6 +532,47 @@ Item { tryCompare(dialog, "opened", false) } + function test_createsPrivateAccount() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + const addButton = findChild(fixture.control, "walletAddAccountButton") + tryVerify(function() { return addButton.visible }) + addButton.clicked() + const dialog = findChild(fixture.control, "createAccountDialog") + tryCompare(dialog, "opened", true) + mouseClick(findChild(dialog, "privateAccountSwitch")) + findChild(dialog, "createAccountButton").clicked() + compare(fixture.backend.privateAccountCalls, 1) + tryCompare(dialog, "opened", false) + } + + function test_warnsWhenCreatedAccountCannotRefresh() { + const fixture = createControl({ + isWalletOpen: true, + walletSyncStatus: "ready", + accountRefreshFails: true + }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + mouseClick(findChild(fixture.control, "walletAccountsButton")) + const addButton = findChild(fixture.control, "walletAddAccountButton") + tryVerify(function() { return addButton.visible }) + addButton.clicked() + const dialog = findChild(fixture.control, "createAccountDialog") + tryCompare(dialog, "opened", true) + findChild(dialog, "createAccountButton").clicked() + tryCompare(dialog, "opened", false) + + const message = findChild(fixture.control, "walletMessageDialog") + tryCompare(message, "opened", true) + compare(message.message, + "Account was created, but could not be refreshed. Reconnect the wallet to refresh it.") + } + function test_compactLayoutHasStableWidth() { const fixture = createControl({ isWalletOpen: false }, []) fixture.control.viewportWidth = 480 @@ -300,12 +611,12 @@ Item { const model = createTemporaryObject(modelComponent, root) verify(backend && model, "Wallet fixture exists") for (let index = 0; index < 10; ++index) { - model.append({ + model.append(accountData({ name: "Account " + index, address: String(index).repeat(64), balance: String(index), isPublic: true - }) + })) } const window = createTemporaryObject(compactWindowComponent, root) diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h index d5dea0a..477b725 100644 --- a/apps/shared/wallet/tests/support/FakeWalletProvider.h +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "WalletProvider.h" class FakeWalletProvider final : public WalletProvider { @@ -9,6 +11,7 @@ public: WalletSnapshot snapshotResult; WalletAccountCreation createAccountResult; WalletAccountRead readResult; + QVector readResults; WalletSubmission submissionResult; int connectCalls = 0; @@ -31,6 +34,13 @@ public: return connectResult; } + void connectAsync(const WalletPaths& paths, SessionCallback callback) override + { + ++connectCalls; + lastPaths = paths; + callback(connectResult); + } + WalletCreation createWallet(const WalletPaths& paths, const QString&) override { @@ -46,6 +56,13 @@ public: return snapshotResult; } + void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override + { + ++snapshotCalls; + lastForceRefresh = forceRefresh; + callback(snapshotResult); + } + void clearSnapshot() override { ++clearCalls; } WalletAccountCreation createAccount(bool isPublic) override @@ -63,6 +80,21 @@ public: return result; } + void readPublicAccountsAsync(const QStringList& accountIds, + AccountReadsCallback callback) override + { + QVector results = readResults; + if (results.isEmpty()) { + results.reserve(accountIds.size()); + for (const QString& accountId : accountIds) { + WalletAccountRead result = readResult; + result.accountId = accountId; + results.append(std::move(result)); + } + } + callback(std::move(results)); + } + WalletSubmission submitPublicTransaction( const WalletTransaction& transaction) override { diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e762aca --- /dev/null +++ b/flake.lock @@ -0,0 +1,44 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "ref": "v0.23.4", + "repo": "crane", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1782841183, + "narHash": "sha256-Ndt/5R7UN4rBdhFR1lxHZZZ42cD6vGlnuxC2VvvsKE4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c9bfd86ed684d27e63b0ff9ebb18699f84f27a3b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11-small", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..80575fb --- /dev/null +++ b/flake.nix @@ -0,0 +1,45 @@ +{ + description = "LEZ program client libraries"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11-small"; + crane.url = "github:ipetkov/crane/v0.23.4"; + }; + + outputs = { nixpkgs, crane, ... }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in { + packages = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + craneLib = crane.mkLib pkgs; + src = craneLib.cleanCargoSource ./.; + commonArgs = { + inherit src; + pname = "wallet-idl-decoder"; + version = "0.1.0"; + strictDeps = true; + cargoExtraArgs = "-p wallet-idl-decoder"; + }; + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + decoder = craneLib.buildPackage (commonArgs // { + inherit cargoArtifacts; + doCheck = false; + postInstall = '' + install -Dm644 ${./tools/wallet-idl-decoder/include/wallet_idl_decoder.h} \ + $out/include/wallet_idl_decoder.h + ''; + }); + in { + default = decoder; + wallet_idl_decoder = decoder; + }); + }; +} diff --git a/tools/wallet-idl-decoder/Cargo.toml b/tools/wallet-idl-decoder/Cargo.toml new file mode 100644 index 0000000..e407f8b --- /dev/null +++ b/tools/wallet-idl-decoder/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "wallet-idl-decoder" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[lib] +name = "wallet_idl_decoder" +crate-type = ["cdylib", "rlib"] + +[dependencies] +base58 = "0.2" +hex = "0.4" +serde = { workspace = true } +serde_json = { workspace = true } +spel-framework-core = { git = "https://github.com/logos-co/spel.git", tag = "v0.6.0" } diff --git a/tools/wallet-idl-decoder/include/wallet_idl_decoder.h b/tools/wallet-idl-decoder/include/wallet_idl_decoder.h new file mode 100644 index 0000000..fd724f3 --- /dev/null +++ b/tools/wallet-idl-decoder/include/wallet_idl_decoder.h @@ -0,0 +1,15 @@ +#ifndef WALLET_IDL_DECODER_H +#define WALLET_IDL_DECODER_H + +#ifdef __cplusplus +extern "C" { +#endif + +char *wallet_idl_decode_accounts(const char *request_json); +void wallet_idl_decoder_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/tools/wallet-idl-decoder/src/lib.rs b/tools/wallet-idl-decoder/src/lib.rs new file mode 100644 index 0000000..db7a201 --- /dev/null +++ b/tools/wallet-idl-decoder/src/lib.rs @@ -0,0 +1,278 @@ +use std::{ + collections::BTreeMap, + ffi::{CStr, CString}, + os::raw::c_char, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use base58::FromBase58; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use spel_framework_core::{decode::decode_account_data_try_all, idl::SpelIdl}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DecodeRequest { + idl: SpelIdl, + accounts: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AccountInput { + id: String, + data_hex: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DecodeResponse { + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'static str>, + accounts: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AccountOutput { + id: String, + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + type_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + account_ids: BTreeMap, +} + +fn decode_request(request: DecodeRequest) -> DecodeResponse { + let accounts = request + .accounts + .into_iter() + .map(|account| decode_account(account, &request.idl)) + .collect(); + DecodeResponse { + status: "ok", + error: None, + accounts, + } +} + +fn decode_account(account: AccountInput, idl: &SpelIdl) -> AccountOutput { + let Ok(data) = hex::decode(&account.data_hex) else { + return AccountOutput { + id: account.id, + status: "invalid_data", + type_name: None, + value: None, + account_ids: BTreeMap::new(), + }; + }; + let Some((type_name, value)) = decode_account_data_try_all(&data, idl) else { + return AccountOutput { + id: account.id, + status: "unknown_type", + type_name: None, + value: None, + account_ids: BTreeMap::new(), + }; + }; + let mut account_ids = BTreeMap::new(); + collect_account_ids(&value, &mut account_ids); + AccountOutput { + id: account.id, + status: "decoded", + type_name: Some(type_name), + value: Some(value), + account_ids, + } +} + +fn collect_account_ids(value: &Value, output: &mut BTreeMap) { + match value { + Value::String(encoded) => { + let Some(base58) = encoded.strip_prefix("Public/") else { + return; + }; + if let Ok(bytes) = base58.from_base58() { + if bytes.len() == 32 { + output.insert(encoded.clone(), hex::encode(bytes)); + } + } + } + Value::Array(values) => { + for nested in values { + collect_account_ids(nested, output); + } + } + Value::Object(values) => { + for nested in values.values() { + collect_account_ids(nested, output); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn error_response(error: &'static str) -> DecodeResponse { + DecodeResponse { + status: "error", + error: Some(error), + accounts: Vec::new(), + } +} + +fn response_pointer(response: &DecodeResponse) -> *mut c_char { + let json = serde_json::to_string(response).unwrap_or_else(|_| { + String::from(r#"{"status":"error","error":"serialization_failed","accounts":[]}"#) + }); + CString::new(json).map_or(std::ptr::null_mut(), CString::into_raw) +} + +#[expect( + unsafe_code, + reason = "C ABI input requires reading a caller-owned C string" +)] +fn decode_pointer(request_json: *const c_char) -> DecodeResponse { + if request_json.is_null() { + return error_response("null_request"); + } + let bytes = unsafe { + // SAFETY: Caller owns a non-null NUL-terminated C string for this call. + CStr::from_ptr(request_json) + }; + let Ok(json) = bytes.to_str() else { + return error_response("invalid_utf8"); + }; + match serde_json::from_str::(json) { + Ok(request) => decode_request(request), + Err(_) => error_response("invalid_request"), + } +} + +/// Decodes a JSON batch request using its embedded SPEL IDL. +/// +/// Returns a library-owned JSON C string. Release it with +/// [`wallet_idl_decoder_free`]. +#[no_mangle] +#[expect(unsafe_code, reason = "C ABI requires a stable exported symbol")] +pub extern "C" fn wallet_idl_decode_accounts(request_json: *const c_char) -> *mut c_char { + let response = catch_unwind(AssertUnwindSafe(|| decode_pointer(request_json))) + .unwrap_or_else(|_| error_response("panic")); + response_pointer(&response) +} + +/// Frees a response allocated by [`wallet_idl_decode_accounts`]. +/// +/// # Safety +/// +/// `value` must be null or a pointer returned by this library that has not +/// already been freed. +#[no_mangle] +#[expect(unsafe_code, reason = "C ABI deallocator reconstructs its CString")] +pub unsafe extern "C" fn wallet_idl_decoder_free(value: *mut c_char) { + if !value.is_null() { + unsafe { + // SAFETY: Pointer must come from CString::into_raw in this library and be freed once. + drop(CString::from_raw(value)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn token_idl() -> SpelIdl { + match serde_json::from_str(include_str!("../../../artifacts/token-idl.json")) { + Ok(idl) => idl, + Err(error) => panic!("committed token IDL should parse: {error}"), + } + } + + #[test] + fn decodes_fungible_definition() { + let request = DecodeRequest { + idl: token_idl(), + accounts: vec![AccountInput { + id: "definition".to_owned(), + data_hex: concat!( + "00", // Fungible variant + "04000000", + "54455354", // TEST + "0a000000000000000000000000000000", // supply 10 + "00", // metadata_id None + "00" // authority None + ) + .to_owned(), + }], + }; + let response = decode_request(request); + let Some(account) = response.accounts.first() else { + panic!("decoder should return one account"); + }; + assert_eq!(account.status, "decoded"); + assert_eq!(account.type_name.as_deref(), Some("TokenDefinition")); + assert_eq!( + account + .value + .as_ref() + .and_then(|value| value.get("Fungible")) + .and_then(|value| value.get("name")) + .and_then(Value::as_str), + Some("TEST") + ); + } + + #[test] + fn maps_decoded_public_ids_to_hex() { + let request = DecodeRequest { + idl: token_idl(), + accounts: vec![AccountInput { + id: "holding".to_owned(), + data_hex: format!("00{}19000000000000000000000000000000", "01".repeat(32)), + }], + }; + let response = decode_request(request); + let Some(account) = response.accounts.first() else { + panic!("decoder should return one account"); + }; + let expected = "01".repeat(32); + assert_eq!(account.status, "decoded"); + assert_eq!(account.type_name.as_deref(), Some("TokenHolding")); + assert_eq!( + account.account_ids.values().next().map(String::as_str), + Some(expected.as_str()) + ); + } + + #[test] + fn rejects_invalid_hex_per_account() { + let response = decode_request(DecodeRequest { + idl: token_idl(), + accounts: vec![AccountInput { + id: "broken".to_owned(), + data_hex: "xyz".to_owned(), + }], + }); + assert_eq!(response.status, "ok"); + assert_eq!( + response.accounts.first().map(|account| account.status), + Some("invalid_data") + ); + } + + #[test] + #[expect(unsafe_code, reason = "test verifies the exported C allocator pair")] + fn ffi_allocates_json_and_accepts_its_pointer_on_free() { + let response = wallet_idl_decode_accounts(std::ptr::null()); + assert!(!response.is_null()); + let json = match unsafe { CStr::from_ptr(response) }.to_str() { + Ok(json) => json, + Err(error) => panic!("response should be UTF-8 JSON: {error}"), + }; + assert!(json.contains("null_request")); + unsafe { wallet_idl_decoder_free(response) }; + } +}