feat(wallet): humanize shared wallet experience

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