diff --git a/Cargo.lock b/Cargo.lock
index 7c3a1403..e9c33a89 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 c508ac99..be6b226e 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 10f4346d..670ed7b3 100644
--- a/apps/amm/CMakeLists.txt
+++ b/apps/amm/CMakeLists.txt
@@ -1,12 +1,25 @@
-cmake_minimum_required(VERSION 3.14)
+cmake_minimum_required(VERSION 3.21)
project(AmmUiPlugin LANGUAGES CXX)
+find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2)
+qt_standard_project_setup(REQUIRES 6.8)
+
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
+set(LOGOS_WALLET_SOURCE_DIR
+ "${CMAKE_CURRENT_SOURCE_DIR}/../shared/wallet"
+ CACHE PATH "Path to the shared Logos wallet module"
+)
+set(LOGOS_WALLET_GENERATED_DIR
+ "${CMAKE_CURRENT_SOURCE_DIR}/generated_code"
+ CACHE PATH "Path to generated Logos SDK sources"
+)
+add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/shared-wallet")
+
# ui_qml module with a hand-written C++ backend (QtRO .rep view contract +
# generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module.
logos_module(
@@ -18,12 +31,90 @@ logos_module(
src/AmmUiPlugin.cpp
src/AmmUiBackend.h
src/AmmUiBackend.cpp
- src/AccountModel.h
- src/AccountModel.cpp
+ src/ActiveNetwork.h
+ src/ActiveNetwork.cpp
+ src/TokenDefinitionCache.h
+ src/TokenDefinitionCache.cpp
+ src/WalletIdlDecoder.h
+ src/WalletIdlDecoder.cpp
FIND_PACKAGES
Qt6Gui
Qt6Network
LINK_LIBRARIES
Qt6::Gui
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)
+
+ add_executable(amm_token_definition_cache_test
+ tests/cpp/TokenDefinitionCacheTest.cpp
+ src/TokenDefinitionCache.h
+ src/TokenDefinitionCache.cpp
+ )
+ set_target_properties(amm_token_definition_cache_test PROPERTIES AUTOMOC ON)
+ target_compile_features(amm_token_definition_cache_test PRIVATE cxx_std_17)
+ target_include_directories(amm_token_definition_cache_test PRIVATE
+ src
+ "${LOGOS_WALLET_SOURCE_DIR}/src"
+ "${LOGOS_WALLET_SOURCE_DIR}/tests/support"
+ )
+ target_link_libraries(amm_token_definition_cache_test PRIVATE Qt6::Core Qt6::Test)
+ add_test(NAME amm_token_definition_cache COMMAND amm_token_definition_cache_test)
+
+ add_executable(amm_backend_definition_cache_test
+ tests/cpp/AmmUiBackendDefinitionCacheTest.cpp
+ )
+ add_dependencies(amm_backend_definition_cache_test amm_ui_module_plugin)
+ set_target_properties(amm_backend_definition_cache_test PROPERTIES AUTOMOC ON)
+ target_compile_features(amm_backend_definition_cache_test PRIVATE cxx_std_17)
+ target_include_directories(amm_backend_definition_cache_test PRIVATE
+ src
+ "${CMAKE_CURRENT_BINARY_DIR}"
+ "${LOGOS_WALLET_SOURCE_DIR}/src"
+ "${LOGOS_WALLET_SOURCE_DIR}/tests/support"
+ )
+ target_link_libraries(amm_backend_definition_cache_test PRIVATE
+ Qt6::Core
+ Qt6::Network
+ Qt6::RemoteObjects
+ Qt6::Test
+ amm_ui_module_plugin
+ )
+ if(UNIX AND NOT APPLE)
+ target_link_options(amm_backend_definition_cache_test PRIVATE
+ "-Wl,--allow-shlib-undefined"
+ )
+ endif()
+ add_test(NAME amm_backend_definition_cache COMMAND amm_backend_definition_cache_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 00000000..dbe12603
--- /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 00000000..18510e49
--- /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 00000000..75e03eba
--- /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 9bc7175f..9fd15fe3 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": [
@@ -2238,17 +2254,17 @@
"rust-rapidsnark": "rust-rapidsnark"
},
"locked": {
- "lastModified": 1782310268,
- "narHash": "sha256-tIIIO+V+w/C/KLtKnLIv8O8/GoJ4PzgJSWrlQCXJ2P4=",
+ "lastModified": 1784202021,
+ "narHash": "sha256-BG94Ob5tbJyw7GclNBdnOWqcBLRld8sc+xeOU8LI5RQ=",
"owner": "logos-blockchain",
- "repo": "lssa",
- "rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a",
+ "repo": "logos-execution-zone",
+ "rev": "a7e06a660940a00093b1760560d37ff84aff5a05",
"type": "github"
},
"original": {
"owner": "logos-blockchain",
- "repo": "lssa",
- "rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a",
+ "repo": "logos-execution-zone",
+ "rev": "a7e06a660940a00093b1760560d37ff84aff5a05",
"type": "github"
}
},
@@ -14438,11 +14454,11 @@
]
},
"locked": {
- "lastModified": 1782082589,
- "narHash": "sha256-Qeqxp0HYb3oTpmfD5YlFPJzpAJa7Ilb9o4sMeVvmHRI=",
+ "lastModified": 1782920676,
+ "narHash": "sha256-Vb81kiYbi8yYDZbUSW6v7QhV6uO/pj7F+lCAW1coAsY=",
"owner": "logos-co",
"repo": "logos-protocol",
- "rev": "315a3a2e0af61bc47aad5601ee44cd7689975820",
+ "rev": "d7ad26d369c4e464a99f2a357f10c5947c7174e1",
"type": "github"
},
"original": {
@@ -16410,17 +16426,17 @@
"nix-bundle-lgx": "nix-bundle-lgx_28"
},
"locked": {
- "lastModified": 1782313954,
- "narHash": "sha256-psh5EtcIZh6kzFD4pQDT8bae9JS9eVtk5nPWxR2aGSA=",
+ "lastModified": 1782741385,
+ "narHash": "sha256-kN6xb1b/Jvu9Ninaqtmi/C4fx8poisGYbThIBIXwvHw=",
"owner": "logos-blockchain",
"repo": "logos-execution-zone-module",
- "rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c",
+ "rev": "d70225ced646934d2294fd9e8f8b03615c104b80",
"type": "github"
},
"original": {
"owner": "logos-blockchain",
"repo": "logos-execution-zone-module",
- "rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c",
+ "rev": "d70225ced646934d2294fd9e8f8b03615c104b80",
"type": "github"
}
},
@@ -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,11 @@
},
"root": {
"inputs": {
+ "crane": "crane",
"logos-module-builder": "logos-module-builder",
- "logos_execution_zone": "logos_execution_zone"
+ "logos_execution_zone": "logos_execution_zone",
+ "nixpkgs": "nixpkgs_399",
+ "shared_wallet": "shared_wallet"
}
},
"rust-overlay": {
@@ -26849,6 +26884,18 @@
"rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f",
"type": "github"
}
+ },
+ "shared_wallet": {
+ "flake": false,
+ "locked": {
+ "path": "../shared/wallet",
+ "type": "path"
+ },
+ "original": {
+ "path": "../shared/wallet",
+ "type": "path"
+ },
+ "parent": []
}
},
"root": "root",
diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix
index 0811f1bb..d01e26b8 100644
--- a/apps/amm/flake.nix
+++ b/apps/amm/flake.nix
@@ -3,18 +3,62 @@
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 = {
+ url = "path:../shared/wallet";
+ flake = false;
+ };
# Core wallet module (the LEZ wallet FFI Qt plugin). The input name must
# match the metadata.json `dependencies` entry so the builder can resolve
- # it as a module dependency. This rev pins LEZ (lssa) at fb8cbac4, which
- # includes the macOS Metal-build fix, so no `--override-input` is needed.
- logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=d2e9400ac06c3cdbfc2405b4f153fff9841a453c";
+ # it as a module dependency. This revision exposes generic transaction
+ # submission by deployed program ID.
+ logos_execution_zone = {
+ url = "github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80";
+
+ # The module pins the monorepo at v0.2.0-rc6 (e37876a), which owns the
+ # xcrun wrapper in its flake.nix. Override that transitive input to the
+ # head of PR #629 (fix(macos): nuke xcrun cache) so the Metal/xcrun build
+ # works on macOS. Note: #629 branches off `dev`, so this also pulls dev's
+ # drift from rc6 — if the wallet_ffi ABI mismatches the module build, fall
+ # back to cherry-picking #629's one line onto e37876a and pin that commit.
+ inputs.logos-execution-zone.url =
+ "github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05";
+ };
};
- outputs = inputs@{ logos-module-builder, ... }:
- 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.
+ test -f ${./qml}/Logos/Wallet/qmldir
+
+ walletQmlDir="shared-wallet/qml/Logos/Wallet"
+ if [ ! -d "$walletQmlDir" ]; then
+ echo "Built Logos.Wallet QML module not found"
+ exit 1
+ fi
+ walletQmlInstallDir="$out/lib/Logos/Wallet"
+ mkdir -p "$walletQmlInstallDir"
+ cp -r "$walletQmlDir/." "$walletQmlInstallDir/"
+ test -f "$walletQmlInstallDir/qmldir"
+ '';
};
}
diff --git a/apps/amm/metadata.json b/apps/amm/metadata.json
index 72508854..c670a3dc 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/qml/Logos/Wallet/qmldir b/apps/amm/qml/Logos/Wallet/qmldir
new file mode 100644
index 00000000..866351b9
--- /dev/null
+++ b/apps/amm/qml/Logos/Wallet/qmldir
@@ -0,0 +1,5 @@
+module Logos.Wallet
+optional plugin logos_wallet_qmlplugin ../../../Logos/Wallet
+classname Logos_WalletPlugin
+prefer :/qt/qml/Logos/Wallet/
+depends QtQuick
diff --git a/apps/amm/qml/NavBar.qml b/apps/amm/qml/NavBar.qml
index f5b78570..3c06f910 100644
--- a/apps/amm/qml/NavBar.qml
+++ b/apps/amm/qml/NavBar.qml
@@ -2,8 +2,7 @@ import QtQuick 2.15
import QtQuick.Layouts 1.15
import Logos.Theme
-
-import "components/wallet"
+import Logos.Wallet
// Self-contained navigation bar — styling is independent of any view's theme.
// Use currentIndex to read the active tab; tabChanged(index) fires on selection.
@@ -94,11 +93,15 @@ Item {
}
// Wallet / account control on the far right.
- AccountControl {
+ WalletControl {
id: accountControl
Layout.leftMargin: 12
- backend: root.backend
+ wallet: root.backend
accountModel: root.accountModel
+ viewportWidth: root.width
+ watchCall: function(result, success, failure) {
+ logos.watch(result, success, failure)
+ }
}
}
}
diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml
deleted file mode 100644
index 16b0dc66..00000000
--- a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml
+++ /dev/null
@@ -1,238 +0,0 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-
-FocusScope {
- id: root
-
- property var snapshot: ({})
- property bool open: false
- readonly property bool isAdd: root.snapshot.action === "add"
-
- signal canceled
- signal confirmed(var snapshot)
-
- visible: root.open
- z: 20
-
- Keys.onEscapePressed: root.cancel()
-
- function openWithSnapshot(nextSnapshot) {
- root.snapshot = nextSnapshot;
- root.open = true;
- root.forceActiveFocus();
- cancelButton.forceActiveFocus();
- }
-
- function cancel() {
- root.open = false;
- root.canceled();
- }
-
- function confirm() {
- const confirmedSnapshot = root.snapshot;
- root.open = false;
- root.confirmed(confirmedSnapshot);
- }
-
- Rectangle {
- anchors.fill: parent
- color: "#99000000"
-
- MouseArea {
- anchors.fill: parent
- }
- }
-
- Rectangle {
- id: panel
-
- anchors.centerIn: parent
- color: "#1D1D1D"
- implicitHeight: dialogContent.implicitHeight + 24
- radius: 8
- width: Math.max(0, Math.min(360, root.width - 32))
- border.color: "#343434"
- border.width: 1
-
- ColumnLayout {
- id: dialogContent
-
- anchors.fill: parent
- anchors.margins: 12
- spacing: 12
-
- Text {
- color: "#E7E1D8"
- font.bold: true
- font.pixelSize: 16
- text: root.isAdd ? qsTr("Confirm add liquidity") : qsTr("Confirm remove liquidity")
-
- Layout.fillWidth: true
- }
-
- ColumnLayout {
- spacing: 8
- visible: root.isAdd
-
- Layout.fillWidth: true
-
- SummaryRow {
- label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "")
- value: root.snapshot.depositA || ""
-
- Layout.fillWidth: true
- }
-
- SummaryRow {
- label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "")
- value: root.snapshot.depositB || ""
-
- Layout.fillWidth: true
- }
-
- SummaryRow {
- label: qsTr("Receive at least")
- value: root.snapshot.minLpReceived || ""
-
- Layout.fillWidth: true
- }
-
- SummaryRow {
- label: qsTr("Current ratio")
- value: root.snapshot.currentRatio || ""
-
- Layout.fillWidth: true
- }
-
- SummaryRow {
- label: qsTr("Fee tier")
- value: root.snapshot.feeTier || ""
-
- Layout.fillWidth: true
- }
-
- SummaryRow {
- label: qsTr("Slippage tolerance")
- value: root.snapshot.slippageTolerance || ""
-
- Layout.fillWidth: true
- }
- }
-
- ColumnLayout {
- spacing: 8
- visible: !root.isAdd
-
- Layout.fillWidth: true
-
- SummaryRow {
- label: qsTr("Burn LP")
- value: qsTr("%1 (%2)").arg(root.snapshot.burnText || "").arg(root.snapshot.burnPercent || "")
-
- Layout.fillWidth: true
- }
-
- SummaryRow {
- label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "")
- value: root.snapshot.minTokenAReceived || ""
-
- Layout.fillWidth: true
- }
-
- SummaryRow {
- label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "")
- value: root.snapshot.minTokenBReceived || ""
-
- Layout.fillWidth: true
- }
-
- SummaryRow {
- label: qsTr("Slippage tolerance")
- value: root.snapshot.slippageTolerance || ""
-
- Layout.fillWidth: true
- }
-
- SummaryRow {
- label: qsTr("Post-removal share")
- value: root.snapshot.postRemovalShare || ""
-
- Layout.fillWidth: true
- }
- }
-
- RowLayout {
- spacing: 8
-
- Layout.fillWidth: true
-
- Button {
- id: cancelButton
-
- activeFocusOnTab: true
- focusPolicy: Qt.StrongFocus
- hoverEnabled: true
- text: qsTr("Cancel")
-
- Accessible.name: cancelButton.text
-
- Layout.fillWidth: true
- Layout.minimumHeight: 44
-
- onClicked: root.cancel()
-
- contentItem: Text {
- color: cancelButton.hovered || cancelButton.activeFocus ? "#151515" : "#E7E1D8"
- elide: Text.ElideRight
- font.bold: true
- font.pixelSize: 13
- horizontalAlignment: Text.AlignHCenter
- text: cancelButton.text
- verticalAlignment: Text.AlignVCenter
- }
-
- background: Rectangle {
- border.color: cancelButton.activeFocus ? "#F26A21" : "#343434"
- border.width: 1
- color: cancelButton.pressed ? "#343434" : cancelButton.hovered || cancelButton.activeFocus ? "#E7E1D8" : "#151515"
- radius: 6
- }
- }
-
- Button {
- id: confirmButton
-
- activeFocusOnTab: true
- focusPolicy: Qt.StrongFocus
- hoverEnabled: true
- text: qsTr("Confirm")
-
- Accessible.name: confirmButton.text
-
- Layout.fillWidth: true
- Layout.minimumHeight: 44
-
- onClicked: root.confirm()
-
- contentItem: Text {
- color: "#151515"
- elide: Text.ElideRight
- font.bold: true
- font.pixelSize: 13
- horizontalAlignment: Text.AlignHCenter
- text: confirmButton.text
- verticalAlignment: Text.AlignVCenter
- }
-
- background: Rectangle {
- border.color: "#F26A21"
- border.width: 1
- color: confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21"
- radius: 6
- }
- }
- }
- }
- }
-}
diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml
new file mode 100644
index 00000000..68af40da
--- /dev/null
+++ b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml
@@ -0,0 +1,91 @@
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ property var snapshot: ({})
+ readonly property bool isAdd: root.snapshot.action === "add"
+
+ spacing: 8
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 8
+ visible: root.isAdd
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "")
+ value: root.snapshot.depositA || ""
+ }
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "")
+ value: root.snapshot.depositB || ""
+ }
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Receive at least")
+ value: root.snapshot.minLpReceived || ""
+ }
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Current ratio")
+ value: root.snapshot.currentRatio || ""
+ }
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Fee tier")
+ value: root.snapshot.feeTier || ""
+ }
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Slippage tolerance")
+ value: root.snapshot.slippageTolerance || ""
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 8
+ visible: !root.isAdd
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Burn LP")
+ value: qsTr("%1 (%2)")
+ .arg(root.snapshot.burnText || "")
+ .arg(root.snapshot.burnPercent || "")
+ }
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "")
+ value: root.snapshot.minTokenAReceived || ""
+ }
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "")
+ value: root.snapshot.minTokenBReceived || ""
+ }
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Slippage tolerance")
+ value: root.snapshot.slippageTolerance || ""
+ }
+
+ SummaryRow {
+ Layout.fillWidth: true
+ label: qsTr("Post-removal share")
+ value: root.snapshot.postRemovalShare || ""
+ }
+ }
+}
diff --git a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml b/apps/amm/qml/components/swap/SwapConfirmationDialog.qml
deleted file mode 100644
index 54f61b44..00000000
--- a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml
+++ /dev/null
@@ -1,225 +0,0 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-
-FocusScope {
- id: root
-
- property var theme
- property var snapshot: ({})
- property bool open: false
-
- signal canceled
- signal confirmed(var snapshot)
-
- visible: root.open
- z: 20
-
- Keys.onEscapePressed: root.cancel()
-
- function openWithSnapshot(nextSnapshot) {
- root.snapshot = nextSnapshot;
- root.open = true;
- root.forceActiveFocus();
- cancelButton.forceActiveFocus();
- }
-
- function cancel() {
- root.open = false;
- root.canceled();
- }
-
- function confirm() {
- const confirmedSnapshot = root.snapshot;
- root.open = false;
- root.confirmed(confirmedSnapshot);
- }
-
- Rectangle {
- anchors.fill: parent
- color: "#99000000"
-
- MouseArea {
- anchors.fill: parent
- }
- }
-
- Rectangle {
- id: panel
-
- anchors.centerIn: parent
- color: root.theme.colors.cardBg
- implicitHeight: dialogContent.implicitHeight + 32
- radius: 16
- width: Math.max(0, Math.min(380, root.width - 32))
- border.color: root.theme.colors.border
- border.width: 1
-
- MouseArea {
- anchors.fill: parent
- }
-
- ColumnLayout {
- id: dialogContent
-
- anchors.fill: parent
- anchors.margins: 16
- spacing: 14
-
- Text {
- color: root.theme.colors.textPrimary
- font.bold: true
- font.pixelSize: 17
- text: qsTr("Confirm swap")
- Layout.fillWidth: true
- }
-
- ColumnLayout {
- spacing: 10
- Layout.fillWidth: true
-
- Rectangle {
- Layout.fillWidth: true
- color: root.theme.colors.inputBg
- radius: 12
- implicitHeight: payColumn.implicitHeight + 24
-
- ColumnLayout {
- id: payColumn
- anchors.fill: parent
- anchors.margins: 12
- spacing: 4
-
- Text {
- text: qsTr("You pay")
- color: root.theme.colors.textSecondary
- font.pixelSize: 12
- Layout.fillWidth: true
- }
- Text {
- text: qsTr("%1 %2")
- .arg(root.snapshot.sellAmount || "")
- .arg(root.snapshot.sellToken || "")
- color: root.theme.colors.textPrimary
- font.bold: true
- font.pixelSize: 18
- elide: Text.ElideRight
- Layout.fillWidth: true
- }
- }
- }
-
- Rectangle {
- Layout.fillWidth: true
- color: root.theme.colors.inputBg
- radius: 12
- implicitHeight: receiveColumn.implicitHeight + 24
-
- ColumnLayout {
- id: receiveColumn
- anchors.fill: parent
- anchors.margins: 12
- spacing: 4
-
- Text {
- text: qsTr("You receive at least")
- color: root.theme.colors.textSecondary
- font.pixelSize: 12
- Layout.fillWidth: true
- }
- Text {
- text: qsTr("%1 %2")
- .arg(root.snapshot.minReceived || "")
- .arg(root.snapshot.buyToken || "")
- color: root.theme.colors.textPrimary
- font.bold: true
- font.pixelSize: 18
- elide: Text.ElideRight
- Layout.fillWidth: true
- }
- }
- }
- }
-
- SwapSummary {
- Layout.fillWidth: true
- theme: root.theme
- swapModeText: root.snapshot.swapModeText || ""
- feeText: root.snapshot.feeAmount || ""
- priceImpactText: root.snapshot.priceImpactPercent || ""
- priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0
- slippageText: root.snapshot.slippageTolerance || ""
- minReceivedText: qsTr("%1 %2")
- .arg(root.snapshot.minReceived || "")
- .arg(root.snapshot.buyToken || "")
- }
-
- RowLayout {
- spacing: 10
- Layout.fillWidth: true
- Layout.topMargin: 4
-
- Button {
- id: cancelButton
- activeFocusOnTab: true
- focusPolicy: Qt.StrongFocus
- hoverEnabled: true
- text: qsTr("Cancel")
- Layout.fillWidth: true
- Layout.minimumHeight: 48
- onClicked: root.cancel()
-
- contentItem: Text {
- color: root.theme.colors.textPrimary
- elide: Text.ElideRight
- font.bold: true
- font.pixelSize: 14
- horizontalAlignment: Text.AlignHCenter
- text: cancelButton.text
- verticalAlignment: Text.AlignVCenter
- }
- background: Rectangle {
- border.color: root.theme.colors.borderStrong
- border.width: 1
- color: cancelButton.pressed
- ? root.theme.colors.panelHoverBg
- : cancelButton.hovered || cancelButton.activeFocus
- ? root.theme.colors.panelBg
- : "transparent"
- radius: 14
- }
- }
-
- Button {
- id: confirmButton
- activeFocusOnTab: true
- focusPolicy: Qt.StrongFocus
- hoverEnabled: true
- text: qsTr("Confirm Swap")
- Layout.fillWidth: true
- Layout.minimumHeight: 48
- onClicked: root.confirm()
-
- contentItem: Text {
- color: "#ffffff"
- elide: Text.ElideRight
- font.bold: true
- font.pixelSize: 14
- horizontalAlignment: Text.AlignHCenter
- text: confirmButton.text
- verticalAlignment: Text.AlignVCenter
- }
- background: Rectangle {
- border.width: 0
- color: confirmButton.pressed
- ? "#D95C1E"
- : confirmButton.hovered || confirmButton.activeFocus
- ? root.theme.colors.ctaHoverBg
- : root.theme.colors.ctaBg
- radius: 14
- }
- }
- }
- }
- }
-}
diff --git a/apps/amm/qml/components/swap/SwapConfirmationSummary.qml b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml
new file mode 100644
index 00000000..c3ff22e4
--- /dev/null
+++ b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml
@@ -0,0 +1,88 @@
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ property var theme
+ property var snapshot: ({})
+
+ spacing: 10
+
+ Rectangle {
+ Layout.fillWidth: true
+ color: root.theme.colors.inputBg
+ radius: 8
+ implicitHeight: payColumn.implicitHeight + 24
+
+ ColumnLayout {
+ id: payColumn
+ anchors.fill: parent
+ anchors.margins: 12
+ spacing: 4
+
+ Text {
+ Layout.fillWidth: true
+ text: qsTr("You pay")
+ color: root.theme.colors.textSecondary
+ font.pixelSize: 12
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: qsTr("%1 %2")
+ .arg(root.snapshot.sellAmount || "")
+ .arg(root.snapshot.sellToken || "")
+ color: root.theme.colors.textPrimary
+ font.bold: true
+ font.pixelSize: 18
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ color: root.theme.colors.inputBg
+ radius: 8
+ implicitHeight: receiveColumn.implicitHeight + 24
+
+ ColumnLayout {
+ id: receiveColumn
+ anchors.fill: parent
+ anchors.margins: 12
+ spacing: 4
+
+ Text {
+ Layout.fillWidth: true
+ text: qsTr("You receive at least")
+ color: root.theme.colors.textSecondary
+ font.pixelSize: 12
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: qsTr("%1 %2")
+ .arg(root.snapshot.minReceived || "")
+ .arg(root.snapshot.buyToken || "")
+ color: root.theme.colors.textPrimary
+ font.bold: true
+ font.pixelSize: 18
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ SwapSummary {
+ Layout.fillWidth: true
+ theme: root.theme
+ swapModeText: root.snapshot.swapModeText || ""
+ feeText: root.snapshot.feeAmount || ""
+ priceImpactText: root.snapshot.priceImpactPercent || ""
+ priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0
+ slippageText: root.snapshot.slippageTolerance || ""
+ minReceivedText: qsTr("%1 %2")
+ .arg(root.snapshot.minReceived || "")
+ .arg(root.snapshot.buyToken || "")
+ }
+}
diff --git a/apps/amm/qml/components/wallet/AccountControl.qml b/apps/amm/qml/components/wallet/AccountControl.qml
deleted file mode 100644
index 9956eebd..00000000
--- a/apps/amm/qml/components/wallet/AccountControl.qml
+++ /dev/null
@@ -1,490 +0,0 @@
-import QtQuick
-import QtQml
-import QtQuick.Controls
-import QtQuick.Layouts
-
-import Logos.Theme
-import Logos.Controls
-
-// Header wallet control (Uniswap-style), with two states:
-// - not connected → a "Connect" button that opens the create-wallet modal
-// - connected → a single button showing the active account address;
-// clicking it opens a popup (top-right, just under the
-// button) holding the account selector, create-account and
-// disconnect actions.
-// The selected account address is exposed via selectedAddress for the
-// trade/liquidity flows to use as the "from" account.
-Item {
- id: root
-
- // Backend replica (logos.module("amm_ui")) and its account model.
- property var backend: null
- property var accountModel: null
-
- readonly property bool connected: backend !== null && backend.isWalletOpen
-
- // Index of the active account. selectedAddress/selectedName are derived from
- // the model mirror below so they stay valid while the popup (and its list)
- // is closed.
- property int selectedIndex: 0
-
- // Non-visual mirror of the account model: realizes every row regardless of
- // popup visibility, so the active account is addressable by index at all
- // times (a ListView only realizes rows while it is shown).
- Instantiator {
- id: accounts
- model: root.accountModel
- delegate: QtObject {
- readonly property string address: model.address ?? ""
- readonly property string name: model.name ?? ""
- readonly property string balance: model.balance ?? ""
- readonly property bool isPublic: model.isPublic ?? false
- }
- }
-
- function entryAt(i) {
- return (i >= 0 && i < accounts.count) ? accounts.objectAt(i) : null
- }
-
- readonly property string selectedAddress: {
- const e = root.entryAt(root.selectedIndex)
- return e ? e.address : ""
- }
- readonly property string selectedName: {
- const e = root.entryAt(root.selectedIndex)
- return e ? e.name : ""
- }
- readonly property string selectedBalance: {
- const e = root.entryAt(root.selectedIndex)
- return e ? e.balance : ""
- }
- readonly property bool selectedIsPublic: {
- const e = root.entryAt(root.selectedIndex)
- return e ? e.isPublic : false
- }
-
- // Keep the selection within bounds as accounts are added/removed.
- function clampSelection() {
- if (accounts.count === 0) { root.selectedIndex = 0; return }
- if (root.selectedIndex < 0) root.selectedIndex = 0
- else if (root.selectedIndex >= accounts.count) root.selectedIndex = accounts.count - 1
- }
- Connections {
- target: root.accountModel
- ignoreUnknownSignals: true
- function onModelReset() { root.clampSelection() }
- function onRowsInserted() { root.clampSelection() }
- function onRowsRemoved() { root.clampSelection() }
- }
-
- // 0x123456…cdef style truncation for the connected button label.
- function truncated(addr) {
- if (!addr) return ""
- return addr.length > 13 ? (addr.substring(0, 6) + "…" + addr.substring(addr.length - 4)) : addr
- }
-
- // Copy on the QML/view side. Routing this through the backend would call
- // QGuiApplication::clipboard() in the (headless) module host process, which
- // has no clipboard — that call tears the backend down, dropping the wallet
- // connection. A hidden TextEdit copies via the GUI process that owns it.
- TextEdit { id: clipboardProxy; visible: false }
- function copyToClipboard(text) {
- if (!text) return
- clipboardProxy.text = text
- clipboardProxy.selectAll()
- clipboardProxy.copy()
- clipboardProxy.deselect()
- clipboardProxy.text = ""
- }
-
- implicitWidth: root.connected ? connectedButton.width : connectButton.width
- implicitHeight: 40
-
- // ── Disconnected: Connect ────────────────────────────────────────────
- LogosButton {
- id: connectButton
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- height: 40
- visible: !root.connected
- enabled: root.backend !== null
- text: qsTr("Connect")
- onClicked: {
- // Re-open an existing wallet; only show the create modal on first run.
- if (root.backend && root.backend.walletExists)
- logos.watch(root.backend.openExisting(),
- function(ok) { if (!ok) console.warn("openExisting failed") },
- function(error) { console.warn("openExisting error:", error) })
- else
- createWalletDialog.open()
- }
- }
-
- // ── Connected: address pill that toggles the wallet menu ─────────────
- Rectangle {
- id: connectedButton
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- visible: root.connected
- implicitHeight: 40
- implicitWidth: connectedRow.implicitWidth + Theme.spacing.medium * 2
- radius: height / 2
- // Keep an opaque dark fill in both states: the navbar is white, and the
- // active "muted" fill is translucent gray, which renders light over white
- // and makes the white label unreadable. Signal "open" with an accent
- // border instead.
- color: Theme.palette.backgroundSecondary
- border.width: 1
- border.color: walletMenu.opened ? Theme.palette.overlayOrange : "transparent"
-
- RowLayout {
- id: connectedRow
- anchors.centerIn: parent
- spacing: Theme.spacing.small
-
- Rectangle {
- Layout.preferredWidth: 8
- Layout.preferredHeight: 8
- radius: 4
- color: "#39c06a"
- }
- LogosText {
- text: root.truncated(root.selectedAddress) || qsTr("Connected")
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.text
- }
- LogosText {
- text: walletMenu.opened ? "▴" : "▾"
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.textSecondary
- }
- }
-
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- // CloseOnPressOutside already dismisses the popup on this same press
- // (the button is outside it), so `opened` is false by the time this
- // fires. Without the recency guard the dismissing click would just
- // reopen it. If it just closed, leave it closed.
- onClicked: {
- if (walletMenu.opened || (Date.now() - walletMenu.lastClosedMs) < 200)
- walletMenu.close()
- else
- walletMenu.open()
- }
- }
- }
-
- // ── Wallet menu popup (top-right, under the connected button) ─────────
- Popup {
- id: walletMenu
- parent: connectedButton
- y: connectedButton.height + Theme.spacing.small
- x: connectedButton.width - width // right-align under the button
- width: 360
- padding: Theme.spacing.medium
- closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
-
- // Timestamp of the last dismissal, used by the toggle button to tell a
- // genuine "open" click from the press that just closed the popup.
- property real lastClosedMs: 0
- onClosed: {
- walletMenu.lastClosedMs = Date.now()
- // Always reopen on the main (selected-account) view.
- if (viewStack.depth > 1)
- viewStack.pop(null, StackView.Immediate)
- }
-
- background: Rectangle {
- color: Theme.palette.backgroundTertiary
- border.width: 1
- border.color: Theme.palette.backgroundElevated
- radius: Theme.spacing.radiusLarge
- }
-
- // Two stacked views: the main view (active account + actions) and the
- // accounts view (full list + create). The popup height follows the
- // active view's natural height, animated so the resize isn't abrupt.
- contentItem: StackView {
- id: viewStack
- clip: true
- implicitWidth: walletMenu.availableWidth
- implicitHeight: currentItem ? currentItem.implicitHeight : 0
- initialItem: mainView
-
- Behavior on implicitHeight {
- NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
- }
-
- pushEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } }
- pushExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } }
- popEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } }
- popExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } }
- }
-
- // ── Main view: account icon + power icon, then the active account ──
- Component {
- id: mainView
-
- ColumnLayout {
- spacing: Theme.spacing.medium
-
- // Top-right actions: open the account list / disconnect.
- RowLayout {
- Layout.fillWidth: true
- spacing: Theme.spacing.small
-
- Item { Layout.fillWidth: true }
-
- WalletIconButton {
- iconSource: Qt.resolvedUrl("icons/account.svg")
- onClicked: viewStack.push(accountsView)
- }
- WalletIconButton {
- iconSource: Qt.resolvedUrl("icons/settings.svg")
- onClicked: viewStack.push(settingsView)
- }
- WalletIconButton {
- iconSource: Qt.resolvedUrl("icons/power.svg")
- onClicked: {
- walletMenu.close()
- if (root.backend) root.backend.disconnectWallet()
- }
- }
- }
-
- // Active account card.
- Rectangle {
- Layout.fillWidth: true
- Layout.preferredHeight: cardColumn.implicitHeight + Theme.spacing.medium * 2
- radius: Theme.spacing.radiusLarge
- color: Theme.palette.backgroundMuted
-
- ColumnLayout {
- id: cardColumn
- anchors.fill: parent
- anchors.margins: Theme.spacing.medium
- spacing: Theme.spacing.small
-
- RowLayout {
- Layout.fillWidth: true
- spacing: Theme.spacing.small
-
- LogosText {
- text: root.selectedName
- font.pixelSize: Theme.typography.secondaryText
- font.bold: true
- }
- Rectangle {
- Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2
- Layout.preferredHeight: tagLabel.implicitHeight + 4
- radius: 4
- color: Theme.palette.backgroundSecondary
- LogosText {
- id: tagLabel
- anchors.centerIn: parent
- text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private")
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.textSecondary
- }
- }
- Item { Layout.fillWidth: true }
- LogosText {
- text: root.selectedBalance.length > 0 ? root.selectedBalance : "—"
- font.bold: true
- }
- }
-
- RowLayout {
- Layout.fillWidth: true
- spacing: 0
- LogosText {
- Layout.fillWidth: true
- verticalAlignment: Text.AlignVCenter
- text: root.selectedAddress
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.textMuted
- elide: Text.ElideMiddle
- }
- LogosCopyButton {
- Layout.preferredHeight: 40
- Layout.preferredWidth: 40
- visible: root.selectedAddress.length > 0
- onCopyText: root.copyToClipboard(root.selectedAddress)
- icon.color: Theme.palette.textMuted
- }
- }
- }
- }
- }
- }
-
- // ── Accounts view: back + full list + create ──────────────────────
- Component {
- id: accountsView
-
- ColumnLayout {
- spacing: Theme.spacing.medium
-
- // Header: back to the main view + title.
- RowLayout {
- Layout.fillWidth: true
- spacing: Theme.spacing.small
-
- WalletIconButton {
- iconSource: Qt.resolvedUrl("icons/back.svg")
- onClicked: viewStack.pop()
- }
- LogosText {
- Layout.fillWidth: true
- text: qsTr("Accounts")
- font.bold: true
- color: Theme.palette.text
- }
- }
-
- // Account list: tap a row to make it the active account, then
- // return to the main view so the selection is reflected.
- ListView {
- Layout.fillWidth: true
- Layout.preferredHeight: Math.min(contentHeight, 260)
- clip: true
- model: root.accountModel
- spacing: Theme.spacing.small
- ScrollIndicator.vertical: ScrollIndicator { }
-
- delegate: AccountDelegate {
- width: ListView.view.width
- highlighted: index === root.selectedIndex
- onClicked: {
- root.selectedIndex = index
- viewStack.pop()
- }
- onCopyRequested: (text) => root.copyToClipboard(text)
- }
- }
-
- LogosButton {
- Layout.fillWidth: true
- height: 40
- text: qsTr("Add")
- // Leave the wallet menu open behind the (modal) dialog.
- onClicked: createAccountDialog.open()
- }
- }
- }
-
- // ── Settings view: back + editable network (sequencer) ────────────
- Component {
- id: settingsView
-
- ColumnLayout {
- spacing: Theme.spacing.medium
-
- // Header: back to the main view + title.
- RowLayout {
- Layout.fillWidth: true
- spacing: Theme.spacing.small
-
- WalletIconButton {
- iconSource: Qt.resolvedUrl("icons/back.svg")
- onClicked: viewStack.pop()
- }
- LogosText {
- Layout.fillWidth: true
- text: qsTr("Settings")
- font.bold: true
- color: Theme.palette.text
- }
- }
-
- LogosText {
- text: qsTr("Network (sequencer URL)")
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.textSecondary
- }
-
- LogosTextField {
- id: seqField
- Layout.fillWidth: true
- placeholderText: "http://127.0.0.1:3040"
- // Initialize from the live value without binding, so typing
- // isn't clobbered when sequencerAddr updates after a save.
- Component.onCompleted: text = root.backend ? root.backend.sequencerAddr : ""
- }
-
- LogosText {
- id: seqStatus
- Layout.fillWidth: true
- visible: text.length > 0
- wrapMode: Text.WordWrap
- font.pixelSize: Theme.typography.secondaryText
- property bool ok: false
- color: ok ? Theme.palette.success : Theme.palette.error
- }
-
- LogosButton {
- Layout.fillWidth: true
- height: 40
- text: qsTr("Save")
- onClicked: {
- if (!root.backend) return
- seqStatus.text = ""
- logos.watch(root.backend.changeSequencerAddr(seqField.text),
- function(ok) {
- seqStatus.ok = ok
- seqStatus.text = ok ? qsTr("Network updated.")
- : qsTr("Failed to update network.")
- },
- function(error) {
- seqStatus.ok = false
- seqStatus.text = qsTr("Error: %1").arg(error)
- })
- }
- }
- }
- }
- }
-
- // ── Dialogs ──────────────────────────────────────────────────────────
- CreateWalletDialog {
- id: createWalletDialog
- walletHome: root.backend ? root.backend.walletHome : ""
- onCreateWallet: function(password) {
- if (!root.backend) return
- // createNewDefault returns the new wallet's seed phrase (empty on
- // failure). On success we hand it to the dialog, which switches to
- // its backup page — we do NOT close here, so the user can't skip it.
- logos.watch(root.backend.createNewDefault(password),
- function(mnemonic) {
- if (mnemonic && mnemonic.length > 0)
- createWalletDialog.mnemonic = mnemonic
- else
- createWalletDialog.createError = qsTr("Failed to create wallet. Please try again.")
- },
- function(error) {
- createWalletDialog.createError = qsTr("Error creating wallet: %1").arg(error)
- })
- }
- onCopyRequested: function(text) {
- if (root.backend) root.backend.copyToClipboard(text)
- }
- }
-
- CreateAccountDialog {
- id: createAccountDialog
- onCreatePublicRequested: {
- if (!root.backend) return
- logos.watch(root.backend.createAccountPublic(),
- function(_id) { /* model updates via NOTIFY after refresh */ },
- function(error) { console.warn("createAccountPublic failed:", error) })
- }
- onCreatePrivateRequested: {
- if (!root.backend) return
- logos.watch(root.backend.createAccountPrivate(),
- function(_id) { /* model updates via NOTIFY after refresh */ },
- function(error) { console.warn("createAccountPrivate failed:", error) })
- }
- }
-}
diff --git a/apps/amm/qml/components/wallet/AccountDelegate.qml b/apps/amm/qml/components/wallet/AccountDelegate.qml
deleted file mode 100644
index 14c03ee2..00000000
--- a/apps/amm/qml/components/wallet/AccountDelegate.qml
+++ /dev/null
@@ -1,84 +0,0 @@
-import QtQuick
-import QtQuick.Controls
-import QtQuick.Layouts
-
-import Logos.Theme
-import Logos.Controls
-
-// One account row in the account dropdown. Ported from the LEZ wallet UI.
-ItemDelegate {
- id: root
-
- // Emitted when the user clicks the copy icon; the parent view connects this
- // to its QML-side clipboard helper (AccountControl.copyToClipboard).
- signal copyRequested(string text)
-
- leftPadding: Theme.spacing.medium
- rightPadding: Theme.spacing.medium
- topPadding: Theme.spacing.medium
- bottomPadding: Theme.spacing.medium
-
- background: Rectangle {
- color: root.highlighted || root.hovered ?
- Theme.palette.backgroundMuted :
- Theme.palette.backgroundTertiary
- radius: Theme.spacing.radiusLarge
- }
-
- contentItem: ColumnLayout {
- spacing: Theme.spacing.small
- RowLayout {
- Layout.fillWidth: true
- spacing: Theme.spacing.small
-
- LogosText {
- text: model.name ?? ""
- font.pixelSize: Theme.typography.secondaryText
- font.bold: true
- }
-
- Rectangle {
- Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2
- Layout.preferredHeight: tagLabel.implicitHeight + 4
- radius: 4
- color: Theme.palette.backgroundSecondary
-
- LogosText {
- id: tagLabel
- anchors.centerIn: parent
- text: model.isPublic ? qsTr("Public") : qsTr("Private")
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.textSecondary
- }
- }
-
- Item { Layout.fillWidth: true }
-
- LogosText {
- text: model.balance && model.balance.length > 0 ? model.balance : "—"
- font.bold: true
- }
- }
-
- RowLayout {
- Layout.fillWidth: true
- spacing: 0
- LogosText {
- id: addressLabel
- Layout.fillWidth: true
- verticalAlignment: Text.AlignVCenter
- text: model.address ?? ""
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.textMuted
- elide: Text.ElideMiddle
- }
- LogosCopyButton {
- Layout.preferredHeight: 40
- Layout.preferredWidth: 40
- onCopyText: root.copyRequested(model.address)
- visible: addressLabel.text
- icon.color: Theme.palette.textMuted
- }
- }
- }
-}
diff --git a/apps/amm/qml/components/wallet/CreateAccountDialog.qml b/apps/amm/qml/components/wallet/CreateAccountDialog.qml
deleted file mode 100644
index 80707958..00000000
--- a/apps/amm/qml/components/wallet/CreateAccountDialog.qml
+++ /dev/null
@@ -1,102 +0,0 @@
-import QtQuick
-import QtQuick.Controls
-import QtQuick.Layouts
-
-import Logos.Theme
-import Logos.Controls
-
-// Public/private account creation dialog. Ported from the LEZ wallet UI.
-Popup {
- id: root
-
- signal createPublicRequested()
- signal createPrivateRequested()
-
- modal: true
- dim: true
- padding: Theme.spacing.large
- closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
-
- // Center on the full-window overlay rather than the small navbar control
- // this popup is declared inside.
- parent: Overlay.overlay
- anchors.centerIn: parent
- width: 360
-
- background: Rectangle {
- color: Theme.palette.backgroundSecondary
- radius: Theme.spacing.radiusXlarge
- border.color: Theme.palette.backgroundElevated
- }
-
- contentItem: ColumnLayout {
- id: contentLayout
- // Pin to the popup's padded width so children stay within the modal.
- width: root.availableWidth
- spacing: Theme.spacing.large
-
- LogosText {
- text: qsTr("Create account")
- font.pixelSize: Theme.typography.titleText
- font.weight: Theme.typography.weightBold
- color: Theme.palette.text
- }
-
- LogosText {
- text: qsTr("Choose account type.")
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.textSecondary
- Layout.topMargin: -Theme.spacing.small
- }
-
- RowLayout {
- Layout.fillWidth: true
- spacing: Theme.spacing.medium
-
- ColumnLayout {
- Layout.fillWidth: true
- spacing: 0
-
- LogosText {
- text: qsTr("Private")
- font.pixelSize: Theme.typography.primaryText
- color: Theme.palette.text
- }
- LogosText {
- text: qsTr("Private balance and activity.")
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.textSecondary
- wrapMode: Text.WordWrap
- Layout.fillWidth: true
- }
- }
-
- LogosSwitch {
- id: privateSwitch
- checked: false
- }
- }
-
- RowLayout {
- Layout.topMargin: Theme.spacing.medium
- spacing: Theme.spacing.medium
- Layout.fillWidth: true
- LogosButton {
- text: qsTr("Cancel")
- Layout.fillWidth: true
- onClicked: root.close()
- }
- LogosButton {
- text: qsTr("Create")
- Layout.fillWidth: true
- onClicked: {
- if (privateSwitch.checked)
- root.createPrivateRequested()
- else
- root.createPublicRequested()
- root.close()
- }
- }
- }
- }
-}
diff --git a/apps/amm/qml/components/wallet/CreateWalletDialog.qml b/apps/amm/qml/components/wallet/CreateWalletDialog.qml
deleted file mode 100644
index 05c8d6e6..00000000
--- a/apps/amm/qml/components/wallet/CreateWalletDialog.qml
+++ /dev/null
@@ -1,201 +0,0 @@
-import QtQuick
-import QtQuick.Controls
-import QtQuick.Layouts
-
-import Logos.Theme
-import Logos.Controls
-
-// Wallet creation modal. Two pages, driven by whether a mnemonic exists yet:
-// 1. Password entry — emits createWallet(password); the parent creates the
-// wallet and, on success, sets `mnemonic` to the returned seed phrase.
-// 2. Seed-phrase backup — shows the mnemonic once and gates dismissal behind
-// an explicit acknowledgement. This is the only time the phrase is shown,
-// so the popup is not auto-dismissable while it is visible.
-// Storage/config live at the per-app default (backend.walletHome) — no path
-// picking. Opened from the navbar "Connect" button.
-Popup {
- id: root
-
- // Where the wallet will be stored, shown for transparency.
- property string walletHome: ""
- property string createError: ""
- // Set by the parent to the BIP39 seed phrase once creation succeeds. A
- // non-empty value flips the dialog to the backup page.
- property string mnemonic: ""
-
- signal createWallet(string password)
- signal copyRequested(string text)
-
- modal: true
- dim: true
- padding: Theme.spacing.large
- // Once the wallet exists we must not let the user dismiss the modal (and
- // lose the only view of their seed phrase) by clicking away or pressing Esc.
- closePolicy: root.mnemonic.length > 0
- ? Popup.NoAutoClose
- : (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
- // Center on the full-window overlay rather than the small navbar control
- // this popup is declared inside.
- parent: Overlay.overlay
- anchors.centerIn: parent
- width: 380
-
- onOpened: {
- passwordField.text = ""
- confirmField.text = ""
- root.createError = ""
- root.mnemonic = ""
- passwordField.forceActiveFocus()
- }
-
- background: Rectangle {
- color: Theme.palette.backgroundSecondary
- radius: Theme.spacing.radiusXlarge
- border.color: Theme.palette.backgroundElevated
- }
-
- contentItem: ColumnLayout {
- // Pin to the popup's padded width so long text wraps and fillWidth
- // children don't push the layout wider than the modal.
- width: root.availableWidth
- spacing: 0
-
- // ── Page 1: password entry ────────────────────────────────────────
- ColumnLayout {
- id: passwordPage
- visible: root.mnemonic.length === 0
- Layout.fillWidth: true
- spacing: Theme.spacing.large
-
- LogosText {
- text: qsTr("Create your wallet")
- font.pixelSize: Theme.typography.titleText
- font.weight: Theme.typography.weightBold
- color: Theme.palette.text
- }
-
- LogosText {
- text: qsTr("Secure your wallet with a password. It will be stored on this device at %1.")
- .arg(root.walletHome || qsTr("the default location"))
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.textSecondary
- wrapMode: Text.WordWrap
- Layout.fillWidth: true
- Layout.topMargin: -Theme.spacing.small
- }
-
- LogosTextField {
- id: passwordField
- Layout.fillWidth: true
- placeholderText: qsTr("Password")
- echoMode: TextInput.Password
- Keys.onReturnPressed: createButton.tryCreate()
- }
- LogosTextField {
- id: confirmField
- Layout.fillWidth: true
- placeholderText: qsTr("Confirm password")
- echoMode: TextInput.Password
- Keys.onReturnPressed: createButton.tryCreate()
- }
-
- LogosText {
- Layout.fillWidth: true
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.error
- wrapMode: Text.WordWrap
- visible: text.length > 0
- text: root.createError
- }
-
- RowLayout {
- Layout.topMargin: Theme.spacing.small
- Layout.fillWidth: true
- spacing: Theme.spacing.medium
- LogosButton {
- text: qsTr("Cancel")
- Layout.fillWidth: true
- onClicked: root.close()
- }
- LogosButton {
- id: createButton
- Layout.fillWidth: true
- text: qsTr("Create Wallet")
- function tryCreate() {
- if (passwordField.text.length === 0) {
- root.createError = qsTr("Password cannot be empty.")
- } else if (passwordField.text !== confirmField.text) {
- root.createError = qsTr("Passwords do not match.")
- } else {
- root.createError = ""
- root.createWallet(passwordField.text)
- }
- }
- onClicked: tryCreate()
- }
- }
- }
-
- // ── Page 2: seed-phrase backup ────────────────────────────────────
- ColumnLayout {
- id: backupPage
- visible: root.mnemonic.length > 0
- Layout.fillWidth: true
- spacing: Theme.spacing.large
-
- LogosText {
- text: qsTr("Back up your recovery phrase")
- font.pixelSize: Theme.typography.titleText
- font.weight: Theme.typography.weightBold
- color: Theme.palette.text
- }
-
- LogosText {
- text: qsTr("Write these words down in order and store them somewhere safe. Anyone with this phrase can control your wallet, and it will not be shown again — it is the only way to recover access.")
- font.pixelSize: Theme.typography.secondaryText
- color: Theme.palette.textSecondary
- wrapMode: Text.WordWrap
- Layout.fillWidth: true
- Layout.topMargin: -Theme.spacing.small
- }
-
- Rectangle {
- Layout.fillWidth: true
- radius: Theme.spacing.radiusLarge
- color: Theme.palette.backgroundElevated
- implicitHeight: phraseText.implicitHeight + 2 * Theme.spacing.medium
-
- LogosText {
- id: phraseText
- anchors.fill: parent
- anchors.margins: Theme.spacing.medium
- text: root.mnemonic
- wrapMode: Text.WordWrap
- lineHeight: 1.4
- font.pixelSize: Theme.typography.primaryText
- font.weight: Theme.typography.weightBold
- color: Theme.palette.text
- }
- }
-
- LogosButton {
- Layout.fillWidth: true
- text: qsTr("Copy to clipboard")
- onClicked: root.copyRequested(root.mnemonic)
- }
-
- LogosCheckbox {
- id: ackCheck
- Layout.fillWidth: true
- text: qsTr("I have safely backed up my recovery phrase")
- }
-
- LogosButton {
- Layout.fillWidth: true
- enabled: ackCheck.checked
- text: qsTr("Continue")
- onClicked: root.close()
- }
- }
- }
-}
diff --git a/apps/amm/qml/components/wallet/LogosCopyButton.qml b/apps/amm/qml/components/wallet/LogosCopyButton.qml
deleted file mode 100644
index 7c1d7bdc..00000000
--- a/apps/amm/qml/components/wallet/LogosCopyButton.qml
+++ /dev/null
@@ -1,39 +0,0 @@
-import QtQuick
-import QtQuick.Controls
-
-import Logos.Theme
-
-Button {
- id: root
-
- signal copyText()
-
- implicitWidth: 24
- implicitHeight: 24
- display: AbstractButton.IconOnly
- flat: true
-
- property string iconSource: Qt.resolvedUrl("icons/copy.svg")
-
- icon.source: root.iconSource
- icon.width: 24
- icon.height: 24
- icon.color: Theme.palette.textSecondary
-
- function reset() {
- iconSource = Qt.resolvedUrl("icons/copy.svg")
- }
-
- Timer {
- id: resetTimer
- interval: 1500
- repeat: false
- onTriggered: root.reset()
- }
-
- onClicked: {
- root.copyText()
- root.iconSource = Qt.resolvedUrl("icons/checkmark.svg")
- resetTimer.restart()
- }
-}
diff --git a/apps/amm/qml/components/wallet/WalletIconButton.qml b/apps/amm/qml/components/wallet/WalletIconButton.qml
deleted file mode 100644
index 61545e0d..00000000
--- a/apps/amm/qml/components/wallet/WalletIconButton.qml
+++ /dev/null
@@ -1,25 +0,0 @@
-import QtQuick
-import QtQuick.Controls
-
-import Logos.Theme
-
-// Small icon-only action button for the wallet menu. Uses the same Button +
-// icon.source/icon.color path as LogosCopyButton, which renders reliably here
-// (LogosIconButton's Image + MultiEffect shader path does not).
-Button {
- id: root
-
- property url iconSource
- property color iconColor: Theme.palette.textSecondary
- property int iconSize: 18
-
- implicitWidth: 32
- implicitHeight: 32
- display: AbstractButton.IconOnly
- flat: true
-
- icon.source: root.iconSource
- icon.width: root.iconSize
- icon.height: root.iconSize
- icon.color: root.iconColor
-}
diff --git a/apps/amm/qml/components/wallet/icons/settings.svg b/apps/amm/qml/components/wallet/icons/settings.svg
deleted file mode 100644
index 77f89ae5..00000000
--- a/apps/amm/qml/components/wallet/icons/settings.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml
index f4719521..9dde13a6 100644
--- a/apps/amm/qml/pages/LiquidityPage.qml
+++ b/apps/amm/qml/pages/LiquidityPage.qml
@@ -1,5 +1,6 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
+import Logos.Wallet
import "../components/shared"
import "../components/liquidity"
import "../state"
@@ -162,10 +163,18 @@ Item {
}
}
- LiquidityConfirmationDialog {
- id: confirmationDialog
+ Component {
+ id: liquidityConfirmationSummary
- anchors.fill: parent
+ LiquidityConfirmationSummary { }
+ }
+
+ TransactionConfirmationDialog {
+ id: confirmationDialog
+ title: snapshot.action === "add"
+ ? qsTr("Confirm add liquidity")
+ : qsTr("Confirm remove liquidity")
+ summary: liquidityConfirmationSummary
onConfirmed: function (snapshot) {
root.confirmLiquidityAction(snapshot);
diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml
index 75b42d4e..f4aca1a2 100644
--- a/apps/amm/qml/pages/SwapPage.qml
+++ b/apps/amm/qml/pages/SwapPage.qml
@@ -1,9 +1,10 @@
+pragma ComponentBehavior: Bound
+
import QtQuick 2.15
-import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
+import Logos.Wallet
import "../components/shared"
import "../components/swap"
-import "../state"
Item {
id: root
@@ -18,7 +19,7 @@ Item {
]
QtObject {
- id: theme
+ id: pageTheme
property bool isDark: true
property var colors: isDark ? dark : light
@@ -61,7 +62,7 @@ Item {
Rectangle {
anchors.fill: parent
- color: theme.colors.background
+ color: pageTheme.colors.background
Behavior on color { ColorAnimation { duration: 300 } }
// Theme toggle
@@ -70,19 +71,19 @@ Item {
anchors.right: parent.right
anchors.margins: 16
width: 44; height: 24; radius: 12
- color: theme.colors.panelBg
- border.color: theme.colors.border
+ color: pageTheme.colors.panelBg
+ border.color: pageTheme.colors.border
border.width: 1
Text {
anchors.centerIn: parent
- text: theme.isDark ? "☀" : "☾"
+ text: pageTheme.isDark ? "☀" : "☾"
font.pixelSize: 13
- color: theme.colors.textSecondary
+ color: pageTheme.colors.textSecondary
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
- onClicked: theme.isDark = !theme.isDark
+ onClicked: pageTheme.isDark = !pageTheme.isDark
}
}
@@ -93,7 +94,7 @@ Item {
SwapCard {
id: swapCard
Layout.alignment: Qt.AlignHCenter
- theme: theme
+ theme: pageTheme
tokens: root.tokens
width: Math.min(480, root.width - 32)
@@ -109,9 +110,9 @@ Item {
Text {
Layout.alignment: Qt.AlignHCenter
- text: "Buy and sell crypto on LEZ."
+ text: "Buy and sell crypto on LEZ."
textFormat: Text.RichText
- color: theme.colors.textSecondary
+ color: pageTheme.colors.textSecondary
font.pixelSize: 15
horizontalAlignment: Text.AlignHCenter
}
@@ -121,7 +122,7 @@ Item {
id: tokenModal
anchors.fill: parent
z: 10
- theme: theme
+ theme: pageTheme
tokens: root.tokens
property string targetSide: "sell"
@@ -144,10 +145,19 @@ Item {
}
}
- SwapConfirmationDialog {
+ Component {
+ id: swapConfirmationSummary
+
+ SwapConfirmationSummary {
+ theme: pageTheme
+ }
+ }
+
+ TransactionConfirmationDialog {
id: swapConfirmationDialog
- anchors.fill: parent
- theme: theme
+ title: qsTr("Confirm swap")
+ confirmText: qsTr("Confirm swap")
+ summary: swapConfirmationSummary
onConfirmed: function(snapshot) {
swapCard.resetAmounts()
diff --git a/apps/amm/src/AccountModel.cpp b/apps/amm/src/AccountModel.cpp
deleted file mode 100644
index 63992f86..00000000
--- a/apps/amm/src/AccountModel.cpp
+++ /dev/null
@@ -1,79 +0,0 @@
-#include "AccountModel.h"
-
-#include
-
-AccountModel::AccountModel(QObject* parent)
- : QAbstractListModel(parent)
-{
-}
-
-int AccountModel::rowCount(const QModelIndex& parent) const
-{
- if (parent.isValid())
- return 0;
- return m_entries.size();
-}
-
-QVariant AccountModel::data(const QModelIndex& index, int role) const
-{
- if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size())
- return QVariant();
-
- const AccountEntry& e = m_entries.at(index.row());
- switch (role) {
- case NameRole: return e.name;
- case AddressRole: return e.address;
- case BalanceRole: return e.balance;
- case IsPublicRole: return e.isPublic;
- default: return QVariant();
- }
-}
-
-QHash AccountModel::roleNames() const
-{
- return {
- { NameRole, "name" },
- { AddressRole, "address" },
- { BalanceRole, "balance" },
- { IsPublicRole, "isPublic" }
- };
-}
-
-void AccountModel::replaceFromJsonArray(const QJsonArray& arr)
-{
- beginResetModel();
- const int oldCount = m_entries.size();
- m_entries.clear();
- int idx = 0;
- for (const QJsonValue& v : arr) {
- AccountEntry e;
- e.name = QStringLiteral("Account %1").arg(++idx);
- e.balance = QString();
- if (v.isObject()) {
- const QJsonObject obj = v.toObject();
- e.address = obj.value(QStringLiteral("account_id")).toString();
- e.isPublic = obj.value(QStringLiteral("is_public")).toBool(true);
- } else {
- e.address = v.toString();
- e.isPublic = true;
- }
- m_entries.append(e);
- }
- endResetModel();
- if (oldCount != m_entries.size())
- emit countChanged();
-}
-
-void AccountModel::setBalanceByAddress(const QString& address, const QString& balance)
-{
- for (int i = 0; i < m_entries.size(); ++i) {
- if (m_entries.at(i).address == address) {
- if (m_entries.at(i).balance != balance) {
- m_entries[i].balance = balance;
- const QModelIndex idx = index(i, 0);
- emit dataChanged(idx, idx, { BalanceRole });
- }
- return;
- }
- }
-}
diff --git a/apps/amm/src/AccountModel.h b/apps/amm/src/AccountModel.h
deleted file mode 100644
index 918839c3..00000000
--- a/apps/amm/src/AccountModel.h
+++ /dev/null
@@ -1,47 +0,0 @@
-#pragma once
-
-#include
-#include
-#include
-#include
-
-// One wallet account row. Mirrors the shape returned by the core wallet
-// module's list_accounts() (account_id + is_public), with a display name and
-// a lazily-fetched balance.
-struct AccountEntry {
- QString name;
- QString address;
- QString balance;
- bool isPublic = true;
-};
-
-// QAbstractListModel of wallet accounts, exposed to QML via
-// logos.model("amm_ui", "accountModel"). Ported from the LEZ wallet UI.
-class AccountModel : public QAbstractListModel {
- Q_OBJECT
- Q_PROPERTY(int count READ count NOTIFY countChanged)
-public:
- enum Role {
- NameRole = Qt::UserRole + 1,
- AddressRole,
- BalanceRole,
- IsPublicRole
- };
- Q_ENUM(Role)
-
- explicit AccountModel(QObject* parent = nullptr);
-
- int rowCount(const QModelIndex& parent = QModelIndex()) const override;
- QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
- QHash roleNames() const override;
-
- void replaceFromJsonArray(const QJsonArray& arr);
- void setBalanceByAddress(const QString& address, const QString& balance);
- int count() const { return m_entries.size(); }
-
-signals:
- void countChanged();
-
-private:
- QVector m_entries;
-};
diff --git a/apps/amm/src/ActiveNetwork.cpp b/apps/amm/src/ActiveNetwork.cpp
new file mode 100644
index 00000000..9bb1e275
--- /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 00000000..9b1db9b7
--- /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 f937d039..17590829 100644
--- a/apps/amm/src/AmmUiBackend.cpp
+++ b/apps/amm/src/AmmUiBackend.cpp
@@ -1,401 +1,534 @@
#include "AmmUiBackend.h"
-#include
-#include
-#include
-#include
+#include
+
#include
-#include
-#include
#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"
-#include "logos_sdk.h"
namespace {
- const char SETTINGS_ORG[] = "Logos";
- const char SETTINGS_APP[] = "AmmUI";
- // Sticky "user pressed Disconnect" flag so the wallet stays locked across
- // relaunches until the user reconnects.
- const char DISCONNECTED_KEY[] = "disconnected";
- const int WALLET_FFI_SUCCESS = 0;
-
- // Wallet home env override. Mirrors LEZ's own var so the app shares the
- // canonical wallet (~/.lee/wallet) used by the wallet UI and other apps.
- const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR";
-
- // Normalise file:// URLs and OS paths to a plain local path.
- QString toLocalPath(const QString& path) {
- if (path.startsWith("file://") || path.contains("/"))
- return QUrl::fromUserInput(path).toLocalFile();
- return path;
- }
-}
+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'));
-QString AmmUiBackend::defaultWalletHome()
+QByteArray resource(const QString& path)
{
- const QByteArray override = qgetenv(WALLET_HOME_ENV);
- if (!override.isEmpty())
- return QString::fromLocal8Bit(override);
- // LEZ's canonical wallet home, shared with the wallet UI and other LEZ apps
- // (matches lez/wallet get_home_default_path()).
- return QDir::homePath() + QStringLiteral("/.lee/wallet");
+ QFile file(path);
+ return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
}
-QString AmmUiBackend::defaultConfigPath() const
+QByteArray jsonRpcBody(const QString& method, const QJsonArray& params)
{
- return defaultWalletHome() + QStringLiteral("/wallet_config.json");
+ return QJsonDocument(QJsonObject {
+ { QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
+ { QStringLiteral("id"), 1 },
+ { QStringLiteral("method"), method },
+ { QStringLiteral("params"), params },
+ }).toJson(QJsonDocument::Compact);
}
-QString AmmUiBackend::defaultStoragePath() const
+QString blockHashFromResponse(const QByteArray& payload)
{
- return defaultWalletHome() + QStringLiteral("/storage.json");
+ 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());
}
-AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
- : AmmUiBackendSimpleSource(parent),
- m_accountModel(new AccountModel(this)),
- m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)),
- m_logos(new LogosModules(m_logosAPI)),
- m_net(new QNetworkAccessManager(this)),
- m_reachabilityTimer(new QTimer(this))
-{
- // PROP defaults via the generated setters.
- setIsWalletOpen(false);
- setLastSyncedBlock(0);
- setCurrentBlockHeight(0);
- setWalletHome(defaultWalletHome());
- // Assume reachable until a probe proves otherwise (avoids a startup flash).
- setSequencerReachable(true);
-
- // Periodically re-probe the sequencer so the banner reacts to a node going
- // up/down while the app is running. Probes are no-ops until a wallet (and
- // thus a sequencer address) is open.
- m_reachabilityTimer->setInterval(10000);
- connect(m_reachabilityTimer, &QTimer::timeout, this, [this]() { checkReachability(); });
- m_reachabilityTimer->start();
-
- // Always resolve against the canonical wallet home (LEE_WALLET_HOME_DIR or
- // ~/.lee/wallet). We intentionally don't seed config/storage paths from
- // QSettings anymore: a previously-persisted per-app path (~/.lee/amm-wallet)
- // would otherwise override the default and pin the app to the old keystore.
-
- // A wallet exists on disk if its storage file is present (drives whether
- // the navbar "Connect" reconnects or offers to create a wallet).
- const QString effectiveStorage = storagePath().isEmpty() ? defaultStoragePath() : storagePath();
- setWalletExists(QFileInfo::exists(effectiveStorage));
-
- // ui-host runs our constructor inside initLogos(), synchronously, BEFORE
- // it enables remoting and emits READY. Any blocking RPC here would stall
- // ui-host startup past its ready watchdog. Defer the open+refresh chain to
- // the first event-loop tick so ui-host finishes wiring itself up first.
- QTimer::singleShot(0, this, [this]() { openOrAdoptWallet(); });
-
- // Save wallet on quit; host may not call destructors so this is best-effort.
- connect(qApp, &QCoreApplication::aboutToQuit, this,
- [this]() { saveWallet(); }, Qt::DirectConnection);
-}
-
-AmmUiBackend::~AmmUiBackend()
+QString channelIdFromResponse(const QByteArray& payload)
{
- saveWallet();
- delete m_logos;
+ 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();
}
-void AmmUiBackend::openOrAdoptWallet()
+QString decimalAdd(const QString& left, const QString& right)
{
- // Respect an explicit user disconnect: stay locked, show "Connect".
- if (QSettings(SETTINGS_ORG, SETTINGS_APP).value(DISCONNECTED_KEY, false).toBool())
- return;
-
- // In Basecamp the logos_execution_zone module is a single shared instance,
- // so the wallet may already be open (e.g. opened by the dedicated wallet
- // app). Adopt that wallet instead of fighting over it: mirror its state
- // rather than re-opening from disk, which could clobber unsaved in-memory
- // accounts the other app holds. A freshly-created shared wallet can be open
- // with zero accounts, so we can't key off list_accounts() alone (see
- // sharedWalletIsOpen).
- if (sharedWalletIsOpen()) {
- const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts());
- qDebug() << "AmmUiBackend: adopting already-open shared wallet"
- << existing.size() << "accounts";
- setIsWalletOpen(true);
- m_accountModel->replaceFromJsonArray(existing);
- refreshBalances();
- refreshSequencerAddr();
- return;
+ 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 {};
}
-
- // Standalone (own core instance): auto-open a previously-created wallet.
- // Use persisted paths if the user picked custom ones, else the per-app
- // default. Only open if the storage actually exists, otherwise stay closed
- // so QML shows the "Connect" entry point (no noisy FFI errors on first run).
- const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath();
- const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath();
- if (!QFileInfo::exists(stg))
- return; // No wallet yet — QML shows "Connect".
-
- qDebug() << "AmmUiBackend: opening wallet with config" << cfg << "storage" << stg;
- const int err = m_logos->logos_execution_zone.open(cfg, stg);
- if (err == WALLET_FFI_SUCCESS) {
- persistConfigPath(cfg);
- persistStoragePath(stg);
- setIsWalletOpen(true);
- refreshAccounts();
- refreshBlockHeights();
- refreshSequencerAddr();
- } else {
- qWarning() << "AmmUiBackend: wallet open failed, code" << err;
+ 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;
}
-bool AmmUiBackend::sharedWalletIsOpen()
+QJsonObject enumFields(const QJsonValue& value, const QString& variant)
{
- // list_accounts() is non-empty only once the wallet holds accounts, so it
- // can't distinguish "no wallet open" from "open but empty" (a wallet that
- // was just created and hasn't had an account added yet). Fall back to a
- // handle-dependent, account-independent signal: an open wallet always has a
- // sequencer address (from its config, defaulted on open), while a closed
- // core returns an empty string. This lets us adopt a freshly-created shared
- // wallet instead of falling through and re-opening it from disk.
- if (!QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()).isEmpty())
- return true;
- return !m_logos->logos_execution_zone.get_sequencer_addr().isEmpty();
+ return value.toObject().value(variant).toObject();
}
-QString AmmUiBackend::createNewDefault(QString password)
+QString decodedDataText(const QJsonValue& value)
{
- QDir().mkpath(defaultWalletHome());
- return createNew(defaultConfigPath(), defaultStoragePath(), password);
+ if (value.isObject()) {
+ return QString::fromUtf8(
+ QJsonDocument(value.toObject()).toJson(QJsonDocument::Indented)).trimmed();
+ }
+ if (value.isArray()) {
+ return QString::fromUtf8(
+ QJsonDocument(value.toArray()).toJson(QJsonDocument::Indented)).trimmed();
+ }
+ return {};
}
-QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password)
+WalletAccountRead accountRead(const WalletAccount& account)
{
- const QString localConfig = toLocalPath(configPath);
- const QString localStorage = toLocalPath(storagePath);
- // create_new returns the new wallet's BIP39 mnemonic (empty on failure). We
- // hand it back to the caller instead of discarding it: wallet creation is
- // the only moment the seed phrase is recoverable, so the UI must force a
- // backup step before the user can proceed.
- const QString mnemonic = m_logos->logos_execution_zone.create_new(localConfig, localStorage, password);
- if (mnemonic.isEmpty()) {
- qWarning() << "AmmUiBackend: create_new failed (empty mnemonic)";
- return QString();
- }
-
- persistConfigPath(localConfig);
- persistStoragePath(localStorage);
- setWalletExists(true);
- QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false);
- setIsWalletOpen(true);
- refreshAccounts();
- refreshBlockHeights();
- refreshSequencerAddr();
- return mnemonic;
+ WalletAccountRead read;
+ read.accountId = account.address;
+ read.status = account.readStatus;
+ read.programOwner = account.programOwner;
+ read.dataHex = account.dataHex;
+ return read;
+}
}
-bool AmmUiBackend::openExisting()
+AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
+ : AmmUiBackendSimpleSource(parent),
+ m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)),
+ m_ownedWallet(std::make_unique(m_logosAPI)),
+ m_wallet(m_ownedWallet.get()),
+ m_definitionCache(*m_wallet),
+ m_walletController(std::make_unique(
+ *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")))
{
- // Adopt a shared open wallet (Basecamp), else open our own from disk. A
- // freshly-created shared wallet can be open with zero accounts, so probe
- // open-ness rather than keying off list_accounts() alone.
- if (sharedWalletIsOpen()) {
- const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts());
- setIsWalletOpen(true);
- m_accountModel->replaceFromJsonArray(existing);
- refreshBalances();
- refreshSequencerAddr();
- QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false);
- return true;
- }
+ initialize();
+}
- const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath();
- const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath();
- if (!QFileInfo::exists(stg))
- return false;
+AmmUiBackend::AmmUiBackend(WalletProvider& wallet, QObject* parent)
+ : AmmUiBackendSimpleSource(parent),
+ m_logosAPI(nullptr),
+ m_wallet(&wallet),
+ m_definitionCache(*m_wallet),
+ m_walletController(std::make_unique(
+ *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")))
+{
+ initialize();
+}
- const int err = m_logos->logos_execution_zone.open(cfg, stg);
- if (err != WALLET_FFI_SUCCESS) {
- qWarning() << "AmmUiBackend: openExisting failed, code" << err;
- return false;
- }
- persistConfigPath(cfg);
- persistStoragePath(stg);
- setIsWalletOpen(true);
- QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false);
- refreshAccounts();
- refreshBlockHeights();
- refreshSequencerAddr();
- return true;
+void AmmUiBackend::initialize()
+{
+ 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();
}
-void AmmUiBackend::disconnectWallet()
+AmmUiBackend::~AmmUiBackend() = default;
+
+WalletAccountModel* AmmUiBackend::accountModel() const
{
- // UI-local lock: persist wallet state, drop our view of it, and remember
- // the choice. We do NOT close the core module's wallet handle — in Basecamp
- // that instance is shared with other apps.
- saveWallet();
- setIsWalletOpen(false);
- m_accountModel->replaceFromJsonArray(QJsonArray());
- QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true);
+ return m_walletController->accountModel();
}
QString AmmUiBackend::createAccountPublic()
{
- const QString result = m_logos->logos_execution_zone.create_account_public();
- if (!result.isEmpty())
- refreshAccounts();
- return result;
+ return m_walletController->createAccount(true);
}
QString AmmUiBackend::createAccountPrivate()
{
- const QString result = m_logos->logos_execution_zone.create_account_private();
- if (!result.isEmpty())
- refreshAccounts();
- return result;
+ return m_walletController->createAccount(false);
}
void AmmUiBackend::refreshAccounts()
{
- const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts());
- m_accountModel->replaceFromJsonArray(arr);
- refreshBalances();
+ m_walletController->refresh();
}
void AmmUiBackend::refreshBalances()
{
- refreshBlockHeights();
- if (currentBlockHeight() > 0)
- m_logos->logos_execution_zone.sync_to_block(static_cast(currentBlockHeight()));
-
- for (int i = 0; i < m_accountModel->count(); ++i) {
- const QModelIndex idx = m_accountModel->index(i, 0);
- const QString addr = m_accountModel->data(idx, AccountModel::AddressRole).toString();
- const bool isPub = m_accountModel->data(idx, AccountModel::IsPublicRole).toBool();
- m_accountModel->setBalanceByAddress(addr, getBalance(addr, isPub));
- }
- saveWallet();
+ m_walletController->refresh();
}
QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic)
{
- return m_logos->logos_execution_zone.get_balance(accountIdHex, isPublic);
+ return m_walletController->balance(accountIdHex, isPublic);
}
-void AmmUiBackend::refreshBlockHeights()
+QString AmmUiBackend::createNewDefault(QString password)
{
- const int lastVal = m_logos->logos_execution_zone.get_last_synced_block();
- const int currentVal = m_logos->logos_execution_zone.get_current_block_height();
- if (lastSyncedBlock() != lastVal)
- setLastSyncedBlock(lastVal);
- if (currentBlockHeight() != currentVal)
- setCurrentBlockHeight(currentVal);
+ return m_walletController->createDefaultWallet(password);
}
-void AmmUiBackend::refreshSequencerAddr()
+QString AmmUiBackend::createNew(QString configPath,
+ QString storagePath,
+ QString password)
{
- const QString addr = m_logos->logos_execution_zone.get_sequencer_addr();
- if (sequencerAddr() != addr)
- setSequencerAddr(addr);
- // Probe right away so the banner reflects the (possibly new) endpoint
- // without waiting for the next periodic tick.
- checkReachability();
+ return m_walletController->createWallet(configPath, storagePath, password);
}
-void AmmUiBackend::checkReachability()
+bool AmmUiBackend::openExisting()
{
- const QString addr = sequencerAddr();
- if (addr.isEmpty())
- return;
-
- QNetworkRequest req{QUrl(addr)};
- req.setTransferTimeout(4000);
- QNetworkReply* reply = m_net->get(req);
- connect(reply, &QNetworkReply::finished, this, [this, reply]() {
- // Any HTTP response (even a 404) means the node is up; only a transport
- // failure (connection refused, host not found, timeout) counts as down.
- const bool gotHttpStatus =
- reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid();
- const bool reachable = gotHttpStatus || reply->error() == QNetworkReply::NoError;
- if (sequencerReachable() != reachable)
- setSequencerReachable(reachable);
- reply->deleteLater();
- });
+ return m_walletController->open();
}
-void AmmUiBackend::saveWallet()
+void AmmUiBackend::disconnectWallet()
{
- if (isWalletOpen())
- m_logos->logos_execution_zone.save();
+ m_walletController->disconnect();
}
-// These only update the in-session PROPs (so subsequent open/refresh calls
-// reuse the same path). They are no longer written to QSettings: the app
-// always resolves against the canonical wallet home, so there's nothing to
-// remember across launches.
-void AmmUiBackend::persistConfigPath(const QString& path)
+bool AmmUiBackend::setAccountAlias(QString accountId, QString alias)
{
- setConfigPath(toLocalPath(path));
+ return m_walletController->setAccountAlias(accountId, alias);
}
-void AmmUiBackend::persistStoragePath(const QString& path)
+bool AmmUiBackend::setPrimaryAccount(QString accountId)
{
- setStoragePath(toLocalPath(path));
+ return m_walletController->setPrimaryAccount(accountId);
}
-bool AmmUiBackend::changeSequencerAddr(QString url)
+void AmmUiBackend::syncWalletState()
{
- const QString trimmed = url.trimmed();
- if (trimmed.isEmpty()) {
- qWarning() << "AmmUiBackend: refusing to set empty sequencer_addr";
- return false;
+ const WalletUiState& state = m_walletController->state();
+ if (state.syncStatus == QStringLiteral("opening")
+ || state.syncStatus == QStringLiteral("syncing")) {
+ m_definitionCache.cancelPending();
}
+ 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);
+ setWalletHome(state.walletHome);
+ setLastSyncedBlock(state.lastSyncedBlock);
+ 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);
+}
- const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath();
+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();
+ });
+}
- // Preserve the other config fields (poll timeouts, retries) — only swap the
- // endpoint. The wallet reads this file on open via from_path_or_initialize_default.
- QJsonObject obj;
- QFile in(cfg);
- if (in.open(QIODevice::ReadOnly)) {
- obj = QJsonDocument::fromJson(in.readAll()).object();
- in.close();
+void AmmUiBackend::refreshPortfolio()
+{
+ const quint64 generation = ++m_portfolioGeneration;
+ if (!m_walletController->state().isWalletOpen) {
+ m_definitionCache.cancelPending();
+ setAssets({});
+ setAssetStatus(QStringLiteral("idle"));
+ setAssetError({});
+ return;
+ }
+ if (m_network.status() != QStringLiteral("ready")) {
+ invalidateDefinitionCache();
+ setAssets({});
+ setAssetStatus(QStringLiteral("blocked"));
+ setAssetError(m_network.status());
+ return;
}
- obj.insert(QStringLiteral("sequencer_addr"), trimmed);
+ if (m_tokenIdl.isEmpty()) {
+ invalidateDefinitionCache();
+ setAssetStatus(QStringLiteral("error"));
+ setAssetError(QStringLiteral("token_idl_missing"));
+ return;
+ }
+ const TokenDefinitionCacheKey key = definitionCacheKey(m_network.snapshot());
+ setAssetStatus(QStringLiteral("loading"));
+ setAssetError({});
+ if (m_appliedDefinitionKey && *m_appliedDefinitionKey == key
+ && m_definitionCache.contains(key)) {
+ applyWalletPortfolio(generation);
+ return;
+ }
+ m_definitionCache.read(
+ key,
+ [this, generation, key](QVector reads) {
+ applyDefinitions(generation, key, reads);
+ });
+}
- QFile out(cfg);
- if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
- qWarning() << "AmmUiBackend: cannot write wallet config" << cfg;
- return false;
+TokenDefinitionCacheKey AmmUiBackend::definitionCacheKey(
+ const ActiveNetworkSnapshot& network) const
+{
+ return {
+ network.id,
+ network.fingerprint,
+ sequencerAddr(),
+ network.tokenIds,
+ };
+}
+
+void AmmUiBackend::invalidateDefinitionCache()
+{
+ m_definitionCache.clear();
+ m_appliedDefinitionKey.reset();
+}
+
+void AmmUiBackend::applyDefinitions(
+ quint64 generation,
+ const TokenDefinitionCacheKey& key,
+ const QVector& reads)
+{
+ if (generation != m_portfolioGeneration)
+ return;
+ const ActiveNetworkSnapshot network = m_network.snapshot();
+ if (!(key == definitionCacheKey(network)))
+ return;
+ const WalletDecodeResult decoded = WalletIdlDecoder::decode(m_tokenIdl, reads);
+ if (!decoded.ok() || reads.size() != network.tokenIds.size()
+ || decoded.accounts.size() != reads.size()) {
+ invalidateDefinitionCache();
+ setAssetStatus(QStringLiteral("error"));
+ setAssetError(decoded.error.isEmpty()
+ ? QStringLiteral("definition_decode_failed")
+ : decoded.error);
+ return;
}
- out.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
- out.close();
-
- // Re-open so the live wallet uses the new endpoint right away.
- if (isWalletOpen()) {
- const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath();
- const int err = m_logos->logos_execution_zone.open(cfg, stg);
- if (err != WALLET_FFI_SUCCESS) {
- qWarning() << "AmmUiBackend: reopen after sequencer change failed, code" << err;
- return false;
+
+ 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) {
+ invalidateDefinitionCache();
+ setAssets({});
+ setAssetStatus(QStringLiteral("error"));
+ setAssetError(QStringLiteral("token_program_mismatch"));
+ return;
+ }
+ } else {
+ ++unavailable;
}
- refreshSequencerAddr();
- refreshAccounts();
+ m_tokens.append(std::move(token));
+ }
+ if (m_tokenProgramId.isEmpty()) {
+ invalidateDefinitionCache();
+ setAssets({});
+ setAssetStatus(QStringLiteral("error"));
+ setAssetError(QStringLiteral("definitions_unavailable"));
+ return;
}
- return true;
+ m_idlRegistry.registerProgram(
+ m_tokenProgramId, QStringLiteral("Token"), m_tokenIdl);
+ if (unavailable > 0)
+ invalidateDefinitionCache();
+ else
+ m_appliedDefinitionKey = key;
+ setAssetError(unavailable > 0
+ ? QStringLiteral("some_definitions_unavailable")
+ : QString());
+ applyWalletPortfolio(generation);
}
-void AmmUiBackend::copyToClipboard(QString text)
+void AmmUiBackend::applyWalletPortfolio(quint64 generation)
{
- if (QGuiApplication::clipboard())
- QGuiApplication::clipboard()->setText(text);
+ 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 (account.status == QStringLiteral("decoded"))
+ presentation.decodedData = decodedDataText(account.value);
+ 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 14d1e80e..32daa4ce 100644
--- a/apps/amm/src/AmmUiBackend.h
+++ b/apps/amm/src/AmmUiBackend.h
@@ -1,77 +1,86 @@
#ifndef AMM_UI_BACKEND_H
#define AMM_UI_BACKEND_H
-#include
+#include
+#include
+
+#include
#include
+#include
#include "rep_AmmUiBackend_source.h"
-#include "AccountModel.h"
+#include "ActiveNetwork.h"
+#include "TokenDefinitionCache.h"
+#include "WalletAccountModel.h"
+#include "WalletIdlDecoder.h"
class LogosAPI;
-struct LogosModules;
+class LogosWalletProvider;
class QNetworkAccessManager;
-class QTimer;
+class WalletController;
-// Source-side implementation of the AmmUiBackend .rep interface.
-// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and
-// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. Talks to
-// the core logos_execution_zone wallet module via LogosModules.
class AmmUiBackend : public AmmUiBackendSimpleSource {
Q_OBJECT
- Q_PROPERTY(AccountModel* accountModel READ accountModel CONSTANT)
+ Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT)
public:
explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr);
+ // The injected provider must outlive the backend.
+ explicit AmmUiBackend(WalletProvider& wallet, QObject* parent = nullptr);
~AmmUiBackend() override;
- AccountModel* accountModel() const { return m_accountModel; }
+ WalletAccountModel* accountModel() const;
public slots:
- // Overrides of the pure-virtual slots generated from the .rep.
QString createAccountPublic() override;
QString createAccountPrivate() override;
void refreshAccounts() override;
void refreshBalances() override;
QString getBalance(QString accountIdHex, bool isPublic) override;
- // Return the new wallet's BIP39 mnemonic (empty string on failure) so the
- // UI can force a one-time seed-phrase backup step.
QString createNewDefault(QString password) override;
QString createNew(QString configPath, QString storagePath, QString password) override;
bool openExisting() override;
void disconnectWallet() override;
- bool changeSequencerAddr(QString url) override;
- void copyToClipboard(QString text) override;
+ bool setAccountAlias(QString accountId, QString alias) override;
+ bool setPrimaryAccount(QString accountId) override;
private:
- // Per-app wallet home (kept distinct from the wallet's canonical
- // ~/.lee/wallet so standalone instances stay isolated; Basecamp sharing
- // is handled by adopting an already-open shared wallet on startup).
- static QString defaultWalletHome();
- QString defaultConfigPath() const;
- QString defaultStoragePath() const;
-
- void persistConfigPath(const QString& path);
- void persistStoragePath(const QString& path);
- void openOrAdoptWallet();
- // True when the shared core already has a wallet open — including a freshly
- // created one with zero accounts. See the definition for why list_accounts()
- // alone is insufficient.
- bool sharedWalletIsOpen();
- void refreshBlockHeights();
- void refreshSequencerAddr();
- void saveWallet();
+ struct TokenInfo {
+ QString id;
+ QString name;
+ QString programOwner;
+ QString status;
+ };
- // Probe the configured sequencer over HTTP and update sequencerReachable.
- void checkReachability();
-
- AccountModel* m_accountModel;
+ void syncWalletState();
+ void publishNetworkState();
+ void initialize();
+ void probeNetworkIdentity();
+ void refreshPortfolio();
+ TokenDefinitionCacheKey definitionCacheKey(
+ const ActiveNetworkSnapshot& network) const;
+ void invalidateDefinitionCache();
+ void applyDefinitions(quint64 generation,
+ const TokenDefinitionCacheKey& key,
+ const QVector& reads);
+ void applyWalletPortfolio(quint64 generation);
LogosAPI* m_logosAPI;
- LogosModules* m_logos;
-
- QNetworkAccessManager* m_net;
- QTimer* m_reachabilityTimer;
+ std::unique_ptr m_ownedWallet;
+ WalletProvider* m_wallet;
+ TokenDefinitionCache m_definitionCache;
+ 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;
+ std::optional m_appliedDefinitionKey;
+ 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 c9e2d50d..d6031ce0 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()
@@ -37,10 +52,4 @@ class AmmUiBackend
// Basecamp, does not close the wallet other apps share.
SLOT(void disconnectWallet())
- // Settings. Rewrites the wallet config's sequencer_addr and re-opens the
- // wallet so the new network takes effect immediately.
- SLOT(bool changeSequencerAddr(QString url))
-
- // Misc
- SLOT(void copyToClipboard(QString text))
}
diff --git a/apps/amm/src/TokenDefinitionCache.cpp b/apps/amm/src/TokenDefinitionCache.cpp
new file mode 100644
index 00000000..c1b424f1
--- /dev/null
+++ b/apps/amm/src/TokenDefinitionCache.cpp
@@ -0,0 +1,104 @@
+#include "TokenDefinitionCache.h"
+
+#include
+
+bool TokenDefinitionCacheKey::isReusable() const
+{
+ return !networkId.isEmpty()
+ && !networkFingerprint.isEmpty()
+ && !sequencerAddress.isEmpty()
+ && !tokenIds.isEmpty();
+}
+
+bool TokenDefinitionCacheKey::operator==(const TokenDefinitionCacheKey& other) const
+{
+ return networkId == other.networkId
+ && networkFingerprint == other.networkFingerprint
+ && sequencerAddress == other.sequencerAddress
+ && tokenIds == other.tokenIds;
+}
+
+TokenDefinitionCache::TokenDefinitionCache(WalletProvider& provider)
+ : m_provider(provider),
+ m_state(std::make_shared())
+{
+}
+
+TokenDefinitionCache::~TokenDefinitionCache()
+{
+ clear();
+}
+
+void TokenDefinitionCache::read(const TokenDefinitionCacheKey& key, Callback callback)
+{
+ if (contains(key)) {
+ callback(m_state->cachedReads);
+ return;
+ }
+ if (m_state->inFlight && m_state->inFlight->key == key) {
+ m_state->inFlight->callbacks.append(std::move(callback));
+ return;
+ }
+ cancelPending();
+
+ const auto request = std::make_shared();
+ request->key = key;
+ request->callbacks.append(std::move(callback));
+ m_state->inFlight = request;
+ const std::weak_ptr state = m_state;
+ m_provider.readPublicAccountsAsync(
+ key.tokenIds,
+ [state, request](QVector reads) mutable {
+ const std::shared_ptr lockedState = state.lock();
+ if (!lockedState || request->cancelled)
+ return;
+ if (lockedState->inFlight == request) {
+ lockedState->inFlight.reset();
+ if (TokenDefinitionCache::isComplete(request->key, reads)) {
+ lockedState->cachedKey = request->key;
+ lockedState->cachedReads = reads;
+ }
+ }
+
+ QVector callbacks = std::move(request->callbacks);
+ for (Callback& callback : callbacks)
+ callback(reads);
+ });
+}
+
+bool TokenDefinitionCache::contains(const TokenDefinitionCacheKey& key) const
+{
+ return m_state->cachedKey && *m_state->cachedKey == key;
+}
+
+void TokenDefinitionCache::cancelPending()
+{
+ if (m_state->inFlight) {
+ m_state->inFlight->cancelled = true;
+ m_state->inFlight->callbacks.clear();
+ }
+ m_state->inFlight.reset();
+}
+
+void TokenDefinitionCache::clear()
+{
+ m_state->cachedKey.reset();
+ m_state->cachedReads.clear();
+ cancelPending();
+}
+
+bool TokenDefinitionCache::isComplete(
+ const TokenDefinitionCacheKey& key,
+ const QVector& reads)
+{
+ if (!key.isReusable() || reads.size() != key.tokenIds.size())
+ return false;
+ for (qsizetype index = 0; index < reads.size(); ++index) {
+ const WalletAccountRead& read = reads.at(index);
+ if (!read.ok() || read.accountId != key.tokenIds.at(index)
+ || read.programOwner.isEmpty() || read.dataHex.isEmpty()) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/apps/amm/src/TokenDefinitionCache.h b/apps/amm/src/TokenDefinitionCache.h
new file mode 100644
index 00000000..3cdf31b5
--- /dev/null
+++ b/apps/amm/src/TokenDefinitionCache.h
@@ -0,0 +1,53 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include "WalletProvider.h"
+
+struct TokenDefinitionCacheKey {
+ QString networkId;
+ QString networkFingerprint;
+ QString sequencerAddress;
+ QStringList tokenIds;
+
+ bool isReusable() const;
+ bool operator==(const TokenDefinitionCacheKey& other) const;
+};
+
+class TokenDefinitionCache final {
+public:
+ using Callback = std::function)>;
+
+ explicit TokenDefinitionCache(WalletProvider& provider);
+ ~TokenDefinitionCache();
+
+ void read(const TokenDefinitionCacheKey& key, Callback callback);
+ bool contains(const TokenDefinitionCacheKey& key) const;
+ void cancelPending();
+ void clear();
+
+private:
+ struct InFlight {
+ TokenDefinitionCacheKey key;
+ QVector callbacks;
+ bool cancelled = false;
+ };
+
+ struct State {
+ std::optional cachedKey;
+ QVector cachedReads;
+ std::shared_ptr inFlight;
+ };
+
+ static bool isComplete(const TokenDefinitionCacheKey& key,
+ const QVector& reads);
+
+ WalletProvider& m_provider;
+ std::shared_ptr m_state;
+};
diff --git a/apps/amm/src/WalletIdlDecoder.cpp b/apps/amm/src/WalletIdlDecoder.cpp
new file mode 100644
index 00000000..986046de
--- /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 00000000..08112266
--- /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 00000000..0f5250ca
--- /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/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp b/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp
new file mode 100644
index 00000000..28e1bcbb
--- /dev/null
+++ b/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp
@@ -0,0 +1,242 @@
+#include "AmmUiBackend.h"
+#include "FakeWalletProvider.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace {
+class ScopedEnvironment final {
+public:
+ ScopedEnvironment(QByteArray name, QByteArray value)
+ : m_name(std::move(name)),
+ m_hadValue(qEnvironmentVariableIsSet(m_name.constData())),
+ m_previous(qgetenv(m_name.constData()))
+ {
+ qputenv(m_name.constData(), value);
+ }
+
+ ~ScopedEnvironment()
+ {
+ if (m_hadValue)
+ qputenv(m_name.constData(), m_previous);
+ else
+ qunsetenv(m_name.constData());
+ }
+
+private:
+ QByteArray m_name;
+ bool m_hadValue;
+ QByteArray m_previous;
+};
+
+class LocalRpcServer final {
+public:
+ explicit LocalRpcServer(QString channelId)
+ : m_channelId(std::move(channelId))
+ {
+ QObject::connect(&m_server, &QTcpServer::newConnection, [&]() {
+ while (m_server.hasPendingConnections()) {
+ QTcpSocket* socket = m_server.nextPendingConnection();
+ QObject::connect(socket, &QTcpSocket::readyRead, socket,
+ [this, socket]() { process(socket); });
+ if (socket->bytesAvailable() > 0)
+ process(socket);
+ }
+ });
+ }
+
+ bool listen()
+ {
+ return m_server.listen(QHostAddress::LocalHost);
+ }
+
+ QString endpoint() const
+ {
+ return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort());
+ }
+
+private:
+ void process(QTcpSocket* socket)
+ {
+ QByteArray& request = m_requests[socket];
+ request.append(socket->readAll());
+ const qsizetype headerEnd = request.indexOf("\r\n\r\n");
+ if (headerEnd < 0)
+ return;
+
+ qsizetype contentLength = 0;
+ for (QByteArray line : request.first(headerEnd).split('\n')) {
+ line = line.trimmed();
+ if (line.toLower().startsWith("content-length:")) {
+ contentLength = line.mid(sizeof("content-length:") - 1)
+ .trimmed().toLongLong();
+ }
+ }
+ if (request.size() - headerEnd - 4 < contentLength)
+ return;
+
+ m_requests.remove(socket);
+ const QByteArray payload = QJsonDocument(QJsonObject {
+ { QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
+ { QStringLiteral("id"), 1 },
+ { QStringLiteral("result"), m_channelId },
+ }).toJson(QJsonDocument::Compact);
+ QByteArray response = QByteArrayLiteral(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ");
+ response += QByteArray::number(payload.size());
+ response += QByteArrayLiteral("\r\nConnection: close\r\n\r\n");
+ response += payload;
+ socket->write(response);
+ socket->disconnectFromHost();
+ }
+
+ QTcpServer m_server;
+ QHash m_requests;
+ QString m_channelId;
+};
+
+QByteArray devnetConfig(const QString& channelId,
+ const QString& ammProgramId,
+ const QString& definitionId)
+{
+ return QJsonDocument(QJsonObject {
+ { QStringLiteral("channelId"), channelId },
+ { QStringLiteral("ammProgramId"), ammProgramId },
+ { QStringLiteral("tokenDefinitionIds"), QJsonArray { definitionId } },
+ }).toJson(QJsonDocument::Compact);
+}
+
+class BackendFixture final {
+public:
+ BackendFixture()
+ : channelId(64, QLatin1Char('a')),
+ ammProgramId(64, QLatin1Char('b')),
+ definitionId(64, QLatin1Char('c')),
+ tokenProgramId(64, QLatin1Char('d')),
+ server(channelId)
+ {
+ }
+
+ bool initialize(bool deferDefinitionReads = false)
+ {
+ if (!server.listen() || !directory.isValid())
+ return false;
+ const QString walletHome = directory.filePath(QStringLiteral("wallet"));
+ if (!QDir().mkpath(walletHome))
+ return false;
+ const QString configPath = directory.filePath(QStringLiteral("devnet.json"));
+ QFile config(configPath);
+ if (!config.open(QIODevice::WriteOnly))
+ return false;
+ const QByteArray configData = devnetConfig(channelId, ammProgramId, definitionId);
+ if (config.write(configData) != qint64(configData.size()))
+ return false;
+ config.close();
+
+ network = std::make_unique(
+ QByteArrayLiteral("AMM_UI_NETWORK"), QByteArrayLiteral("devnet"));
+ devnetFile = std::make_unique(
+ QByteArrayLiteral("AMM_UI_DEVNET_FILE"), configPath.toLocal8Bit());
+ walletHomeEnvironment = std::make_unique(
+ QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit());
+ settingsHome = std::make_unique(
+ QByteArrayLiteral("XDG_CONFIG_HOME"),
+ directory.filePath(QStringLiteral("settings")).toLocal8Bit());
+ QSettings settings(QStringLiteral("Logos"), QStringLiteral("AmmUI"));
+ settings.setValue(QStringLiteral("disconnected"), false);
+ settings.sync();
+
+ provider.connectResult.adopted = true;
+ provider.connectResult.snapshot.sequencerAddress = server.endpoint();
+ provider.snapshotResult = provider.connectResult.snapshot;
+ provider.readResult.status = QStringLiteral("ok");
+ provider.readResult.programOwner = tokenProgramId;
+ provider.readResult.dataHex = QStringLiteral(
+ "0004000000544553540a0000000000000000000000000000000000");
+ provider.deferPublicAccountReads = deferDefinitionReads;
+ backend = std::make_unique(provider);
+ return true;
+ }
+
+ QString channelId;
+ QString ammProgramId;
+ QString definitionId;
+ QString tokenProgramId;
+ LocalRpcServer server;
+ QTemporaryDir directory;
+ std::unique_ptr network;
+ std::unique_ptr devnetFile;
+ std::unique_ptr walletHomeEnvironment;
+ std::unique_ptr settingsHome;
+ FakeWalletProvider provider;
+ std::unique_ptr backend;
+};
+}
+
+class AmmUiBackendDefinitionCacheTest : public QObject {
+ Q_OBJECT
+
+private slots:
+ void reusesDefinitionsAfterRefreshAndReopen();
+ void restartsDefinitionReadAfterRefreshAndReopen();
+};
+
+void AmmUiBackendDefinitionCacheTest::reusesDefinitionsAfterRefreshAndReopen()
+{
+ BackendFixture fixture;
+ QVERIFY(fixture.initialize());
+
+ QTRY_COMPARE(fixture.backend->networkStatus(), QStringLiteral("ready"));
+ QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready"));
+ QCOMPARE(fixture.provider.publicAccountReadCalls, 1);
+ QCOMPARE(fixture.provider.lastPublicAccountIds,
+ QStringList { fixture.definitionId });
+
+ fixture.backend->refreshBalances();
+ QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready"));
+ QCOMPARE(fixture.provider.publicAccountReadCalls, 1);
+
+ fixture.backend->disconnectWallet();
+ QTRY_COMPARE(fixture.backend->walletSyncStatus(), QStringLiteral("closed"));
+ QVERIFY(fixture.backend->openExisting());
+ QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready"));
+ QCOMPARE(fixture.provider.publicAccountReadCalls, 1);
+}
+
+void AmmUiBackendDefinitionCacheTest::restartsDefinitionReadAfterRefreshAndReopen()
+{
+ BackendFixture fixture;
+ QVERIFY(fixture.initialize(true));
+
+ QTRY_COMPARE(fixture.backend->networkStatus(), QStringLiteral("ready"));
+ QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 1);
+ QCOMPARE(fixture.backend->assetStatus(), QStringLiteral("loading"));
+
+ fixture.backend->refreshBalances();
+ QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 2);
+
+ fixture.backend->disconnectWallet();
+ QTRY_COMPARE(fixture.backend->walletSyncStatus(), QStringLiteral("closed"));
+ QVERIFY(fixture.backend->openExisting());
+ QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 3);
+
+ fixture.provider.completePendingPublicAccountReads();
+ QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready"));
+ QCOMPARE(fixture.provider.publicAccountReadCalls, 3);
+}
+
+QTEST_GUILESS_MAIN(AmmUiBackendDefinitionCacheTest)
+#include "AmmUiBackendDefinitionCacheTest.moc"
diff --git a/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp b/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp
new file mode 100644
index 00000000..1eecc303
--- /dev/null
+++ b/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp
@@ -0,0 +1,185 @@
+#include "FakeWalletProvider.h"
+#include "TokenDefinitionCache.h"
+
+#include
+
+#include
+
+namespace {
+TokenDefinitionCacheKey cacheKey(const QString& fingerprint = QStringLiteral("channel:one"))
+{
+ return {
+ QStringLiteral("devnet"),
+ fingerprint,
+ QStringLiteral("http://127.0.0.1:8080"),
+ {
+ QString(64, QLatin1Char('a')),
+ QString(64, QLatin1Char('b')),
+ },
+ };
+}
+
+void makeReadsReady(FakeWalletProvider& provider)
+{
+ provider.readResult.status = QStringLiteral("ok");
+ provider.readResult.programOwner = QString(64, QLatin1Char('c'));
+ provider.readResult.dataHex = QStringLiteral("00");
+}
+}
+
+class TokenDefinitionCacheTest : public QObject {
+ Q_OBJECT
+
+private slots:
+ void reusesCompleteReads();
+ void retriesIncompleteReads();
+ void separatesNetworkKeys();
+ void coalescesInFlightReads();
+ void restartsCancelledRead();
+ void dropsPendingCallbackOnDestruction();
+};
+
+void TokenDefinitionCacheTest::reusesCompleteReads()
+{
+ FakeWalletProvider provider;
+ makeReadsReady(provider);
+ TokenDefinitionCache cache(provider);
+ const TokenDefinitionCacheKey key = cacheKey();
+
+ QVector first;
+ cache.read(key, [&first](QVector reads) {
+ first = std::move(reads);
+ });
+
+ QCOMPARE(provider.publicAccountReadCalls, 1);
+ QCOMPARE(provider.lastPublicAccountIds, key.tokenIds);
+ QCOMPARE(first.size(), key.tokenIds.size());
+ QVERIFY(cache.contains(key));
+
+ QVector second;
+ cache.read(key, [&second](QVector reads) {
+ second = std::move(reads);
+ });
+
+ QCOMPARE(provider.publicAccountReadCalls, 1);
+ QCOMPARE(second.size(), first.size());
+ for (qsizetype index = 0; index < second.size(); ++index) {
+ QCOMPARE(second.at(index).accountId, first.at(index).accountId);
+ QCOMPARE(second.at(index).status, first.at(index).status);
+ }
+}
+
+void TokenDefinitionCacheTest::retriesIncompleteReads()
+{
+ FakeWalletProvider provider;
+ TokenDefinitionCache cache(provider);
+ const TokenDefinitionCacheKey key = cacheKey();
+
+ cache.read(key, [](QVector) {});
+ cache.read(key, [](QVector) {});
+
+ QCOMPARE(provider.publicAccountReadCalls, 2);
+ QVERIFY(!cache.contains(key));
+}
+
+void TokenDefinitionCacheTest::separatesNetworkKeys()
+{
+ FakeWalletProvider provider;
+ makeReadsReady(provider);
+ TokenDefinitionCache cache(provider);
+ const TokenDefinitionCacheKey baseKey = cacheKey();
+ TokenDefinitionCacheKey fingerprintKey = baseKey;
+ fingerprintKey.networkFingerprint = QStringLiteral("channel:two");
+ TokenDefinitionCacheKey endpointKey = baseKey;
+ endpointKey.sequencerAddress = QStringLiteral("http://127.0.0.1:8081");
+ TokenDefinitionCacheKey definitionsKey = baseKey;
+ definitionsKey.tokenIds = {
+ baseKey.tokenIds.at(1),
+ baseKey.tokenIds.at(0),
+ };
+ TokenDefinitionCacheKey networkKey = baseKey;
+ networkKey.networkId = QStringLiteral("testnet");
+
+ cache.read(baseKey, [](QVector) {});
+ cache.read(fingerprintKey, [](QVector) {});
+ cache.read(endpointKey, [](QVector) {});
+ cache.read(definitionsKey, [](QVector) {});
+ cache.read(networkKey, [](QVector) {});
+
+ QCOMPARE(provider.publicAccountReadCalls, 5);
+ QVERIFY(!cache.contains(baseKey));
+ QVERIFY(cache.contains(networkKey));
+}
+
+void TokenDefinitionCacheTest::coalescesInFlightReads()
+{
+ FakeWalletProvider provider;
+ makeReadsReady(provider);
+ provider.deferPublicAccountReads = true;
+ TokenDefinitionCache cache(provider);
+ const TokenDefinitionCacheKey key = cacheKey();
+ bool firstCalled = false;
+ bool secondCalled = false;
+
+ cache.read(key, [&firstCalled](QVector) {
+ firstCalled = true;
+ });
+ cache.read(key, [&secondCalled](QVector) {
+ secondCalled = true;
+ });
+
+ QCOMPARE(provider.publicAccountReadCalls, 1);
+ provider.completePendingPublicAccountReads();
+
+ QVERIFY(firstCalled);
+ QVERIFY(secondCalled);
+ QVERIFY(cache.contains(key));
+}
+
+void TokenDefinitionCacheTest::restartsCancelledRead()
+{
+ FakeWalletProvider provider;
+ makeReadsReady(provider);
+ provider.deferPublicAccountReads = true;
+ TokenDefinitionCache cache(provider);
+ const TokenDefinitionCacheKey key = cacheKey();
+ bool cancelledCallback = false;
+ bool retryCallback = false;
+
+ cache.read(key, [&cancelledCallback](QVector) {
+ cancelledCallback = true;
+ });
+ cache.cancelPending();
+ cache.read(key, [&retryCallback](QVector) {
+ retryCallback = true;
+ });
+
+ QCOMPARE(provider.publicAccountReadCalls, 2);
+ provider.completePendingPublicAccountReads();
+
+ QVERIFY(!cancelledCallback);
+ QVERIFY(retryCallback);
+ QVERIFY(cache.contains(key));
+}
+
+void TokenDefinitionCacheTest::dropsPendingCallbackOnDestruction()
+{
+ FakeWalletProvider provider;
+ makeReadsReady(provider);
+ provider.deferPublicAccountReads = true;
+ const TokenDefinitionCacheKey key = cacheKey();
+ bool callbackCalled = false;
+
+ {
+ TokenDefinitionCache cache(provider);
+ cache.read(key, [&callbackCalled](QVector) {
+ callbackCalled = true;
+ });
+ }
+ provider.completePendingPublicAccountReads();
+
+ QVERIFY(!callbackCalled);
+}
+
+QTEST_GUILESS_MAIN(TokenDefinitionCacheTest)
+#include "TokenDefinitionCacheTest.moc"
diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt
new file mode 100644
index 00000000..8c32c116
--- /dev/null
+++ b/apps/shared/wallet/CMakeLists.txt
@@ -0,0 +1,187 @@
+cmake_minimum_required(VERSION 3.21)
+
+if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
+ project(LogosWallet LANGUAGES CXX)
+ include(CTest)
+endif()
+
+option(LOGOS_WALLET_BUILD_QML "Build the Logos.Wallet QML module" ON)
+option(LOGOS_WALLET_BUILD_ACCESS "Build the generated-SDK wallet adapter" ON)
+set(LOGOS_WALLET_GENERATED_DIR "" CACHE PATH "Path to generated Logos SDK sources")
+
+if(LOGOS_WALLET_BUILD_ACCESS
+ AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/logos_sdk.h"
+ AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/include/logos_sdk.h")
+ message(FATAL_ERROR
+ "logos_wallet_access requires logos_sdk.h; set LOGOS_WALLET_GENERATED_DIR"
+ )
+endif()
+
+find_package(Qt6 6.8 REQUIRED COMPONENTS Core)
+if(LOGOS_WALLET_BUILD_ACCESS OR BUILD_TESTING)
+ find_package(Qt6 6.8 REQUIRED COMPONENTS Network)
+endif()
+set(CMAKE_AUTOMOC ON)
+
+if(LOGOS_WALLET_BUILD_ACCESS)
+ add_library(logos_wallet_access STATIC
+ src/WalletProvider.h
+ src/WalletProvider.cpp
+ src/LogosWalletProvider.h
+ src/LogosWalletProvider.cpp
+ src/WalletAccountId.h
+ src/WalletAccountId.cpp
+ src/WalletAccountModel.h
+ src/WalletAccountModel.cpp
+ src/WalletController.h
+ src/WalletController.cpp
+ )
+ set_target_properties(logos_wallet_access PROPERTIES
+ AUTOMOC ON
+ POSITION_INDEPENDENT_CODE ON
+ )
+ target_compile_features(logos_wallet_access PUBLIC cxx_std_17)
+ target_include_directories(logos_wallet_access
+ PUBLIC
+ "$"
+ PRIVATE
+ "${LOGOS_WALLET_GENERATED_DIR}"
+ "${LOGOS_WALLET_GENERATED_DIR}/include"
+ )
+ target_link_libraries(logos_wallet_access
+ PUBLIC Qt6::Core
+ PRIVATE Qt6::Network
+ )
+endif()
+
+if(LOGOS_WALLET_BUILD_QML)
+ find_package(Qt6 6.8 REQUIRED COMPONENTS Qml Quick QuickControls2)
+ qt_policy(SET QTP0004 NEW)
+
+ set(wallet_qml_output_dir "${CMAKE_CURRENT_BINARY_DIR}/qml/Logos/Wallet")
+ set(wallet_internal_qml
+ qml/internal/WalletIconButton.qml
+ qml/internal/AccountDelegate.qml
+ qml/internal/CreateAccountDialog.qml
+ qml/internal/CreateWalletDialog.qml
+ qml/internal/WalletMessageDialog.qml
+ )
+ set(wallet_public_qml
+ qml/internal/CopyButton.qml
+ qml/WalletControl.qml
+ qml/TransactionConfirmationDialog.qml
+ qml/SubmittedTransaction.qml
+ )
+ set(wallet_icons
+ qml/internal/icons/account.svg
+ qml/internal/icons/back.svg
+ qml/internal/icons/checkmark.svg
+ qml/internal/icons/copy.svg
+ qml/internal/icons/power.svg
+ )
+ foreach(qml_file IN LISTS wallet_public_qml wallet_internal_qml)
+ get_filename_component(qml_name "${qml_file}" NAME)
+ set_source_files_properties("${qml_file}" PROPERTIES QT_RESOURCE_ALIAS "${qml_name}")
+ endforeach()
+ foreach(icon IN LISTS wallet_icons)
+ get_filename_component(icon_name "${icon}" NAME)
+ set_source_files_properties("${icon}" PROPERTIES QT_RESOURCE_ALIAS "icons/${icon_name}")
+ endforeach()
+ set_source_files_properties(${wallet_internal_qml} PROPERTIES QT_QML_INTERNAL_TYPE TRUE)
+
+ qt_add_library(logos_wallet_qml SHARED)
+ qt_add_qml_module(logos_wallet_qml
+ URI Logos.Wallet
+ VERSION 1.0
+ RESOURCE_PREFIX /qt/qml
+ OUTPUT_DIRECTORY "${wallet_qml_output_dir}"
+ TYPEINFO plugins.qmltypes
+ QML_FILES
+ ${wallet_public_qml}
+ ${wallet_internal_qml}
+ RESOURCES
+ ${wallet_icons}
+ )
+ target_link_libraries(logos_wallet_qml PRIVATE
+ Qt6::Core
+ Qt6::Qml
+ Qt6::Quick
+ Qt6::QuickControls2
+ )
+ # Resolve the sibling liblogos_wallet_qml.dylib/.so next to the plugin.
+ # macOS dyld does not expand the ELF "$ORIGIN" token — it uses @loader_path.
+ if(APPLE)
+ set(wallet_qml_rpath "@loader_path")
+ else()
+ set(wallet_qml_rpath "$ORIGIN")
+ endif()
+ set_target_properties(logos_wallet_qml logos_wallet_qmlplugin PROPERTIES
+ LIBRARY_OUTPUT_DIRECTORY "${wallet_qml_output_dir}"
+ RUNTIME_OUTPUT_DIRECTORY "${wallet_qml_output_dir}"
+ BUILD_WITH_INSTALL_RPATH ON
+ INSTALL_RPATH "${wallet_qml_rpath}"
+ )
+
+ set(wallet_qml_install_dir "lib/qml/Logos/Wallet")
+ install(TARGETS logos_wallet_qml logos_wallet_qmlplugin
+ LIBRARY DESTINATION "${wallet_qml_install_dir}"
+ RUNTIME DESTINATION "${wallet_qml_install_dir}"
+ )
+ install(FILES
+ "${wallet_qml_output_dir}/qmldir"
+ "${wallet_qml_output_dir}/plugins.qmltypes"
+ DESTINATION "${wallet_qml_install_dir}"
+ OPTIONAL
+ )
+endif()
+
+if(BUILD_TESTING)
+ find_package(Qt6 6.8 REQUIRED COMPONENTS Test)
+
+ add_executable(logos_wallet_access_test
+ 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
+ src/WalletController.h
+ )
+ set_target_properties(logos_wallet_access_test PROPERTIES AUTOMOC ON)
+ target_compile_features(logos_wallet_access_test PRIVATE cxx_std_17)
+ target_include_directories(logos_wallet_access_test PRIVATE
+ tests/cpp/fixtures
+ tests/support
+ src
+ )
+ target_link_libraries(logos_wallet_access_test PRIVATE
+ Qt6::Core
+ Qt6::Network
+ Qt6::Test
+ )
+ add_test(NAME logos_wallet_access COMMAND logos_wallet_access_test)
+
+ if(LOGOS_WALLET_BUILD_QML)
+ find_package(Qt6 6.8 REQUIRED COMPONENTS QuickTest)
+ add_executable(logos_wallet_qml_test tests/qml/main.cpp)
+ target_compile_definitions(logos_wallet_qml_test PRIVATE
+ QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/qml"
+ )
+ target_link_libraries(logos_wallet_qml_test PRIVATE Qt6::QuickTest)
+ add_dependencies(logos_wallet_qml_test logos_wallet_qmlplugin)
+ add_test(NAME logos_wallet_qml COMMAND logos_wallet_qml_test)
+ get_target_property(wallet_qml_library Qt6::Qml IMPORTED_LOCATION)
+ get_filename_component(wallet_qml_library_dir "${wallet_qml_library}" DIRECTORY)
+ get_filename_component(wallet_qml_prefix "${wallet_qml_library_dir}" DIRECTORY)
+ set(wallet_qml_test_import_paths
+ "${CMAKE_CURRENT_BINARY_DIR}/qml"
+ "${wallet_qml_prefix}/${QT6_INSTALL_QML}"
+ )
+ string(JOIN ":" wallet_qml_test_import_path ${wallet_qml_test_import_paths})
+ set_tests_properties(logos_wallet_qml PROPERTIES ENVIRONMENT
+ "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML2_IMPORT_PATH=${wallet_qml_test_import_path};QML_IMPORT_PATH=${wallet_qml_test_import_path}"
+ )
+ endif()
+endif()
diff --git a/apps/shared/wallet/qml/SubmittedTransaction.qml b/apps/shared/wallet/qml/SubmittedTransaction.qml
new file mode 100644
index 00000000..00cc3ba8
--- /dev/null
+++ b/apps/shared/wallet/qml/SubmittedTransaction.qml
@@ -0,0 +1,86 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ property string title: qsTr("Transaction submitted")
+ property string transactionId: ""
+ readonly property bool base58Shape: root.transactionId.length > 0
+ && /^[1-9A-HJ-NP-Za-km-z]+$/.test(root.transactionId)
+
+ implicitWidth: 420
+ implicitHeight: resultCard.implicitHeight
+
+ TextEdit {
+ id: clipboardProxy
+ visible: false
+ }
+
+ function copyTransactionId() {
+ if (!root.transactionId)
+ return
+ clipboardProxy.text = root.transactionId
+ clipboardProxy.selectAll()
+ clipboardProxy.copy()
+ clipboardProxy.deselect()
+ clipboardProxy.text = ""
+ }
+
+ Rectangle {
+ id: resultCard
+ anchors.left: parent.left
+ anchors.right: parent.right
+ implicitHeight: resultContent.implicitHeight + 32
+ color: "#18181b"
+ border.color: "#3f3f46"
+ border.width: 1
+ radius: 8
+
+ ColumnLayout {
+ id: resultContent
+ anchors.fill: parent
+ anchors.margins: 16
+ spacing: 12
+
+ Label {
+ Layout.fillWidth: true
+ text: root.title
+ color: "#f4f4f5"
+ font.bold: true
+ font.pixelSize: 17
+ wrapMode: Text.WordWrap
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: qsTr("Transaction ID")
+ color: "#a1a1aa"
+ font.pixelSize: 12
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ Label {
+ id: transactionLabel
+ objectName: "submittedTransactionId"
+ Layout.fillWidth: true
+ text: root.transactionId
+ color: "#f4f4f5"
+ font.family: "monospace"
+ wrapMode: Text.WrapAnywhere
+ Accessible.name: qsTr("Transaction ID %1").arg(root.transactionId)
+ }
+
+ CopyButton {
+ objectName: "copySubmittedTransactionButton"
+ enabled: root.transactionId.length > 0
+ onCopyRequested: root.copyTransactionId()
+ }
+ }
+ }
+ }
+}
diff --git a/apps/shared/wallet/qml/TransactionConfirmationDialog.qml b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml
new file mode 100644
index 00000000..76cc91a0
--- /dev/null
+++ b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml
@@ -0,0 +1,251 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+Popup {
+ id: root
+
+ property string title: qsTr("Confirm transaction")
+ property string cancelText: qsTr("Cancel")
+ property string confirmText: qsTr("Confirm")
+ property bool busy: false
+ property string busyText: qsTr("Submitting…")
+ property bool activityBusy: false
+ property string activityText: qsTr("Updating…")
+ property bool showInlineBusyIndicator: true
+ property var snapshot: ({})
+ property Component summary: null
+ property bool confirmationPending: false
+ property bool confirmEnabled: true
+ property bool roundedCancelButton: false
+ property bool closeWhenSettled: true
+ readonly property bool actionPending: root.busy || root.activityBusy
+
+ signal canceled
+ signal confirmed(var snapshot)
+ signal summaryEdited(var snapshot)
+
+ modal: true
+ dim: true
+ padding: 20
+ width: Math.max(0, Math.min(420, parent ? parent.width - 32 : 420))
+ height: Math.max(0, Math.min(implicitHeight, parent ? parent.height - 32 : implicitHeight))
+ x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0
+ y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0
+ closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside
+ focus: true
+
+ function cloneSnapshot(value) {
+ if (value === undefined || value === null)
+ return ({})
+ try {
+ return JSON.parse(JSON.stringify(value))
+ } catch (_error) {
+ return value
+ }
+ }
+
+ function openWithSnapshot(nextSnapshot) {
+ root.snapshot = root.cloneSnapshot(nextSnapshot)
+ root.confirmationPending = false
+ root.open()
+ Qt.callLater(function() {
+ if (cancelButtonLoader.item)
+ cancelButtonLoader.item.forceActiveFocus()
+ })
+ }
+
+ function updateSnapshot(nextSnapshot) {
+ root.snapshot = root.cloneSnapshot(nextSnapshot)
+ }
+
+ function cancel() {
+ if (root.busy)
+ return
+ root.confirmationPending = false
+ root.close()
+ root.canceled()
+ }
+
+ function confirm() {
+ if (root.actionPending || !root.confirmEnabled)
+ return
+ root.confirmationPending = true
+ root.confirmed(root.snapshot)
+ if (!root.busy) {
+ root.confirmationPending = false
+ root.close()
+ }
+ }
+
+ Connections {
+ target: summaryLoader.item
+ ignoreUnknownSignals: true
+
+ function onSnapshotEdited(snapshot) {
+ root.updateSnapshot(snapshot)
+ root.summaryEdited(root.snapshot)
+ }
+ }
+
+ onBusyChanged: {
+ if (!root.busy && root.confirmationPending) {
+ root.confirmationPending = false
+ if (root.closeWhenSettled)
+ root.close()
+ }
+ }
+
+ onSnapshotChanged: {
+ if (summaryLoader.item && summaryLoader.item.hasOwnProperty("snapshot"))
+ summaryLoader.item.snapshot = root.snapshot
+ }
+
+ Overlay.modal: Rectangle { color: "#99000000" }
+
+ background: Rectangle {
+ color: "#18181b"
+ border.color: "#3f3f46"
+ border.width: 1
+ radius: 8
+ }
+
+ contentItem: ColumnLayout {
+ spacing: 16
+
+ Label {
+ Layout.fillWidth: true
+ text: root.title
+ color: "#f4f4f5"
+ font.bold: true
+ font.pixelSize: 17
+ wrapMode: Text.WordWrap
+ }
+
+ ScrollView {
+ id: summaryScroll
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumHeight: 0
+ Layout.preferredHeight: summaryLoader.implicitHeight
+ clip: true
+ contentWidth: availableWidth
+ ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+
+ Loader {
+ id: summaryLoader
+ objectName: "transactionSummaryLoader"
+ width: summaryScroll.availableWidth
+ sourceComponent: root.summary
+ onLoaded: {
+ if (item && item.hasOwnProperty("snapshot"))
+ item.snapshot = root.snapshot
+ }
+ }
+ }
+
+ Item {
+ id: inlineBusyIndicator
+
+ property bool active: root.showInlineBusyIndicator && root.actionPending
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: active ? busySpinner.implicitWidth : 0
+ Layout.preferredHeight: active ? busySpinner.implicitHeight : 0
+ implicitWidth: busySpinner.implicitWidth
+ implicitHeight: busySpinner.implicitHeight
+ visible: active
+
+ BusyIndicator {
+ id: busySpinner
+
+ anchors.centerIn: parent
+ running: inlineBusyIndicator.active
+ Accessible.name: root.busy ? root.busyText : root.activityText
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 10
+
+ Loader {
+ id: cancelButtonLoader
+ objectName: "transactionCancelButtonLoader"
+ Layout.fillWidth: true
+ Layout.preferredHeight: 44
+ sourceComponent: root.roundedCancelButton
+ ? roundedCancelButtonComponent : defaultCancelButtonComponent
+ }
+
+ Button {
+ id: confirmButton
+ objectName: "transactionConfirmButton"
+ Layout.fillWidth: true
+ implicitHeight: 44
+ text: root.busy ? root.busyText : root.confirmText
+ enabled: !root.actionPending && root.confirmEnabled
+ Accessible.name: text
+ onClicked: root.confirm()
+
+ background: Rectangle {
+ color: confirmButton.enabled
+ ? confirmButton.pressed ? "#d95c1e" : "#f26a21"
+ : "#52525b"
+ radius: 6
+ }
+
+ contentItem: Label {
+ text: confirmButton.text
+ color: "#ffffff"
+ font.bold: true
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ }
+ }
+
+ Component {
+ id: defaultCancelButtonComponent
+
+ Button {
+ objectName: "transactionCancelButton"
+ anchors.fill: parent
+ text: root.cancelText
+ enabled: !root.busy
+ Accessible.name: text
+ onClicked: root.cancel()
+ }
+ }
+
+ Component {
+ id: roundedCancelButtonComponent
+
+ Button {
+ id: cancelButton
+
+ objectName: "transactionCancelButton"
+ anchors.fill: parent
+ text: root.cancelText
+ enabled: !root.busy
+ Accessible.name: text
+ onClicked: root.cancel()
+
+ background: Rectangle {
+ color: cancelButton.pressed ? "#3f3f46"
+ : cancelButton.hovered ? "#27272a" : "#18181b"
+ border.color: "#52525b"
+ border.width: 1
+ radius: 6
+ }
+
+ contentItem: Label {
+ text: cancelButton.text
+ color: "#f4f4f5"
+ font.bold: true
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ }
+}
diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml
new file mode 100644
index 00000000..9167dc1c
--- /dev/null
+++ b/apps/shared/wallet/qml/WalletControl.qml
@@ -0,0 +1,939 @@
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ property var wallet: null
+ property var accountModel: null
+ property var watchCall: null
+ property bool compact: false
+ property real viewportWidth: width
+ property int selectedIndex: -1
+ property bool busy: false
+ property bool openPending: false
+ property bool advancedExpanded: false
+ property bool availableExpanded: false
+ property bool primarySelectionQueued: false
+ property string postCreationWarning: ""
+ property bool createdWalletAwaitingAcknowledgement: false
+ property string reportedOpenErrorKey: ""
+
+ readonly property bool connected: root.wallet !== null && root.wallet.isWalletOpen
+ readonly property string syncStatus: root.wallet
+ ? String(root.wallet.walletSyncStatus || "closed")
+ : "closed"
+ 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
+
+ Instantiator {
+ id: accounts
+ 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.syncPrimarySelection()
+ root.schedulePrimarySelection()
+ }
+ onObjectAdded: root.schedulePrimarySelection()
+ }
+
+ function accountAt(index, field) {
+ const entry = index >= 0 && index < accounts.count ? accounts.objectAt(index) : null
+ return entry ? entry[field] : (field === "isPublic" ? false : "")
+ }
+
+ 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 (!account)
+ continue
+ 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)
+ continue
+ if (account.kind === "user" && account.canBePrimary) {
+ root.selectedIndex = index
+ return
+ }
+ }
+ root.selectedIndex = -1
+ }
+
+ function schedulePrimarySelection() {
+ if (root.primarySelectionQueued)
+ return
+ root.primarySelectionQueued = true
+ Qt.callLater(function() {
+ root.primarySelectionQueued = false
+ if (root.connected)
+ root.syncPrimarySelection()
+ else
+ root.selectedIndex = -1
+ })
+ }
+
+ function shortAddress(address) {
+ return address && address.length > 13
+ ? address.substring(0, 6) + "…" + address.substring(address.length - 4)
+ : address || ""
+ }
+
+ function watchResult(result, success, failure) {
+ if (root.watchCall)
+ root.watchCall(result, success, failure)
+ else
+ success(result)
+ }
+
+ function showError(message) {
+ messageDialog.message = message
+ 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.busy = false
+ 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 || root.walletOpening)
+ return
+ root.busy = true
+ root.openPending = true
+ try {
+ root.watchResult(root.wallet.openExisting(), function(ok) {
+ if (!root.openAccepted(ok)) {
+ root.failOpen(qsTr("Wallet could not be opened."))
+ return
+ }
+ Qt.callLater(root.settleOpenFromWalletState)
+ }, function(error) {
+ root.failOpen(qsTr("Wallet could not be opened: %1").arg(error))
+ })
+ } catch (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))
+ }
+ }
+
+ TextEdit {
+ id: clipboardProxy
+ visible: false
+ }
+
+ function copyToClipboard(text) {
+ if (!text)
+ return
+ clipboardProxy.text = text
+ clipboardProxy.selectAll()
+ clipboardProxy.copy()
+ clipboardProxy.deselect()
+ clipboardProxy.text = ""
+ }
+
+ Connections {
+ target: root.accountModel
+ ignoreUnknownSignals: true
+ function onModelReset() {
+ root.syncPrimarySelection()
+ root.schedulePrimarySelection()
+ }
+ function onRowsInserted() {
+ root.syncPrimarySelection()
+ root.schedulePrimarySelection()
+ }
+ function onRowsRemoved() {
+ root.syncPrimarySelection()
+ root.schedulePrimarySelection()
+ }
+ function onDataChanged() {
+ root.syncPrimarySelection()
+ if (root.selectedIndex < 0)
+ root.schedulePrimarySelection()
+ }
+ }
+
+ Connections {
+ target: root.wallet
+ ignoreUnknownSignals: true
+ function onPrimaryAccountAddressChanged() {
+ root.syncPrimarySelection()
+ root.schedulePrimarySelection()
+ }
+ 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 = -1
+ walletMenu.close()
+ } else {
+ root.syncPrimarySelection()
+ root.schedulePrimarySelection()
+ }
+ }
+ onViewportWidthChanged: {
+ if (walletMenu.opened)
+ Qt.callLater(walletMenu.updateAnchor)
+ }
+
+ Button {
+ id: connectButton
+ objectName: "walletConnectButton"
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !root.connected
+ enabled: root.wallet !== null && !root.busy && !root.walletOpening
+ implicitHeight: 40
+ implicitWidth: root.compactLayout ? 40 : 108
+ text: root.compactLayout ? "" : (root.busy || root.walletOpening
+ ? qsTr("Connecting…") : qsTr("Connect"))
+ icon.source: Qt.resolvedUrl("icons/account.svg")
+ Accessible.name: qsTr("Connect AMM Wallet")
+ ToolTip.text: Accessible.name
+ ToolTip.visible: hovered && root.compactLayout
+
+ background: Rectangle {
+ color: connectButton.pressed ? "#d97706" : "#f59e0b"
+ radius: 8
+ }
+ contentItem: RowLayout {
+ spacing: 6
+ Image {
+ visible: root.compactLayout
+ source: connectButton.icon.source
+ sourceSize.width: 18
+ sourceSize.height: 18
+ }
+ Label {
+ Layout.fillWidth: true
+ visible: !root.compactLayout
+ text: connectButton.text
+ color: "#18181b"
+ font.bold: true
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ onClicked: {
+ if (root.wallet && root.wallet.walletExists)
+ root.openWallet()
+ else
+ createWalletDialog.open()
+ }
+ }
+
+ Button {
+ id: connectedButton
+ objectName: "walletAccountButton"
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.connected
+ enabled: !root.busy
+ implicitHeight: 40
+ 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: "#f59e0b"
+ radius: 8
+ }
+ contentItem: RowLayout {
+ spacing: 8
+ Rectangle {
+ Layout.preferredWidth: 8
+ Layout.preferredHeight: 8
+ radius: 4
+ 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.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 ? "▴" : "▾"
+ color: "#a1a1aa"
+ }
+ }
+ onClicked: {
+ if (walletMenu.opened || Date.now() - walletMenu.lastClosedMs < 200)
+ walletMenu.close()
+ else
+ walletMenu.open()
+ }
+ }
+
+ Popup {
+ id: walletMenu
+ objectName: "walletMenu"
+ palette.windowText: "#d4d4d8"
+ property real lastClosedMs: 0
+ property point anchorPosition: Qt.point(0, 0)
+ readonly property var viewport: Overlay.overlay
+ readonly property real spaceAbove: Math.max(0, anchorPosition.y - 20)
+ readonly property real spaceBelow: viewport
+ ? Math.max(0, viewport.height - anchorPosition.y - connectedButton.height - 20)
+ : implicitHeight
+ readonly property bool opensAbove: spaceBelow < implicitHeight && spaceAbove > spaceBelow
+ readonly property real availableMenuHeight: opensAbove ? spaceAbove : spaceBelow
+ parent: connectedButton
+ x: viewport
+ ? Math.max(12 - anchorPosition.x,
+ Math.min(connectedButton.width - width,
+ viewport.width - width - 12 - anchorPosition.x))
+ : connectedButton.width - width
+ 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
+ padding: 12
+ focus: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ function updateAnchor() {
+ 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: 10
+ }
+
+ contentItem: StackView {
+ id: walletStack
+ objectName: "walletStack"
+ clip: true
+ width: walletMenu.availableWidth
+ height: walletMenu.availableHeight
+ implicitWidth: walletMenu.availableWidth
+ implicitHeight: currentItem ? currentItem.implicitHeight : 0
+ initialItem: walletOverview
+ }
+
+ Component {
+ id: walletOverview
+ ScrollView {
+ implicitHeight: Math.min(overviewContent.implicitHeight, 520)
+ contentWidth: availableWidth
+ ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+
+ ColumnLayout {
+ id: overviewContent
+ objectName: "walletOverviewContent"
+ width: parent.width
+ spacing: 12
+
+ RowLayout {
+ Layout.fillWidth: true
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 1
+ Label {
+ text: qsTr("AMM Wallet")
+ color: "#fafafa"
+ font.bold: true
+ font.pixelSize: 16
+ }
+ Label {
+ text: root.wallet && root.wallet.activeNetwork
+ ? root.wallet.activeNetwork : qsTr("Network unavailable")
+ color: "#a1a1aa"
+ font.pixelSize: 11
+ }
+ }
+ 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()
+ }
+ }
+ }
+
+ 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
+ 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 {
+ objectName: "walletPrimaryAccountType"
+ visible: root.selectedIndex >= 0
+ 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)
+ }
+ }
+ }
+ }
+
+ Label {
+ text: qsTr("Assets")
+ color: "#fafafa"
+ font.bold: true
+ }
+ Label {
+ visible: root.wallet && root.wallet.assetStatus === "loading"
+ text: qsTr("Loading balances…")
+ color: "#a1a1aa"
+ }
+ Repeater {
+ objectName: "walletAssetRepeater"
+ model: root.walletAssets
+ delegate: Rectangle {
+ required property var modelData
+ objectName: "walletAssetBox"
+ Layout.fillWidth: true
+ visible: modelData.section === "assets"
+ implicitHeight: visible ? 68 : 0
+ color: "#27272a"
+ radius: 10
+ border.width: 1
+ border.color: "#3f3f46"
+ Accessible.name: qsTr("%1 token, balance %2")
+ .arg(modelData.name).arg(modelData.balance)
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 12
+ 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 {
+ objectName: "walletAvailableAssetRepeater"
+ model: root.walletAssets
+ delegate: Rectangle {
+ required property var modelData
+ objectName: "walletAvailableAssetBox"
+ Layout.fillWidth: true
+ visible: root.availableExpanded && modelData.section === "available"
+ implicitHeight: visible ? 64 : 0
+ color: "#202023"
+ radius: 10
+ border.width: 1
+ border.color: "#3f3f46"
+ Accessible.name: qsTr("%1 token, no balance")
+ .arg(modelData.name)
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 12
+ 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)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: accountList
+ ColumnLayout {
+ spacing: 10
+ RowLayout {
+ Layout.fillWidth: true
+ WalletIconButton {
+ objectName: "walletAccountsBackButton"
+ iconSource: Qt.resolvedUrl("icons/back.svg")
+ accessibleName: qsTr("Back")
+ onClicked: walletStack.pop(null, StackView.Immediate)
+ }
+ Label {
+ Layout.fillWidth: true
+ text: qsTr("Accounts")
+ 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: 48
+ Layout.preferredHeight: Math.min(contentHeight, 300)
+ clip: true
+ 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 decodedData
+ required property string visibility
+ required property bool canBePrimary
+ required property bool isPrimary
+
+ readonly property bool shown: section === "accounts"
+ || (root.advancedExpanded && section === "advanced")
+ width: ListView.view.width
+ 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
+ decodedData: accountWrapper.decodedData
+ 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) }
+ }
+ }
+ }
+ RowLayout {
+ Layout.fillWidth: true
+ 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) {
+ createWalletDialog.mnemonic = mnemonic
+ root.createdWalletAwaitingAcknowledgement = true
+ root.postCreationWarning = root.walletRefreshWarning(qsTr("Wallet"))
+ } else
+ createWalletDialog.errorText = qsTr("Wallet could not be created.")
+ }, function(error) {
+ root.busy = false
+ createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error)
+ })
+ } catch (error) {
+ root.busy = false
+ 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()
+ root.watchResult(request, function(accountId) {
+ root.busy = false
+ if (accountId && accountId.length > 0) {
+ createAccountDialog.close()
+ 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))
+ })
+ } catch (error) {
+ root.busy = false
+ root.showError(qsTr("Account could not be created: %1").arg(error))
+ }
+ }
+ }
+
+ WalletMessageDialog {
+ id: messageDialog
+ objectName: "walletMessageDialog"
+ }
+}
diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml
new file mode 100644
index 00000000..43a2f211
--- /dev/null
+++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml
@@ -0,0 +1,182 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+ItemDelegate {
+ id: root
+
+ 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 decodedData
+ 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
+
+ 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.isPrimary || root.hovered ? "#27272a" : "#18181b"
+ radius: 8
+ border.width: root.activeFocus || root.isPrimary ? 1 : 0
+ border.color: root.isPrimary ? "#f59e0b" : "#52525b"
+ }
+
+ contentItem: ColumnLayout {
+ spacing: 7
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 7
+
+ Label {
+ Layout.fillWidth: true
+ text: root.name
+ 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.kindLabel()
+ color: "#a1a1aa"
+ font.pixelSize: 11
+ }
+
+ Label {
+ text: root.visibility === "private" ? qsTr("Private") : qsTr("Public")
+ color: root.visibility === "private" ? "#c4b5fd" : "#93c5fd"
+ font.pixelSize: 11
+ }
+ }
+
+ Label {
+ objectName: "walletProgramName"
+ visible: root.section === "advanced" && root.programName.length > 0
+ Layout.fillWidth: true
+ text: qsTr("Program: %1").arg(root.programName)
+ color: "#a1a1aa"
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
+
+ ColumnLayout {
+ visible: root.section === "advanced" && root.decodedData.length > 0
+ Layout.fillWidth: true
+ spacing: 4
+
+ Label {
+ objectName: "walletDecodedDataLabel"
+ text: qsTr("Decoded data")
+ color: "#a1a1aa"
+ font.pixelSize: 11
+ }
+
+ Rectangle {
+ objectName: "walletDecodedDataBox"
+ Layout.fillWidth: true
+ implicitHeight: decodedDataText.implicitHeight + 16
+ color: "#18181b"
+ radius: 6
+ border.width: 1
+ border.color: "#3f3f46"
+
+ Text {
+ id: decodedDataText
+ objectName: "walletDecodedData"
+ anchors.fill: parent
+ anchors.margins: 8
+ text: root.decodedData
+ color: "#d4d4d8"
+ font.family: "monospace"
+ font.pixelSize: 10
+ textFormat: Text.PlainText
+ wrapMode: Text.WrapAnywhere
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 4
+
+ Label {
+ Layout.fillWidth: true
+ text: root.displayAddress
+ color: "#71717a"
+ font.family: "monospace"
+ font.pixelSize: 11
+ elide: Text.ElideMiddle
+ }
+
+ CopyButton {
+ visible: root.displayAddress.length > 0
+ onCopyRequested: root.copyRequested(root.displayAddress)
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 6
+
+ Button {
+ objectName: "walletRenameButton"
+ text: qsTr("Rename")
+ flat: true
+ onClicked: root.renameRequested(root.address, root.alias)
+ }
+
+ Item { Layout.fillWidth: true }
+
+ Button {
+ objectName: "walletMakePrimaryButton"
+ 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/qml/internal/CopyButton.qml b/apps/shared/wallet/qml/internal/CopyButton.qml
new file mode 100644
index 00000000..b2f664fb
--- /dev/null
+++ b/apps/shared/wallet/qml/internal/CopyButton.qml
@@ -0,0 +1,45 @@
+import QtQuick
+
+WalletIconButton {
+ id: root
+
+ signal copyRequested
+
+ property string copyText: ""
+ property string copyLabel: qsTr("Copy")
+ property bool copied: false
+
+ accessibleName: root.copied ? qsTr("Copied") : root.copyLabel
+ iconSource: root.copied
+ ? Qt.resolvedUrl("icons/checkmark.svg")
+ : Qt.resolvedUrl("icons/copy.svg")
+
+ Timer {
+ id: resetTimer
+ interval: 1500
+ onTriggered: root.copied = false
+ }
+
+ TextEdit {
+ id: clipboardProxy
+
+ visible: false
+ }
+
+ function copyToClipboard() {
+ if (root.copyText.length === 0)
+ return
+ clipboardProxy.text = root.copyText
+ clipboardProxy.selectAll()
+ clipboardProxy.copy()
+ clipboardProxy.deselect()
+ clipboardProxy.text = ""
+ }
+
+ onClicked: {
+ root.copyToClipboard()
+ root.copyRequested()
+ root.copied = true
+ resetTimer.restart()
+ }
+}
diff --git a/apps/shared/wallet/qml/internal/CreateAccountDialog.qml b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml
new file mode 100644
index 00000000..b4998da1
--- /dev/null
+++ b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml
@@ -0,0 +1,98 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+Popup {
+ id: root
+
+ property bool busy: false
+
+ signal createRequested(bool isPublic)
+
+ modal: true
+ dim: true
+ parent: Overlay.overlay
+ width: Math.max(0, Math.min(380, parent ? parent.width - 32 : 380))
+ x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0
+ y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0
+ padding: 20
+ focus: true
+ closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ onOpened: {
+ privateSwitch.checked = false
+ Qt.callLater(function() { privateSwitch.forceActiveFocus() })
+ }
+
+ background: Rectangle {
+ color: "#18181b"
+ border.color: "#3f3f46"
+ border.width: 1
+ radius: 8
+ }
+
+ contentItem: ColumnLayout {
+ spacing: 16
+
+ Label {
+ Layout.fillWidth: true
+ text: qsTr("Create account")
+ color: "#f4f4f5"
+ font.bold: true
+ font.pixelSize: 17
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 12
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 2
+
+ Label {
+ text: privateSwitch.checked ? qsTr("Private") : qsTr("Public")
+ color: "#f4f4f5"
+ font.bold: true
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: privateSwitch.checked
+ ? qsTr("Private balance and activity")
+ : qsTr("Public balance and activity")
+ color: "#a1a1aa"
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ Switch {
+ id: privateSwitch
+ objectName: "privateAccountSwitch"
+ Accessible.name: qsTr("Create private account")
+ enabled: !root.busy
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 10
+
+ Button {
+ Layout.fillWidth: true
+ text: qsTr("Cancel")
+ enabled: !root.busy
+ onClicked: root.close()
+ }
+
+ Button {
+ id: createButton
+ objectName: "createAccountButton"
+ Layout.fillWidth: true
+ text: root.busy ? qsTr("Creating...") : qsTr("Create")
+ enabled: !root.busy
+ onClicked: root.createRequested(!privateSwitch.checked)
+ }
+ }
+ }
+}
diff --git a/apps/shared/wallet/qml/internal/CreateWalletDialog.qml b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml
new file mode 100644
index 00000000..e1ca2dee
--- /dev/null
+++ b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml
@@ -0,0 +1,191 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+Popup {
+ id: root
+
+ property string walletHome: ""
+ property string mnemonic: ""
+ property string errorText: ""
+ property bool busy: false
+
+ signal createRequested(string password)
+ signal copyRequested(string text)
+
+ modal: true
+ dim: true
+ parent: Overlay.overlay
+ width: Math.max(0, Math.min(420, parent ? parent.width - 32 : 420))
+ x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0
+ y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0
+ padding: 20
+ focus: true
+ closePolicy: root.busy || root.mnemonic.length > 0
+ ? Popup.NoAutoClose
+ : Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ onOpened: {
+ passwordField.text = ""
+ confirmField.text = ""
+ acknowledgement.checked = false
+ root.errorText = ""
+ root.mnemonic = ""
+ passwordField.forceActiveFocus()
+ }
+
+ background: Rectangle {
+ color: "#18181b"
+ border.color: "#3f3f46"
+ border.width: 1
+ radius: 8
+ }
+
+ contentItem: ColumnLayout {
+ spacing: 16
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 14
+ visible: root.mnemonic.length === 0
+
+ Label {
+ Layout.fillWidth: true
+ text: qsTr("Create wallet")
+ color: "#f4f4f5"
+ font.bold: true
+ font.pixelSize: 17
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: root.walletHome.length > 0
+ ? qsTr("Wallet will be stored at %1").arg(root.walletHome)
+ : qsTr("Wallet will use default storage")
+ color: "#a1a1aa"
+ wrapMode: Text.WordWrap
+ }
+
+ TextField {
+ id: passwordField
+ objectName: "walletPasswordField"
+ Layout.fillWidth: true
+ placeholderText: qsTr("Password")
+ echoMode: TextInput.Password
+ enabled: !root.busy
+ Keys.onReturnPressed: createButton.tryCreate()
+ }
+
+ TextField {
+ id: confirmField
+ objectName: "walletConfirmPasswordField"
+ Layout.fillWidth: true
+ placeholderText: qsTr("Confirm password")
+ echoMode: TextInput.Password
+ enabled: !root.busy
+ Keys.onReturnPressed: createButton.tryCreate()
+ }
+
+ Label {
+ Layout.fillWidth: true
+ visible: root.errorText.length > 0
+ text: root.errorText
+ color: "#f87171"
+ wrapMode: Text.WordWrap
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 10
+
+ Button {
+ Layout.fillWidth: true
+ text: qsTr("Cancel")
+ enabled: !root.busy
+ onClicked: root.close()
+ }
+
+ Button {
+ id: createButton
+ objectName: "createWalletButton"
+ Layout.fillWidth: true
+ text: root.busy ? qsTr("Creating...") : qsTr("Create wallet")
+ enabled: !root.busy
+
+ function tryCreate() {
+ if (passwordField.text.length === 0) {
+ root.errorText = qsTr("Password cannot be empty.")
+ } else if (passwordField.text !== confirmField.text) {
+ root.errorText = qsTr("Passwords do not match.")
+ } else {
+ root.errorText = ""
+ root.createRequested(passwordField.text)
+ }
+ }
+
+ onClicked: tryCreate()
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 14
+ visible: root.mnemonic.length > 0
+
+ Label {
+ Layout.fillWidth: true
+ text: qsTr("Back up recovery phrase")
+ color: "#f4f4f5"
+ font.bold: true
+ font.pixelSize: 17
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: qsTr("Write these words down in order. Anyone with this phrase can control this wallet.")
+ color: "#d4d4d8"
+ wrapMode: Text.WordWrap
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ implicitHeight: phraseLabel.implicitHeight + 24
+ color: "#27272a"
+ radius: 6
+
+ Label {
+ id: phraseLabel
+ objectName: "walletMnemonic"
+ anchors.fill: parent
+ anchors.margins: 12
+ text: root.mnemonic
+ color: "#f4f4f5"
+ font.bold: true
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: qsTr("Copy recovery phrase")
+ onClicked: root.copyRequested(root.mnemonic)
+ }
+
+ CheckBox {
+ id: acknowledgement
+ objectName: "walletBackupAcknowledgement"
+ Layout.fillWidth: true
+ text: qsTr("I have safely backed up my recovery phrase")
+ }
+
+ Button {
+ objectName: "walletContinueButton"
+ Layout.fillWidth: true
+ text: qsTr("Continue")
+ enabled: acknowledgement.checked
+ onClicked: root.close()
+ }
+ }
+ }
+}
diff --git a/apps/shared/wallet/qml/internal/WalletIconButton.qml b/apps/shared/wallet/qml/internal/WalletIconButton.qml
new file mode 100644
index 00000000..159d5e10
--- /dev/null
+++ b/apps/shared/wallet/qml/internal/WalletIconButton.qml
@@ -0,0 +1,31 @@
+import QtQuick
+import QtQuick.Controls.Basic
+
+Button {
+ id: root
+
+ property url iconSource
+ property string accessibleName: ""
+ property color iconColor: "#d4d4d8"
+
+ implicitWidth: 36
+ implicitHeight: 36
+ display: AbstractButton.IconOnly
+ hoverEnabled: true
+
+ Accessible.name: root.accessibleName
+ ToolTip.text: root.accessibleName
+ ToolTip.visible: root.hovered && root.accessibleName.length > 0
+
+ icon.source: root.iconSource
+ icon.width: 18
+ icon.height: 18
+ icon.color: root.iconColor
+
+ background: Rectangle {
+ color: root.pressed ? "#3f3f46" : root.hovered || root.activeFocus ? "#27272a" : "transparent"
+ radius: 6
+ border.width: root.activeFocus ? 1 : 0
+ border.color: "#f26a21"
+ }
+}
diff --git a/apps/shared/wallet/qml/internal/WalletMessageDialog.qml b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml
new file mode 100644
index 00000000..47c39633
--- /dev/null
+++ b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml
@@ -0,0 +1,57 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+Popup {
+ id: root
+
+ property string title: qsTr("Wallet error")
+ property string message: ""
+
+ modal: true
+ dim: true
+ parent: Overlay.overlay
+ width: Math.max(0, Math.min(380, parent ? parent.width - 32 : 380))
+ x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0
+ y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0
+ padding: 20
+ focus: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ onOpened: Qt.callLater(function() { closeButton.forceActiveFocus() })
+
+ background: Rectangle {
+ color: "#18181b"
+ border.color: "#3f3f46"
+ border.width: 1
+ radius: 8
+ }
+
+ contentItem: ColumnLayout {
+ spacing: 16
+
+ Label {
+ Layout.fillWidth: true
+ text: root.title
+ color: "#f4f4f5"
+ font.bold: true
+ font.pixelSize: 17
+ wrapMode: Text.WordWrap
+ }
+
+ Label {
+ Layout.fillWidth: true
+ text: root.message
+ color: "#d4d4d8"
+ wrapMode: Text.WordWrap
+ }
+
+ Button {
+ id: closeButton
+
+ Layout.alignment: Qt.AlignRight
+ text: qsTr("Close")
+ onClicked: root.close()
+ }
+ }
+}
diff --git a/apps/amm/qml/components/wallet/icons/account.svg b/apps/shared/wallet/qml/internal/icons/account.svg
similarity index 100%
rename from apps/amm/qml/components/wallet/icons/account.svg
rename to apps/shared/wallet/qml/internal/icons/account.svg
diff --git a/apps/amm/qml/components/wallet/icons/back.svg b/apps/shared/wallet/qml/internal/icons/back.svg
similarity index 100%
rename from apps/amm/qml/components/wallet/icons/back.svg
rename to apps/shared/wallet/qml/internal/icons/back.svg
diff --git a/apps/amm/qml/components/wallet/icons/checkmark.svg b/apps/shared/wallet/qml/internal/icons/checkmark.svg
similarity index 100%
rename from apps/amm/qml/components/wallet/icons/checkmark.svg
rename to apps/shared/wallet/qml/internal/icons/checkmark.svg
diff --git a/apps/amm/qml/components/wallet/icons/copy.svg b/apps/shared/wallet/qml/internal/icons/copy.svg
similarity index 100%
rename from apps/amm/qml/components/wallet/icons/copy.svg
rename to apps/shared/wallet/qml/internal/icons/copy.svg
diff --git a/apps/amm/qml/components/wallet/icons/power.svg b/apps/shared/wallet/qml/internal/icons/power.svg
similarity index 100%
rename from apps/amm/qml/components/wallet/icons/power.svg
rename to apps/shared/wallet/qml/internal/icons/power.svg
diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp
new file mode 100644
index 00000000..394c01c9
--- /dev/null
+++ b/apps/shared/wallet/src/LogosWalletProvider.cpp
@@ -0,0 +1,927 @@
+#include "LogosWalletProvider.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "logos_sdk.h"
+
+namespace {
+constexpr int WALLET_FFI_SUCCESS = 0;
+
+bool isHex(const QString& value, qsizetype size, bool lowercaseOnly = true)
+{
+ if (value.size() != size)
+ return false;
+
+ for (const QChar character : value) {
+ const bool digit = character >= QLatin1Char('0')
+ && character <= QLatin1Char('9');
+ const bool lowercase = character >= QLatin1Char('a')
+ && character <= QLatin1Char('f');
+ const bool uppercase = character >= QLatin1Char('A')
+ && character <= QLatin1Char('F');
+ if (!digit && !lowercase && (!uppercase || lowercaseOnly))
+ return false;
+ }
+ return true;
+}
+
+QString littleEndianU128ToDecimal(const QString& value)
+{
+ if (!isHex(value, 32))
+ return {};
+
+ QString decimal = QStringLiteral("0");
+ for (int byteIndex = 15; byteIndex >= 0; --byteIndex) {
+ bool parsed = false;
+ int carry = value.mid(byteIndex * 2, 2).toInt(&parsed, 16);
+ if (!parsed)
+ return {};
+
+ for (qsizetype digit = decimal.size(); digit-- > 0;) {
+ const int next = (decimal.at(digit).unicode() - QLatin1Char('0').unicode())
+ * 256 + carry;
+ decimal[digit] = QLatin1Char(static_cast('0' + next % 10));
+ carry = next / 10;
+ }
+ while (carry > 0) {
+ decimal.prepend(QLatin1Char(static_cast('0' + carry % 10)));
+ carry /= 10;
+ }
+ }
+
+ return decimal;
+}
+
+WalletSession failedSession(WalletFailure failure)
+{
+ WalletSession session;
+ session.failure = failure;
+ session.snapshot.failure = failure;
+ return session;
+}
+
+WalletCreation failedCreation(WalletFailure failure)
+{
+ WalletCreation creation;
+ creation.failure = 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;
+}
+
+bool encodeTransaction(const WalletTransaction& transaction,
+ QVariantList* signingRequirements,
+ QVariantList* instruction)
+{
+ if (!isHex(transaction.programId, 64)
+ || transaction.accountIds.size() != transaction.signingRequirements.size()) {
+ return false;
+ }
+ for (const QString& accountId : transaction.accountIds) {
+ if (!isHex(accountId, 64))
+ return false;
+ }
+
+ signingRequirements->reserve(transaction.signingRequirements.size());
+ for (bool required : transaction.signingRequirements)
+ signingRequirements->append(required);
+
+ instruction->reserve(transaction.instruction.size());
+ for (quint32 word : transaction.instruction)
+ instruction->append(word);
+ return true;
+}
+
+WalletSubmission parseSubmission(const QString& response)
+{
+ WalletSubmission submission;
+ QJsonParseError parseError;
+ const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError);
+ if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
+ submission.failure = WalletFailure::SubmissionFailed;
+ return submission;
+ }
+
+ const QJsonObject result = document.object();
+ const QJsonValue success = result.value(QStringLiteral("success"));
+ const QJsonValue error = result.value(QStringLiteral("error"));
+ const QString hash = result.value(QStringLiteral("tx_hash")).toString();
+ const bool emptyError = error.isUndefined()
+ || error.isNull()
+ || (error.isString() && error.toString().isEmpty());
+ if (!success.isBool()
+ || !success.toBool()
+ || !emptyError
+ || !isHex(hash, 64, false)) {
+ submission.failure = WalletFailure::SubmissionFailed;
+ return submission;
+ }
+
+ submission.nativeHash = hash.toLower();
+ return submission;
+}
+}
+
+struct LogosWalletProvider::Impl {
+ explicit Impl(LogosAPI* api)
+ : ownedLogos(std::make_unique(api)), logos(ownedLogos.get())
+ {
+ }
+
+ explicit Impl(LogosModules* value)
+ : logos(value)
+ {
+ }
+
+ std::unique_ptr ownedLogos;
+ LogosModules* logos = nullptr;
+};
+
+LogosWalletProvider::LogosWalletProvider(LogosAPI* api)
+ : m_impl(std::make_unique(api))
+{
+}
+
+LogosWalletProvider::LogosWalletProvider(LogosModules* logos)
+ : m_impl(std::make_unique(logos))
+{
+}
+
+LogosWalletProvider::~LogosWalletProvider()
+{
+ ++m_generation;
+ ++m_sessionGeneration;
+ if (m_connected)
+ save();
+}
+
+WalletSession LogosWalletProvider::connect(const WalletPaths& paths)
+{
+ ++m_generation;
+ ++m_sessionGeneration;
+ clearSnapshot();
+ if (!m_impl->logos)
+ return failedSession(WalletFailure::WalletUnavailable);
+
+ WalletSession session;
+ if (sharedWalletIsOpen()) {
+ session.adopted = true;
+ } else {
+ if (!QFileInfo::exists(paths.storage))
+ return failedSession(WalletFailure::WalletMissing);
+ if (m_impl->logos->logos_execution_zone.open(paths.config, paths.storage)
+ != WALLET_FFI_SUCCESS) {
+ return failedSession(WalletFailure::OpenFailed);
+ }
+ }
+
+ m_connected = true;
+ session.snapshot = snapshot(true);
+ session.failure = session.snapshot.failure;
+ return session;
+}
+
+void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback callback)
+{
+ ++m_sessionGeneration;
+ 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;
+ }
+ openStored();
+ });
+}
+
+WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths,
+ const QString& password)
+{
+ ++m_generation;
+ ++m_sessionGeneration;
+ clearSnapshot();
+ if (!m_impl->logos)
+ return failedCreation(WalletFailure::WalletUnavailable);
+
+ const QFileInfo configInfo(paths.config);
+ const QFileInfo storageInfo(paths.storage);
+ if (!QDir().mkpath(configInfo.absolutePath())
+ || !QDir().mkpath(storageInfo.absolutePath())) {
+ return failedCreation(WalletFailure::CreateFailed);
+ }
+
+ WalletCreation creation;
+ creation.mnemonic = m_impl->logos->logos_execution_zone.create_new(
+ paths.config, paths.storage, password);
+ if (creation.mnemonic.isEmpty())
+ return failedCreation(WalletFailure::CreateFailed);
+
+ m_connected = true;
+ if (!save()) {
+ creation.failure = WalletFailure::SaveFailed;
+ creation.snapshot.failure = creation.failure;
+ return creation;
+ }
+
+ return creation;
+}
+
+WalletSnapshot LogosWalletProvider::snapshot(bool forceRefresh)
+{
+ if (m_snapshotReady && !forceRefresh)
+ return m_snapshot;
+ if (!m_connected) {
+ WalletSnapshot result;
+ result.failure = WalletFailure::WalletUnavailable;
+ return result;
+ }
+
+ WalletSnapshot result = loadSnapshot();
+ if (result.ok()) {
+ m_snapshot = result;
+ m_snapshotReady = true;
+ }
+ 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 = {};
+ m_snapshotReady = false;
+}
+
+WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic)
+{
+ WalletAccountCreation creation;
+ if (!m_connected || !m_impl->logos) {
+ creation.failure = WalletFailure::WalletUnavailable;
+ return creation;
+ }
+
+ creation.accountId = isPublic
+ ? m_impl->logos->logos_execution_zone.create_account_public()
+ : m_impl->logos->logos_execution_zone.create_account_private();
+ if (!isHex(creation.accountId, 64)) {
+ creation.failure = WalletFailure::CreateFailed;
+ return creation;
+ }
+ if (!save()) {
+ creation.failure = WalletFailure::SaveFailed;
+ return creation;
+ }
+
+ if (isPublic)
+ creation.publicAccount = readPublicAccount(creation.accountId);
+ if (m_snapshotReady) {
+ WalletAccount account;
+ account.address = creation.accountId;
+ account.isPublic = isPublic;
+ if (isPublic && creation.publicAccount.ok()) {
+ account.balance = littleEndianU128ToDecimal(creation.publicAccount.balanceHex);
+ auto read = std::find_if(
+ m_snapshot.publicAccountReads.begin(),
+ m_snapshot.publicAccountReads.end(),
+ [&creation](const WalletAccountRead& existing) {
+ return existing.accountId == creation.accountId;
+ });
+ if (read == m_snapshot.publicAccountReads.end())
+ m_snapshot.publicAccountReads.append(creation.publicAccount);
+ else
+ *read = creation.publicAccount;
+ } else {
+ account.balance = m_impl->logos->logos_execution_zone.get_balance(
+ creation.accountId, isPublic);
+ }
+ auto existing = std::find_if(
+ m_snapshot.accounts.begin(), m_snapshot.accounts.end(),
+ [&creation](const WalletAccount& candidate) {
+ return candidate.address == creation.accountId;
+ });
+ if (existing == m_snapshot.accounts.end())
+ m_snapshot.accounts.append(account);
+ else
+ *existing = account;
+ creation.snapshot = m_snapshot;
+ }
+ return creation;
+}
+
+void LogosWalletProvider::createAccountAsync(bool isPublic,
+ AccountCreationCallback callback)
+{
+ QPointer guard(this);
+ if (!m_connected || !m_impl->logos) {
+ QTimer::singleShot(0, [guard, callback = std::move(callback)]() mutable {
+ if (!guard)
+ return;
+ WalletAccountCreation creation;
+ creation.failure = WalletFailure::WalletUnavailable;
+ callback(std::move(creation));
+ });
+ return;
+ }
+
+ const quint64 sessionGeneration = m_sessionGeneration;
+ auto finish = [guard, sessionGeneration, isPublic,
+ callback = std::move(callback)](
+ WalletAccountCreation creation, QString fallbackBalance) mutable {
+ if (!guard)
+ return;
+ if (sessionGeneration != guard->m_sessionGeneration) {
+ WalletAccountCreation failed;
+ failed.failure = WalletFailure::WalletUnavailable;
+ callback(std::move(failed));
+ return;
+ }
+
+ if (guard->m_snapshotReady) {
+ WalletAccount account;
+ account.address = creation.accountId;
+ account.isPublic = isPublic;
+ if (isPublic && creation.publicAccount.ok()) {
+ account.balance = littleEndianU128ToDecimal(
+ creation.publicAccount.balanceHex);
+ auto read = std::find_if(
+ guard->m_snapshot.publicAccountReads.begin(),
+ guard->m_snapshot.publicAccountReads.end(),
+ [&creation](const WalletAccountRead& existing) {
+ return existing.accountId == creation.accountId;
+ });
+ if (read == guard->m_snapshot.publicAccountReads.end())
+ guard->m_snapshot.publicAccountReads.append(creation.publicAccount);
+ else
+ *read = creation.publicAccount;
+ } else {
+ account.balance = std::move(fallbackBalance);
+ }
+ auto existing = std::find_if(
+ guard->m_snapshot.accounts.begin(),
+ guard->m_snapshot.accounts.end(),
+ [&creation](const WalletAccount& candidate) {
+ return candidate.address == creation.accountId;
+ });
+ if (existing == guard->m_snapshot.accounts.end())
+ guard->m_snapshot.accounts.append(account);
+ else
+ *existing = account;
+ creation.snapshot = guard->m_snapshot;
+ }
+ callback(std::move(creation));
+ };
+
+ auto created = [guard, sessionGeneration, isPublic,
+ finish = std::move(finish)](QString accountId) mutable {
+ if (!guard)
+ return;
+ if (sessionGeneration != guard->m_sessionGeneration) {
+ WalletAccountCreation failed;
+ failed.failure = WalletFailure::WalletUnavailable;
+ finish(std::move(failed), {});
+ return;
+ }
+
+ WalletAccountCreation creation;
+ creation.accountId = std::move(accountId);
+ if (!isHex(creation.accountId, 64)) {
+ creation.failure = WalletFailure::CreateFailed;
+ finish(std::move(creation), {});
+ return;
+ }
+
+ guard->m_impl->logos->logos_execution_zone.saveAsync(
+ [guard, sessionGeneration, isPublic, creation = std::move(creation),
+ finish = std::move(finish)](int result) mutable {
+ if (!guard)
+ return;
+ if (sessionGeneration != guard->m_sessionGeneration) {
+ WalletAccountCreation failed;
+ failed.failure = WalletFailure::WalletUnavailable;
+ finish(std::move(failed), {});
+ return;
+ }
+ if (result != WALLET_FFI_SUCCESS) {
+ creation.failure = WalletFailure::SaveFailed;
+ finish(std::move(creation), {});
+ return;
+ }
+
+ if (!isPublic) {
+ guard->m_impl->logos->logos_execution_zone.get_balanceAsync(
+ creation.accountId, false,
+ [guard, sessionGeneration, creation = std::move(creation),
+ finish = std::move(finish)](QString balance) mutable {
+ if (!guard)
+ return;
+ if (sessionGeneration != guard->m_sessionGeneration) {
+ WalletAccountCreation failed;
+ failed.failure = WalletFailure::WalletUnavailable;
+ finish(std::move(failed), {});
+ return;
+ }
+ finish(std::move(creation), std::move(balance));
+ });
+ return;
+ }
+
+ const QString accountId = creation.accountId;
+ guard->m_impl->logos->logos_execution_zone.get_account_publicAsync(
+ accountId,
+ [guard, sessionGeneration, creation = std::move(creation),
+ finish = std::move(finish)](QString payload) mutable {
+ if (!guard)
+ return;
+ if (sessionGeneration != guard->m_sessionGeneration) {
+ WalletAccountCreation failed;
+ failed.failure = WalletFailure::WalletUnavailable;
+ finish(std::move(failed), {});
+ return;
+ }
+ creation.publicAccount = parsePublicAccount(
+ creation.accountId, payload);
+ if (creation.publicAccount.ok()) {
+ finish(std::move(creation), {});
+ return;
+ }
+ guard->m_impl->logos->logos_execution_zone.get_balanceAsync(
+ creation.accountId, true,
+ [guard, sessionGeneration,
+ creation = std::move(creation),
+ finish = std::move(finish)](QString balance) mutable {
+ if (!guard)
+ return;
+ if (sessionGeneration != guard->m_sessionGeneration) {
+ WalletAccountCreation failed;
+ failed.failure = WalletFailure::WalletUnavailable;
+ finish(std::move(failed), {});
+ return;
+ }
+ finish(std::move(creation), std::move(balance));
+ });
+ });
+ });
+ };
+
+ if (isPublic)
+ m_impl->logos->logos_execution_zone.create_account_publicAsync(std::move(created));
+ else
+ m_impl->logos->logos_execution_zone.create_account_privateAsync(std::move(created));
+}
+
+WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const
+{
+ if (!m_impl->logos || !isHex(accountId, 64))
+ return WalletAccountRead { accountId };
+ return parsePublicAccount(
+ accountId,
+ m_impl->logos->logos_execution_zone.get_account_public(accountId));
+}
+
+void LogosWalletProvider::readPublicAccountsAsync(
+ const QStringList& accountIds,
+ AccountReadsCallback callback)
+{
+ if (!m_impl->logos || accountIds.isEmpty()) {
+ QTimer::singleShot(0,
+ [callback = std::move(callback)]() mutable { callback({}); });
+ return;
+ }
+
+ 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(
+ const WalletTransaction& transaction)
+{
+ WalletSubmission submission;
+ if (!m_connected || !m_impl->logos) {
+ submission.failure = WalletFailure::WalletUnavailable;
+ return submission;
+ }
+ QVariantList signingRequirements;
+ QVariantList instruction;
+ if (!encodeTransaction(transaction, &signingRequirements, &instruction)) {
+ submission.failure = WalletFailure::InvalidRequest;
+ return submission;
+ }
+
+ const QString response =
+ m_impl->logos->logos_execution_zone.send_generic_public_transaction(
+ transaction.accountIds,
+ signingRequirements,
+ QVariant::fromValue(instruction),
+ transaction.programId);
+ return parseSubmission(response);
+}
+
+void LogosWalletProvider::submitPublicTransactionAsync(
+ const WalletTransaction& transaction, SubmissionCallback callback)
+{
+ QPointer guard(this);
+ WalletSubmission submission;
+ if (!m_connected || !m_impl->logos) {
+ submission.failure = WalletFailure::WalletUnavailable;
+ QTimer::singleShot(0, [guard, callback = std::move(callback),
+ submission = std::move(submission)]() mutable {
+ if (guard)
+ callback(std::move(submission));
+ });
+ return;
+ }
+
+ QVariantList signingRequirements;
+ QVariantList instruction;
+ if (!encodeTransaction(transaction, &signingRequirements, &instruction)) {
+ submission.failure = WalletFailure::InvalidRequest;
+ QTimer::singleShot(0, [guard, callback = std::move(callback),
+ submission = std::move(submission)]() mutable {
+ if (guard)
+ callback(std::move(submission));
+ });
+ return;
+ }
+
+ const quint64 sessionGeneration = m_sessionGeneration;
+ m_impl->logos->logos_execution_zone.send_generic_public_transactionAsync(
+ transaction.accountIds,
+ signingRequirements,
+ QVariant::fromValue(instruction),
+ transaction.programId,
+ [guard, sessionGeneration, callback = std::move(callback)](
+ QString response) mutable {
+ if (!guard)
+ return;
+ if (sessionGeneration != guard->m_sessionGeneration) {
+ WalletSubmission failed;
+ failed.failure = WalletFailure::WalletUnavailable;
+ callback(std::move(failed));
+ return;
+ }
+ callback(parseSubmission(response));
+ });
+}
+
+void LogosWalletProvider::disconnect()
+{
+ ++m_generation;
+ ++m_sessionGeneration;
+ if (m_connected)
+ save();
+ clearSnapshot();
+ m_connected = false;
+}
+
+bool LogosWalletProvider::sharedWalletIsOpen() const
+{
+ if (!m_impl->logos)
+ return false;
+ // A live wallet always has a configured, non-empty sequencer URL.
+ return !m_impl->logos->logos_execution_zone.get_sequencer_addr().isEmpty();
+}
+
+WalletSnapshot LogosWalletProvider::loadSnapshot()
+{
+ WalletSnapshot result;
+ result.currentBlockHeight = static_cast(
+ qMax(0, m_impl->logos->logos_execution_zone.get_current_block_height()));
+ if (result.currentBlockHeight > 0
+ && m_impl->logos->logos_execution_zone.sync_to_block(result.currentBlockHeight)
+ != WALLET_FFI_SUCCESS) {
+ result.failure = WalletFailure::ReadFailed;
+ return result;
+ }
+ result.lastSyncedBlock = static_cast(
+ qMax(0, m_impl->logos->logos_execution_zone.get_last_synced_block()));
+ result.sequencerAddress = m_impl->logos->logos_execution_zone.get_sequencer_addr();
+
+ const QVariantList entries = m_impl->logos->logos_execution_zone.list_accounts();
+ result.accounts.reserve(entries.size());
+ result.publicAccountReads.reserve(entries.size());
+ for (const QVariant& value : entries) {
+ const QVariantMap entry = value.toMap();
+ const QString address = entry.value(QStringLiteral("account_id")).toString();
+ if (entry.isEmpty() || !isHex(address, 64)) {
+ result.failure = WalletFailure::ReadFailed;
+ return result;
+ }
+
+ WalletAccount account;
+ account.address = address;
+ account.isPublic = entry.value(QStringLiteral("is_public"), true).toBool();
+ 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);
+ }
+
+ 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(),
+ };
+ state->publicFlags[index] =
+ state->snapshot.accounts.at(index).isPublic;
+ }
+
+ auto finishOne = std::make_shared>();
+ *finishOne = [this, generation, state]() 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));
+ }
+ 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
+ && m_impl->logos->logos_execution_zone.save() == WALLET_FFI_SUCCESS;
+}
diff --git a/apps/shared/wallet/src/LogosWalletProvider.h b/apps/shared/wallet/src/LogosWalletProvider.h
new file mode 100644
index 00000000..32b0137e
--- /dev/null
+++ b/apps/shared/wallet/src/LogosWalletProvider.h
@@ -0,0 +1,49 @@
+#pragma once
+
+#include
+
+#include
+
+#include "WalletProvider.h"
+
+class LogosAPI;
+struct LogosModules;
+
+class LogosWalletProvider final : public QObject, public WalletProvider {
+public:
+ explicit LogosWalletProvider(LogosAPI* api);
+ explicit LogosWalletProvider(LogosModules* logos);
+ ~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;
+ void createAccountAsync(bool isPublic, AccountCreationCallback callback) override;
+ WalletAccountRead readPublicAccount(const QString& accountId) const override;
+ void readPublicAccountsAsync(const QStringList& accountIds,
+ AccountReadsCallback callback) override;
+ WalletSubmission submitPublicTransaction(
+ const WalletTransaction& transaction) override;
+ void submitPublicTransactionAsync(
+ const WalletTransaction& transaction, SubmissionCallback callback) override;
+ void disconnect() override;
+
+private:
+ bool sharedWalletIsOpen() const;
+ WalletSnapshot loadSnapshot();
+ void loadSnapshotAsync(quint64 generation, SnapshotCallback callback);
+ bool save() const;
+
+ struct Impl;
+ std::unique_ptr m_impl;
+ WalletSnapshot m_snapshot;
+ bool m_snapshotReady = false;
+ bool m_connected = false;
+ quint64 m_generation = 0;
+ quint64 m_sessionGeneration = 0;
+};
diff --git a/apps/shared/wallet/src/WalletAccountId.cpp b/apps/shared/wallet/src/WalletAccountId.cpp
new file mode 100644
index 00000000..4d3d0639
--- /dev/null
+++ b/apps/shared/wallet/src/WalletAccountId.cpp
@@ -0,0 +1,112 @@
+#include "WalletAccountId.h"
+
+#include
+#include
+
+namespace {
+constexpr char BASE58_ALPHABET[] =
+ "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+constexpr qsizetype ACCOUNT_ID_BYTES = 32;
+constexpr qsizetype MIN_BASE58_ACCOUNT_ID_SIZE = 32;
+constexpr qsizetype MAX_BASE58_ACCOUNT_ID_SIZE = 44;
+
+bool isHexCharacter(QChar character)
+{
+ const ushort value = character.unicode();
+ return (value >= '0' && value <= '9')
+ || (value >= 'a' && value <= 'f')
+ || (value >= 'A' && value <= 'F');
+}
+
+int base58Digit(QChar character)
+{
+ const ushort value = character.unicode();
+ if (value > 0x7f)
+ return -1;
+ for (int digit = 0; BASE58_ALPHABET[digit] != '\0'; ++digit) {
+ if (BASE58_ALPHABET[digit] == static_cast(value))
+ return digit;
+ }
+ return -1;
+}
+}
+
+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() != ACCOUNT_ID_BYTES)
+ 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;
+}
+
+QString walletAccountIdFromBase58(const QString& accountId)
+{
+ if (accountId.size() < MIN_BASE58_ACCOUNT_ID_SIZE
+ || accountId.size() > MAX_BASE58_ACCOUNT_ID_SIZE) {
+ return {};
+ }
+
+ qsizetype leadingZeroes = 0;
+ while (leadingZeroes < accountId.size()
+ && accountId.at(leadingZeroes) == QLatin1Char('1')) {
+ ++leadingZeroes;
+ }
+
+ QVector bytes;
+ bytes.reserve(ACCOUNT_ID_BYTES);
+ for (const QChar character : accountId) {
+ int carry = base58Digit(character);
+ if (carry < 0)
+ return {};
+ for (unsigned char& byte : bytes) {
+ carry += static_cast(byte) * 58;
+ byte = static_cast(carry % 256);
+ carry /= 256;
+ }
+ while (carry > 0) {
+ bytes.append(static_cast(carry % 256));
+ carry /= 256;
+ }
+ }
+
+ if (leadingZeroes > ACCOUNT_ID_BYTES
+ || bytes.size() != ACCOUNT_ID_BYTES - leadingZeroes) {
+ return {};
+ }
+
+ QByteArray decoded(ACCOUNT_ID_BYTES, '\0');
+ for (qsizetype index = 0; index < bytes.size(); ++index)
+ decoded[ACCOUNT_ID_BYTES - index - 1] = static_cast(bytes.at(index));
+ return QString::fromLatin1(decoded.toHex());
+}
diff --git a/apps/shared/wallet/src/WalletAccountId.h b/apps/shared/wallet/src/WalletAccountId.h
new file mode 100644
index 00000000..57fd8a76
--- /dev/null
+++ b/apps/shared/wallet/src/WalletAccountId.h
@@ -0,0 +1,6 @@
+#pragma once
+
+#include
+
+QString walletAccountIdToBase58(const QString& accountId);
+QString walletAccountIdFromBase58(const QString& accountId);
diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp
new file mode 100644
index 00000000..f7bdfcad
--- /dev/null
+++ b/apps/shared/wallet/src/WalletAccountModel.cpp
@@ -0,0 +1,294 @@
+#include "WalletAccountModel.h"
+
+#include "WalletAccountId.h"
+
+#include
+
+namespace {
+const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0'));
+}
+
+WalletAccountModel::WalletAccountModel(QObject* parent)
+ : QAbstractListModel(parent)
+{
+}
+
+int WalletAccountModel::rowCount(const QModelIndex& parent) const
+{
+ return parent.isValid() ? 0 : m_accounts.size();
+}
+
+QVariant WalletAccountModel::data(const QModelIndex& index, int role) const
+{
+ if (!index.isValid() || index.row() < 0 || index.row() >= m_accounts.size())
+ return {};
+
+ const Entry& account = m_accounts.at(index.row());
+ switch (role) {
+ case NameRole:
+ 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;
+ case DecodedDataRole:
+ return account.decodedData;
+ default:
+ return {};
+ }
+}
+
+QHash WalletAccountModel::roleNames() const
+{
+ return {
+ { NameRole, "name" },
+ { 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" },
+ { DecodedDataRole, "decodedData" },
+ };
+}
+
+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 (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();
+}
+
+bool WalletAccountModel::applyPresentations(
+ const QVector& presentations)
+{
+ QHash rowsByAddress;
+ rowsByAddress.reserve(m_accounts.size());
+ for (int row = 0; row < m_accounts.size(); ++row) {
+ const QString& address = m_accounts.at(row).address;
+ if (!rowsByAddress.contains(address))
+ rowsByAddress.insert(address, row);
+ }
+
+ int firstChanged = m_accounts.size();
+ int lastChanged = -1;
+ for (const WalletAccountPresentation& presentation : presentations) {
+ const QString decodedAddress = walletAccountIdFromBase58(presentation.address);
+ const QString& address = decodedAddress.isEmpty()
+ ? presentation.address : decodedAddress;
+ const auto row = rowsByAddress.constFind(address);
+ if (row == rowsByAddress.cend())
+ continue;
+ const Entry current = m_accounts.at(row.value());
+ Entry entry = current;
+ if (!presentation.kind.isEmpty())
+ entry.kind = presentation.kind;
+ entry.programName = presentation.programName;
+ entry.accountType = presentation.accountType;
+ entry.definitionId = presentation.definitionId;
+ entry.decodedData = presentation.decodedData;
+ 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);
+ if (entry.alias == current.alias
+ && entry.semanticName == current.semanticName
+ && entry.name == current.name
+ && entry.address == current.address
+ && entry.displayAddress == current.displayAddress
+ && entry.balance == current.balance
+ && entry.isPublic == current.isPublic
+ && entry.kind == current.kind
+ && entry.section == current.section
+ && entry.programOwner == current.programOwner
+ && entry.readStatus == current.readStatus
+ && entry.programName == current.programName
+ && entry.accountType == current.accountType
+ && entry.definitionId == current.definitionId
+ && entry.decodedData == current.decodedData
+ && entry.canBePrimary == current.canBePrimary
+ && entry.isPrimary == current.isPrimary) {
+ continue;
+ }
+ m_accounts[row.value()] = std::move(entry);
+ if (row.value() < firstChanged)
+ firstChanged = row.value();
+ if (row.value() > lastChanged)
+ lastChanged = row.value();
+ }
+ if (lastChanged < 0)
+ return false;
+ emit dataChanged(index(firstChanged), index(lastChanged), {
+ NameRole,
+ KindRole,
+ SectionRole,
+ ProgramNameRole,
+ AccountTypeRole,
+ DecodedDataRole,
+ CanBePrimaryRole,
+ IsPrimaryRole,
+ DefinitionIdRole,
+ });
+ return true;
+}
+
+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
new file mode 100644
index 00000000..7eb3f2c0
--- /dev/null
+++ b/apps/shared/wallet/src/WalletAccountModel.h
@@ -0,0 +1,94 @@
+#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;
+ QString decodedData;
+};
+
+class WalletAccountModel final : public QAbstractListModel {
+ Q_OBJECT
+ Q_PROPERTY(int count READ count NOTIFY countChanged)
+
+public:
+ enum Role {
+ NameRole = Qt::UserRole + 1,
+ AddressRole,
+ BalanceRole,
+ IsPublicRole,
+ KindRole,
+ SectionRole,
+ ProgramOwnerRole,
+ ReadStatusRole,
+ ProgramNameRole,
+ AccountTypeRole,
+ VisibilityRole,
+ ControlRole,
+ CanBePrimaryRole,
+ IsPrimaryRole,
+ DefinitionIdRole,
+ AliasRole,
+ DisplayAddressRole,
+ DecodedDataRole,
+ };
+ Q_ENUM(Role)
+
+ explicit WalletAccountModel(QObject* parent = nullptr);
+
+ int rowCount(const QModelIndex& parent = QModelIndex()) const override;
+ QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
+ QHash roleNames() const override;
+
+ void replaceAccounts(const QVector& accounts,
+ const QHash& aliases = {},
+ const QString& primaryAddress = {});
+ bool 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:
+ void countChanged();
+
+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;
+ QString decodedData;
+ 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
new file mode 100644
index 00000000..9a64970d
--- /dev/null
+++ b/apps/shared/wallet/src/WalletController.cpp
@@ -0,0 +1,553 @@
+#include "WalletController.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "WalletAccountModel.h"
+
+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)
+{
+ if (path.startsWith(QStringLiteral("file://")) || path.contains(QLatin1Char('/')))
+ 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,
+ QString settingsApplication,
+ QObject* parent)
+ : QObject(parent),
+ m_wallet(wallet),
+ m_settingsApplication(std::move(settingsApplication)),
+ m_accountModel(new WalletAccountModel(this)),
+ m_network(new QNetworkAccessManager(this)),
+ 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,
+ this, &WalletController::checkReachability);
+}
+
+WalletController::~WalletController() = default;
+
+QString WalletController::defaultWalletHome()
+{
+ const QByteArray override = qgetenv(WALLET_HOME_ENV);
+ if (!override.isEmpty())
+ return QString::fromLocal8Bit(override);
+ return QDir::homePath() + QStringLiteral("/.lee/wallet");
+}
+
+QString WalletController::defaultConfigPath() const
+{
+ return m_state.walletHome + QStringLiteral("/wallet_config.json");
+}
+
+QString WalletController::defaultStoragePath() const
+{
+ return m_state.walletHome + QStringLiteral("/storage.json");
+}
+
+void WalletController::setDefaultSequencerAddress(const QString& address)
+{
+ const QString normalized = address.trimmed();
+ const QUrl endpoint(normalized);
+ const QString scheme = endpoint.scheme().toLower();
+ if (endpoint.isValid()
+ && !endpoint.host().isEmpty()
+ && (scheme == QStringLiteral("http") || scheme == QStringLiteral("https"))) {
+ m_defaultSequencerAddress = normalized;
+ } else {
+ m_defaultSequencerAddress.clear();
+ }
+}
+
+bool WalletController::seedDefaultWalletConfig(const QString& configPath) const
+{
+ if (m_defaultSequencerAddress.isEmpty() || QFileInfo::exists(configPath))
+ return true;
+
+ const QFileInfo configInfo(configPath);
+ if (!QDir().mkpath(configInfo.absolutePath())) {
+ qWarning() << "WalletController: failed to create wallet configuration directory";
+ return false;
+ }
+
+ QSaveFile config(configPath);
+ if (!config.open(QIODevice::WriteOnly)) {
+ qWarning() << "WalletController: failed to open wallet configuration";
+ return false;
+ }
+
+ const QByteArray contents = QJsonDocument(QJsonObject {
+ { QStringLiteral("sequencer_addr"), m_defaultSequencerAddress },
+ { QStringLiteral("seq_poll_timeout"), QStringLiteral("12s") },
+ { QStringLiteral("seq_tx_poll_max_blocks"), 5 },
+ { QStringLiteral("seq_poll_max_retries"), 5 },
+ { QStringLiteral("seq_block_poll_max_amount"), 100 },
+ }).toJson(QJsonDocument::Compact);
+ if (config.write(contents) != contents.size() || !config.commit()) {
+ qWarning() << "WalletController: failed to save wallet configuration";
+ return false;
+ }
+ return true;
+}
+
+void WalletController::start()
+{
+ if (m_started)
+ return;
+ m_started = true;
+ QTimer::singleShot(0, this, &WalletController::openOnStartup);
+}
+
+void WalletController::openOnStartup()
+{
+ if (QSettings(SETTINGS_ORG, m_settingsApplication)
+ .value(DISCONNECTED_KEY, false).toBool()) {
+ return;
+ }
+
+ const QString config = defaultConfigPath();
+ const QString storage = defaultStoragePath();
+ 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.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();
+ }
+ });
+ const QPointer guard(this);
+ m_wallet.connectAsync({ config, storage },
+ [guard, generation, config, storage](WalletSession session) {
+ if (!guard || generation != guard->m_operationGeneration)
+ return;
+ if (session.failure == WalletFailure::WalletMissing) {
+ guard->m_state.syncStatus = QStringLiteral("closed");
+ guard->m_state.walletExists = false;
+ emit guard->stateChanged();
+ return;
+ }
+ if (!session.ok()) {
+ qWarning() << "WalletController: wallet connection failed"
+ << walletFailureCode(session.failure);
+ guard->m_state.syncStatus = QStringLiteral("error");
+ guard->m_state.syncError = walletFailureCode(session.failure);
+ emit guard->stateChanged();
+ return;
+ }
+
+ guard->m_state.configPath = config;
+ guard->m_state.storagePath = storage;
+ guard->m_state.walletExists = QFileInfo::exists(storage) || session.adopted;
+ guard->m_state.isWalletOpen = true;
+ guard->m_state.syncStatus = QStringLiteral("ready");
+ guard->applySnapshot(session.snapshot);
+ });
+ return true;
+}
+
+QString WalletController::createDefaultWallet(const QString& password)
+{
+ const QString config = defaultConfigPath();
+ if (!seedDefaultWalletConfig(config))
+ return {};
+ return createWallet(config, defaultStoragePath(), password);
+}
+
+QString WalletController::createWallet(const QString& configPath,
+ const QString& storagePath,
+ const QString& password)
+{
+ const quint64 generation = ++m_operationGeneration;
+ const QString config = toLocalPath(configPath);
+ const QString storage = toLocalPath(storagePath);
+ const WalletCreation creation = m_wallet.createWallet(
+ { config, storage }, password);
+ if (creation.mnemonic.isEmpty()) {
+ qWarning() << "WalletController: wallet creation failed"
+ << walletFailureCode(creation.failure);
+ return {};
+ }
+ stopReachability();
+
+ m_state.configPath = config;
+ m_state.storagePath = storage;
+ QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false);
+ if (!creation.ok()) {
+ qWarning() << "WalletController: wallet creation failed"
+ << walletFailureCode(creation.failure);
+ m_state.walletExists = QFileInfo::exists(storage);
+ m_state.isWalletOpen = false;
+ m_state.syncStatus = QStringLiteral("error");
+ m_state.syncError = walletFailureCode(creation.failure);
+ emit stateChanged();
+ return creation.mnemonic;
+ }
+
+ m_state.walletExists = true;
+ m_state.isWalletOpen = true;
+ m_state.syncStatus = QStringLiteral("syncing");
+ m_state.syncError.clear();
+ m_accountModel->replaceAccounts({});
+ emit stateChanged();
+
+ const QPointer guard(this);
+ QTimer::singleShot(0, this, [guard, generation]() {
+ if (!guard || generation != guard->m_operationGeneration)
+ return;
+ guard->m_wallet.snapshotAsync(true,
+ [guard, generation](WalletSnapshot snapshot) {
+ if (!guard || generation != guard->m_operationGeneration)
+ return;
+ if (snapshot.ok()) {
+ guard->m_state.syncStatus = QStringLiteral("ready");
+ guard->applySnapshot(snapshot);
+ return;
+ }
+
+ qWarning() << "WalletController: initial wallet sync failed"
+ << walletFailureCode(snapshot.failure);
+ guard->m_state.syncStatus = QStringLiteral("error");
+ guard->m_state.syncError = walletFailureCode(snapshot.failure);
+ emit guard->stateChanged();
+ });
+ });
+ return creation.mnemonic;
+}
+
+bool WalletController::open()
+{
+ const QString config = m_state.configPath.isEmpty()
+ ? defaultConfigPath() : m_state.configPath;
+ const QString storage = m_state.storagePath.isEmpty()
+ ? defaultStoragePath() : m_state.storagePath;
+ QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false);
+ return beginOpen(config, storage);
+}
+
+void WalletController::disconnect()
+{
+ ++m_operationGeneration;
+ stopReachability();
+ m_wallet.disconnect();
+ m_state.isWalletOpen = false;
+ m_state.syncStatus = QStringLiteral("closed");
+ m_state.syncError.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)
+{
+ if (!m_accountModel->applyPresentations(presentations))
+ return;
+
+ const QString previousPrimary = m_state.primaryAccountAddress;
+ const QString previousPrimaryName = m_state.primaryAccountName;
+ QString primary = m_state.primaryAccountAddress;
+ if (!m_accountModel->canBePrimary(primary))
+ primary = m_accountModel->firstAutomaticPrimary();
+ m_accountModel->setPrimaryAddress(primary);
+ if (primary != previousPrimary)
+ storePrimaryAccount(primary);
+ updatePrimaryState(primary);
+ if (m_state.primaryAccountAddress != previousPrimary
+ || m_state.primaryAccountName != previousPrimaryName) {
+ emit stateChanged();
+ }
+}
+
+QString WalletController::createAccount(bool isPublic)
+{
+ const WalletAccountCreation creation = m_wallet.createAccount(isPublic);
+ if (!creation.ok()) {
+ qWarning() << "WalletController: account creation failed"
+ << walletFailureCode(creation.failure);
+ 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()
+{
+ 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();
+ const QPointer guard(this);
+ m_wallet.snapshotAsync(true, [guard, generation](WalletSnapshot next) {
+ if (!guard || generation != guard->m_operationGeneration)
+ return;
+ if (next.ok()) {
+ guard->m_state.syncStatus = QStringLiteral("ready");
+ guard->applySnapshot(next);
+ } else {
+ qWarning() << "WalletController: wallet refresh failed"
+ << walletFailureCode(next.failure);
+ guard->m_state.syncStatus = QStringLiteral("error");
+ guard->m_state.syncError = walletFailureCode(next.failure);
+ emit guard->stateChanged();
+ }
+ });
+}
+
+QString WalletController::balance(const QString& accountId, bool isPublic)
+{
+ const WalletSnapshot current = m_wallet.snapshot();
+ for (const WalletAccount& account : current.accounts) {
+ if (account.address == accountId && account.isPublic == isPublic)
+ return account.balance;
+ }
+ return {};
+}
+
+void WalletController::applySnapshot(const WalletSnapshot& snapshot)
+{
+ 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);
+ if (!snapshot.sequencerAddress.isEmpty())
+ m_state.sequencerAddress = snapshot.sequencerAddress;
+ emit snapshotChanged();
+ emit stateChanged();
+ if (!m_reachabilityTimer->isActive())
+ m_reachabilityTimer->start();
+ 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::stopReachability()
+{
+ m_reachabilityTimer->stop();
+ ++m_reachabilityGeneration;
+ if (m_reachabilityReply) {
+ QNetworkReply* reply = m_reachabilityReply;
+ m_reachabilityReply = nullptr;
+ m_reachabilityEndpoint.clear();
+ reply->abort();
+ }
+}
+
+void WalletController::checkReachability()
+{
+ if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty())
+ return;
+
+ const QString endpoint = m_state.sequencerAddress;
+ if (m_reachabilityReply && endpoint == m_reachabilityEndpoint)
+ return;
+
+ const quint64 generation = ++m_reachabilityGeneration;
+ if (m_reachabilityReply)
+ m_reachabilityReply->abort();
+ QNetworkRequest request{QUrl(endpoint)};
+ request.setTransferTimeout(4000);
+ QNetworkReply* reply = m_network->get(request);
+ m_reachabilityReply = reply;
+ m_reachabilityEndpoint = endpoint;
+ connect(reply, &QNetworkReply::finished, this,
+ [this, reply, generation, endpoint]() {
+ if (m_reachabilityReply == reply) {
+ m_reachabilityReply = nullptr;
+ m_reachabilityEndpoint.clear();
+ }
+ if (!m_state.isWalletOpen
+ || generation != m_reachabilityGeneration
+ || endpoint != m_state.sequencerAddress) {
+ reply->deleteLater();
+ return;
+ }
+ const bool receivedHttp =
+ reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid();
+ const bool reachable = receivedHttp || reply->error() == QNetworkReply::NoError;
+ if (m_state.sequencerReachable != reachable) {
+ m_state.sequencerReachable = reachable;
+ emit stateChanged();
+ }
+ reply->deleteLater();
+ });
+}
diff --git a/apps/shared/wallet/src/WalletController.h b/apps/shared/wallet/src/WalletController.h
new file mode 100644
index 00000000..e30f733e
--- /dev/null
+++ b/apps/shared/wallet/src/WalletController.h
@@ -0,0 +1,103 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include "WalletProvider.h"
+
+class QNetworkAccessManager;
+class QNetworkReply;
+class QTimer;
+class WalletAccountModel;
+struct WalletAccountPresentation;
+
+struct WalletUiState {
+ bool isWalletOpen = false;
+ bool walletExists = false;
+ QString configPath;
+ QString storagePath;
+ QString walletHome;
+ int lastSyncedBlock = 0;
+ 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 {
+ Q_OBJECT
+
+public:
+ // The provider must outlive the controller.
+ explicit WalletController(WalletProvider& wallet,
+ QString settingsApplication,
+ QObject* parent = nullptr);
+ ~WalletController() override;
+
+ WalletAccountModel* accountModel() const { return m_accountModel; }
+ const WalletUiState& state() const { return m_state; }
+ const WalletSnapshot& snapshot() const { return m_snapshot; }
+
+ void start();
+ void setDefaultSequencerAddress(const QString& address);
+ QString createAccount(bool isPublic);
+ void refresh();
+ QString balance(const QString& accountId, bool isPublic);
+ QString createDefaultWallet(const QString& password);
+ QString createWallet(const QString& configPath,
+ const QString& storagePath,
+ 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();
+ QString defaultConfigPath() const;
+ QString defaultStoragePath() const;
+ bool seedDefaultWalletConfig(const QString& configPath) const;
+
+ void openOnStartup();
+ bool beginOpen(const QString& config, const QString& storage);
+ void applySnapshot(const WalletSnapshot& snapshot);
+ void checkReachability();
+ void stopReachability();
+ 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;
+ QNetworkReply* m_reachabilityReply = nullptr;
+ QString m_reachabilityEndpoint;
+ QString m_defaultSequencerAddress;
+ QTimer* m_reachabilityTimer;
+ bool m_started = false;
+ quint64 m_operationGeneration = 0;
+ quint64 m_reachabilityGeneration = 0;
+};
diff --git a/apps/shared/wallet/src/WalletProvider.cpp b/apps/shared/wallet/src/WalletProvider.cpp
new file mode 100644
index 00000000..9cb259d4
--- /dev/null
+++ b/apps/shared/wallet/src/WalletProvider.cpp
@@ -0,0 +1,26 @@
+#include "WalletProvider.h"
+
+QString walletFailureCode(WalletFailure failure)
+{
+ switch (failure) {
+ case WalletFailure::None:
+ return {};
+ case WalletFailure::WalletMissing:
+ return QStringLiteral("wallet_missing");
+ case WalletFailure::WalletUnavailable:
+ return QStringLiteral("wallet_unavailable");
+ case WalletFailure::OpenFailed:
+ return QStringLiteral("open_failed");
+ case WalletFailure::CreateFailed:
+ return QStringLiteral("create_failed");
+ case WalletFailure::SaveFailed:
+ return QStringLiteral("save_failed");
+ case WalletFailure::ReadFailed:
+ return QStringLiteral("read_failed");
+ case WalletFailure::InvalidRequest:
+ return QStringLiteral("invalid_request");
+ case WalletFailure::SubmissionFailed:
+ return QStringLiteral("submission_failed");
+ }
+ return QStringLiteral("wallet_unavailable");
+}
diff --git a/apps/shared/wallet/src/WalletProvider.h b/apps/shared/wallet/src/WalletProvider.h
new file mode 100644
index 00000000..434b5635
--- /dev/null
+++ b/apps/shared/wallet/src/WalletProvider.h
@@ -0,0 +1,124 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+enum class WalletFailure {
+ None,
+ WalletMissing,
+ WalletUnavailable,
+ OpenFailed,
+ CreateFailed,
+ SaveFailed,
+ ReadFailed,
+ InvalidRequest,
+ SubmissionFailed,
+};
+
+QString walletFailureCode(WalletFailure failure);
+
+struct WalletPaths {
+ QString config;
+ QString storage;
+};
+
+struct WalletAccountRead {
+ QString accountId;
+ QString status = QStringLiteral("read_failed");
+ QString programOwner;
+ QString balanceHex;
+ QString nonceHex;
+ QString dataHex;
+
+ bool ok() const { return status == QStringLiteral("ok"); }
+};
+
+struct WalletAccount {
+ QString address;
+ QString balance;
+ bool isPublic = true;
+ QString readStatus;
+ QString programOwner;
+ QString dataHex;
+};
+
+struct WalletSnapshot {
+ WalletFailure failure = WalletFailure::None;
+ QVector accounts;
+ QVector publicAccountReads;
+ quint64 lastSyncedBlock = 0;
+ quint64 currentBlockHeight = 0;
+ QString sequencerAddress;
+
+ bool ok() const { return failure == WalletFailure::None; }
+};
+
+struct WalletSession {
+ WalletFailure failure = WalletFailure::None;
+ WalletSnapshot snapshot;
+ bool adopted = false;
+
+ bool ok() const { return failure == WalletFailure::None; }
+};
+
+struct WalletCreation {
+ WalletFailure failure = WalletFailure::None;
+ QString mnemonic;
+ WalletSnapshot snapshot;
+
+ bool ok() const { return failure == WalletFailure::None; }
+};
+
+struct WalletAccountCreation {
+ WalletFailure failure = WalletFailure::None;
+ QString accountId;
+ WalletAccountRead publicAccount;
+ WalletSnapshot snapshot;
+
+ bool ok() const { return failure == WalletFailure::None; }
+};
+
+struct WalletTransaction {
+ QString programId;
+ QStringList accountIds;
+ QVector signingRequirements;
+ QVector instruction;
+};
+
+struct WalletSubmission {
+ WalletFailure failure = WalletFailure::None;
+ QString nativeHash;
+
+ bool accepted() const { return failure == WalletFailure::None && !nativeHash.isEmpty(); }
+};
+
+class WalletProvider {
+public:
+ using SessionCallback = std::function;
+ using SnapshotCallback = std::function;
+ using AccountReadsCallback = std::function)>;
+ using AccountCreationCallback = std::function;
+ using SubmissionCallback = 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 void createAccountAsync(bool isPublic, AccountCreationCallback callback) = 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 submitPublicTransactionAsync(
+ const WalletTransaction& transaction, SubmissionCallback callback) = 0;
+ virtual void disconnect() = 0;
+};
diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp
new file mode 100644
index 00000000..9fdc6aa6
--- /dev/null
+++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp
@@ -0,0 +1,1107 @@
+#include
+#include
+#include
+#include
+#include