feat(wallet): humanize shared wallet experience

Consolidate wallet account decoding and portfolio handling around the shared Rust IDL decoder. Simplify AMM wallet integration, remove obsolete caches and network plumbing, and cover account selection and live flows.
This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-21 17:38:45 -03:00
parent 62d133e909
commit 8c3e6fccfe
64 changed files with 6268 additions and 27501 deletions
Generated
+10
View File
@@ -4532,6 +4532,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "wallet-idl-decoder"
version = "0.1.0"
dependencies = [
"hex",
"serde",
"serde_json",
"spel-framework-core",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
+1
View File
@@ -20,6 +20,7 @@ members = [
"programs/integration_tests", "programs/integration_tests",
"tools/idl-gen", "tools/idl-gen",
"tools/risc0-packager", "tools/risc0-packager",
"tools/wallet-idl-decoder",
] ]
exclude = [ exclude = [
"programs/token/methods/guest", "programs/token/methods/guest",
+52
View File
@@ -20,6 +20,28 @@ set(LOGOS_WALLET_GENERATED_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/generated_code" "${CMAKE_CURRENT_SOURCE_DIR}/generated_code"
CACHE PATH "Path to generated Logos SDK sources" CACHE PATH "Path to generated Logos SDK sources"
) )
# LogosModule.cmake stages external libraries after this shared target is
# configured. Resolve the decoder from the staged dependency before adding the
# shared wallet module so its static target can link successfully.
if(NOT LOGOS_WALLET_IDL_DECODER_LIBRARY)
set(amm_wallet_decoder_search_path "${CMAKE_CURRENT_SOURCE_DIR}/lib")
if(DEFINED ENV{LOGOS_EXT_ROOT_WALLET_IDL_DECODER})
set(amm_wallet_decoder_search_path
"$ENV{LOGOS_EXT_ROOT_WALLET_IDL_DECODER}/lib")
endif()
find_library(AMM_WALLET_IDL_DECODER_LIBRARY
NAMES wallet_idl_decoder
PATHS "${amm_wallet_decoder_search_path}"
NO_DEFAULT_PATH
)
if(AMM_WALLET_IDL_DECODER_LIBRARY)
set(LOGOS_WALLET_IDL_DECODER_LIBRARY
"${AMM_WALLET_IDL_DECODER_LIBRARY}"
CACHE FILEPATH
"wallet_idl_decoder library required by logos_wallet_access"
)
endif()
endif()
add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/shared-wallet") add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/shared-wallet")
# ui_qml module with a hand-written C++ backend (QtRO .rep view contract + # ui_qml module with a hand-written C++ backend (QtRO .rep view contract +
@@ -42,4 +64,34 @@ logos_module(
Qt6::Gui Qt6::Gui
LINK_TARGETS LINK_TARGETS
logos_wallet_access logos_wallet_access
EXTERNAL_LIBS
wallet_idl_decoder
)
set(LEZ_IDL_ARTIFACTS_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../../artifacts"
CACHE PATH "Path to committed LEZ program IDL artifacts"
)
set(AMM_TOKEN_IDL "${LEZ_IDL_ARTIFACTS_DIR}/token-idl.json")
set(AMM_IDL "${LEZ_IDL_ARTIFACTS_DIR}/amm-idl.json")
foreach(idl_file IN ITEMS "${AMM_TOKEN_IDL}" "${AMM_IDL}")
if(NOT EXISTS "${idl_file}")
message(FATAL_ERROR "Committed IDL artifact not found: ${idl_file}")
endif()
endforeach()
set_source_files_properties(
"${AMM_TOKEN_IDL}"
PROPERTIES QT_RESOURCE_ALIAS "idl/token-idl.json"
)
set_source_files_properties(
"${AMM_IDL}"
PROPERTIES QT_RESOURCE_ALIAS "idl/amm-idl.json"
)
qt_add_resources(amm_ui_module_plugin amm_ui_wallet_data
PREFIX "/amm"
FILES
"${AMM_TOKEN_IDL}"
"${AMM_IDL}"
) )
+25 -8
View File
@@ -152,6 +152,23 @@ nix run .#amm-ui
Without `AMM_PROGRAM_BIN` the Swap and Liquidity views stay disabled; without Without `AMM_PROGRAM_BIN` the Swap and Liquidity views stay disabled; without
`TOKENS_CONFIG` the token picker is empty. Each is detailed below. `TOKENS_CONFIG` the token picker is empty. Each is detailed below.
### Network identity
The shared wallet verifies the sequencer before it enables network-dependent
portfolio data or AMM quotes. Testnet uses the bundled checkpoint identity. For
devnet, provide the channel identity emitted by the local sequencer:
```bash
LOGOS_WALLET_NETWORK=devnet \
LOGOS_WALLET_DEVNET_FILE=/abs/path/to/devnet.json \
nix run .#amm-ui
```
`devnet.json` must contain a 64-character lowercase-hex `channelId`. The
legacy `AMM_UI_NETWORK` and `AMM_UI_DEVNET_FILE` names remain accepted. AMM
deployment and token selection stay app-specific through `AMM_PROGRAM_BIN` and
`TOKENS_CONFIG`.
### AMM program binary (required for swaps and liquidity) ### AMM program binary (required for swaps and liquidity)
To execute a swap, the app must submit a transaction against the **exact AMM To execute a swap, the app must submit a transaction against the **exact AMM
@@ -291,7 +308,7 @@ New Position validation commands and acceptance criteria live in
## Running the UI tests ## Running the UI tests
The UI tests live in `apps/amm/tests/` (e.g. `swap.mjs`). They drive the running The live-chain UI tests live in `apps/amm/tests/e2e/`. They drive the running
app through a QML inspector: each test connects to the inspector's TCP server, app through a QML inspector: each test connects to the inspector's TCP server,
finds elements, clicks them, and asserts on the resulting state. `swap.mjs` finds elements, clicks them, and asserts on the resulting state. `swap.mjs`
selects two tokens, enters a sell amount, submits a swap end-to-end, and then selects two tokens, enters a sell amount, submits a swap end-to-end, and then
@@ -309,8 +326,7 @@ import from `test-framework/framework.mjs` — comes from the
It isn't vendored here; the `nix build .#test-framework` step below materializes It isn't vendored here; the `nix build .#test-framework` step below materializes
it (Nix resolves it via this app's flake inputs, pinned in `flake.lock`). it (Nix resolves it via this app's flake inputs, pinned in `flake.lock`).
Run everything **from the repository root** (the `apps/amm` flake can't resolve Run everything **from the repository root**.
`amm_client_ffi` on its own).
**Prerequisites** for the swap test to complete: **Prerequisites** for the swap test to complete:
@@ -324,23 +340,23 @@ Run everything **from the repository root** (the `apps/amm` flake can't resolve
```bash ```bash
# 1. Build the JS test framework once. The -o path is where the tests expect it # 1. Build the JS test framework once. The -o path is where the tests expect it
# (apps/amm/tests/swap.mjs imports ../result-mcp); or set LOGOS_QT_MCP instead. # (apps/amm/tests/e2e/swap.mjs imports ../../result-mcp); or set LOGOS_QT_MCP.
nix build .#test-framework -o apps/amm/result-mcp nix build .#test-framework -o apps/amm/result-mcp
# 2. Terminal 1 — launch the AMM UI with a real, visible window. The inspector # 2. Terminal 1 — launch the AMM UI with a real, visible window. The inspector
# listens on localhost:3768. Absolute paths ($(pwd)/…) because nix run may # listens on localhost:3768. Absolute paths ($(pwd)/…) because nix run may
# not preserve the working directory. # not preserve the working directory.
AMM_DEBUG=1 \ AMM_DEBUG=1 \
AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \ AMM_PROGRAM_BIN=$(pwd)/target/guest/amm.bin \
TOKENS_CONFIG=$(pwd)/apps/amm/amm-tokens.json \ TOKENS_CONFIG=$(pwd)/apps/amm/amm-tokens.json \
nix run .#amm-ui nix run .#amm-ui
# 3. Terminal 2 — run a test against the running app; watch it drive the UI. # 3. Terminal 2 — run a test against the running app; watch it drive the UI.
node apps/amm/tests/swap.mjs node apps/amm/tests/e2e/swap.mjs
``` ```
On failure the test prints the relevant `SwapCard` state and saves screenshot On failure the test prints the relevant `SwapCard` state and saves screenshot
PNGs next to the test (`apps/amm/tests/swap-*.png`, git-ignored) for inspection. PNGs next to the test (`apps/amm/tests/e2e/swap-*.png`, git-ignored) for inspection.
**Headless CI variant** (no window, launches the app itself, pass/fail only): **Headless CI variant** (no window, launches the app itself, pass/fail only):
@@ -348,7 +364,8 @@ PNGs next to the test (`apps/amm/tests/swap-*.png`, git-ignored) for inspection.
nix build .#integration-test -L nix build .#integration-test -L
``` ```
It runs every `*.mjs` under `apps/amm/tests/` with `QT_QPA_PLATFORM=offscreen`. It runs the hermetic UI smoke test with `QT_QPA_PLATFORM=offscreen`. The
live-chain tests remain in `apps/amm/tests/e2e/` and use the isolated setup above.
## Updating Dependencies ## Updating Dependencies
+1 -1
View File
@@ -11,7 +11,7 @@ Run from the repository root:
cargo +1.94.0 test -p amm_ffi cargo +1.94.0 test -p amm_ffi
cargo +1.94.0 clippy -p amm_ffi --all-targets -- -D warnings cargo +1.94.0 clippy -p amm_ffi --all-targets -- -D warnings
logos_qml=$(nix build github:logos-co/logos-design-system/6176f0d7a5dfeb64a7f0f98e7ca2bf71a4804772 --no-link --print-out-paths) logos_qml=$(nix build github:logos-co/logos-design-system/6176f0d7a5dfeb64a7f0f98e7ca2bf71a4804772 --no-link --print-out-paths)
amm_qml=$(nix build ./apps/amm#packages.x86_64-linux.default --no-link --print-out-paths) amm_qml=$(nix build .#amm-ui --no-link --print-out-paths)
qt_qml=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtdeclarative-[0-9]') qt_qml=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtdeclarative-[0-9]')
qt_svg=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtsvg-[0-9]') qt_svg=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtsvg-[0-9]')
export QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_PLUGIN_PATH="$qt_svg/lib/qt-6/plugins" export QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_PLUGIN_PATH="$qt_svg/lib/qt-6/plugins"
-26869
View File
File diff suppressed because it is too large Load Diff
-63
View File
@@ -1,63 +0,0 @@
{
description = "Logos AMM QML UI trade and provide liquidity on the LEZ AMM";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
# Shared C++ wallet access and Logos.Wallet QML sources.
shared_wallet = {
url = "path:../shared/wallet";
flake = false;
};
# Core wallet module (the LEZ wallet FFI Qt plugin). The input name must
# match the metadata.json `dependencies` entry so the builder can resolve
# it as a module dependency. This revision exposes generic transaction
# submission by deployed program ID.
logos_execution_zone = {
url = "github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80";
# The module pins the monorepo at v0.2.0-rc6 (e37876a), which owns the
# xcrun wrapper in its flake.nix. Override that transitive input to the
# head of PR #629 (fix(macos): nuke xcrun cache) so the Metal/xcrun build
# works on macOS. Note: #629 branches off `dev`, so this also pulls dev's
# drift from rc6 — if the wallet_ffi ABI mismatches the module build, fall
# back to cherry-picking #629's one line onto e37876a and pin that commit.
inputs.logos-execution-zone.url =
"github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05";
};
};
# NOTE: this flake is no longer built standalone; the repo-root flake.nix
# builds the UI directly (src = ./apps/amm). The UI links no external lib of
# its own — the AMM logic lives in the amm_ffi crate, which the amm_module
# core module links; the UI reaches it via modules().amm_module (declared in
# metadata.json `dependencies`). The repo-root flake exposes the UI as a named
# attribute (there is no bare `default`): run it with `nix run .#amm-ui`, and
# build the AMM logic crate with `nix build .#amm_ffi`.
outputs = inputs@{ logos-module-builder, shared_wallet, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
preConfigure = ''
cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}")
'';
externalLibInputs = { };
postInstall = ''
# The builder installs the view under lib/qml after this hook. Its
# import descriptor points back to this compiled shared QML module.
test -f ${./qml}/Logos/Wallet/qmldir
walletQmlDir="shared-wallet/qml/Logos/Wallet"
if [ ! -d "$walletQmlDir" ]; then
echo "Built Logos.Wallet QML module not found"
exit 1
fi
walletQmlInstallDir="$out/lib/Logos/Wallet"
mkdir -p "$walletQmlInstallDir"
cp -r "$walletQmlDir/." "$walletQmlInstallDir/"
test -f "$walletQmlInstallDir/qmldir"
'';
};
}
+3 -1
View File
@@ -14,7 +14,9 @@
"build": ["pkg-config"], "build": ["pkg-config"],
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"] "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"]
}, },
"external_libraries": [], "external_libraries": [
{ "name": "wallet_idl_decoder" }
],
"cmake": { "cmake": {
"find_packages": [], "find_packages": [],
"extra_sources": [], "extra_sources": [],
+198 -14
View File
@@ -1,5 +1,7 @@
#include "AmmUiBackend.h" #include "AmmUiBackend.h"
#include <utility>
#include <QByteArray> #include <QByteArray>
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
@@ -12,13 +14,34 @@
#include <QJsonValue> #include <QJsonValue>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTimer> #include <QTimer>
#include <QUrl>
#include "LogosWalletProvider.h" #include "LogosWalletProvider.h"
#include "SequencerIdentityProbe.h"
#include "SequencerNetworkSettings.h"
#include "WalletController.h" #include "WalletController.h"
#include "WalletPortfolioService.h"
#include "logos_api.h" #include "logos_api.h"
#include "logos_sdk.h" #include "logos_sdk.h"
namespace { namespace {
constexpr char WALLET_NETWORK_ENV[] = "LOGOS_WALLET_NETWORK";
constexpr char LEGACY_NETWORK_ENV[] = "AMM_UI_NETWORK";
constexpr char WALLET_DEVNET_FILE_ENV[] = "LOGOS_WALLET_DEVNET_FILE";
constexpr char LEGACY_DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE";
QByteArray resource(const QString& path)
{
QFile file(path);
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
}
QString environmentValue(const char* primary, const char* fallback)
{
const QByteArray value = qgetenv(primary);
return QString::fromLocal8Bit(value.isEmpty() ? qgetenv(fallback) : value).trimmed();
}
// Absolute path to the JSON known-pools config consumed by poolList(). // Absolute path to the JSON known-pools config consumed by poolList().
// Mirrors TOKENS_CONFIG for the token list; produced by the AMM testnet // Mirrors TOKENS_CONFIG for the token list; produced by the AMM testnet
// setup script (apps/amm/tests/testnet/setup-amm-testnet.sh). // setup script (apps/amm/tests/testnet/setup-amm-testnet.sh).
@@ -127,15 +150,32 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
m_logos(std::make_unique<LogosModules>(m_logosAPI)), m_logos(std::make_unique<LogosModules>(m_logosAPI)),
m_wallet(std::make_unique<LogosWalletProvider>(m_logosAPI)), m_wallet(std::make_unique<LogosWalletProvider>(m_logosAPI)),
m_walletController(std::make_unique<WalletController>( m_walletController(std::make_unique<WalletController>(
*m_wallet, QStringLiteral("AmmUI"))) *m_wallet, QStringLiteral("AmmUI"))),
m_portfolio(std::make_unique<WalletPortfolioService>()),
m_networkProbe(std::make_unique<SequencerIdentityProbe>(this)),
m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))),
m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json")))
{ {
setWalletStateReady(false); setWalletStateReady(false);
setAssets({});
setAssetStatus(QStringLiteral("idle"));
setAssetError({});
connect(m_networkProbe.get(), &SequencerIdentityProbe::snapshotChanged,
this, [this]() {
publishNetworkState();
refreshPortfolio();
});
configureNetworkIdentity();
connect(m_walletController.get(), &WalletController::stateChanged, connect(m_walletController.get(), &WalletController::stateChanged,
this, &AmmUiBackend::syncWalletState); this, &AmmUiBackend::syncWalletState);
// Publishes an initial "loading" context (walletStateReady is still false, connect(m_walletController.get(), &WalletController::snapshotChanged,
// so it does not yet reach the module). this, [this]() {
refreshPortfolio();
});
syncWalletState(); syncWalletState();
publishNetworkState();
m_walletController->start(); m_walletController->start();
QTimer::singleShot(0, this, [this]() { QTimer::singleShot(0, this, [this]() {
setWalletStateReady(true); setWalletStateReady(true);
@@ -184,6 +224,16 @@ void AmmUiBackend::disconnectWallet()
setWalletStateReady(true); setWalletStateReady(true);
} }
bool AmmUiBackend::setAccountAlias(QString accountId, QString alias)
{
return m_walletController->setAccountAlias(accountId, alias);
}
bool AmmUiBackend::setPrimaryAccount(QString accountId)
{
return m_walletController->setPrimaryAccount(accountId);
}
QString AmmUiBackend::createAccountPublic() QString AmmUiBackend::createAccountPublic()
{ {
return m_walletController->createAccount(true); return m_walletController->createAccount(true);
@@ -212,8 +262,19 @@ QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic)
void AmmUiBackend::syncWalletState() void AmmUiBackend::syncWalletState()
{ {
const WalletUiState& state = m_walletController->state(); const WalletUiState& state = m_walletController->state();
const bool walletWasOpen = isWalletOpen();
const bool walletWasReady = walletStateReady();
const QString previousSyncStatus = walletSyncStatus();
const QString previousAddress = sequencerAddr();
const bool wasReachable = sequencerReachable();
const bool nextReady = state.syncStatus != QStringLiteral("opening")
&& state.syncStatus != QStringLiteral("syncing");
setIsWalletOpen(state.isWalletOpen); setIsWalletOpen(state.isWalletOpen);
setWalletStateReady(nextReady);
setWalletSyncStatus(state.syncStatus);
setWalletSyncError(state.syncError);
setWalletCanSubmit(state.canSubmit());
setWalletExists(state.walletExists); setWalletExists(state.walletExists);
setConfigPath(state.configPath); setConfigPath(state.configPath);
setStoragePath(state.storagePath); setStoragePath(state.storagePath);
@@ -222,6 +283,23 @@ void AmmUiBackend::syncWalletState()
setCurrentBlockHeight(state.currentBlockHeight); setCurrentBlockHeight(state.currentBlockHeight);
setSequencerAddr(state.sequencerAddress); setSequencerAddr(state.sequencerAddress);
setSequencerReachable(state.sequencerReachable); setSequencerReachable(state.sequencerReachable);
setPrimaryAccountAddress(state.primaryAccountAddress);
setPrimaryAccountName(state.primaryAccountName);
m_networkProbe->setEndpoint(QUrl(state.sequencerAddress));
m_networkProbe->setSequencerAvailable(!state.sequencerAddress.isEmpty());
m_networkProbe->setReachable(state.sequencerReachable);
m_networkProbe->start();
const bool lifecycleChanged = walletWasOpen != state.isWalletOpen
|| walletWasReady != nextReady
|| previousSyncStatus != state.syncStatus
|| previousAddress != state.sequencerAddress
|| wasReachable != state.sequencerReachable;
if (lifecycleChanged) {
publishNetworkState();
refreshPortfolio();
}
} }
QVariantMap AmmUiBackend::resolvePoolAccount(QString defAHex, QString defBHex) QVariantMap AmmUiBackend::resolvePoolAccount(QString defAHex, QString defBHex)
@@ -229,6 +307,107 @@ QVariantMap AmmUiBackend::resolvePoolAccount(QString defAHex, QString defBHex)
return m_logos->amm_module.resolvePoolAccount(defAHex, defBHex); return m_logos->amm_module.resolvePoolAccount(defAHex, defBHex);
} }
void AmmUiBackend::configureNetworkIdentity()
{
const QString networkId = environmentValue(WALLET_NETWORK_ENV, LEGACY_NETWORK_ENV);
const QString devnetConfig = environmentValue(
WALLET_DEVNET_FILE_ENV, LEGACY_DEVNET_FILE_ENV);
const auto settings = SequencerNetworkSettingsLoader::load(networkId, devnetConfig);
if (!settings) {
qWarning() << "AmmUiBackend: shared network identity configuration is unavailable";
m_networkProbe->clearConfiguration();
return;
}
SequencerIdentityProbe::Request request;
if (settings->identityMethod == SequencerIdentityMethod::ChannelId) {
request.method = QStringLiteral("getChannelId");
request.identityFromResult = SequencerIdentityProbe::stringIdentity;
} else {
request.method = QStringLiteral("getBlock");
request.params = QJsonArray { 10 };
request.identityFromResult = SequencerIdentityProbe::checkpointBlockHash;
}
m_networkProbe->configure(settings->context, std::move(request));
}
bool AmmUiBackend::resolveProgramIds()
{
if (m_networkResolved)
return true;
const QVariantMap config = m_logos->amm_module.configAccount();
if (config.value(QStringLiteral("status")).toString() == QStringLiteral("ok")) {
m_ammProgramIdCache = m_logos->logos_execution_zone.account_id_from_base58(
config.value(QStringLiteral("ammProgramId")).toString());
m_tokenProgramIdCache = m_logos->logos_execution_zone.account_id_from_base58(
config.value(QStringLiteral("tokenProgramId")).toString());
}
m_networkResolved = !m_ammProgramIdCache.isEmpty()
&& !m_tokenProgramIdCache.isEmpty();
return m_networkResolved;
}
void AmmUiBackend::publishNetworkState()
{
const SequencerNetworkSnapshot& network = m_networkProbe->snapshot();
setActiveNetwork(network.id);
setNetworkStatus(network.status);
setNetworkFingerprint(network.fingerprint);
}
void AmmUiBackend::refreshPortfolio()
{
if (!m_portfolio)
return;
if (!m_walletController->state().isWalletOpen) {
setAssets({});
setAssetStatus(QStringLiteral("idle"));
setAssetError({});
return;
}
QString networkStatus = m_networkProbe->snapshot().status;
const QStringList tokenIds = knownTokenIds();
if (!walletStateReady())
networkStatus = QStringLiteral("loading");
else if (!resolveProgramIds() || tokenIds.isEmpty()
|| networkStatus == QStringLiteral("config_missing"))
networkStatus = QStringLiteral("config_missing");
if (networkStatus != QStringLiteral("ready")) {
setAssets({});
setAssetStatus(QStringLiteral("blocked"));
setAssetError(networkStatus);
return;
}
if (m_tokenIdl.isEmpty()) {
setAssetStatus(QStringLiteral("error"));
setAssetError(QStringLiteral("token_idl_missing"));
return;
}
if (!m_ammProgramIdCache.isEmpty() && !m_ammIdl.isEmpty()) {
m_portfolio->registerProgram(
m_ammProgramIdCache, QStringLiteral("AMM"), m_ammIdl);
}
WalletPortfolioRequest request(m_walletController->snapshot());
request.tokenDefinitionIds = tokenIds;
request.tokens = resolveTokens();
request.tokenProgramId = m_tokenProgramIdCache;
request.tokenIdl = m_tokenIdl;
setAssetStatus(QStringLiteral("loading"));
setAssetError({});
applyPortfolio(m_portfolio->refresh(request));
}
void AmmUiBackend::applyPortfolio(WalletPortfolioResult result)
{
m_walletController->applyAccountPresentations(result.presentations);
setAssets(std::move(result.assets));
setAssetStatus(result.status);
setAssetError(result.error);
}
QVariantMap AmmUiBackend::configAccount() QVariantMap AmmUiBackend::configAccount()
{ {
return m_logos->amm_module.configAccount(); return m_logos->amm_module.configAccount();
@@ -387,19 +566,9 @@ QVariantList AmmUiBackend::resolveTokens()
// holdingId/balance for whichever of these ids the wallet does hold. // holdingId/balance for whichever of these ids the wallet does hold.
const bool wallet_open = isWalletOpen(); const bool wallet_open = isWalletOpen();
QVariantList ids;
const QVariantList configured = readTokensConfig(); const QVariantList configured = readTokensConfig();
for (const QVariant& entry : configured) {
const QString id = entry.toMap().value(QStringLiteral("definitionId")).toString();
if (!id.isEmpty())
ids.append(id);
}
const QStringList custom = loadCustomTokenIds();
for (const QString& id : custom)
ids.append(id);
QVariantMap request; QVariantMap request;
request.insert(QStringLiteral("tokenIds"), ids); request.insert(QStringLiteral("tokenIds"), knownTokenIds());
QVariantList rows = m_logos->amm_module.resolveTokens(request, wallet_open); QVariantList rows = m_logos->amm_module.resolveTokens(request, wallet_open);
// The module resolves on-chain fields (definitionId/name/holding/balance) but // The module resolves on-chain fields (definitionId/name/holding/balance) but
@@ -501,6 +670,21 @@ QStringList AmmUiBackend::loadCustomTokenIds() const
return ids; return ids;
} }
QStringList AmmUiBackend::knownTokenIds() const
{
QStringList ids;
for (const QVariant& entry : readTokensConfig()) {
const QString id = entry.toMap().value(QStringLiteral("definitionId")).toString();
if (!id.isEmpty() && !ids.contains(id))
ids.append(id);
}
for (const QString& id : loadCustomTokenIds()) {
if (!ids.contains(id))
ids.append(id);
}
return ids;
}
bool AmmUiBackend::saveCustomTokenIds(const QStringList& ids) const bool AmmUiBackend::saveCustomTokenIds(const QStringList& ids) const
{ {
const QString path = customTokenStorePath(); const QString path = customTokenStorePath();
+20
View File
@@ -3,6 +3,7 @@
#include <memory> #include <memory>
#include <QByteArray>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
@@ -17,7 +18,10 @@
class LogosAPI; class LogosAPI;
struct LogosModules; struct LogosModules;
class LogosWalletProvider; class LogosWalletProvider;
class SequencerIdentityProbe;
class WalletController; class WalletController;
class WalletPortfolioService;
struct WalletPortfolioResult;
// Source-side implementation of the AmmUiBackend .rep interface. // Source-side implementation of the AmmUiBackend .rep interface.
// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and // Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and
@@ -52,6 +56,8 @@ public slots:
QString createNew(QString configPath, QString storagePath, QString password) override; QString createNew(QString configPath, QString storagePath, QString password) override;
bool openExisting() override; bool openExisting() override;
void disconnectWallet() override; void disconnectWallet() override;
bool setAccountAlias(QString accountId, QString alias) override;
bool setPrimaryAccount(QString accountId) override;
// AMM — all forwarded to the amm_module core module. // AMM — all forwarded to the amm_module core module.
QVariantMap resolvePoolAccount(QString defAHex, QString defBHex) override; QVariantMap resolvePoolAccount(QString defAHex, QString defBHex) override;
@@ -110,6 +116,12 @@ private:
QStringList loadCustomTokenIds() const; QStringList loadCustomTokenIds() const;
bool saveCustomTokenIds(const QStringList& ids) const; bool saveCustomTokenIds(const QStringList& ids) const;
QString customTokenStorePath() const; QString customTokenStorePath() const;
QStringList knownTokenIds() const;
bool resolveProgramIds();
void configureNetworkIdentity();
void publishNetworkState();
void refreshPortfolio();
void applyPortfolio(WalletPortfolioResult result);
LogosAPI* m_logosAPI; LogosAPI* m_logosAPI;
// Handle for the amm_module core module (resolvePool / swapExactInput / // Handle for the amm_module core module (resolvePool / swapExactInput /
@@ -120,6 +132,14 @@ private:
std::unique_ptr<LogosModules> m_logos; std::unique_ptr<LogosModules> m_logos;
std::unique_ptr<LogosWalletProvider> m_wallet; std::unique_ptr<LogosWalletProvider> m_wallet;
std::unique_ptr<WalletController> m_walletController; std::unique_ptr<WalletController> m_walletController;
std::unique_ptr<WalletPortfolioService> m_portfolio;
std::unique_ptr<SequencerIdentityProbe> m_networkProbe;
bool m_networkResolved = false;
QString m_ammProgramIdCache;
QString m_tokenProgramIdCache;
QByteArray m_tokenIdl;
QByteArray m_ammIdl;
}; };
#endif // AMM_UI_BACKEND_H #endif // AMM_UI_BACKEND_H
+17 -2
View File
@@ -8,6 +8,9 @@ class AmmUiBackend
// False while startup or reconnect is still resolving wallet state. This // False while startup or reconnect is still resolving wallet state. This
// stays distinct from isWalletOpen because a disconnected wallet is ready. // stays distinct from isWalletOpen because a disconnected wallet is ready.
PROP(bool walletStateReady READONLY) PROP(bool walletStateReady READONLY)
PROP(QString walletSyncStatus READONLY)
PROP(QString walletSyncError READONLY)
PROP(bool walletCanSubmit READONLY)
PROP(bool walletExists READONLY) PROP(bool walletExists READONLY)
PROP(QString configPath READONLY) PROP(QString configPath READONLY)
PROP(QString storagePath READONLY) PROP(QString storagePath READONLY)
@@ -18,6 +21,15 @@ class AmmUiBackend
// Whether the configured sequencer answered the last reachability probe. // Whether the configured sequencer answered the last reachability probe.
// Defaults true so the UI doesn't flash a warning before the first check. // Defaults true so the UI doesn't flash a warning before the first check.
PROP(bool sequencerReachable READONLY) 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 // Account management
SLOT(QString createAccountPublic()) SLOT(QString createAccountPublic())
@@ -25,6 +37,8 @@ class AmmUiBackend
SLOT(void refreshAccounts()) SLOT(void refreshAccounts())
SLOT(void refreshBalances()) SLOT(void refreshBalances())
SLOT(QString getBalance(QString accountIdHex, bool isPublic)) 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 // Wallet lifecycle. createNewDefault() is the happy path: it creates a
// fresh wallet at the canonical walletHome with no path picking. createNew() // fresh wallet at the canonical walletHome with no path picking. createNew()
@@ -192,8 +206,9 @@ class AmmUiBackend
// the configured tokens (TOKENS_CONFIG) plus the user's persisted custom tokens // the configured tokens (TOKENS_CONFIG) plus the user's persisted custom tokens
// (see addCustomToken) — the same "known list" shape the swap side shows. Tokens // (see addCustomToken) — the same "known list" shape the swap side shows. Tokens
// the wallet merely holds are NOT auto-listed; add an unlisted one by id. Returns // the wallet merely holds are NOT auto-listed; add an unlisted one by id. Returns
// [{ definitionId (base58), name, totalSupply, holdingId, balance }] — every row // [{ definitionId (base58), definitionIdHex, name, totalSupply, holdingId,
// the same shape, held tokens first (holdingId "" / balance "0" when not held). // balance }] — every row the same shape, held tokens first (holdingId "" /
// balance "0" when not held).
SLOT(QVariantList resolveTokens()) SLOT(QVariantList resolveTokens())
// Adds a user-pasted custom token id (base58 or hex) to the persisted set, after // Adds a user-pasted custom token id (base58 or hex) to the persisted set, after
+18 -16
View File
@@ -4,15 +4,15 @@ UI-driven tests for the AMM app, driving the running app through the QML
inspector (framework from inspector (framework from
[`logos-co/logos-qt-mcp`](https://github.com/logos-co/logos-qt-mcp)): [`logos-co/logos-qt-mcp`](https://github.com/logos-co/logos-qt-mcp)):
- `swap.mjs` selects two tokens, enters an amount, submits a swap, and verifies - `e2e/swap.mjs` selects two tokens, enters an amount, submits a swap, and verifies
the **A/B** pool reserves changed **on-chain**. the **A/B** pool reserves changed **on-chain**.
- `create-pool.mjs` selects the **A/C** pair (which the setup script leaves - `e2e/create-pool.mjs` selects the **A/C** pair (which the setup script leaves
unseeded — only A/B is created), submits a pool creation, and verifies the A/C unseeded — only A/B is created), submits a pool creation, and verifies the A/C
pool now exists **on-chain**. pool now exists **on-chain**.
- `add-liquidity.mjs` selects the seeded **A/B** pair, asserts the CTA stays - `e2e/add-liquidity.mjs` selects the seeded **A/B** pair, asserts the CTA stays
disabled until deposit amounts are entered, submits an add, and verifies the disabled until deposit amounts are entered, submits an add, and verifies the
A/B pool reserves grew **on-chain**. A/B pool reserves grew **on-chain**.
- `custom-token.mjs` pastes token **D**'s id (created on-chain by the setup but - `e2e/custom-token.mjs` pastes token **D**'s id (created on-chain by the setup but
deliberately **absent** from the token config) into a Liquidity token slot and deliberately **absent** from the token config) into a Liquidity token slot and
verifies the app resolves it, selects it, and **persists** it to the custom-token verifies the app resolves it, selects it, and **persists** it to the custom-token
store — the "add an unlisted token by id" path. No pool / submit involved. store — the "add an unlisted token by id" path. No pool / submit involved.
@@ -51,19 +51,19 @@ TEST_SEQUENCER_ADDR=http://127.0.0.1:3040 apps/amm/tests/testnet/setup-amm-testn
# (it clears this file before + after running). Required for custom-token.mjs to # (it clears this file before + after running). Required for custom-token.mjs to
# avoid touching your real custom-token store. # avoid touching your real custom-token store.
LEE_WALLET_HOME_DIR=$(pwd)/apps/amm/tests/testnet/.wallet \ LEE_WALLET_HOME_DIR=$(pwd)/apps/amm/tests/testnet/.wallet \
AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \ AMM_PROGRAM_BIN=$(pwd)/target/guest/amm.bin \
TOKENS_CONFIG=$(pwd)/apps/amm/tests/testnet/amm-tokens.json \ TOKENS_CONFIG=$(pwd)/apps/amm/tests/testnet/amm-tokens.json \
CUSTOM_TOKEN_CONFIG=$(pwd)/apps/amm/tests/testnet/custom-tokens.json \ CUSTOM_TOKEN_CONFIG=$(pwd)/apps/amm/tests/testnet/custom-tokens.json \
nix run .#amm-ui nix run .#amm-ui
# 3. Terminal 2 — drive a test; watch it click through the live UI. # 3. Terminal 2 — drive a test; watch it click through the live UI.
node apps/amm/tests/swap.mjs # swap against the seeded A/B pool node apps/amm/tests/e2e/swap.mjs # swap against the seeded A/B pool
node apps/amm/tests/create-pool.mjs # create the (unseeded) A/C pool node apps/amm/tests/e2e/create-pool.mjs # create the (unseeded) A/C pool
node apps/amm/tests/add-liquidity.mjs # add liquidity to the seeded A/B pool node apps/amm/tests/e2e/add-liquidity.mjs # add liquidity to the seeded A/B pool
node apps/amm/tests/custom-token.mjs # add token D (unlisted) by id node apps/amm/tests/e2e/custom-token.mjs # add token D (unlisted) by id
``` ```
Headless CI variant (no window, launches the app itself, pass/fail only): Hermetic headless smoke test (no wallet or sequencer required):
```bash ```bash
nix build .#integration-test -L nix build .#integration-test -L
@@ -83,16 +83,18 @@ nix build .#integration-test -L
re-restores the isolated `.wallet` (rewrites only that directory). re-restores the isolated `.wallet` (rewrites only that directory).
- **Overrides.** `TEST_WALLET_HOME`, `TEST_MNEMONIC`, `TEST_WALLET_PASSWORD`, - **Overrides.** `TEST_WALLET_HOME`, `TEST_MNEMONIC`, `TEST_WALLET_PASSWORD`,
`TEST_WALLET_DEPTH`, `TEST_SEQUENCER_ADDR` — see the script header. `TEST_WALLET_DEPTH`, `TEST_SEQUENCER_ADDR` — see the script header.
- **Framework location.** `swap.mjs` loads `../result-mcp` by default; override - **Framework location.** `e2e/swap.mjs` loads `../../result-mcp` by default; override
with `LOGOS_QT_MCP=/abs/path/to/result-mcp`. with `LOGOS_QT_MCP=/abs/path/to/result-mcp`.
- **Artifacts.** On failure `swap.mjs` prints the `SwapCard` state and saves - **Artifacts.** On failure `e2e/swap.mjs` prints the `SwapCard` state and saves
`apps/amm/tests/swap-*.png` (git-ignored) for inspection. `apps/amm/tests/e2e/swap-*.png` (git-ignored) for inspection.
## Files ## Files
- `swap.mjs` — the end-to-end swap UI test (A/B pool). - `ui-smoke.mjs` — the hermetic headless UI integration test.
- `create-pool.mjs` — the end-to-end create-pool UI test (creates the A/C pool). - `e2e/swap.mjs` — the end-to-end swap UI test (A/B pool).
- `add-liquidity.mjs` — the end-to-end add-liquidity UI test (adds to the A/B pool). - `e2e/create-pool.mjs` — the end-to-end create-pool UI test (creates the A/C pool).
- `e2e/add-liquidity.mjs` — the end-to-end add-liquidity UI test (adds to the A/B pool).
- `e2e/custom-token.mjs` — the custom-token persistence test.
- `testnet/setup-amm-testnet.sh` — isolated testnet + wallet bootstrap (TKA/TKB/TKC, - `testnet/setup-amm-testnet.sh` — isolated testnet + wallet bootstrap (TKA/TKB/TKC,
seeds the A/B pool only). seeds the A/B pool only).
- `qml/`, `cpp/` — the module's own QML/C++ unit tests. - `qml/`, `cpp/` — the module's own QML/C++ unit tests.
@@ -18,13 +18,13 @@ import { readFile, writeFile } from "node:fs/promises";
const fwRoot = const fwRoot =
process.env.LOGOS_QT_MCP || process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname; new URL("../../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs")); const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs"));
// The token config the app was launched with (same file the setup script writes). // The token config the app was launched with (same file the setup script writes).
const TOKENS_CONFIG = const TOKENS_CONFIG =
process.env.TOKENS_CONFIG || process.env.TOKENS_CONFIG ||
new URL("./testnet/amm-tokens.json", import.meta.url).pathname; new URL("../testnet/amm-tokens.json", import.meta.url).pathname;
// Deposit added for token A (raw base units — the form treats amounts as raw). The // Deposit added for token A (raw base units — the form treats amounts as raw). The
// seeded A/B pool is 10000/10000, so 1000 mints a nonzero LP and is trivially funded. // seeded A/B pool is 10000/10000, so 1000 mints a nonzero LP and is trivially funded.
@@ -18,13 +18,13 @@ import { readFile, writeFile } from "node:fs/promises";
const fwRoot = const fwRoot =
process.env.LOGOS_QT_MCP || process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname; new URL("../../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs")); const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs"));
// The token config the app was launched with (same file the setup script writes). // The token config the app was launched with (same file the setup script writes).
const TOKENS_CONFIG = const TOKENS_CONFIG =
process.env.TOKENS_CONFIG || process.env.TOKENS_CONFIG ||
new URL("./testnet/amm-tokens.json", import.meta.url).pathname; new URL("../testnet/amm-tokens.json", import.meta.url).pathname;
// --- small helpers (mirrors swap.mjs) -------------------------------------- // --- small helpers (mirrors swap.mjs) --------------------------------------
@@ -26,14 +26,14 @@ import { execFileSync } from "node:child_process";
const fwRoot = const fwRoot =
process.env.LOGOS_QT_MCP || process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname; new URL("../../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs")); const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs"));
// The isolated test wallet home (set by the setup script) — used to resolve token // The isolated test wallet home (set by the setup script) — used to resolve token
// D's deterministic definition id via the wallet CLI, the same way the setup does. // D's deterministic definition id via the wallet CLI, the same way the setup does.
const WALLET_HOME = const WALLET_HOME =
process.env.LEE_WALLET_HOME_DIR || process.env.LEE_WALLET_HOME_DIR ||
new URL("./testnet/.wallet", import.meta.url).pathname; new URL("../testnet/.wallet", import.meta.url).pathname;
// Where the app persists custom tokens. Defaults to the isolated test store; set // Where the app persists custom tokens. Defaults to the isolated test store; set
// CUSTOM_TOKEN_CONFIG to override. IMPORTANT: launch the app with the SAME path // CUSTOM_TOKEN_CONFIG to override. IMPORTANT: launch the app with the SAME path
@@ -42,7 +42,7 @@ const WALLET_HOME =
// from a stale slate. Only used to pre-clear; persistence is verified through the app. // from a stale slate. Only used to pre-clear; persistence is verified through the app.
const CUSTOM_TOKEN_CONFIG = const CUSTOM_TOKEN_CONFIG =
process.env.CUSTOM_TOKEN_CONFIG || process.env.CUSTOM_TOKEN_CONFIG ||
new URL("./testnet/custom-tokens.json", import.meta.url).pathname; new URL("../testnet/custom-tokens.json", import.meta.url).pathname;
// --- small helpers (mirror create-pool.mjs) -------------------------------- // --- small helpers (mirror create-pool.mjs) --------------------------------
@@ -21,7 +21,7 @@ import { writeFile } from "node:fs/promises";
// Override with LOGOS_QT_MCP=/abs/path/to/result-mcp if it lives elsewhere. // Override with LOGOS_QT_MCP=/abs/path/to/result-mcp if it lives elsewhere.
const fwRoot = const fwRoot =
process.env.LOGOS_QT_MCP || process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname; new URL("../../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs")); const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs"));
const SELL_AMOUNT = "100"; const SELL_AMOUNT = "100";
@@ -22,8 +22,9 @@ TestCase {
var summary = createTemporaryObject(summaryComponent, testCase) var summary = createTemporaryObject(summaryComponent, testCase)
verify(summary) verify(summary)
compare(summary.actionText("NewDefinition"), "Create pool") summary.snapshot = { "poolExists": false }
compare(summary.actionText("AddLiquidity"), "Add liquidity") compare(summary.actionText(), "Create pool")
compare(summary.actionText(""), "-") summary.snapshot = { "poolExists": true }
compare(summary.actionText(), "Add liquidity")
} }
} }
+67 -121
View File
@@ -22,7 +22,8 @@ TestCase {
Liquidity.NewPositionForm { Liquidity.NewPositionForm {
visible: false visible: false
width: 760 width: 760
newPositionContext: testCase.readyContext() tokens: testCase.readyTokens()
walletReady: true
} }
} }
@@ -37,72 +38,68 @@ TestCase {
signalName: "quoteRequested" signalName: "quoteRequested"
} }
function readyContext() { function readyTokens() {
return { return [
"status": "ready", {
"tokens": [ "definitionId": tokenLow,
{ "name": "Low",
"definitionId": tokenLow, "totalSupply": "1000000",
"name": "Low", "balance": "1000"
"totalSupply": "1000000", },
"balanceRaw": "1000", {
"selectable": true "definitionId": tokenHigh,
}, "name": "High",
{ "totalSupply": "1000000000000",
"definitionId": tokenHigh, "balance": "5000000000"
"name": "High", }
"totalSupply": "1000000000000", ]
"balanceRaw": "5000000000",
"selectable": true
}
]
}
} }
function rawAmountsContext() { function rawAmountTokens() {
return { return [
"status": "ready", {
"tokens": [ "definitionId": tokenLow,
{ "name": "Sir Mints-a-Lot",
"definitionId": tokenLow, "totalSupply": "1000000000000",
"name": "Sir Mints-a-Lot", "balance": "1000000000"
"totalSupply": "1000000000000", },
"balanceRaw": "1000000000", {
"selectable": true "definitionId": tokenHigh,
}, "name": "Aurora",
{ "totalSupply": "1000000000000",
"definitionId": tokenHigh, "balance": "1000000000"
"name": "Aurora", }
"totalSupply": "1000000000000", ]
"balanceRaw": "1000000000",
"selectable": true
}
]
}
} }
function flowState(quote) { function flowState(quote, poolExists) {
return { var state = {
"quote": quote || ({}), "quote": quote || ({}),
"contextLoading": false,
"quoteLoading": false, "quoteLoading": false,
"quoteStale": false, "quoteStale": false,
"submitting": false "submitting": false
} }
if (poolExists !== undefined)
state.poolExists = poolExists
else if (quote && quote.poolStatus === "active_pool")
state.poolExists = true
else if (quote && quote.poolStatus === "missing_pool")
state.poolExists = false
return state
} }
function createEmptyForm(context) { function createEmptyForm(tokens) {
var form = createTemporaryObject(formComponent, testCase, { var form = createTemporaryObject(formComponent, testCase, {
"flowState": flowState(({})), "flowState": flowState(({})),
"newPositionContext": context || readyContext() "tokens": tokens || readyTokens()
}) })
verify(form) verify(form)
wait(0) wait(0)
return form return form
} }
function createForm(context) { function createForm(tokens) {
var form = createEmptyForm(context) var form = createEmptyForm(tokens)
form.selectToken("A", tokenLow) form.selectToken("A", tokenLow)
form.selectToken("B", tokenHigh) form.selectToken("B", tokenHigh)
compare(form.selectedTokenAId, tokenLow) compare(form.selectedTokenAId, tokenLow)
@@ -175,7 +172,7 @@ TestCase {
} }
function test_missingPoolAcceptsLargeDirectAmountsFromEitherSide() { function test_missingPoolAcceptsLargeDirectAmountsFromEitherSide() {
var form = createForm(rawAmountsContext()) var form = createForm(rawAmountTokens())
form.priceAmountA = "15" form.priceAmountA = "15"
form.priceAmountB = "10" form.priceAmountB = "10"
form.flowState = flowState({ form.flowState = flowState({
@@ -210,7 +207,7 @@ TestCase {
} }
function test_missingPoolRoundsPairedRawAmounts() { function test_missingPoolRoundsPairedRawAmounts() {
var form = createForm(rawAmountsContext()) var form = createForm(rawAmountTokens())
form.priceAmountA = "15" form.priceAmountA = "15"
form.priceAmountB = "10" form.priceAmountB = "10"
form.flowState = flowState({ form.flowState = flowState({
@@ -378,7 +375,6 @@ TestCase {
compare(form.fieldError("amountB"), "") compare(form.fieldError("amountB"), "")
compare(form.formErrorText(), "") compare(form.formErrorText(), "")
compare(form.accountPreview().length, 0)
} }
function test_activePoolEditUsesDisplayReserveRatio() { function test_activePoolEditUsesDisplayReserveRatio() {
@@ -421,6 +417,8 @@ TestCase {
function test_activePoolEditRecoversAfterInvalidQuote() { function test_activePoolEditRecoversAfterInvalidQuote() {
var form = createForm() var form = createForm()
form.flowState = flowState(({}), true)
wait(0)
form.flowState = flowState({ form.flowState = flowState({
"status": "ok", "status": "ok",
"tokenAId": tokenHigh, "tokenAId": tokenHigh,
@@ -441,7 +439,7 @@ TestCase {
"code": "value_must_be_positive", "code": "value_must_be_positive",
"tokenAId": tokenHigh, "tokenAId": tokenHigh,
"tokenBId": tokenLow "tokenBId": tokenLow
}) }, true)
wait(0) wait(0)
compare(form.poolFeeBps, 30) compare(form.poolFeeBps, 30)
@@ -485,7 +483,7 @@ TestCase {
"code": "fee_tier_mismatch", "code": "fee_tier_mismatch",
"details": { "poolFeeBps": "5" } "details": { "poolFeeBps": "5" }
}] }]
}) }, true)
wait(0) wait(0)
compare(form.selectedFeeBps, 5) compare(form.selectedFeeBps, 5)
@@ -497,17 +495,12 @@ TestCase {
compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountB, "1000") compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountB, "1000")
} }
function test_contextFailureFinishesTokenResolution() { function test_tokenResolutionFailureClearsPendingState() {
var form = createForm() var form = createForm()
form.resolvingTokenId = tokenThird form.resolvingTokenId = tokenThird
form.resolvingTokenSide = "A" form.resolvingTokenSide = "A"
form.newPositionContext = { form.failTokenResolution("config_unavailable")
"status": "error",
"code": "config_unavailable",
"tokens": []
}
form.finishTokenResolution(true)
wait(0) wait(0)
compare(form.resolvingTokenId, "") compare(form.resolvingTokenId, "")
@@ -515,53 +508,27 @@ TestCase {
compare(form.tokenResolutionError, form.issueText("config_unavailable")) compare(form.tokenResolutionError, form.issueText("config_unavailable"))
} }
function test_staleContextDoesNotFinishNewerTokenResolution() {
var form = createForm()
form.resolvingTokenId = tokenThird
form.resolvingTokenSide = "B"
form.newPositionContext = {
"status": "ready",
"tokens": [{
"definitionId": tokenLow,
"name": "Earlier token",
"selectable": true
}]
}
wait(0)
compare(form.resolvingTokenId, tokenThird)
compare(form.resolvingTokenSide, "B")
compare(form.tokenResolutionError, "")
}
function test_replacingSelectedTokensClearsPairDraft() { function test_replacingSelectedTokensClearsPairDraft() {
var form = createForm() var form = createForm()
form.amountA = "12" form.amountA = "12"
form.amountB = "34" form.amountB = "34"
form.minimumAmountA = "12" form.minimumAmountA = "12"
form.minimumAmountB = "34" form.minimumAmountB = "34"
form.confirmedPoolStatus = "active_pool"
form.newPositionContext = { form.tokens = [
"status": "ready", {
"tokens": [ "definitionId": tokenHigh,
{ "name": "High",
"definitionId": tokenHigh, "totalSupply": "1000000000000",
"name": "High", "balance": "5000000000"
"totalSupply": "1000000000000", },
"balanceRaw": "5000000000", {
"selectable": true "definitionId": tokenThird,
}, "name": "Third",
{ "totalSupply": "1000000",
"definitionId": tokenThird, "balance": "100"
"name": "Third", }
"totalSupply": "1000000", ]
"balanceRaw": "100",
"selectable": true
}
]
}
wait(0) wait(0)
compare(form.selectedTokenAId, "") compare(form.selectedTokenAId, "")
@@ -570,27 +537,6 @@ TestCase {
compare(form.amountB, "") compare(form.amountB, "")
compare(form.minimumAmountA, "") compare(form.minimumAmountA, "")
compare(form.minimumAmountB, "") compare(form.minimumAmountB, "")
compare(form.confirmedPoolStatus, "")
}
function test_networkFailurePreservesPairDraft() {
var form = createForm()
form.amountA = "12"
form.amountB = "34"
form.confirmedPoolStatus = "active_pool"
form.newPositionContext = {
"status": "network_mismatch",
"tokens": []
}
wait(0)
compare(form.selectedTokenAId, tokenLow)
compare(form.selectedTokenBId, tokenHigh)
compare(form.amountA, "12")
compare(form.amountB, "34")
compare(form.confirmedPoolStatus, "active_pool")
verify(form.contextBlocksForm())
} }
function test_submittedBase58TransactionIdIsCopied() { function test_submittedBase58TransactionIdIsCopied() {
+65
View File
@@ -0,0 +1,65 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtTest
import "../../qml/components/liquidity" as Liquidity
import "../../qml/components/swap" as Swap
TestCase {
id: testCase
name: "TokenInput"
readonly property string base58Id: "Eiw5zDP1BKukkxMY8dj7Hkw9NfCTh6iU5av5F7FT8ExC"
readonly property string hexId: "cbe5e5fed00f9af47a0cbc6f96de828dd8e72090971fa1b904bec2014e3f634d"
Component {
id: inputComponent
Swap.TokenInput {
visible: false
width: 400
theme: inputTheme
selectorObjectName: "accountSelector"
Liquidity.AmmTheme {
id: inputTheme
}
}
}
function createInput(definitionId) {
return createTemporaryObject(inputComponent, testCase, {
"token": { "definitionId": definitionId, "symbol": "TKA" },
"holdings": [{
"accountId": "holding-a",
"accountType": "TokenHolding",
"balanceRaw": "10",
"definitionId": base58Id,
"definitionIdHex": hexId
}]
})
}
function test_base58DefinitionSelectsHolding() {
var input = createInput(base58Id)
verify(input)
var selector = findChild(input, "accountSelector")
verify(selector)
compare(selector.stateField, "definitionId")
tryCompare(selector, "hasFunds", true)
tryCompare(selector, "selectedAccountId", "holding-a")
}
function test_hexDefinitionSelectsHolding() {
var input = createInput(hexId)
verify(input)
var selector = findChild(input, "accountSelector")
verify(selector)
compare(selector.stateField, "definitionIdHex")
tryCompare(selector, "hasFunds", true)
tryCompare(selector, "selectedAccountId", "holding-a")
}
}
+9 -8
View File
@@ -70,10 +70,10 @@ TEST_SEQUENCER_ADDR="${TEST_SEQUENCER_ADDR:-}"
# Deterministic accounts, created in THIS fixed order after a fresh restore so # Deterministic accounts, created in THIS fixed order after a fresh restore so
# their ids are reproducible. Resolved to ids at runtime via `wallet account id`. # their ids are reproducible. Resolved to ids at runtime via `wallet account id`.
# token-c-*/token-d-* are APPENDED (not inserted) so the pre-existing a/b/lp ids don't shift. # token-c-*/token-d-* are APPENDED (not inserted) so the pre-existing a/b/lp ids don't shift.
# Token C has no seeded pool — the create-pool UI test (apps/amm/tests/create-pool.mjs) # Token C has no seeded pool — the create-pool UI test (apps/amm/tests/e2e/create-pool.mjs)
# creates the A/C pool itself, minting its own LP holding via the app. # creates the A/C pool itself, minting its own LP holding via the app.
# Token D is created but LEFT OUT of the token config — the custom-token UI test # Token D is created but LEFT OUT of the token config — the custom-token UI test
# (apps/amm/tests/custom-token.mjs) adds it by id. # (apps/amm/tests/e2e/custom-token.mjs) adds it by id.
ACCOUNT_LABELS=(token-a-def token-a-holding token-b-def token-b-holding lp-holding token-c-def token-c-holding token-d-def token-d-holding) ACCOUNT_LABELS=(token-a-def token-a-holding token-b-def token-b-holding lp-holding token-c-def token-c-holding token-d-def token-d-holding)
############################################################################### ###############################################################################
@@ -81,9 +81,9 @@ ACCOUNT_LABELS=(token-a-def token-a-holding token-b-def token-b-holding lp-holdi
############################################################################### ###############################################################################
# --- Program binaries (docker release builds; image ids must match deployment) --- # --- Program binaries (docker release builds; image ids must match deployment) ---
TOKEN_BIN="programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin" TOKEN_BIN="${TOKEN_BIN:-target/guest/token.bin}"
AMM_BIN="programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin" AMM_BIN="${AMM_BIN:-target/guest/amm.bin}"
TWAP_BIN="programs/twap_oracle/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/twap_oracle.bin" TWAP_BIN="${TWAP_BIN:-target/guest/twap_oracle.bin}"
# --- IDLs --- # --- IDLs ---
TOKEN_IDL="artifacts/token-idl.json" TOKEN_IDL="artifacts/token-idl.json"
@@ -522,6 +522,7 @@ log ""
log "Token D was created ON-CHAIN but left out of the token config (the ${DIM}custom${RST}" log "Token D was created ON-CHAIN but left out of the token config (the ${DIM}custom${RST}"
log "token). Its id: ${DIM}$TOKEN_D_DEF${RST}" log "token). Its id: ${DIM}$TOKEN_D_DEF${RST}"
log "" log ""
log "Then in another terminal: ${DIM}node apps/amm/tests/swap.mjs${RST} (swap A/B)" log "Then in another terminal: ${DIM}node apps/amm/tests/e2e/swap.mjs${RST} (swap A/B)"
log " or: ${DIM}node apps/amm/tests/create-pool.mjs${RST} (create A/C pool)" log " or: ${DIM}node apps/amm/tests/e2e/create-pool.mjs${RST} (create A/C pool)"
log " or: ${DIM}node apps/amm/tests/custom-token.mjs${RST} (add token D by id)" log " or: ${DIM}node apps/amm/tests/e2e/add-liquidity.mjs${RST} (add A/B liquidity)"
log " or: ${DIM}node apps/amm/tests/e2e/custom-token.mjs${RST} (add token D by id)"
+16
View File
@@ -0,0 +1,16 @@
import { resolve } from "node:path";
const frameworkRoot = process.env.LOGOS_QT_MCP;
if (!frameworkRoot)
throw new Error("LOGOS_QT_MCP is required");
const { test, run } = await import(resolve(frameworkRoot, "test-framework/framework.mjs"));
test("amm ui: renders primary navigation", async (app) => {
await app.waitFor(
async () => { await app.expectTexts(["Trade", "Liquidity", "Pools"]); },
{ timeout: 20000, interval: 500, description: "primary navigation" },
);
});
run();
+141 -3
View File
@@ -8,6 +8,8 @@ endif()
option(LOGOS_WALLET_BUILD_QML "Build the Logos.Wallet QML module" ON) option(LOGOS_WALLET_BUILD_QML "Build the Logos.Wallet QML module" ON)
option(LOGOS_WALLET_BUILD_ACCESS "Build the generated-SDK wallet adapter" ON) option(LOGOS_WALLET_BUILD_ACCESS "Build the generated-SDK wallet adapter" ON)
set(LOGOS_WALLET_GENERATED_DIR "" CACHE PATH "Path to generated Logos SDK sources") set(LOGOS_WALLET_GENERATED_DIR "" CACHE PATH "Path to generated Logos SDK sources")
set(LOGOS_WALLET_IDL_DECODER_LIBRARY "" CACHE FILEPATH
"wallet_idl_decoder library required by logos_wallet_access")
if(LOGOS_WALLET_BUILD_ACCESS if(LOGOS_WALLET_BUILD_ACCESS
AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/logos_sdk.h" AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/logos_sdk.h"
@@ -24,6 +26,28 @@ endif()
set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOMOC ON)
if(LOGOS_WALLET_BUILD_ACCESS) if(LOGOS_WALLET_BUILD_ACCESS)
if(NOT LOGOS_WALLET_IDL_DECODER_LIBRARY)
find_library(LOGOS_WALLET_IDL_DECODER_LIBRARY
NAMES wallet_idl_decoder
HINTS "$ENV{LOGOS_EXT_ROOT_WALLET_IDL_DECODER}/lib"
)
endif()
if(NOT LOGOS_WALLET_IDL_DECODER_INCLUDE_DIR)
find_path(LOGOS_WALLET_IDL_DECODER_INCLUDE_DIR
NAMES wallet_idl_decoder.h
HINTS
"$ENV{LOGOS_EXT_ROOT_WALLET_IDL_DECODER}/include"
"${CMAKE_SOURCE_DIR}/include"
"${CMAKE_SOURCE_DIR}/lib"
"${CMAKE_CURRENT_SOURCE_DIR}/../../../tools/wallet-idl-decoder/include"
)
endif()
if(NOT LOGOS_WALLET_IDL_DECODER_LIBRARY
OR NOT LOGOS_WALLET_IDL_DECODER_INCLUDE_DIR)
message(FATAL_ERROR
"logos_wallet_access requires wallet_idl_decoder library and headers"
)
endif()
add_library(logos_wallet_access STATIC add_library(logos_wallet_access STATIC
src/WalletProvider.h src/WalletProvider.h
src/WalletProvider.cpp src/WalletProvider.cpp
@@ -33,6 +57,16 @@ if(LOGOS_WALLET_BUILD_ACCESS)
src/WalletAccountModel.cpp src/WalletAccountModel.cpp
src/WalletController.h src/WalletController.h
src/WalletController.cpp src/WalletController.cpp
src/WalletIdlDecoder.h
src/WalletIdlDecoder.cpp
src/SequencerNetworkContext.h
src/SequencerNetworkContext.cpp
src/SequencerNetworkSettings.h
src/SequencerNetworkSettings.cpp
src/SequencerIdentityProbe.h
src/SequencerIdentityProbe.cpp
src/WalletPortfolioService.h
src/WalletPortfolioService.cpp
) )
set_target_properties(logos_wallet_access PROPERTIES set_target_properties(logos_wallet_access PROPERTIES
AUTOMOC ON AUTOMOC ON
@@ -45,10 +79,18 @@ if(LOGOS_WALLET_BUILD_ACCESS)
PRIVATE PRIVATE
"${LOGOS_WALLET_GENERATED_DIR}" "${LOGOS_WALLET_GENERATED_DIR}"
"${LOGOS_WALLET_GENERATED_DIR}/include" "${LOGOS_WALLET_GENERATED_DIR}/include"
"${LOGOS_WALLET_IDL_DECODER_INCLUDE_DIR}"
) )
target_link_libraries(logos_wallet_access target_link_libraries(logos_wallet_access
PUBLIC Qt6::Core PUBLIC Qt6::Core
PRIVATE Qt6::Network PRIVATE
Qt6::Network
"${LOGOS_WALLET_IDL_DECODER_LIBRARY}"
)
qt_add_resources(logos_wallet_access logos_wallet_access_network_data
PREFIX "/wallet"
FILES
config/networks.json
) )
endif() endif()
@@ -59,13 +101,13 @@ if(LOGOS_WALLET_BUILD_QML)
set(wallet_qml_output_dir "${CMAKE_CURRENT_BINARY_DIR}/qml/Logos/Wallet") set(wallet_qml_output_dir "${CMAKE_CURRENT_BINARY_DIR}/qml/Logos/Wallet")
set(wallet_internal_qml set(wallet_internal_qml
qml/internal/WalletIconButton.qml qml/internal/WalletIconButton.qml
qml/internal/CopyButton.qml
qml/internal/AccountDelegate.qml qml/internal/AccountDelegate.qml
qml/internal/CreateAccountDialog.qml qml/internal/CreateAccountDialog.qml
qml/internal/CreateWalletDialog.qml qml/internal/CreateWalletDialog.qml
qml/internal/WalletMessageDialog.qml qml/internal/WalletMessageDialog.qml
) )
set(wallet_public_qml set(wallet_public_qml
qml/internal/CopyButton.qml
qml/WalletControl.qml qml/WalletControl.qml
qml/ProgramAccountSelector.qml qml/ProgramAccountSelector.qml
qml/TransactionConfirmationDialog.qml qml/TransactionConfirmationDialog.qml
@@ -160,6 +202,94 @@ if(BUILD_TESTING)
) )
add_test(NAME logos_wallet_access COMMAND logos_wallet_access_test) add_test(NAME logos_wallet_access COMMAND logos_wallet_access_test)
if(LOGOS_WALLET_BUILD_ACCESS)
add_executable(logos_wallet_idl_decoder_link_test
tests/cpp/WalletIdlDecoderLinkTest.cpp
)
target_compile_features(logos_wallet_idl_decoder_link_test PRIVATE cxx_std_17)
target_link_libraries(logos_wallet_idl_decoder_link_test PRIVATE
Qt6::Core
Qt6::Test
logos_wallet_access
)
add_test(NAME logos_wallet_idl_decoder_link
COMMAND logos_wallet_idl_decoder_link_test)
endif()
add_executable(logos_wallet_sequencer_network_context_test
tests/cpp/SequencerNetworkContextTest.cpp
src/SequencerNetworkContext.h
src/SequencerNetworkContext.cpp
)
set_target_properties(logos_wallet_sequencer_network_context_test PROPERTIES AUTOMOC ON)
target_compile_features(logos_wallet_sequencer_network_context_test PRIVATE cxx_std_17)
target_include_directories(logos_wallet_sequencer_network_context_test PRIVATE src)
target_link_libraries(logos_wallet_sequencer_network_context_test PRIVATE
Qt6::Core
Qt6::Test
)
add_test(NAME logos_wallet_sequencer_network_context
COMMAND logos_wallet_sequencer_network_context_test)
add_executable(logos_wallet_sequencer_network_settings_test
tests/cpp/SequencerNetworkSettingsTest.cpp
src/SequencerNetworkContext.h
src/SequencerNetworkContext.cpp
src/SequencerNetworkSettings.h
src/SequencerNetworkSettings.cpp
)
set_target_properties(logos_wallet_sequencer_network_settings_test PROPERTIES AUTOMOC ON)
target_compile_features(logos_wallet_sequencer_network_settings_test PRIVATE cxx_std_17)
target_include_directories(logos_wallet_sequencer_network_settings_test PRIVATE src)
target_link_libraries(logos_wallet_sequencer_network_settings_test PRIVATE
Qt6::Core
Qt6::Test
)
qt_add_resources(logos_wallet_sequencer_network_settings_test
logos_wallet_access_network_data
PREFIX "/wallet"
FILES
config/networks.json
)
add_test(NAME logos_wallet_sequencer_network_settings
COMMAND logos_wallet_sequencer_network_settings_test)
add_executable(logos_wallet_sequencer_identity_probe_test
tests/cpp/SequencerIdentityProbeTest.cpp
src/SequencerIdentityProbe.h
src/SequencerIdentityProbe.cpp
src/SequencerNetworkContext.h
src/SequencerNetworkContext.cpp
)
set_target_properties(logos_wallet_sequencer_identity_probe_test PROPERTIES AUTOMOC ON)
target_compile_features(logos_wallet_sequencer_identity_probe_test PRIVATE cxx_std_17)
target_include_directories(logos_wallet_sequencer_identity_probe_test PRIVATE src)
target_link_libraries(logos_wallet_sequencer_identity_probe_test PRIVATE
Qt6::Core
Qt6::Network
Qt6::Test
)
add_test(NAME logos_wallet_sequencer_identity_probe
COMMAND logos_wallet_sequencer_identity_probe_test)
add_executable(logos_wallet_portfolio_service_test
tests/cpp/WalletPortfolioServiceTest.cpp
src/WalletPortfolioService.h
src/WalletPortfolioService.cpp
src/WalletProvider.cpp
)
set_target_properties(logos_wallet_portfolio_service_test PROPERTIES AUTOMOC ON)
target_compile_features(logos_wallet_portfolio_service_test PRIVATE cxx_std_17)
target_include_directories(logos_wallet_portfolio_service_test PRIVATE
src
)
target_link_libraries(logos_wallet_portfolio_service_test PRIVATE
Qt6::Core
Qt6::Test
)
add_test(NAME logos_wallet_portfolio_service
COMMAND logos_wallet_portfolio_service_test)
if(LOGOS_WALLET_BUILD_QML) if(LOGOS_WALLET_BUILD_QML)
find_package(Qt6 6.8 REQUIRED COMPONENTS QuickTest) find_package(Qt6 6.8 REQUIRED COMPONENTS QuickTest)
add_executable(logos_wallet_qml_test tests/qml/main.cpp) add_executable(logos_wallet_qml_test tests/qml/main.cpp)
@@ -169,8 +299,16 @@ if(BUILD_TESTING)
target_link_libraries(logos_wallet_qml_test PRIVATE Qt6::QuickTest) target_link_libraries(logos_wallet_qml_test PRIVATE Qt6::QuickTest)
add_dependencies(logos_wallet_qml_test logos_wallet_qmlplugin) add_dependencies(logos_wallet_qml_test logos_wallet_qmlplugin)
add_test(NAME logos_wallet_qml COMMAND logos_wallet_qml_test) add_test(NAME logos_wallet_qml COMMAND logos_wallet_qml_test)
get_target_property(wallet_qml_library Qt6::Qml IMPORTED_LOCATION)
get_filename_component(wallet_qml_library_dir "${wallet_qml_library}" DIRECTORY)
get_filename_component(wallet_qml_prefix "${wallet_qml_library_dir}" DIRECTORY)
set(wallet_qml_test_import_paths
"${CMAKE_CURRENT_BINARY_DIR}/qml"
"${wallet_qml_prefix}/${QT6_INSTALL_QML}"
)
string(JOIN ":" wallet_qml_test_import_path ${wallet_qml_test_import_paths})
set_tests_properties(logos_wallet_qml PROPERTIES ENVIRONMENT set_tests_properties(logos_wallet_qml PROPERTIES ENVIRONMENT
"QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML2_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml;QML_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml" "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML2_IMPORT_PATH=${wallet_qml_test_import_path};QML_IMPORT_PATH=${wallet_qml_test_import_path}"
) )
endif() endif()
endif() endif()
+5
View File
@@ -0,0 +1,5 @@
{
"testnet": {
"checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a"
}
}
@@ -9,12 +9,21 @@ Popup {
property string cancelText: qsTr("Cancel") property string cancelText: qsTr("Cancel")
property string confirmText: qsTr("Confirm") property string confirmText: qsTr("Confirm")
property bool busy: false property bool busy: false
property string busyText: qsTr("Submitting…")
property bool activityBusy: false
property string activityText: qsTr("Updating…")
property bool showInlineBusyIndicator: true
property var snapshot: ({}) property var snapshot: ({})
property Component summary: null property Component summary: null
property bool confirmationPending: false property bool confirmationPending: false
property bool confirmEnabled: true
property bool roundedCancelButton: false
property bool closeWhenSettled: true
readonly property bool actionPending: root.busy || root.activityBusy
signal canceled signal canceled
signal confirmed(var snapshot) signal confirmed(var snapshot)
signal summaryEdited(var snapshot)
modal: true modal: true
dim: true dim: true
@@ -40,7 +49,14 @@ Popup {
root.snapshot = root.cloneSnapshot(nextSnapshot) root.snapshot = root.cloneSnapshot(nextSnapshot)
root.confirmationPending = false root.confirmationPending = false
root.open() root.open()
cancelButton.forceActiveFocus() Qt.callLater(function() {
if (cancelButtonLoader.item)
cancelButtonLoader.item.forceActiveFocus()
})
}
function updateSnapshot(nextSnapshot) {
root.snapshot = root.cloneSnapshot(nextSnapshot)
} }
function cancel() { function cancel() {
@@ -52,7 +68,7 @@ Popup {
} }
function confirm() { function confirm() {
if (root.busy) if (root.actionPending || !root.confirmEnabled)
return return
root.confirmationPending = true root.confirmationPending = true
root.confirmed(root.snapshot) root.confirmed(root.snapshot)
@@ -62,10 +78,21 @@ Popup {
} }
} }
Connections {
target: summaryLoader.item
ignoreUnknownSignals: true
function onSnapshotEdited(snapshot) {
root.updateSnapshot(snapshot)
root.summaryEdited(root.snapshot)
}
}
onBusyChanged: { onBusyChanged: {
if (!root.busy && root.confirmationPending) { if (!root.busy && root.confirmationPending) {
root.confirmationPending = false root.confirmationPending = false
root.close() if (root.closeWhenSettled)
root.close()
} }
} }
@@ -117,26 +144,37 @@ Popup {
} }
} }
BusyIndicator { Item {
id: inlineBusyIndicator
property bool active: root.showInlineBusyIndicator && root.actionPending
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
visible: root.busy Layout.preferredWidth: active ? busySpinner.implicitWidth : 0
running: root.busy Layout.preferredHeight: active ? busySpinner.implicitHeight : 0
Accessible.name: qsTr("Submitting transaction") implicitWidth: busySpinner.implicitWidth
implicitHeight: busySpinner.implicitHeight
visible: active
BusyIndicator {
id: busySpinner
anchors.centerIn: parent
running: inlineBusyIndicator.active
Accessible.name: root.busy ? root.busyText : root.activityText
}
} }
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 10 spacing: 10
Button { Loader {
id: cancelButton id: cancelButtonLoader
objectName: "transactionCancelButton" objectName: "transactionCancelButtonLoader"
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 44 Layout.preferredHeight: 44
text: root.cancelText sourceComponent: root.roundedCancelButton
enabled: !root.busy ? roundedCancelButtonComponent : defaultCancelButtonComponent
Accessible.name: text
onClicked: root.cancel()
} }
Button { Button {
@@ -144,8 +182,8 @@ Popup {
objectName: "transactionConfirmButton" objectName: "transactionConfirmButton"
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 44 implicitHeight: 44
text: root.busy ? qsTr("Submitting...") : root.confirmText text: root.busy ? root.busyText : root.confirmText
enabled: !root.busy enabled: !root.actionPending && root.confirmEnabled
Accessible.name: text Accessible.name: text
onClicked: root.confirm() onClicked: root.confirm()
@@ -166,4 +204,48 @@ Popup {
} }
} }
} }
Component {
id: defaultCancelButtonComponent
Button {
objectName: "transactionCancelButton"
anchors.fill: parent
text: root.cancelText
enabled: !root.busy
Accessible.name: text
onClicked: root.cancel()
}
}
Component {
id: roundedCancelButtonComponent
Button {
id: cancelButton
objectName: "transactionCancelButton"
anchors.fill: parent
text: root.cancelText
enabled: !root.busy
Accessible.name: text
onClicked: root.cancel()
background: Rectangle {
color: cancelButton.pressed ? "#3f3f46"
: cancelButton.hovered ? "#27272a" : "#18181b"
border.color: "#52525b"
border.width: 1
radius: 6
}
contentItem: Label {
text: cancelButton.text
color: "#f4f4f5"
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
} }
File diff suppressed because it is too large Load Diff
@@ -5,53 +5,127 @@ import QtQuick.Layouts
ItemDelegate { ItemDelegate {
id: root id: root
required property int index
required property string name required property string name
required property string alias
required property string address required property string address
required property string balance required property string displayAddress
required property bool isPublic required property string kind
required property string section
required property string programName
required property string accountType
required property string decodedData
required property string visibility
required property bool canBePrimary
required property bool isPrimary
signal copyRequested(string text) signal makePrimaryRequested(string address)
signal renameRequested(string address, string alias)
leftPadding: 12 leftPadding: 12
rightPadding: 8 rightPadding: 8
topPadding: 10 topPadding: 10
bottomPadding: 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 { background: Rectangle {
color: root.highlighted || root.hovered ? "#27272a" : "#18181b" color: root.isPrimary || root.hovered ? "#27272a" : "#18181b"
radius: 6 radius: 8
border.width: root.activeFocus ? 1 : 0 border.width: root.activeFocus || root.isPrimary ? 1 : 0
border.color: "#f26a21" border.color: root.isPrimary ? "#f59e0b" : "#52525b"
} }
contentItem: ColumnLayout { contentItem: ColumnLayout {
spacing: 6 spacing: 7
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 8 spacing: 7
Label { Label {
Layout.fillWidth: true
text: root.name 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 font.bold: true
} }
Label { Label {
text: root.isPublic ? qsTr("Public") : qsTr("Private") text: root.kindLabel()
color: "#a1a1aa" color: "#a1a1aa"
font.pixelSize: 11 font.pixelSize: 11
} }
Item { Layout.fillWidth: true } Label {
text: root.visibility === "private" ? qsTr("Private") : qsTr("Public")
color: root.visibility === "private" ? "#c4b5fd" : "#93c5fd"
font.pixelSize: 11
}
}
Label {
objectName: "walletProgramName"
visible: root.section === "advanced" && root.programName.length > 0
Layout.fillWidth: true
text: qsTr("Program: %1").arg(root.programName)
color: "#a1a1aa"
font.pixelSize: 11
elide: Text.ElideRight
}
ColumnLayout {
visible: root.section === "advanced" && root.decodedData.length > 0
Layout.fillWidth: true
spacing: 4
Label { Label {
text: root.balance.length > 0 ? root.balance : "-" objectName: "walletDecodedDataLabel"
color: "#f4f4f5" text: qsTr("Decoded data")
font.bold: true color: "#a1a1aa"
font.pixelSize: 11
}
Rectangle {
objectName: "walletDecodedDataBox"
Layout.fillWidth: true
implicitHeight: decodedDataText.implicitHeight + 16
color: "#18181b"
radius: 6
border.width: 1
border.color: "#3f3f46"
Text {
id: decodedDataText
objectName: "walletDecodedData"
anchors.fill: parent
anchors.margins: 8
text: root.decodedData
color: "#d4d4d8"
font.family: "monospace"
font.pixelSize: 10
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
}
} }
} }
@@ -61,17 +135,45 @@ ItemDelegate {
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
text: root.address text: root.displayAddress
color: "#a1a1aa" color: "#71717a"
font.family: "monospace" font.family: "monospace"
font.pixelSize: 11 font.pixelSize: 11
elide: Text.ElideMiddle elide: Text.ElideMiddle
} }
CopyButton { CopyButton {
visible: root.address.length > 0 visible: root.displayAddress.length > 0
onCopyRequested: root.copyRequested(root.address) copyText: root.displayAddress
copyLabel: qsTr("Copy address")
}
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Button {
objectName: "walletRenameButton"
text: qsTr("Rename")
flat: true
onClicked: root.renameRequested(root.address, root.alias)
}
Item { Layout.fillWidth: true }
Button {
objectName: "walletMakePrimaryButton"
visible: root.canBePrimary && !root.isPrimary
text: qsTr("Make primary")
flat: true
onClicked: root.makePrimaryRequested(root.address)
} }
} }
} }
onClicked: {
if (root.canBePrimary && !root.isPrimary)
root.makePrimaryRequested(root.address)
}
} }
+20 -1
View File
@@ -5,9 +5,11 @@ WalletIconButton {
signal copyRequested signal copyRequested
property string copyText: ""
property string copyLabel: qsTr("Copy")
property bool copied: false property bool copied: false
accessibleName: root.copied ? qsTr("Copied") : qsTr("Copy") accessibleName: root.copied ? qsTr("Copied") : root.copyLabel
iconSource: root.copied iconSource: root.copied
? Qt.resolvedUrl("icons/checkmark.svg") ? Qt.resolvedUrl("icons/checkmark.svg")
: Qt.resolvedUrl("icons/copy.svg") : Qt.resolvedUrl("icons/copy.svg")
@@ -18,7 +20,24 @@ WalletIconButton {
onTriggered: root.copied = false onTriggered: root.copied = false
} }
TextEdit {
id: clipboardProxy
visible: false
}
function copyToClipboard() {
if (root.copyText.length === 0)
return
clipboardProxy.text = root.copyText
clipboardProxy.selectAll()
clipboardProxy.copy()
clipboardProxy.deselect()
clipboardProxy.text = ""
}
onClicked: { onClicked: {
root.copyToClipboard()
root.copyRequested() root.copyRequested()
root.copied = true root.copied = true
resetTimer.restart() resetTimer.restart()
@@ -16,9 +16,13 @@ Popup {
x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0
y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0
padding: 20 padding: 20
focus: true
closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside
onOpened: privateSwitch.checked = false onOpened: {
privateSwitch.checked = false
Qt.callLater(function() { privateSwitch.forceActiveFocus() })
}
background: Rectangle { background: Rectangle {
color: "#18181b" color: "#18181b"
@@ -20,6 +20,7 @@ Popup {
x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0
y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0
padding: 20 padding: 20
focus: true
closePolicy: root.busy || root.mnemonic.length > 0 closePolicy: root.busy || root.mnemonic.length > 0
? Popup.NoAutoClose ? Popup.NoAutoClose
: Popup.CloseOnEscape | Popup.CloseOnPressOutside : Popup.CloseOnEscape | Popup.CloseOnPressOutside
@@ -15,8 +15,11 @@ Popup {
x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0
y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0
padding: 20 padding: 20
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
onOpened: Qt.callLater(function() { closeButton.forceActiveFocus() })
background: Rectangle { background: Rectangle {
color: "#18181b" color: "#18181b"
border.color: "#3f3f46" border.color: "#3f3f46"
@@ -44,6 +47,8 @@ Popup {
} }
Button { Button {
id: closeButton
Layout.alignment: Qt.AlignRight Layout.alignment: Qt.AlignRight
text: qsTr("Close") text: qsTr("Close")
onClicked: root.close() onClicked: root.close()
+386 -94
View File
@@ -1,11 +1,13 @@
#include "LogosWalletProvider.h" #include "LogosWalletProvider.h"
#include <QByteArray> #include <QByteArray>
#include <algorithm>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError> #include <QJsonParseError>
#include <QTimer>
#include <QVariantList> #include <QVariantList>
#include <QVariantMap> #include <QVariantMap>
@@ -74,6 +76,103 @@ WalletCreation failedCreation(WalletFailure failure)
creation.snapshot.failure = failure; creation.snapshot.failure = failure;
return creation; return creation;
} }
WalletAccountRead parsePublicAccount(const QString& accountId, const QString& payload)
{
WalletAccountRead read;
read.accountId = accountId;
if (!isHex(accountId, 64))
return read;
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
return read;
const QJsonObject account = document.object();
const QString owner = account.value(QStringLiteral("program_owner")).toString();
const QString balance = account.value(QStringLiteral("balance")).toString();
const QString nonce = account.value(QStringLiteral("nonce")).toString();
const QString data = account.value(QStringLiteral("data")).toString();
if (!isHex(owner, 64)
|| !isHex(balance, 32)
|| !isHex(nonce, 32)
|| data.size() % 2 != 0
|| !isHex(data, data.size())) {
return read;
}
read.status = QStringLiteral("ok");
read.programOwner = owner;
read.balanceHex = balance;
read.nonceHex = nonce;
read.dataHex = data;
return read;
}
void applyPublicRead(WalletAccount& account, const WalletAccountRead& read)
{
account.readStatus = read.status;
account.programOwner = read.programOwner;
account.dataHex = read.dataHex;
}
bool encodeTransaction(const WalletTransaction& transaction,
QVariantList* signingRequirements,
QByteArray* instruction)
{
if (!isHex(transaction.programId, 64)
|| transaction.accountIds.size() != transaction.signingRequirements.size()) {
return false;
}
for (const QString& accountId : transaction.accountIds) {
if (!isHex(accountId, 64))
return false;
}
signingRequirements->reserve(transaction.signingRequirements.size());
for (bool required : transaction.signingRequirements)
signingRequirements->append(required);
instruction->reserve(
static_cast<int>(transaction.instruction.size() * sizeof(quint32)));
for (const quint32 word : transaction.instruction) {
instruction->append(static_cast<char>(word & 0xff));
instruction->append(static_cast<char>((word >> 8) & 0xff));
instruction->append(static_cast<char>((word >> 16) & 0xff));
instruction->append(static_cast<char>((word >> 24) & 0xff));
}
return true;
}
WalletSubmission parseSubmission(const QString& response)
{
WalletSubmission submission;
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
}
const QJsonObject result = document.object();
const QJsonValue success = result.value(QStringLiteral("success"));
const QJsonValue error = result.value(QStringLiteral("error"));
const QString hash = result.value(QStringLiteral("tx_hash")).toString();
const bool emptyError = error.isUndefined()
|| error.isNull()
|| (error.isString() && error.toString().isEmpty());
if (!success.isBool()
|| !success.toBool()
|| !emptyError
|| !isHex(hash, 64, false)) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
}
submission.nativeHash = hash.toLower();
return submission;
}
} }
struct LogosWalletProvider::Impl { struct LogosWalletProvider::Impl {
@@ -103,12 +202,14 @@ LogosWalletProvider::LogosWalletProvider(LogosModules* logos)
LogosWalletProvider::~LogosWalletProvider() LogosWalletProvider::~LogosWalletProvider()
{ {
++m_generation;
if (m_connected) if (m_connected)
save(); save();
} }
WalletSession LogosWalletProvider::connect(const WalletPaths& paths) WalletSession LogosWalletProvider::connect(const WalletPaths& paths)
{ {
++m_generation;
clearSnapshot(); clearSnapshot();
if (!m_impl->logos) if (!m_impl->logos)
return failedSession(WalletFailure::WalletUnavailable); return failedSession(WalletFailure::WalletUnavailable);
@@ -131,9 +232,72 @@ WalletSession LogosWalletProvider::connect(const WalletPaths& paths)
return session; 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;
}
openStored();
});
}
WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths,
const QString& password) const QString& password)
{ {
++m_generation;
clearSnapshot(); clearSnapshot();
if (!m_impl->logos) if (!m_impl->logos)
return failedCreation(WalletFailure::WalletUnavailable); return failedCreation(WalletFailure::WalletUnavailable);
@@ -158,8 +322,6 @@ WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths,
return creation; return creation;
} }
creation.snapshot = snapshot(true);
creation.failure = creation.snapshot.failure;
return creation; return creation;
} }
@@ -181,6 +343,26 @@ WalletSnapshot LogosWalletProvider::snapshot(bool forceRefresh)
return result; 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() void LogosWalletProvider::clearSnapshot()
{ {
m_snapshot = {}; m_snapshot = {};
@@ -209,45 +391,49 @@ WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic)
if (isPublic) if (isPublic)
creation.publicAccount = readPublicAccount(creation.accountId); creation.publicAccount = readPublicAccount(creation.accountId);
if (m_snapshotReady) {
clearSnapshot(); WalletAccount account;
creation.snapshot = snapshot(true); account.address = creation.accountId;
account.displayAddress =
m_impl->logos->logos_execution_zone.account_id_to_base58(creation.accountId);
account.isPublic = isPublic;
if (isPublic && creation.publicAccount.ok()) {
account.balance = littleEndianU128ToDecimal(creation.publicAccount.balanceHex);
auto read = std::find_if(
m_snapshot.publicAccountReads.begin(),
m_snapshot.publicAccountReads.end(),
[&creation](const WalletAccountRead& existing) {
return existing.accountId == creation.accountId;
});
if (read == m_snapshot.publicAccountReads.end())
m_snapshot.publicAccountReads.append(creation.publicAccount);
else
*read = creation.publicAccount;
} else {
account.balance = m_impl->logos->logos_execution_zone.get_balance(
creation.accountId, isPublic);
}
auto existing = std::find_if(
m_snapshot.accounts.begin(), m_snapshot.accounts.end(),
[&creation](const WalletAccount& candidate) {
return candidate.address == creation.accountId;
});
if (existing == m_snapshot.accounts.end())
m_snapshot.accounts.append(account);
else
*existing = account;
creation.snapshot = m_snapshot;
}
return creation; return creation;
} }
WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const
{ {
WalletAccountRead read;
read.accountId = accountId;
if (!m_impl->logos || !isHex(accountId, 64)) if (!m_impl->logos || !isHex(accountId, 64))
return read; return WalletAccountRead { accountId };
return parsePublicAccount(
QJsonParseError parseError; accountId,
const QJsonDocument document = QJsonDocument::fromJson( m_impl->logos->logos_execution_zone.get_account_public(accountId));
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;
}
read.status = QStringLiteral("ok");
read.programOwner = owner;
read.balanceHex = balance;
read.nonceHex = nonce;
read.dataHex = data;
return read;
} }
WalletSubmission LogosWalletProvider::submitPublicTransaction( WalletSubmission LogosWalletProvider::submitPublicTransaction(
@@ -258,73 +444,25 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction(
submission.failure = WalletFailure::WalletUnavailable; submission.failure = WalletFailure::WalletUnavailable;
return submission; return submission;
} }
if (!isHex(transaction.programId, 64) QVariantList signingRequirements;
|| transaction.accountIds.size() != transaction.signingRequirements.size()) { QByteArray instruction;
if (!encodeTransaction(transaction, &signingRequirements, &instruction)) {
submission.failure = WalletFailure::InvalidRequest; submission.failure = WalletFailure::InvalidRequest;
return submission; return submission;
} }
for (const QString& accountId : transaction.accountIds) {
if (!isHex(accountId, 64)) {
submission.failure = WalletFailure::InvalidRequest;
return submission;
}
}
QVariantList signingRequirements;
signingRequirements.reserve(transaction.signingRequirements.size());
for (bool required : transaction.signingRequirements)
signingRequirements.append(required);
// `send_generic_public_transaction`'s `instruction` param is a byte string
// (bstr). Passing a QVariantList<u32> makes the module's QtRO glue mangle it,
// so the guest reads a garbage Instruction variant. Send the little-endian
// bytes of the u32 words instead — same encoding the AMM swap path uses.
// See docs/amm-swap-qtro-serialization-bug.md.
QByteArray instructionBytes;
instructionBytes.reserve(
static_cast<int>(transaction.instruction.size() * sizeof(quint32)));
for (const quint32 word : transaction.instruction) {
instructionBytes.append(static_cast<char>(word & 0xff));
instructionBytes.append(static_cast<char>((word >> 8) & 0xff));
instructionBytes.append(static_cast<char>((word >> 16) & 0xff));
instructionBytes.append(static_cast<char>((word >> 24) & 0xff));
}
const QString response = const QString response =
m_impl->logos->logos_execution_zone.send_generic_public_transaction( m_impl->logos->logos_execution_zone.send_generic_public_transaction(
transaction.accountIds, transaction.accountIds,
signingRequirements, signingRequirements,
QVariant::fromValue(instructionBytes), QVariant::fromValue(instruction),
transaction.programId); transaction.programId);
return parseSubmission(response);
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
}
const QJsonObject result = document.object();
const QJsonValue success = result.value(QStringLiteral("success"));
const QJsonValue error = result.value(QStringLiteral("error"));
const QString hash = result.value(QStringLiteral("tx_hash")).toString();
const bool emptyError = error.isUndefined()
|| error.isNull()
|| (error.isString() && error.toString().isEmpty());
if (!success.isBool()
|| !success.toBool()
|| !emptyError
|| !isHex(hash, 64, false)) {
submission.failure = WalletFailure::SubmissionFailed;
return submission;
}
submission.nativeHash = hash.toLower();
return submission;
} }
void LogosWalletProvider::disconnect() void LogosWalletProvider::disconnect()
{ {
++m_generation;
if (m_connected) if (m_connected)
save(); save();
clearSnapshot(); clearSnapshot();
@@ -335,9 +473,8 @@ bool LogosWalletProvider::sharedWalletIsOpen() const
{ {
if (!m_impl->logos) if (!m_impl->logos)
return false; return false;
if (!m_impl->logos->logos_execution_zone.get_sequencer_addr().isEmpty()) // A live wallet always has a configured, non-empty sequencer URL.
return true; return !m_impl->logos->logos_execution_zone.get_sequencer_addr().isEmpty();
return !m_impl->logos->logos_execution_zone.list_accounts().isEmpty();
} }
WalletSnapshot LogosWalletProvider::loadSnapshot() WalletSnapshot LogosWalletProvider::loadSnapshot()
@@ -368,24 +505,179 @@ WalletSnapshot LogosWalletProvider::loadSnapshot()
WalletAccount account; WalletAccount account;
account.address = address; account.address = address;
account.displayAddress =
m_impl->logos->logos_execution_zone.account_id_to_base58(address);
account.isPublic = entry.value(QStringLiteral("is_public"), true).toBool(); account.isPublic = entry.value(QStringLiteral("is_public"), true).toBool();
if (account.isPublic) { if (account.isPublic) {
const WalletAccountRead read = readPublicAccount(address); const WalletAccountRead read = readPublicAccount(address);
result.publicAccountReads.append(read); result.publicAccountReads.append(read);
applyPublicRead(account, read);
account.balance = read.ok() account.balance = read.ok()
? littleEndianU128ToDecimal(read.balanceHex) ? littleEndianU128ToDecimal(read.balanceHex)
: m_impl->logos->logos_execution_zone.get_balance(address, true); : m_impl->logos->logos_execution_zone.get_balance(address, true);
} else { } else {
account.readStatus = QStringLiteral("private");
account.balance = m_impl->logos->logos_execution_zone.get_balance(address, false); account.balance = m_impl->logos->logos_execution_zone.get_balance(address, false);
} }
result.accounts.append(account); result.accounts.append(account);
} }
if (!save())
result.failure = WalletFailure::SaveFailed;
return result; 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(),
};
state->snapshot.accounts[index].displayAddress =
m_impl->logos->logos_execution_zone
.account_id_to_base58(accountId);
state->publicFlags[index] =
state->snapshot.accounts.at(index).isPublic;
}
auto finishOne = std::make_shared<std::function<void()>>();
*finishOne = [this, generation, state]() mutable {
if (generation != m_generation || --state->remaining > 0)
return;
for (qsizetype index = 0;
index < state->publicReads.size(); ++index) {
if (state->publicFlags.at(index))
state->snapshot.publicAccountReads.append(
state->publicReads.at(index));
}
if (state->snapshot.ok()) {
m_snapshot = state->snapshot;
m_snapshotReady = true;
}
state->callback(std::move(state->snapshot));
};
if (entries.isEmpty()) {
state->remaining = 1;
(*finishOne)();
return;
}
for (qsizetype index = 0; index < entries.size(); ++index) {
const WalletAccount account = state->snapshot.accounts.at(index);
if (!account.isPublic) {
m_impl->logos->logos_execution_zone.get_balanceAsync(
account.address, false,
[state, finishOne, index](QString balance) {
state->snapshot.accounts[index].balance =
std::move(balance);
(*finishOne)();
});
continue;
}
m_impl->logos->logos_execution_zone.get_account_publicAsync(
account.address,
[this, state, finishOne, index,
accountId = account.address](QString payload) {
const WalletAccountRead read =
parsePublicAccount(accountId, payload);
state->publicReads[index] = read;
applyPublicRead(
state->snapshot.accounts[index], read);
if (read.ok()) {
state->snapshot.accounts[index].balance =
littleEndianU128ToDecimal(read.balanceHex);
(*finishOne)();
return;
}
m_impl->logos->logos_execution_zone.get_balanceAsync(
accountId, true,
[state, finishOne, index](QString balance) {
state->snapshot.accounts[index].balance =
std::move(balance);
(*finishOne)();
});
});
}
});
});
});
};
if (currentHeight > 0) {
m_impl->logos->logos_execution_zone.sync_to_blockAsync(
currentHeight, std::move(afterSync));
} else {
afterSync(WALLET_FFI_SUCCESS);
}
});
}
bool LogosWalletProvider::save() const bool LogosWalletProvider::save() const
{ {
return m_impl->logos return m_impl->logos
+7 -1
View File
@@ -2,21 +2,25 @@
#include <memory> #include <memory>
#include <QObject>
#include "WalletProvider.h" #include "WalletProvider.h"
class LogosAPI; class LogosAPI;
struct LogosModules; struct LogosModules;
class LogosWalletProvider final : public WalletProvider { class LogosWalletProvider final : public QObject, public WalletProvider {
public: public:
explicit LogosWalletProvider(LogosAPI* api); explicit LogosWalletProvider(LogosAPI* api);
explicit LogosWalletProvider(LogosModules* logos); explicit LogosWalletProvider(LogosModules* logos);
~LogosWalletProvider() override; ~LogosWalletProvider() override;
WalletSession connect(const WalletPaths& paths) override; WalletSession connect(const WalletPaths& paths) override;
void connectAsync(const WalletPaths& paths, SessionCallback callback) override;
WalletCreation createWallet(const WalletPaths& paths, WalletCreation createWallet(const WalletPaths& paths,
const QString& password) override; const QString& password) override;
WalletSnapshot snapshot(bool forceRefresh = false) override; WalletSnapshot snapshot(bool forceRefresh = false) override;
void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override;
void clearSnapshot() override; void clearSnapshot() override;
WalletAccountCreation createAccount(bool isPublic) override; WalletAccountCreation createAccount(bool isPublic) override;
WalletAccountRead readPublicAccount(const QString& accountId) const override; WalletAccountRead readPublicAccount(const QString& accountId) const override;
@@ -27,6 +31,7 @@ public:
private: private:
bool sharedWalletIsOpen() const; bool sharedWalletIsOpen() const;
WalletSnapshot loadSnapshot(); WalletSnapshot loadSnapshot();
void loadSnapshotAsync(quint64 generation, SnapshotCallback callback);
bool save() const; bool save() const;
struct Impl; struct Impl;
@@ -34,4 +39,5 @@ private:
WalletSnapshot m_snapshot; WalletSnapshot m_snapshot;
bool m_snapshotReady = false; bool m_snapshotReady = false;
bool m_connected = false; bool m_connected = false;
quint64 m_generation = 0;
}; };
@@ -0,0 +1,285 @@
#include "SequencerIdentityProbe.h"
#include <algorithm>
#include <utility>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QTimer>
#include <QVariant>
namespace {
constexpr int REQUEST_TIMEOUT_MILLISECONDS = 4000;
constexpr int INITIAL_RETRY_DELAY_MILLISECONDS = 250;
constexpr int MAX_RETRY_DELAY_MILLISECONDS = 4000;
constexpr qsizetype CHECKPOINT_BLOCK_HASH_OFFSET = 40;
constexpr qsizetype CHECKPOINT_BLOCK_HASH_SIZE = 32;
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);
}
bool hasSuccessStatus(const QVariant& status)
{
if (!status.isValid())
return false;
const int code = status.toInt();
return code >= 200 && code < 300;
}
}
SequencerIdentityProbe::SequencerIdentityProbe(QObject* parent)
: QObject(parent),
m_network(new QNetworkAccessManager(this)),
m_retryTimer(new QTimer(this))
{
m_retryTimer->setSingleShot(true);
connect(m_retryTimer, &QTimer::timeout, this, &SequencerIdentityProbe::start);
}
SequencerIdentityProbe::~SequencerIdentityProbe()
{
cancelPendingWork();
}
bool SequencerIdentityProbe::configure(SequencerNetworkContext::Configuration network,
Request request)
{
cancelPendingWork();
m_networkConfiguration = std::move(network);
m_request = std::move(request);
m_requestConfigured = isValidRequest(m_request)
&& m_context.configure(m_networkConfiguration);
if (!m_requestConfigured) {
if (m_context.isConfigured())
m_context.clearConfiguration();
emit snapshotChanged();
return false;
}
updateContextAvailability();
emit snapshotChanged();
start();
return true;
}
bool SequencerIdentityProbe::setEndpoint(QUrl endpoint)
{
Request updated = m_request;
updated.endpoint = std::move(endpoint);
if (!m_requestConfigured || !isValidRequest(updated))
return false;
if (updated.endpoint == m_request.endpoint)
return true;
m_request.endpoint = std::move(updated.endpoint);
restartContext();
return true;
}
void SequencerIdentityProbe::clearConfiguration()
{
cancelPendingWork();
m_requestConfigured = false;
m_request = {};
m_networkConfiguration = {};
m_context.clearConfiguration();
emit snapshotChanged();
}
void SequencerIdentityProbe::setSequencerAvailable(bool available)
{
if (m_sequencerAvailable == available)
return;
cancelPendingWork();
m_sequencerAvailable = available;
updateContextAvailability();
emit snapshotChanged();
start();
}
void SequencerIdentityProbe::setReachable(bool reachable)
{
if (m_reachable == reachable)
return;
cancelPendingWork();
m_reachable = reachable;
updateContextAvailability();
emit snapshotChanged();
start();
}
void SequencerIdentityProbe::start()
{
if (!m_requestConfigured
|| !isValidEndpoint(m_request.endpoint)
|| m_reply
|| m_retryTimer->isActive())
return;
const std::optional<quint64> contextGeneration = m_context.beginIdentityProbe();
if (!contextGeneration)
return;
emit snapshotChanged();
QNetworkRequest request(m_request.endpoint);
request.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/json"));
request.setTransferTimeout(REQUEST_TIMEOUT_MILLISECONDS);
QNetworkReply* reply = m_network->post(request,
jsonRpcBody(m_request.method, m_request.params));
m_reply = reply;
const quint64 requestGeneration = m_requestGeneration;
connect(reply, &QNetworkReply::finished, this,
[this, reply, contextGeneration = *contextGeneration, requestGeneration]() {
handleReply(reply, contextGeneration, requestGeneration);
});
}
bool SequencerIdentityProbe::isValidRequest(const Request& request)
{
return !request.method.trimmed().isEmpty()
&& static_cast<bool>(request.identityFromResult);
}
bool SequencerIdentityProbe::isValidEndpoint(const QUrl& endpoint)
{
return endpoint.isValid()
&& (endpoint.scheme() == QStringLiteral("http")
|| endpoint.scheme() == QStringLiteral("https"))
&& !endpoint.host().isEmpty();
}
QString SequencerIdentityProbe::stringIdentity(const QJsonValue& result)
{
return result.isString() ? result.toString() : QString();
}
QString SequencerIdentityProbe::checkpointBlockHash(const QJsonValue& result)
{
if (!result.isString())
return {};
const QByteArray block = QByteArray::fromBase64(result.toString().toLatin1());
if (block.size() < CHECKPOINT_BLOCK_HASH_OFFSET + CHECKPOINT_BLOCK_HASH_SIZE)
return {};
return QString::fromLatin1(
block.mid(CHECKPOINT_BLOCK_HASH_OFFSET, CHECKPOINT_BLOCK_HASH_SIZE).toHex());
}
void SequencerIdentityProbe::restartContext()
{
cancelPendingWork();
if (!m_context.configure(m_networkConfiguration)) {
m_requestConfigured = false;
emit snapshotChanged();
return;
}
updateContextAvailability();
emit snapshotChanged();
start();
}
void SequencerIdentityProbe::updateContextAvailability()
{
m_context.setSequencerAvailable(m_sequencerAvailable
&& isValidEndpoint(m_request.endpoint));
m_context.setReachable(m_reachable);
}
void SequencerIdentityProbe::cancelPendingWork()
{
m_retryTimer->stop();
m_nextRetryDelayMilliseconds = INITIAL_RETRY_DELAY_MILLISECONDS;
++m_requestGeneration;
if (!m_reply)
return;
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
reply->deleteLater();
}
void SequencerIdentityProbe::handleReply(QNetworkReply* reply,
quint64 contextGeneration,
quint64 requestGeneration)
{
if (m_reply == reply)
m_reply = nullptr;
if (requestGeneration != m_requestGeneration) {
reply->deleteLater();
return;
}
QString failure;
QString identity;
const QVariant status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute);
if (status.isValid() && !hasSuccessStatus(status)) {
failure = QStringLiteral("http_status");
} else if (reply->error() != QNetworkReply::NoError) {
failure = QStringLiteral("transport_error");
} else if (!hasSuccessStatus(status)) {
failure = QStringLiteral("http_status");
} else {
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(reply->readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
failure = QStringLiteral("malformed_response");
} else {
const QJsonObject response = document.object();
const QJsonValue rpcError = response.value(QStringLiteral("error"));
if ((!rpcError.isUndefined() && !rpcError.isNull())) {
failure = QStringLiteral("json_rpc_error");
} else {
const QJsonValue result = response.value(QStringLiteral("result"));
if (result.isUndefined()) {
failure = QStringLiteral("malformed_response");
} else {
identity = m_request.identityFromResult(result);
if (identity.isEmpty())
failure = QStringLiteral("malformed_response");
}
}
}
}
const bool accepted = m_context.finishIdentityProbe(contextGeneration, identity);
reply->deleteLater();
if (!accepted)
return;
emit snapshotChanged();
if (!failure.isEmpty()) {
emit probeFailed(failure);
scheduleRetry();
} else if (!m_context.isReady() && m_context.needsIdentityProbe()) {
emit probeFailed(QStringLiteral("invalid_identity"));
scheduleRetry();
} else if (m_context.isReady()) {
m_nextRetryDelayMilliseconds = INITIAL_RETRY_DELAY_MILLISECONDS;
}
}
void SequencerIdentityProbe::scheduleRetry()
{
if (!m_requestConfigured || !m_context.needsIdentityProbe() || m_retryTimer->isActive())
return;
const int delay = m_nextRetryDelayMilliseconds;
m_nextRetryDelayMilliseconds = std::min(m_nextRetryDelayMilliseconds * 2,
MAX_RETRY_DELAY_MILLISECONDS);
m_retryTimer->start(delay);
}
@@ -0,0 +1,89 @@
#pragma once
#include <functional>
#include <optional>
#include <QJsonArray>
#include <QJsonValue>
#include <QObject>
#include <QString>
#include <QUrl>
#include "SequencerNetworkContext.h"
class QNetworkAccessManager;
class QNetworkReply;
class QTimer;
// Owns the JSON-RPC lifecycle used to prove that a wallet endpoint belongs to
// a configured network. Consumers supply only their RPC method, parameters,
// and the protocol-specific extraction of an identity from `result`.
class SequencerIdentityProbe final : public QObject {
Q_OBJECT
public:
using IdentityParser = std::function<QString(const QJsonValue& result)>;
struct Request {
QUrl endpoint;
QString method;
QJsonArray params;
IdentityParser identityFromResult;
};
explicit SequencerIdentityProbe(QObject* parent = nullptr);
~SequencerIdentityProbe() override;
// Replaces both the expected network identity and RPC request. Existing
// replies are aborted before the new context can issue a probe.
bool configure(SequencerNetworkContext::Configuration network, Request request);
// An endpoint change invalidates the previous identity result, including a
// reply that may already be in flight. An invalid/empty endpoint leaves the
// configured network in network_unknown until a valid endpoint is supplied.
bool setEndpoint(QUrl endpoint);
void clearConfiguration();
void setSequencerAvailable(bool available);
void setReachable(bool reachable);
// Safe to call after every wallet/network state update. It starts a probe
// only when the context currently needs one and no retry is pending.
void start();
const SequencerNetworkSnapshot& snapshot() const { return m_context.snapshot(); }
// Common JSON-RPC result parsers. `checkpointBlockHash` decodes the
// fixed-layout base64 block response used by checkpoint probes.
static QString stringIdentity(const QJsonValue& result);
static QString checkpointBlockHash(const QJsonValue& result);
signals:
// Emitted whenever the externally visible network state changes.
void snapshotChanged();
// A transient RPC failure was rejected and a retry may be scheduled.
void probeFailed(const QString& reason);
private:
static bool isValidRequest(const Request& request);
static bool isValidEndpoint(const QUrl& endpoint);
void restartContext();
void updateContextAvailability();
void cancelPendingWork();
void handleReply(QNetworkReply* reply, quint64 contextGeneration,
quint64 requestGeneration);
void scheduleRetry();
SequencerNetworkContext m_context;
SequencerNetworkContext::Configuration m_networkConfiguration;
Request m_request;
QNetworkAccessManager* m_network;
QNetworkReply* m_reply = nullptr;
QTimer* m_retryTimer;
quint64 m_requestGeneration = 0;
int m_nextRetryDelayMilliseconds = 250;
bool m_requestConfigured = false;
bool m_sequencerAvailable = false;
bool m_reachable = false;
};
@@ -0,0 +1,133 @@
#include "SequencerNetworkContext.h"
#include <utility>
namespace {
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 SequencerNetworkContext::configure(Configuration configuration)
{
clearConfiguration();
m_snapshot.id = std::move(configuration.id);
if (!isValidIdentity(configuration.expectedIdentity))
return false;
m_expectedIdentity = std::move(configuration.expectedIdentity);
m_fingerprintPrefix = std::move(configuration.fingerprintPrefix);
m_configured = true;
clearIdentity(QStringLiteral("network_unknown"));
return true;
}
void SequencerNetworkContext::clearConfiguration()
{
invalidateProbe();
m_snapshot = {};
m_snapshot.status = QStringLiteral("config_missing");
m_expectedIdentity.clear();
m_fingerprintPrefix.clear();
m_configured = false;
m_sequencerAvailable = false;
m_reachable = false;
}
bool SequencerNetworkContext::needsIdentityProbe() const
{
return m_configured
&& m_sequencerAvailable
&& m_reachable
&& !m_probeInFlight
&& (m_snapshot.status == QStringLiteral("loading")
|| m_snapshot.status == QStringLiteral("network_unknown"));
}
void SequencerNetworkContext::setSequencerAvailable(bool available)
{
if (m_sequencerAvailable == available)
return;
m_sequencerAvailable = available;
if (!m_configured)
return;
invalidateProbe();
clearIdentity(available && m_reachable ? QStringLiteral("loading")
: QStringLiteral("network_unknown"));
}
void SequencerNetworkContext::setReachable(bool reachable)
{
if (m_reachable == reachable)
return;
m_reachable = reachable;
if (!m_configured)
return;
invalidateProbe();
clearIdentity(reachable && m_sequencerAvailable ? QStringLiteral("loading")
: QStringLiteral("network_unknown"));
}
std::optional<quint64> SequencerNetworkContext::beginIdentityProbe()
{
if (!needsIdentityProbe())
return std::nullopt;
m_probeInFlight = true;
const quint64 generation = ++m_probeGeneration;
clearIdentity(QStringLiteral("loading"));
return generation;
}
bool SequencerNetworkContext::finishIdentityProbe(quint64 generation,
const QString& identity)
{
if (!m_configured
|| !m_sequencerAvailable
|| !m_reachable
|| !m_probeInFlight
|| generation != m_probeGeneration) {
return false;
}
m_probeInFlight = false;
if (!isValidIdentity(identity)) {
clearIdentity(QStringLiteral("network_unknown"));
} else if (identity != m_expectedIdentity) {
clearIdentity(QStringLiteral("network_mismatch"));
} else {
m_snapshot.status = QStringLiteral("ready");
m_snapshot.fingerprint = m_fingerprintPrefix + identity;
}
return true;
}
bool SequencerNetworkContext::isValidIdentity(const QString& value)
{
return isLowerHex(value, 64);
}
void SequencerNetworkContext::clearIdentity(const QString& status)
{
m_snapshot.status = status;
m_snapshot.fingerprint.clear();
}
void SequencerNetworkContext::invalidateProbe()
{
++m_probeGeneration;
m_probeInFlight = false;
}
@@ -0,0 +1,59 @@
#pragma once
#include <optional>
#include <QString>
#include <QtGlobal>
// State shared by consumers that need to verify they are talking to a known
// sequencer. Deployment-specific configuration belongs to the consumer; this
// type only compares a supplied identity and tracks the probe lifecycle.
struct SequencerNetworkSnapshot {
QString id;
QString status = QStringLiteral("config_missing");
QString fingerprint;
};
class SequencerNetworkContext final {
public:
struct Configuration {
QString id;
QString expectedIdentity;
QString fingerprintPrefix;
};
// Replaces the active network. Returns false and publishes config_missing
// when the expected identity is not a 64-character lowercase hex value.
bool configure(Configuration configuration);
void clearConfiguration();
bool isConfigured() const { return m_configured; }
bool isReady() const { return m_snapshot.status == QStringLiteral("ready"); }
bool needsIdentityProbe() const;
const SequencerNetworkSnapshot& snapshot() const { return m_snapshot; }
// These inputs are intentionally separate: an endpoint can be configured
// while it is unreachable. Either loss invalidates an outstanding probe.
void setSequencerAvailable(bool available);
void setReachable(bool reachable);
// A caller must retain this generation and pass it back when its async RPC
// completes. Empty means a probe cannot currently start.
std::optional<quint64> beginIdentityProbe();
bool finishIdentityProbe(quint64 generation, const QString& identity);
static bool isValidIdentity(const QString& value);
private:
void clearIdentity(const QString& status);
void invalidateProbe();
SequencerNetworkSnapshot m_snapshot;
QString m_expectedIdentity;
QString m_fingerprintPrefix;
quint64 m_probeGeneration = 0;
bool m_configured = false;
bool m_sequencerAvailable = false;
bool m_reachable = false;
bool m_probeInFlight = false;
};
@@ -0,0 +1,60 @@
#include "SequencerNetworkSettings.h"
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QResource>
namespace {
std::optional<SequencerNetworkSettings> settingsForIdentity(
const QString& id,
const QString& identity,
const QString& fingerprintPrefix,
SequencerIdentityMethod method)
{
if (!SequencerNetworkContext::isValidIdentity(identity))
return std::nullopt;
SequencerNetworkSettings settings;
settings.context = { id, identity, fingerprintPrefix };
settings.identityMethod = method;
return settings;
}
}
std::optional<SequencerNetworkSettings> SequencerNetworkSettingsLoader::load(
const QString& networkId,
const QString& devnetConfigPath,
const QString& resourcePath)
{
Q_INIT_RESOURCE(logos_wallet_access_network_data);
const QString id = networkId.trimmed().isEmpty()
? QStringLiteral("testnet") : networkId.trimmed();
if (id == QStringLiteral("devnet")) {
QFile file(devnetConfigPath);
if (devnetConfigPath.isEmpty() || !file.open(QIODevice::ReadOnly))
return std::nullopt;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject())
return std::nullopt;
return settingsForIdentity(
id,
document.object().value(QStringLiteral("channelId")).toString(),
QStringLiteral("channel:"),
SequencerIdentityMethod::ChannelId);
}
QFile file(resourcePath);
if (!file.open(QIODevice::ReadOnly))
return std::nullopt;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject())
return std::nullopt;
const QJsonObject entry = document.object().value(id).toObject();
return settingsForIdentity(
id,
entry.value(QStringLiteral("checkpointHash")).toString(),
QStringLiteral("block10:"),
SequencerIdentityMethod::CheckpointBlock);
}
@@ -0,0 +1,27 @@
#pragma once
#include <optional>
#include <QString>
#include "SequencerNetworkContext.h"
enum class SequencerIdentityMethod {
CheckpointBlock,
ChannelId,
};
struct SequencerNetworkSettings {
SequencerNetworkContext::Configuration context;
SequencerIdentityMethod identityMethod = SequencerIdentityMethod::CheckpointBlock;
};
// Loads the identity contract for a wallet network. Program deployments and
// application-specific assets deliberately stay outside this loader.
class SequencerNetworkSettingsLoader final {
public:
static std::optional<SequencerNetworkSettings> load(
const QString& networkId,
const QString& devnetConfigPath,
const QString& resourcePath = QStringLiteral(":/wallet/config/networks.json"));
};
+306 -9
View File
@@ -1,5 +1,11 @@
#include "WalletAccountModel.h" #include "WalletAccountModel.h"
#include <utility>
namespace {
const QString DEFAULT_PROGRAM_OWNER(64, QLatin1Char('0'));
}
WalletAccountModel::WalletAccountModel(QObject* parent) WalletAccountModel::WalletAccountModel(QObject* parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
{ {
@@ -21,10 +27,38 @@ QVariant WalletAccountModel::data(const QModelIndex& index, int role) const
return account.name; return account.name;
case AddressRole: case AddressRole:
return account.address; return account.address;
case DisplayAddressRole:
return account.displayAddress;
case BalanceRole: case BalanceRole:
return account.balance; return account.balance;
case IsPublicRole: case IsPublicRole:
return account.isPublic; return account.isPublic;
case KindRole:
return account.kind;
case SectionRole:
return account.section;
case ProgramOwnerRole:
return account.programOwner;
case ReadStatusRole:
return account.readStatus;
case ProgramNameRole:
return account.programName;
case AccountTypeRole:
return account.accountType;
case VisibilityRole:
return account.isPublic ? QStringLiteral("public") : QStringLiteral("private");
case ControlRole:
return QStringLiteral("wallet");
case CanBePrimaryRole:
return account.canBePrimary;
case IsPrimaryRole:
return account.isPrimary;
case DefinitionIdRole:
return account.definitionId;
case AliasRole:
return account.alias;
case DecodedDataRole:
return account.decodedData;
default: default:
return {}; return {};
} }
@@ -37,25 +71,288 @@ QHash<int, QByteArray> WalletAccountModel::roleNames() const
{ AddressRole, "address" }, { AddressRole, "address" },
{ BalanceRole, "balance" }, { BalanceRole, "balance" },
{ IsPublicRole, "isPublic" }, { IsPublicRole, "isPublic" },
{ KindRole, "kind" },
{ SectionRole, "section" },
{ ProgramOwnerRole, "programOwner" },
{ ReadStatusRole, "readStatus" },
{ ProgramNameRole, "programName" },
{ AccountTypeRole, "accountType" },
{ VisibilityRole, "visibility" },
{ ControlRole, "control" },
{ CanBePrimaryRole, "canBePrimary" },
{ IsPrimaryRole, "isPrimary" },
{ DefinitionIdRole, "definitionId" },
{ AliasRole, "alias" },
{ DisplayAddressRole, "displayAddress" },
{ DecodedDataRole, "decodedData" },
}; };
} }
void WalletAccountModel::replaceAccounts(const QVector<WalletAccount>& accounts) void WalletAccountModel::replaceAccounts(const QVector<WalletAccount>& accounts,
const QHash<QString, QString>& aliases,
const QString& primaryAddress)
{ {
beginResetModel(); beginResetModel();
const qsizetype oldCount = m_accounts.size(); const qsizetype oldCount = m_accounts.size();
m_accounts.clear(); m_accounts.clear();
m_accounts.reserve(accounts.size()); m_accounts.reserve(accounts.size());
for (qsizetype index = 0; index < accounts.size(); ++index) { for (const WalletAccount& account : accounts) {
const WalletAccount& account = accounts.at(index); Entry entry;
m_accounts.append({ entry.alias = aliases.value(account.address);
QStringLiteral("Account %1").arg(index + 1), entry.address = account.address;
account.address, entry.displayAddress = account.displayAddress.isEmpty()
account.balance, ? account.address : account.displayAddress;
account.isPublic, 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(); endResetModel();
if (oldCount != m_accounts.size()) if (oldCount != m_accounts.size())
emit countChanged(); emit countChanged();
} }
bool WalletAccountModel::applyPresentations(
const QVector<WalletAccountPresentation>& presentations)
{
if (presentations.isEmpty())
return clearPresentations();
QHash<QString, int> rowsByAddress;
rowsByAddress.reserve(m_accounts.size());
for (int row = 0; row < m_accounts.size(); ++row) {
const QString& address = m_accounts.at(row).address;
if (!rowsByAddress.contains(address))
rowsByAddress.insert(address, row);
}
int firstChanged = m_accounts.size();
int lastChanged = -1;
for (const WalletAccountPresentation& presentation : presentations) {
const auto row = rowsByAddress.constFind(presentation.address);
if (row == rowsByAddress.cend())
continue;
const Entry current = m_accounts.at(row.value());
Entry entry = current;
if (!presentation.kind.isEmpty())
entry.kind = presentation.kind;
entry.programName = presentation.programName;
entry.accountType = presentation.accountType;
entry.definitionId = presentation.definitionId;
entry.decodedData = presentation.decodedData;
entry.semanticName = presentation.semanticName;
entry.section = sectionFor(entry, presentation.hiddenFromAccounts);
entry.canBePrimary = entry.kind == QStringLiteral("user")
|| entry.kind == QStringLiteral("private");
if (!entry.canBePrimary)
entry.isPrimary = false;
updateEntryName(entry);
if (entry.alias == current.alias
&& entry.semanticName == current.semanticName
&& entry.name == current.name
&& entry.address == current.address
&& entry.displayAddress == current.displayAddress
&& entry.balance == current.balance
&& entry.isPublic == current.isPublic
&& entry.kind == current.kind
&& entry.section == current.section
&& entry.programOwner == current.programOwner
&& entry.readStatus == current.readStatus
&& entry.programName == current.programName
&& entry.accountType == current.accountType
&& entry.definitionId == current.definitionId
&& entry.decodedData == current.decodedData
&& entry.canBePrimary == current.canBePrimary
&& entry.isPrimary == current.isPrimary) {
continue;
}
m_accounts[row.value()] = std::move(entry);
if (row.value() < firstChanged)
firstChanged = row.value();
if (row.value() > lastChanged)
lastChanged = row.value();
}
if (lastChanged < 0)
return false;
emit dataChanged(index(firstChanged), index(lastChanged), {
NameRole,
KindRole,
SectionRole,
ProgramNameRole,
AccountTypeRole,
DecodedDataRole,
CanBePrimaryRole,
IsPrimaryRole,
DefinitionIdRole,
});
return true;
}
bool WalletAccountModel::clearPresentations()
{
int firstChanged = m_accounts.size();
int lastChanged = -1;
for (int row = 0; row < m_accounts.size(); ++row) {
Entry& entry = m_accounts[row];
const Entry current = entry;
resetPresentation(entry);
if (entry.alias == current.alias
&& entry.semanticName == current.semanticName
&& entry.name == current.name
&& entry.kind == current.kind
&& entry.section == current.section
&& entry.programName == current.programName
&& entry.accountType == current.accountType
&& entry.definitionId == current.definitionId
&& entry.decodedData == current.decodedData
&& entry.canBePrimary == current.canBePrimary
&& entry.isPrimary == current.isPrimary) {
continue;
}
if (row < firstChanged)
firstChanged = row;
lastChanged = row;
}
if (lastChanged < 0)
return false;
emit dataChanged(index(firstChanged), index(lastChanged), {
NameRole,
KindRole,
SectionRole,
ProgramNameRole,
AccountTypeRole,
DecodedDataRole,
CanBePrimaryRole,
IsPrimaryRole,
DefinitionIdRole,
});
return true;
}
void WalletAccountModel::setAlias(const QString& address, const QString& alias)
{
const int row = indexOf(address);
if (row < 0)
return;
Entry& entry = m_accounts[row];
entry.alias = alias;
updateEntryName(entry);
emit dataChanged(index(row), index(row), { NameRole, AliasRole });
}
void WalletAccountModel::setPrimaryAddress(const QString& address)
{
for (int row = 0; row < m_accounts.size(); ++row) {
Entry& entry = m_accounts[row];
const bool next = entry.address == address && entry.canBePrimary;
if (entry.isPrimary == next)
continue;
entry.isPrimary = next;
emit dataChanged(index(row), index(row), { IsPrimaryRole });
}
}
bool WalletAccountModel::contains(const QString& address) const
{
return indexOf(address) >= 0;
}
bool WalletAccountModel::canBePrimary(const QString& address) const
{
const int row = indexOf(address);
return row >= 0 && m_accounts.at(row).canBePrimary;
}
QString WalletAccountModel::firstAutomaticPrimary() const
{
for (const Entry& entry : m_accounts) {
if (entry.kind == QStringLiteral("user"))
return entry.address;
}
return {};
}
int WalletAccountModel::indexOf(const QString& address) const
{
for (int row = 0; row < m_accounts.size(); ++row) {
if (m_accounts.at(row).address == address)
return row;
}
return -1;
}
QString WalletAccountModel::defaultName(const Entry& entry)
{
if (!entry.accountType.isEmpty()) {
QString name = entry.accountType;
for (qsizetype index = 1; index < name.size(); ++index) {
if (name.at(index).isUpper() && name.at(index - 1).isLower())
name.insert(index++, QLatin1Char(' '));
}
return name;
}
if (entry.kind == QStringLiteral("user"))
return QStringLiteral("User account");
if (entry.kind == QStringLiteral("private"))
return QStringLiteral("Private account");
if (entry.kind == QStringLiteral("unknown"))
return QStringLiteral("Unknown account");
return QStringLiteral("Program account");
}
QString WalletAccountModel::sectionFor(const Entry& entry, bool hiddenFromAccounts)
{
if (hiddenFromAccounts || entry.kind == QStringLiteral("token_holding"))
return QStringLiteral("hidden");
if (entry.kind == QStringLiteral("user") || entry.kind == QStringLiteral("private"))
return QStringLiteral("accounts");
return QStringLiteral("advanced");
}
void WalletAccountModel::resetPresentation(Entry& entry)
{
entry.semanticName.clear();
entry.programName.clear();
entry.accountType.clear();
entry.definitionId.clear();
entry.decodedData.clear();
if (!entry.isPublic) {
entry.kind = QStringLiteral("private");
entry.canBePrimary = true;
} else if (entry.readStatus != QStringLiteral("ok")) {
entry.kind = QStringLiteral("unknown");
entry.canBePrimary = false;
} else if (entry.programOwner == DEFAULT_PROGRAM_OWNER) {
entry.kind = QStringLiteral("user");
entry.canBePrimary = true;
} else {
entry.kind = QStringLiteral("program");
entry.canBePrimary = false;
}
if (!entry.canBePrimary)
entry.isPrimary = false;
entry.section = sectionFor(entry);
updateEntryName(entry);
}
void WalletAccountModel::updateEntryName(Entry& entry)
{
entry.name = !entry.alias.isEmpty()
? entry.alias
: (!entry.semanticName.isEmpty() ? entry.semanticName : defaultName(entry));
}
+55 -1
View File
@@ -1,10 +1,22 @@
#pragma once #pragma once
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QHash>
#include <QVector> #include <QVector>
#include "WalletProvider.h" #include "WalletProvider.h"
struct WalletAccountPresentation {
QString address;
QString kind;
QString semanticName;
QString programName;
QString accountType;
QString definitionId;
bool hiddenFromAccounts = false;
QString decodedData;
};
class WalletAccountModel final : public QAbstractListModel { class WalletAccountModel final : public QAbstractListModel {
Q_OBJECT Q_OBJECT
Q_PROPERTY(int count READ count NOTIFY countChanged) Q_PROPERTY(int count READ count NOTIFY countChanged)
@@ -15,6 +27,20 @@ public:
AddressRole, AddressRole,
BalanceRole, BalanceRole,
IsPublicRole, IsPublicRole,
KindRole,
SectionRole,
ProgramOwnerRole,
ReadStatusRole,
ProgramNameRole,
AccountTypeRole,
VisibilityRole,
ControlRole,
CanBePrimaryRole,
IsPrimaryRole,
DefinitionIdRole,
AliasRole,
DisplayAddressRole,
DecodedDataRole,
}; };
Q_ENUM(Role) Q_ENUM(Role)
@@ -24,7 +50,17 @@ public:
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() 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 = {});
bool applyPresentations(const QVector<WalletAccountPresentation>& presentations);
bool clearPresentations();
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(); } int count() const { return m_accounts.size(); }
signals: signals:
@@ -32,11 +68,29 @@ signals:
private: private:
struct Entry { struct Entry {
QString alias;
QString semanticName;
QString name; QString name;
QString address; QString address;
QString displayAddress;
QString balance; QString balance;
bool isPublic = true; bool isPublic = true;
QString kind;
QString section;
QString programOwner;
QString readStatus;
QString programName;
QString accountType;
QString definitionId;
QString decodedData;
bool canBePrimary = false;
bool isPrimary = false;
}; };
static QString defaultName(const Entry& entry);
static QString sectionFor(const Entry& entry, bool hiddenFromAccounts = false);
void resetPresentation(Entry& entry);
void updateEntryName(Entry& entry);
QVector<Entry> m_accounts; QVector<Entry> m_accounts;
}; };
+354 -39
View File
@@ -3,11 +3,17 @@
#include <utility> #include <utility>
#include <QDebug> #include <QDebug>
#include <QCryptographicHash>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QPointer>
#include <QSaveFile>
#include <QSettings> #include <QSettings>
#include <QTimer> #include <QTimer>
#include <QUrl> #include <QUrl>
@@ -18,6 +24,10 @@ namespace {
const char SETTINGS_ORG[] = "Logos"; const char SETTINGS_ORG[] = "Logos";
const char DISCONNECTED_KEY[] = "disconnected"; const char DISCONNECTED_KEY[] = "disconnected";
const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; 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) QString toLocalPath(const QString& path)
{ {
@@ -25,6 +35,26 @@ QString toLocalPath(const QString& path)
return QUrl::fromUserInput(path).toLocalFile(); return QUrl::fromUserInput(path).toLocalFile();
return path; 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, WalletController::WalletController(WalletProvider& wallet,
@@ -38,7 +68,10 @@ WalletController::WalletController(WalletProvider& wallet,
m_reachabilityTimer(new QTimer(this)) m_reachabilityTimer(new QTimer(this))
{ {
m_state.walletHome = defaultWalletHome(); m_state.walletHome = defaultWalletHome();
m_state.configPath = defaultConfigPath();
m_state.storagePath = defaultStoragePath();
m_state.walletExists = QFileInfo::exists(defaultStoragePath()); m_state.walletExists = QFileInfo::exists(defaultStoragePath());
m_state.sequencerAddress = configuredSequencer(defaultConfigPath());
m_reachabilityTimer->setInterval(10000); m_reachabilityTimer->setInterval(10000);
connect(m_reachabilityTimer, &QTimer::timeout, connect(m_reachabilityTimer, &QTimer::timeout,
@@ -65,12 +98,56 @@ QString WalletController::defaultStoragePath() const
return m_state.walletHome + QStringLiteral("/storage.json"); return m_state.walletHome + QStringLiteral("/storage.json");
} }
void WalletController::setDefaultSequencerAddress(const QString& address)
{
const QString normalized = address.trimmed();
const QUrl endpoint(normalized);
const QString scheme = endpoint.scheme().toLower();
if (endpoint.isValid()
&& !endpoint.host().isEmpty()
&& (scheme == QStringLiteral("http") || scheme == QStringLiteral("https"))) {
m_defaultSequencerAddress = normalized;
} else {
m_defaultSequencerAddress.clear();
}
}
bool WalletController::seedDefaultWalletConfig(const QString& configPath) const
{
if (m_defaultSequencerAddress.isEmpty() || QFileInfo::exists(configPath))
return true;
const QFileInfo configInfo(configPath);
if (!QDir().mkpath(configInfo.absolutePath())) {
qWarning() << "WalletController: failed to create wallet configuration directory";
return false;
}
QSaveFile config(configPath);
if (!config.open(QIODevice::WriteOnly)) {
qWarning() << "WalletController: failed to open wallet configuration";
return false;
}
const QByteArray contents = QJsonDocument(QJsonObject {
{ QStringLiteral("sequencer_addr"), m_defaultSequencerAddress },
{ QStringLiteral("seq_poll_timeout"), QStringLiteral("12s") },
{ QStringLiteral("seq_tx_poll_max_blocks"), 5 },
{ QStringLiteral("seq_poll_max_retries"), 5 },
{ QStringLiteral("seq_block_poll_max_amount"), 100 },
}).toJson(QJsonDocument::Compact);
if (config.write(contents) != contents.size() || !config.commit()) {
qWarning() << "WalletController: failed to save wallet configuration";
return false;
}
return true;
}
void WalletController::start() void WalletController::start()
{ {
if (m_started) if (m_started)
return; return;
m_started = true; m_started = true;
m_reachabilityTimer->start();
QTimer::singleShot(0, this, &WalletController::openOnStartup); QTimer::singleShot(0, this, &WalletController::openOnStartup);
} }
@@ -83,31 +160,76 @@ void WalletController::openOnStartup()
const QString config = defaultConfigPath(); const QString config = defaultConfigPath();
const QString storage = defaultStoragePath(); const QString storage = defaultStoragePath();
const WalletSession session = m_wallet.connect({ config, storage }); beginOpen(config, storage);
if (session.failure == WalletFailure::WalletMissing) }
return;
if (!session.ok()) { bool WalletController::beginOpen(const QString& config, const QString& storage)
qWarning() << "WalletController: wallet connection failed" {
<< walletFailureCode(session.failure); if (m_state.syncStatus == QStringLiteral("opening")
return; || m_state.syncStatus == QStringLiteral("syncing")) {
return false;
} }
const quint64 generation = ++m_operationGeneration;
m_state.configPath = config; m_state.configPath = config;
m_state.storagePath = storage; m_state.storagePath = storage;
m_state.walletExists = QFileInfo::exists(storage) || session.adopted; m_state.syncStatus = QStringLiteral("opening");
m_state.isWalletOpen = true; m_state.syncError.clear();
applySnapshot(session.snapshot); const QString endpoint = configuredSequencer(config);
if (!endpoint.isEmpty())
m_state.sequencerAddress = endpoint;
emit stateChanged();
QTimer::singleShot(0, this, [this, generation]() {
if (generation == m_operationGeneration
&& m_state.syncStatus == QStringLiteral("opening")) {
m_state.syncStatus = QStringLiteral("syncing");
emit stateChanged();
}
});
const QPointer<WalletController> guard(this);
m_wallet.connectAsync({ config, storage },
[guard, generation, config, storage](WalletSession session) {
if (!guard || generation != guard->m_operationGeneration)
return;
if (session.failure == WalletFailure::WalletMissing) {
guard->m_state.syncStatus = QStringLiteral("closed");
guard->m_state.walletExists = false;
emit guard->stateChanged();
return;
}
if (!session.ok()) {
qWarning() << "WalletController: wallet connection failed"
<< walletFailureCode(session.failure);
guard->m_state.syncStatus = QStringLiteral("error");
guard->m_state.syncError = walletFailureCode(session.failure);
emit guard->stateChanged();
return;
}
guard->m_state.configPath = config;
guard->m_state.storagePath = storage;
guard->m_state.walletExists = QFileInfo::exists(storage) || session.adopted;
guard->m_state.isWalletOpen = true;
guard->m_state.syncStatus = QStringLiteral("ready");
guard->applySnapshot(session.snapshot);
});
return true;
} }
QString WalletController::createDefaultWallet(const QString& password) QString WalletController::createDefaultWallet(const QString& password)
{ {
return createWallet(defaultConfigPath(), defaultStoragePath(), password); const QString config = defaultConfigPath();
if (!seedDefaultWalletConfig(config))
return {};
return createWallet(config, defaultStoragePath(), password);
} }
QString WalletController::createWallet(const QString& configPath, QString WalletController::createWallet(const QString& configPath,
const QString& storagePath, const QString& storagePath,
const QString& password) const QString& password)
{ {
const quint64 generation = ++m_operationGeneration;
const QString config = toLocalPath(configPath); const QString config = toLocalPath(configPath);
const QString storage = toLocalPath(storagePath); const QString storage = toLocalPath(storagePath);
const WalletCreation creation = m_wallet.createWallet( const WalletCreation creation = m_wallet.createWallet(
@@ -117,20 +239,50 @@ QString WalletController::createWallet(const QString& configPath,
<< walletFailureCode(creation.failure); << walletFailureCode(creation.failure);
return {}; return {};
} }
stopReachability();
m_state.configPath = config; m_state.configPath = config;
m_state.storagePath = storage; m_state.storagePath = storage;
m_state.walletExists = true;
QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false);
if (!creation.ok()) { if (!creation.ok()) {
qWarning() << "WalletController: wallet creation failed" qWarning() << "WalletController: wallet creation failed"
<< walletFailureCode(creation.failure); << walletFailureCode(creation.failure);
m_state.walletExists = QFileInfo::exists(storage);
m_state.isWalletOpen = false;
m_state.syncStatus = QStringLiteral("error");
m_state.syncError = walletFailureCode(creation.failure);
emit stateChanged(); emit stateChanged();
return creation.mnemonic; return creation.mnemonic;
} }
m_state.walletExists = true;
m_state.isWalletOpen = true; m_state.isWalletOpen = true;
applySnapshot(creation.snapshot); m_state.syncStatus = QStringLiteral("syncing");
m_state.syncError.clear();
m_accountModel->replaceAccounts({});
emit stateChanged();
const QPointer<WalletController> guard(this);
QTimer::singleShot(0, this, [guard, generation]() {
if (!guard || generation != guard->m_operationGeneration)
return;
guard->m_wallet.snapshotAsync(true,
[guard, generation](WalletSnapshot snapshot) {
if (!guard || generation != guard->m_operationGeneration)
return;
if (snapshot.ok()) {
guard->m_state.syncStatus = QStringLiteral("ready");
guard->applySnapshot(snapshot);
return;
}
qWarning() << "WalletController: initial wallet sync failed"
<< walletFailureCode(snapshot.failure);
guard->m_state.syncStatus = QStringLiteral("error");
guard->m_state.syncError = walletFailureCode(snapshot.failure);
emit guard->stateChanged();
});
});
return creation.mnemonic; return creation.mnemonic;
} }
@@ -140,29 +292,72 @@ bool WalletController::open()
? defaultConfigPath() : m_state.configPath; ? defaultConfigPath() : m_state.configPath;
const QString storage = m_state.storagePath.isEmpty() const QString storage = m_state.storagePath.isEmpty()
? defaultStoragePath() : m_state.storagePath; ? 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); QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false);
applySnapshot(session.snapshot); return beginOpen(config, storage);
return true;
} }
void WalletController::disconnect() void WalletController::disconnect()
{ {
++m_operationGeneration;
stopReachability();
m_wallet.disconnect(); m_wallet.disconnect();
m_state.isWalletOpen = false; m_state.isWalletOpen = false;
m_state.syncStatus = QStringLiteral("closed");
m_state.syncError.clear();
m_accountModel->replaceAccounts({}); m_accountModel->replaceAccounts({});
QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true); QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true);
emit stateChanged(); 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)
{
if (!m_accountModel->applyPresentations(presentations))
return;
const QString previousPrimary = m_state.primaryAccountAddress;
const QString previousPrimaryName = m_state.primaryAccountName;
QString primary = m_state.primaryAccountAddress;
if (!m_accountModel->canBePrimary(primary))
primary = m_accountModel->firstAutomaticPrimary();
m_accountModel->setPrimaryAddress(primary);
if (primary != previousPrimary)
storePrimaryAccount(primary);
updatePrimaryState(primary);
if (m_state.primaryAccountAddress != previousPrimary
|| m_state.primaryAccountName != previousPrimaryName) {
emit stateChanged();
}
} }
QString WalletController::createAccount(bool isPublic) QString WalletController::createAccount(bool isPublic)
@@ -174,23 +369,42 @@ QString WalletController::createAccount(bool isPublic)
return {}; return {};
} }
if (creation.snapshot.ok()) { if (creation.snapshot.ok()) {
m_state.syncStatus = QStringLiteral("ready");
m_state.syncError.clear();
applySnapshot(creation.snapshot); applySnapshot(creation.snapshot);
} else { } else {
qWarning() << "WalletController: account refresh failed" qWarning() << "WalletController: account refresh failed"
<< walletFailureCode(creation.snapshot.failure); << walletFailureCode(creation.snapshot.failure);
m_state.syncStatus = QStringLiteral("error");
m_state.syncError = walletFailureCode(creation.snapshot.failure);
emit stateChanged();
} }
return creation.accountId; return creation.accountId;
} }
void WalletController::refresh() void WalletController::refresh()
{ {
const WalletSnapshot next = m_wallet.snapshot(true); if (!m_state.isWalletOpen || m_state.syncStatus == QStringLiteral("syncing"))
if (next.ok()) { return;
applySnapshot(next); const quint64 generation = ++m_operationGeneration;
} else { m_state.syncStatus = QStringLiteral("syncing");
qWarning() << "WalletController: wallet refresh failed" m_state.syncError.clear();
<< walletFailureCode(next.failure); emit stateChanged();
} const QPointer<WalletController> guard(this);
m_wallet.snapshotAsync(true, [guard, generation](WalletSnapshot next) {
if (!guard || generation != guard->m_operationGeneration)
return;
if (next.ok()) {
guard->m_state.syncStatus = QStringLiteral("ready");
guard->applySnapshot(next);
} else {
qWarning() << "WalletController: wallet refresh failed"
<< walletFailureCode(next.failure);
guard->m_state.syncStatus = QStringLiteral("error");
guard->m_state.syncError = walletFailureCode(next.failure);
emit guard->stateChanged();
}
});
} }
QString WalletController::balance(const QString& accountId, bool isPublic) QString WalletController::balance(const QString& accountId, bool isPublic)
@@ -205,24 +419,125 @@ QString WalletController::balance(const QString& accountId, bool isPublic)
void WalletController::applySnapshot(const WalletSnapshot& snapshot) 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.lastSyncedBlock = static_cast<int>(snapshot.lastSyncedBlock);
m_state.currentBlockHeight = static_cast<int>(snapshot.currentBlockHeight); 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(); emit stateChanged();
if (!m_reachabilityTimer->isActive())
m_reachabilityTimer->start();
checkReachability(); 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::stopReachability()
{
m_reachabilityTimer->stop();
++m_reachabilityGeneration;
if (m_reachabilityReply) {
QNetworkReply* reply = m_reachabilityReply;
m_reachabilityReply = nullptr;
m_reachabilityEndpoint.clear();
reply->abort();
}
}
void WalletController::checkReachability() void WalletController::checkReachability()
{ {
if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty()) if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty())
return; return;
QNetworkRequest request{QUrl(m_state.sequencerAddress)}; const QString endpoint = m_state.sequencerAddress;
if (m_reachabilityReply && endpoint == m_reachabilityEndpoint)
return;
const quint64 generation = ++m_reachabilityGeneration;
if (m_reachabilityReply)
m_reachabilityReply->abort();
QNetworkRequest request{QUrl(endpoint)};
request.setTransferTimeout(4000); request.setTransferTimeout(4000);
QNetworkReply* reply = m_network->get(request); QNetworkReply* reply = m_network->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() { m_reachabilityReply = reply;
if (!m_state.isWalletOpen) { m_reachabilityEndpoint = endpoint;
connect(reply, &QNetworkReply::finished, this,
[this, reply, generation, endpoint]() {
if (m_reachabilityReply == reply) {
m_reachabilityReply = nullptr;
m_reachabilityEndpoint.clear();
}
if (!m_state.isWalletOpen
|| generation != m_reachabilityGeneration
|| endpoint != m_state.sequencerAddress) {
reply->deleteLater(); reply->deleteLater();
return; return;
} }
+36
View File
@@ -1,13 +1,17 @@
#pragma once #pragma once
#include <QObject> #include <QObject>
#include <QHash>
#include <QString> #include <QString>
#include <QVector>
#include "WalletProvider.h" #include "WalletProvider.h"
class QNetworkAccessManager; class QNetworkAccessManager;
class QNetworkReply;
class QTimer; class QTimer;
class WalletAccountModel; class WalletAccountModel;
struct WalletAccountPresentation;
struct WalletUiState { struct WalletUiState {
bool isWalletOpen = false; bool isWalletOpen = false;
@@ -19,6 +23,15 @@ struct WalletUiState {
int currentBlockHeight = 0; int currentBlockHeight = 0;
QString sequencerAddress; QString sequencerAddress;
bool sequencerReachable = true; 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 { class WalletController final : public QObject {
@@ -33,8 +46,10 @@ public:
WalletAccountModel* accountModel() const { return m_accountModel; } WalletAccountModel* accountModel() const { return m_accountModel; }
const WalletUiState& state() const { return m_state; } const WalletUiState& state() const { return m_state; }
const WalletSnapshot& snapshot() const { return m_snapshot; }
void start(); void start();
void setDefaultSequencerAddress(const QString& address);
QString createAccount(bool isPublic); QString createAccount(bool isPublic);
void refresh(); void refresh();
QString balance(const QString& accountId, bool isPublic); QString balance(const QString& accountId, bool isPublic);
@@ -44,24 +59,45 @@ public:
const QString& password); const QString& password);
bool open(); bool open();
void disconnect(); void disconnect();
bool setAccountAlias(const QString& address, const QString& alias);
bool setPrimaryAccount(const QString& address);
void applyAccountPresentations(
const QVector<WalletAccountPresentation>& presentations);
signals: signals:
void stateChanged(); void stateChanged();
void snapshotChanged();
private: private:
static QString defaultWalletHome(); static QString defaultWalletHome();
QString defaultConfigPath() const; QString defaultConfigPath() const;
QString defaultStoragePath() const; QString defaultStoragePath() const;
bool seedDefaultWalletConfig(const QString& configPath) const;
void openOnStartup(); void openOnStartup();
bool beginOpen(const QString& config, const QString& storage);
void applySnapshot(const WalletSnapshot& snapshot); void applySnapshot(const WalletSnapshot& snapshot);
void checkReachability(); void checkReachability();
void stopReachability();
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; WalletProvider& m_wallet;
QString m_settingsApplication; QString m_settingsApplication;
WalletUiState m_state; WalletUiState m_state;
WalletSnapshot m_snapshot;
QHash<QString, QString> m_aliases;
WalletAccountModel* m_accountModel; WalletAccountModel* m_accountModel;
QNetworkAccessManager* m_network; QNetworkAccessManager* m_network;
QNetworkReply* m_reachabilityReply = nullptr;
QString m_reachabilityEndpoint;
QString m_defaultSequencerAddress;
QTimer* m_reachabilityTimer; QTimer* m_reachabilityTimer;
bool m_started = false; bool m_started = false;
quint64 m_operationGeneration = 0;
quint64 m_reachabilityGeneration = 0;
}; };
@@ -0,0 +1,70 @@
#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;
}
+31
View File
@@ -0,0 +1,31 @@
#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);
};
@@ -0,0 +1,377 @@
#include "WalletPortfolioService.h"
#include <algorithm>
#include <utility>
#include <QCryptographicHash>
#include <QHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#include <QVariantMap>
namespace {
QJsonObject enumFields(const QJsonValue& value, const QString& variant)
{
return value.toObject().value(variant).toObject();
}
QString decodedDataText(const QJsonValue& value)
{
if (value.isObject()) {
return QString::fromUtf8(
QJsonDocument(value.toObject()).toJson(QJsonDocument::Indented)).trimmed();
}
if (value.isArray()) {
return QString::fromUtf8(
QJsonDocument(value.toArray()).toJson(QJsonDocument::Indented)).trimmed();
}
return {};
}
QString 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;
}
void addField(QCryptographicHash& hash, const QString& value)
{
const QByteArray utf8 = value.toUtf8();
hash.addData(QByteArray::number(utf8.size()));
hash.addData(QByteArrayLiteral(":"));
hash.addData(utf8);
hash.addData(QByteArrayLiteral(";"));
}
QByteArray accountReadsSignature(const QVector<WalletAccountRead>& reads)
{
QCryptographicHash hash(QCryptographicHash::Sha256);
hash.addData(QByteArray::number(reads.size()));
hash.addData(QByteArrayLiteral(";"));
for (const WalletAccountRead& read : reads) {
addField(hash, read.accountId);
addField(hash, read.status);
addField(hash, read.programOwner);
addField(hash, read.balanceHex);
addField(hash, read.nonceHex);
addField(hash, read.dataHex);
}
return hash.result();
}
WalletPortfolioResult failureResult(const QString& status, const QString& error)
{
WalletPortfolioResult result;
result.status = status;
result.error = error;
return result;
}
}
struct WalletPortfolioService::State {
struct Program {
QString name;
QByteArray idl;
};
struct Token {
QString id;
QString displayId;
QString name;
};
struct DecodeCache {
QByteArray idl;
QByteArray readsSignature;
WalletDecodeResult result;
};
explicit State(Decoder decoderFunction)
: decoder(decoderFunction ? std::move(decoderFunction)
: Decoder(WalletIdlDecoder::decode))
{
}
WalletDecodeResult decode(const QString& programId,
const Program& program,
const QVector<WalletAccountRead>& reads)
{
const QByteArray signature = accountReadsSignature(reads);
const auto cached = decodedPrograms.constFind(programId);
if (cached != decodedPrograms.cend()
&& cached->idl == program.idl
&& cached->readsSignature == signature) {
return cached->result;
}
WalletDecodeResult result = decoder(program.idl, reads);
decodedPrograms.insert(programId, { program.idl, signature, result });
return result;
}
Decoder decoder;
QHash<QString, Program> programs;
QHash<QString, DecodeCache> decodedPrograms;
};
WalletPortfolioService::WalletPortfolioService(Decoder decoder)
: m_state(std::make_unique<State>(std::move(decoder)))
{
}
WalletPortfolioService::~WalletPortfolioService() = default;
void WalletPortfolioService::registerProgram(const QString& programId,
const QString& programName,
const QByteArray& idlJson)
{
if (programId.isEmpty() || programName.isEmpty() || idlJson.isEmpty())
return;
const auto existing = m_state->programs.constFind(programId);
if (existing != m_state->programs.cend()
&& existing->name == programName
&& existing->idl == idlJson) {
return;
}
m_state->programs.insert(programId, { programName, idlJson });
m_state->decodedPrograms.remove(programId);
}
WalletPortfolioResult WalletPortfolioService::refresh(
const WalletPortfolioRequest& request)
{
if (request.walletFailure != WalletFailure::None)
return failureResult(QStringLiteral("error"), walletFailureCode(request.walletFailure));
if (request.tokenProgramId.isEmpty() || request.tokenDefinitionIds.isEmpty()) {
return failureResult(
QStringLiteral("blocked"), QStringLiteral("network_context_missing"));
}
if (request.tokenIdl.isEmpty())
return failureResult(QStringLiteral("error"), QStringLiteral("token_idl_missing"));
QVector<State::Token> tokens;
QSet<QString> resolvedIds;
QSet<QString> resolvedTokenIds;
for (const QVariant& value : request.tokens) {
const QVariantMap row = value.toMap();
const QString id = row.value(QStringLiteral("definitionIdHex")).toString();
const QString displayId = row.value(QStringLiteral("definitionId")).toString();
if (id.isEmpty() || displayId.isEmpty()
|| resolvedTokenIds.contains(id.toLower())) {
continue;
}
const bool requested = std::any_of(
request.tokenDefinitionIds.cbegin(),
request.tokenDefinitionIds.cend(),
[&id, &displayId](const QString& expected) {
return expected == displayId
|| (expected.size() == id.size()
&& expected.compare(id, Qt::CaseInsensitive) == 0);
});
if (!requested) {
continue;
}
QString name = row.value(QStringLiteral("name")).toString().trimmed();
if (name.isEmpty())
name = QStringLiteral("Unnamed token");
tokens.append({ id, displayId, std::move(name) });
resolvedTokenIds.insert(id.toLower());
resolvedIds.insert(id);
resolvedIds.insert(displayId);
}
QHash<QString, State::Program> programs = m_state->programs;
programs.insert(request.tokenProgramId, {
request.tokenProgramName.isEmpty() ? QStringLiteral("Token")
: request.tokenProgramName,
request.tokenIdl,
});
QHash<QString, QString> tokenNames;
for (const State::Token& token : tokens)
tokenNames.insert(token.id, token.name);
WalletPortfolioResult result;
QHash<QString, QString> balances;
bool tokenHoldingFailure = false;
bool unreadPublicAccount = false;
bool programFailure = false;
for (auto program = programs.cbegin(); program != programs.cend(); ++program) {
QVector<WalletAccountRead> programReads;
for (const WalletAccountRead& read : request.publicAccountReads) {
if (!read.ok()) {
unreadPublicAccount = true;
continue;
}
if (read.programOwner == program.key())
programReads.append(read);
}
if (programReads.isEmpty())
continue;
const WalletDecodeResult decoded = m_state->decode(
program.key(), program.value(), programReads);
if (!decoded.ok()) {
programFailure = true;
if (program.key() == request.tokenProgramId)
tokenHoldingFailure = true;
continue;
}
if (decoded.accounts.size() != programReads.size()) {
programFailure = true;
if (program.key() == request.tokenProgramId)
tokenHoldingFailure = true;
}
const qsizetype count = std::min(decoded.accounts.size(), programReads.size());
for (qsizetype index = 0; index < count; ++index) {
const WalletDecodedAccount& account = decoded.accounts.at(index);
const WalletAccountRead& read = programReads.at(index);
if (account.id != read.accountId) {
programFailure = true;
if (program.key() == request.tokenProgramId)
tokenHoldingFailure = true;
continue;
}
WalletAccountPresentation presentation;
presentation.address = read.accountId;
presentation.programName = program.value().name;
presentation.accountType = account.typeName;
if (account.status == QStringLiteral("decoded"))
presentation.decodedData = decodedDataText(account.value);
if (program.key() == request.tokenProgramId
&& account.typeName == QStringLiteral("TokenHolding")) {
const QJsonObject fungible = enumFields(account.value, QStringLiteral("Fungible"));
const QString encodedDefinitionId = fungible.value(
QStringLiteral("definition_id")).toString();
const QString definitionId = account.accountIds.value(encodedDefinitionId);
const QString amount = fungible.value(QStringLiteral("balance")).toString();
const QString total = decimalAdd(
balances.value(definitionId, QStringLiteral("0")), amount);
if (account.status != QStringLiteral("decoded")
|| fungible.isEmpty()
|| definitionId.isEmpty()
|| total.isEmpty()) {
tokenHoldingFailure = true;
} else {
balances.insert(definitionId, total);
}
presentation.kind = QStringLiteral("token_holding");
presentation.definitionId = definitionId;
presentation.hiddenFromAccounts = true;
const QString tokenName = tokenNames.value(definitionId);
if (!tokenName.isEmpty())
presentation.semanticName = tokenName + QStringLiteral(" holding");
} else if (program.key() == request.tokenProgramId
&& account.typeName == QStringLiteral("TokenDefinition")) {
presentation.kind = QStringLiteral("token_definition");
presentation.semanticName = enumFields(
account.value, QStringLiteral("Fungible"))
.value(QStringLiteral("name")).toString();
} else if (program.key() == request.tokenProgramId
&& account.typeName == QStringLiteral("TokenMetadata")) {
presentation.kind = QStringLiteral("token_metadata");
} else {
presentation.kind = QStringLiteral("program");
presentation.semanticName = account.typeName;
}
result.presentations.append(std::move(presentation));
}
}
QVariantList available;
auto appendAsset = [&result, &available](const QString& id,
const QString& displayId,
const QString& name,
const QString& programOwner,
const QString& balance,
bool unavailable) {
const bool positive = !unavailable
&& balance != QStringLiteral("0") && !balance.isEmpty();
QVariantMap asset {
{ QStringLiteral("name"), name },
{ QStringLiteral("symbol"), name },
{ QStringLiteral("balance"), unavailable ? QString() : balance },
{ QStringLiteral("definitionId"), id },
{ QStringLiteral("displayDefinitionId"), displayId },
{ QStringLiteral("programOwner"), programOwner },
{ QStringLiteral("status"), unavailable ? QStringLiteral("unavailable")
: QStringLiteral("ready") },
{ QStringLiteral("section"), positive ? QStringLiteral("assets")
: QStringLiteral("available") },
};
if (positive)
result.assets.append(std::move(asset));
else
available.append(std::move(asset));
};
const bool balancesUnavailable = tokenHoldingFailure || unreadPublicAccount;
for (const State::Token& token : tokens) {
appendAsset(token.id,
token.displayId,
token.name,
request.tokenProgramId,
balances.value(token.id, QStringLiteral("0")),
balancesUnavailable);
}
QSet<QString> missingIds;
for (const QString& id : request.tokenDefinitionIds) {
if (resolvedIds.contains(id)
|| resolvedTokenIds.contains(id.toLower())
|| missingIds.contains(id)) {
continue;
}
missingIds.insert(id);
appendAsset(id,
id,
QStringLiteral("Unknown token"),
{},
{},
true);
}
result.assets.append(available);
if (tokens.isEmpty()) {
result.status = QStringLiteral("error");
result.error = QStringLiteral("definitions_unavailable");
} else if (!missingIds.isEmpty() || tokenHoldingFailure || unreadPublicAccount) {
result.status = QStringLiteral("partial");
result.error = tokenHoldingFailure
? QStringLiteral("holding_decode_failed")
: unreadPublicAccount
? QStringLiteral("public_account_read_failed")
: QStringLiteral("some_definitions_unavailable");
} else if (programFailure) {
result.status = QStringLiteral("partial");
result.error = QStringLiteral("program_decode_failed");
} else {
result.status = QStringLiteral("ready");
}
return result;
}
@@ -0,0 +1,67 @@
#pragma once
#include <functional>
#include <memory>
#include <QByteArray>
#include <QString>
#include <QStringList>
#include <QVariantList>
#include <QVector>
#include "WalletAccountModel.h"
#include "WalletIdlDecoder.h"
#include "WalletProvider.h"
// Input for a portfolio refresh. The snapshot constructor deliberately copies
// only `WalletSnapshot::publicAccountReads`; callers must not reconstruct reads
// from the display-model accounts.
struct WalletPortfolioRequest {
WalletPortfolioRequest() = default;
explicit WalletPortfolioRequest(const WalletSnapshot& snapshot)
: walletFailure(snapshot.failure),
publicAccountReads(snapshot.publicAccountReads)
{
}
WalletFailure walletFailure = WalletFailure::None;
QVector<WalletAccountRead> publicAccountReads;
QStringList tokenDefinitionIds;
QVariantList tokens;
QString tokenProgramId;
QByteArray tokenIdl;
QString tokenProgramName = QStringLiteral("Token");
};
struct WalletPortfolioResult {
QVector<WalletAccountPresentation> presentations;
QVariantList assets;
QString status = QStringLiteral("idle");
QString error;
};
// Presents accounts owned by registered IDL programs and combines decoded token
// holdings with token definitions already resolved by the token module.
class WalletPortfolioService final {
public:
using Decoder = std::function<WalletDecodeResult(
const QByteArray&, const QVector<WalletAccountRead>&)>;
explicit WalletPortfolioService(Decoder decoder = {});
~WalletPortfolioService();
WalletPortfolioService(const WalletPortfolioService&) = delete;
WalletPortfolioService& operator=(const WalletPortfolioService&) = delete;
// Adds an IDL-backed account presentation. Later registrations for the
// same program id replace the prior definition.
void registerProgram(const QString& programId,
const QString& programName,
const QByteArray& idlJson);
WalletPortfolioResult refresh(const WalletPortfolioRequest& request);
private:
struct State;
std::unique_ptr<State> m_state;
};
+10
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <functional>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QVector> #include <QVector>
@@ -38,6 +39,10 @@ struct WalletAccount {
QString address; QString address;
QString balance; QString balance;
bool isPublic = true; bool isPublic = true;
QString readStatus;
QString programOwner;
QString dataHex;
QString displayAddress;
}; };
struct WalletSnapshot { struct WalletSnapshot {
@@ -92,12 +97,17 @@ struct WalletSubmission {
class WalletProvider { class WalletProvider {
public: public:
using SessionCallback = std::function<void(WalletSession)>;
using SnapshotCallback = std::function<void(WalletSnapshot)>;
virtual ~WalletProvider() = default; virtual ~WalletProvider() = default;
virtual WalletSession connect(const WalletPaths& paths) = 0; virtual WalletSession connect(const WalletPaths& paths) = 0;
virtual void connectAsync(const WalletPaths& paths, SessionCallback callback) = 0;
virtual WalletCreation createWallet(const WalletPaths& paths, virtual WalletCreation createWallet(const WalletPaths& paths,
const QString& password) = 0; const QString& password) = 0;
virtual WalletSnapshot snapshot(bool forceRefresh = false) = 0; virtual WalletSnapshot snapshot(bool forceRefresh = false) = 0;
virtual void snapshotAsync(bool forceRefresh, SnapshotCallback callback) = 0;
virtual void clearSnapshot() = 0; virtual void clearSnapshot() = 0;
virtual WalletAccountCreation createAccount(bool isPublic) = 0; virtual WalletAccountCreation createAccount(bool isPublic) = 0;
virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0; virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0;
@@ -1,13 +1,20 @@
#include <QDir>
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QHostAddress>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QSettings> #include <QSettings>
#include <QSignalSpy> #include <QSignalSpy>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <QTimer> #include <QTimer>
#include <QtTest> #include <QtTest>
#include <memory>
#include <utility>
#include "FakeWalletProvider.h" #include "FakeWalletProvider.h"
#include "LogosWalletProvider.h" #include "LogosWalletProvider.h"
#include "WalletAccountModel.h" #include "WalletAccountModel.h"
@@ -17,7 +24,33 @@
namespace { namespace {
const QString ACCOUNT_A(64, QLatin1Char('a')); const QString ACCOUNT_A(64, QLatin1Char('a'));
const QString ACCOUNT_B(64, QLatin1Char('b')); const QString ACCOUNT_B(64, QLatin1Char('b'));
const QString ACCOUNT_C(64, QLatin1Char('d'));
const QString PROGRAM_ID(64, QLatin1Char('c')); const QString PROGRAM_ID(64, QLatin1Char('c'));
const QString EOA_OWNER(64, QLatin1Char('0'));
class ScopedEnvironment final {
public:
ScopedEnvironment(QByteArray name, QByteArray value)
: m_name(std::move(name)),
m_hadValue(qEnvironmentVariableIsSet(m_name.constData())),
m_previous(qgetenv(m_name.constData()))
{
qputenv(m_name.constData(), value);
}
~ScopedEnvironment()
{
if (m_hadValue)
qputenv(m_name.constData(), m_previous);
else
qunsetenv(m_name.constData());
}
private:
QByteArray m_name;
bool m_hadValue;
QByteArray m_previous;
};
QString publicAccountJson(const QString& owner = PROGRAM_ID, QString publicAccountJson(const QString& owner = PROGRAM_ID,
const QString& balance = QStringLiteral("01000000000000000000000000000000"), const QString& balance = QStringLiteral("01000000000000000000000000000000"),
@@ -39,6 +72,7 @@ QVariantMap accountEntry(const QString& id, bool isPublic)
{ QStringLiteral("is_public"), isPublic }, { QStringLiteral("is_public"), isPublic },
}; };
} }
} }
class LogosWalletProviderTest : public QObject { class LogosWalletProviderTest : public QObject {
@@ -47,18 +81,33 @@ class LogosWalletProviderTest : public QObject {
private slots: private slots:
void adoptsOpenWalletAndCachesSnapshots(); void adoptsOpenWalletAndCachesSnapshots();
void opensConfiguredWalletWhenNoSharedSessionExists(); void opensConfiguredWalletWhenNoSharedSessionExists();
void opensStoredWalletAsynchronouslyWithoutAccountProbe();
void opensAndReadsAsynchronously();
void avoidsSavingAfterUnchangedAsynchronousSnapshots();
void createsAndPersistsWallet(); void createsAndPersistsWallet();
void validatesCompletePublicAccountPayloads(); void validatesCompletePublicAccountPayloads();
void fallsBackToBalanceWhenPublicReadFails(); void fallsBackToBalanceWhenPublicReadFails();
void createsAndPersistsAccounts(); void createsAndPersistsAccounts();
void preservesCreatedAccountWhenPublicReadFails(); void preservesCreatedAccountWhenPublicReadFails();
void preservesCreatedAccountWhenSnapshotRefreshFails(); void createdAccountDoesNotRescanWallet();
void dispatchesExactGenericTransaction(); void dispatchesExactGenericTransaction();
void rejectsInvalidSubmissionResponses(); void rejectsInvalidSubmissionResponses();
void exposesStableAccountModelRoles(); void exposesStableAccountModelRoles();
void clearsStaleAccountPresentationsWithoutInvalidatingPrimary();
void persistsHumanizedWalletPreferences();
void fakeProviderImplementsConsumerContract(); void fakeProviderImplementsConsumerContract();
void controllerOwnsUiWalletFlow(); void controllerOwnsUiWalletFlow();
void controllerSeparatesSnapshotsFromCosmeticState();
void controllerOpenDoesNotWaitForWalletSync();
void controllerCreationDoesNotWaitForWalletSync();
void controllerSeedsDefaultWalletConfigWithConfiguredEndpoint();
void controllerPreservesExistingDefaultWalletConfig();
void controllerStopsReachabilityChecksAfterDisconnect(); void controllerStopsReachabilityChecksAfterDisconnect();
void completedAsyncSnapshotReleasesCallback();
void deferredCallbacksIgnoreDestroyedController();
void newerReachabilityResultWins();
void coalescesReachabilityChecksForSameEndpoint();
void controllerReportsPartialWalletCreation();
}; };
void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
@@ -84,11 +133,18 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("1")); QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("1"));
QCOMPARE(session.snapshot.accounts.at(1).balance, QStringLiteral("42")); QCOMPARE(session.snapshot.accounts.at(1).balance, QStringLiteral("42"));
QCOMPARE(session.snapshot.publicAccountReads.size(), 1); 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(0).displayAddress,
QStringLiteral("base58-") + ACCOUNT_A);
QCOMPARE(session.snapshot.accounts.at(1).readStatus, QStringLiteral("private"));
QCOMPARE(session.snapshot.currentBlockHeight, quint64(12)); QCOMPARE(session.snapshot.currentBlockHeight, quint64(12));
QCOMPARE(session.snapshot.lastSyncedBlock, quint64(11)); QCOMPARE(session.snapshot.lastSyncedBlock, quint64(11));
const int listCalls = modules.logos_execution_zone.listCalls; const int listCalls = modules.logos_execution_zone.listCalls;
const int readCalls = modules.logos_execution_zone.publicReadCalls; const int readCalls = modules.logos_execution_zone.publicReadCalls;
const int saveCalls = modules.logos_execution_zone.saveCalls;
QVERIFY(provider.snapshot().ok()); QVERIFY(provider.snapshot().ok());
QCOMPARE(modules.logos_execution_zone.listCalls, listCalls); QCOMPARE(modules.logos_execution_zone.listCalls, listCalls);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, readCalls); QCOMPARE(modules.logos_execution_zone.publicReadCalls, readCalls);
@@ -96,6 +152,7 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
QVERIFY(provider.snapshot(true).ok()); QVERIFY(provider.snapshot(true).ok());
QVERIFY(modules.logos_execution_zone.listCalls > listCalls); QVERIFY(modules.logos_execution_zone.listCalls > listCalls);
QVERIFY(modules.logos_execution_zone.publicReadCalls > readCalls); QVERIFY(modules.logos_execution_zone.publicReadCalls > readCalls);
QCOMPARE(modules.logos_execution_zone.saveCalls, saveCalls);
modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(
PROGRAM_ID, QString(32, QLatin1Char('f'))); PROGRAM_ID, QString(32, QLatin1Char('f')));
@@ -131,6 +188,7 @@ void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists()
QVERIFY(!session.adopted); QVERIFY(!session.adopted);
QCOMPARE(modules.logos_execution_zone.openCalls, 1); QCOMPARE(modules.logos_execution_zone.openCalls, 1);
QCOMPARE(modules.logos_execution_zone.openedStorage, storage); QCOMPARE(modules.logos_execution_zone.openedStorage, storage);
QCOMPARE(modules.logos_execution_zone.listCalls, 1);
LogosModules missingModules; LogosModules missingModules;
LogosWalletProvider missingProvider(&missingModules); LogosWalletProvider missingProvider(&missingModules);
@@ -138,12 +196,83 @@ void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists()
WalletFailure::WalletMissing); WalletFailure::WalletMissing);
} }
void LogosWalletProviderTest::opensStoredWalletAsynchronouslyWithoutAccountProbe()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString storage = directory.filePath(QStringLiteral("storage.json"));
QFile file(storage);
QVERIFY(file.open(QIODevice::WriteOnly));
file.close();
LogosModules modules;
LogosWalletProvider provider(&modules);
bool connected = false;
provider.connectAsync({ directory.filePath(QStringLiteral("wallet.json")), storage },
[&connected](WalletSession session) {
connected = session.ok() && !session.adopted;
});
QVERIFY(connected);
QCOMPARE(modules.logos_execution_zone.openCalls, 1);
QCOMPARE(modules.logos_execution_zone.listCalls, 1);
}
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);
}
void LogosWalletProviderTest::avoidsSavingAfterUnchangedAsynchronousSnapshots()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
modules.logos_execution_zone.currentBlockHeight = 12;
modules.logos_execution_zone.lastSyncedBlock = 12;
LogosWalletProvider provider(&modules);
bool connected = false;
provider.connectAsync({}, [&connected](WalletSession session) {
connected = session.ok();
});
QVERIFY(connected);
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
bool refreshed = false;
provider.snapshotAsync(true, [&refreshed](WalletSnapshot snapshot) {
refreshed = snapshot.ok();
});
QVERIFY(refreshed);
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
}
void LogosWalletProviderTest::createsAndPersistsWallet() void LogosWalletProviderTest::createsAndPersistsWallet()
{ {
QTemporaryDir directory; QTemporaryDir directory;
QVERIFY(directory.isValid()); QVERIFY(directory.isValid());
LogosModules modules; LogosModules modules;
modules.logos_execution_zone.currentBlockHeight = 12;
modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) };
modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson());
LogosWalletProvider provider(&modules); LogosWalletProvider provider(&modules);
const WalletPaths paths { const WalletPaths paths {
directory.filePath(QStringLiteral("config/wallet.json")), directory.filePath(QStringLiteral("config/wallet.json")),
@@ -157,6 +286,9 @@ void LogosWalletProviderTest::createsAndPersistsWallet()
QCOMPARE(modules.logos_execution_zone.createdStorage, paths.storage); QCOMPARE(modules.logos_execution_zone.createdStorage, paths.storage);
QCOMPARE(modules.logos_execution_zone.createdPassword, QStringLiteral("secret")); QCOMPARE(modules.logos_execution_zone.createdPassword, QStringLiteral("secret"));
QVERIFY(modules.logos_execution_zone.saveCalls >= 1); QVERIFY(modules.logos_execution_zone.saveCalls >= 1);
QCOMPARE(modules.logos_execution_zone.syncCalls, 0);
QCOMPARE(modules.logos_execution_zone.listCalls, 0);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, 0);
LogosModules rejectedModules; LogosModules rejectedModules;
rejectedModules.logos_execution_zone.mnemonic.clear(); rejectedModules.logos_execution_zone.mnemonic.clear();
@@ -227,12 +359,14 @@ void LogosWalletProviderTest::createsAndPersistsAccounts()
QVERIFY(provider.connect({}).ok()); QVERIFY(provider.connect({}).ok());
const int savesBeforeCreate = modules.logos_execution_zone.saveCalls; const int savesBeforeCreate = modules.logos_execution_zone.saveCalls;
const int publicReadsBeforeCreate = modules.logos_execution_zone.publicReadCalls;
const WalletAccountCreation creation = provider.createAccount(true); const WalletAccountCreation creation = provider.createAccount(true);
QVERIFY(creation.ok()); QVERIFY(creation.ok());
QCOMPARE(creation.accountId, ACCOUNT_A); QCOMPARE(creation.accountId, ACCOUNT_A);
QVERIFY(creation.publicAccount.ok()); QVERIFY(creation.publicAccount.ok());
QCOMPARE(creation.snapshot.accounts.size(), 1); QCOMPARE(creation.snapshot.accounts.size(), 1);
QVERIFY(modules.logos_execution_zone.saveCalls > savesBeforeCreate); QVERIFY(modules.logos_execution_zone.saveCalls > savesBeforeCreate);
QCOMPARE(modules.logos_execution_zone.publicReadCalls, publicReadsBeforeCreate + 1);
modules.logos_execution_zone.saveResult = 1; modules.logos_execution_zone.saveResult = 1;
QCOMPARE(provider.createAccount(true).failure, WalletFailure::SaveFailed); QCOMPARE(provider.createAccount(true).failure, WalletFailure::SaveFailed);
@@ -257,7 +391,7 @@ void LogosWalletProviderTest::preservesCreatedAccountWhenPublicReadFails()
QCOMPARE(creation.snapshot.accounts.at(0).balance, QStringLiteral("7")); QCOMPARE(creation.snapshot.accounts.at(0).balance, QStringLiteral("7"));
} }
void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails() void LogosWalletProviderTest::createdAccountDoesNotRescanWallet()
{ {
LogosModules modules; LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
@@ -268,11 +402,13 @@ void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails()
modules.logos_execution_zone.currentBlockHeight = 1; modules.logos_execution_zone.currentBlockHeight = 1;
modules.logos_execution_zone.syncResult = 1; modules.logos_execution_zone.syncResult = 1;
const int syncCalls = modules.logos_execution_zone.syncCalls;
const WalletAccountCreation creation = provider.createAccount(true); const WalletAccountCreation creation = provider.createAccount(true);
QVERIFY(creation.ok()); QVERIFY(creation.ok());
QCOMPARE(creation.accountId, ACCOUNT_A); QCOMPARE(creation.accountId, ACCOUNT_A);
QCOMPARE(creation.snapshot.failure, WalletFailure::ReadFailed); QVERIFY(creation.snapshot.ok());
QCOMPARE(modules.logos_execution_zone.syncCalls, syncCalls);
} }
void LogosWalletProviderTest::dispatchesExactGenericTransaction() void LogosWalletProviderTest::dispatchesExactGenericTransaction()
@@ -298,8 +434,8 @@ void LogosWalletProviderTest::dispatchesExactGenericTransaction()
QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds); QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds);
QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements, QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements,
QVariantList({ true, false })); QVariantList({ true, false }));
QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(), QCOMPARE(modules.logos_execution_zone.submittedInstruction.toByteArray(),
QVariantList({ 7U, 0U, 4294967295U })); QByteArray::fromHex("0700000000000000ffffffff"));
} }
void LogosWalletProviderTest::rejectsInvalidSubmissionResponses() void LogosWalletProviderTest::rejectsInvalidSubmissionResponses()
@@ -338,19 +474,171 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles()
WalletAccountModel model; WalletAccountModel model;
QSignalSpy countChanged(&model, &WalletAccountModel::countChanged); QSignalSpy countChanged(&model, &WalletAccountModel::countChanged);
model.replaceAccounts({ model.replaceAccounts({
{ ACCOUNT_A, QStringLiteral("10"), true }, { ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
{ ACCOUNT_B, QStringLiteral("20"), false }, { 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(countChanged.count(), 1);
QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name")); QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name"));
QCOMPARE(model.data(model.index(0), WalletAccountModel::NameRole).toString(), 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.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B);
QCOMPARE(model.roleNames().value(WalletAccountModel::DisplayAddressRole),
QByteArray("displayAddress"));
QCOMPARE(model.roleNames().value(WalletAccountModel::DecodedDataRole),
QByteArray("decodedData"));
QCOMPARE(model.data(model.index(1), WalletAccountModel::DisplayAddressRole).toString(),
ACCOUNT_B);
QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(), QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(),
QStringLiteral("20")); QStringLiteral("20"));
QVERIFY(!model.data(model.index(1), WalletAccountModel::IsPublicRole).toBool()); 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());
QSignalSpy presentationsChanged(&model, &QAbstractItemModel::dataChanged);
const QVector<WalletAccountPresentation> presentations {
{
ACCOUNT_A,
QStringLiteral("program"),
{},
QStringLiteral("System"),
QStringLiteral("UserAccount"),
{},
false,
QStringLiteral("{\"public_key\":\"Public/test\"}"),
},
{
ACCOUNT_C,
QStringLiteral("token_holding"),
QStringLiteral("TEST holding"),
QStringLiteral("Token"),
QStringLiteral("TokenHolding"),
ACCOUNT_A,
true,
},
};
model.applyPresentations(presentations);
QCOMPARE(presentationsChanged.count(), 1);
QCOMPARE(model.data(model.index(2), WalletAccountModel::SectionRole).toString(),
QStringLiteral("hidden"));
QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(),
QStringLiteral("TEST holding"));
QCOMPARE(model.data(model.index(0), WalletAccountModel::DecodedDataRole).toString(),
QStringLiteral("{\"public_key\":\"Public/test\"}"));
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"));
QSignalSpy redundantPresentation(&model, &QAbstractItemModel::dataChanged);
QVERIFY(!model.applyPresentations(presentations));
QCOMPARE(redundantPresentation.count(), 0);
}
void LogosWalletProviderTest::clearsStaleAccountPresentationsWithoutInvalidatingPrimary()
{
const QString settingsApplication = QStringLiteral("WalletPresentationClearingTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.connectResult.adopted = true;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
{ ACCOUNT_C, QStringLiteral("20"), true, QStringLiteral("ok"), PROGRAM_ID,
QStringLiteral("00ff") },
};
WalletController controller(provider, settingsApplication);
QVERIFY(controller.open());
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("User account"));
controller.applyAccountPresentations({
{
ACCOUNT_C,
QStringLiteral("token_holding"),
QStringLiteral("TEST holding"),
QStringLiteral("Token"),
QStringLiteral("TokenHolding"),
ACCOUNT_A,
true,
QStringLiteral("{\"amount\":\"20\"}"),
},
});
WalletAccountModel* model = controller.accountModel();
const QModelIndex holding = model->index(model->indexOf(ACCOUNT_C));
QCOMPARE(model->data(holding, WalletAccountModel::KindRole).toString(),
QStringLiteral("token_holding"));
QCOMPARE(model->data(holding, WalletAccountModel::SectionRole).toString(),
QStringLiteral("hidden"));
QCOMPARE(model->data(holding, WalletAccountModel::NameRole).toString(),
QStringLiteral("TEST holding"));
QCOMPARE(model->data(holding, WalletAccountModel::DecodedDataRole).toString(),
QStringLiteral("{\"amount\":\"20\"}"));
controller.applyAccountPresentations({});
QCOMPARE(model->data(holding, WalletAccountModel::KindRole).toString(),
QStringLiteral("program"));
QCOMPARE(model->data(holding, WalletAccountModel::SectionRole).toString(),
QStringLiteral("advanced"));
QCOMPARE(model->data(holding, WalletAccountModel::NameRole).toString(),
QStringLiteral("Program account"));
QCOMPARE(model->data(holding, WalletAccountModel::ProgramNameRole).toString(), QString());
QCOMPARE(model->data(holding, WalletAccountModel::AccountTypeRole).toString(), QString());
QCOMPARE(model->data(holding, WalletAccountModel::DefinitionIdRole).toString(), QString());
QCOMPARE(model->data(holding, WalletAccountModel::DecodedDataRole).toString(), QString());
QVERIFY(!model->data(holding, WalletAccountModel::CanBePrimaryRole).toBool());
const QModelIndex primary = model->index(model->indexOf(ACCOUNT_A));
QVERIFY(model->data(primary, WalletAccountModel::CanBePrimaryRole).toBool());
QVERIFY(model->data(primary, WalletAccountModel::IsPrimaryRole).toBool());
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("User account"));
settings.clear();
}
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() void LogosWalletProviderTest::fakeProviderImplementsConsumerContract()
@@ -419,6 +707,164 @@ void LogosWalletProviderTest::controllerOwnsUiWalletFlow()
settings.clear(); settings.clear();
} }
void LogosWalletProviderTest::controllerSeparatesSnapshotsFromCosmeticState()
{
const QString settingsApplication = QStringLiteral("WalletSnapshotSignalTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("5"), true },
};
provider.snapshotResult = provider.connectResult.snapshot;
WalletController controller(provider, settingsApplication);
QSignalSpy stateChanged(&controller, &WalletController::stateChanged);
QSignalSpy snapshotChanged(&controller, &WalletController::snapshotChanged);
QVERIFY(controller.open());
stateChanged.clear();
snapshotChanged.clear();
QVERIFY(controller.setAccountAlias(ACCOUNT_A, QStringLiteral("Spending")));
QCOMPARE(stateChanged.count(), 1);
QCOMPARE(snapshotChanged.count(), 0);
controller.refresh();
QCOMPARE(stateChanged.count(), 3);
QCOMPARE(snapshotChanged.count(), 1);
settings.clear();
}
void LogosWalletProviderTest::controllerOpenDoesNotWaitForWalletSync()
{
const QString settingsApplication = QStringLiteral("WalletAsyncOpenTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.deferAsync = true;
provider.connectResult.snapshot.accounts = {
{ ACCOUNT_A, QStringLiteral("5"), true },
};
WalletController controller(provider, settingsApplication);
QVERIFY(controller.open());
QCOMPARE(provider.connectCalls, 1);
QVERIFY(!controller.state().isWalletOpen);
QCOMPARE(controller.state().syncStatus, QStringLiteral("opening"));
QCOMPARE(controller.accountModel()->count(), 0);
provider.finishConnect();
QVERIFY(controller.state().isWalletOpen);
QVERIFY(controller.state().canSubmit());
QCOMPARE(controller.state().syncStatus, QStringLiteral("ready"));
QCOMPARE(controller.accountModel()->count(), 1);
settings.clear();
}
void LogosWalletProviderTest::controllerCreationDoesNotWaitForWalletSync()
{
const QString settingsApplication = QStringLiteral("WalletAsyncCreationTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.deferAsync = true;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
provider.snapshotResult.accounts = {
{ ACCOUNT_A, QStringLiteral("5"), true },
};
WalletController controller(provider, settingsApplication);
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
QCOMPARE(provider.createWalletCalls, 1);
QCOMPARE(provider.snapshotCalls, 0);
QVERIFY(controller.state().isWalletOpen);
QCOMPARE(controller.state().syncStatus, QStringLiteral("syncing"));
QVERIFY(!controller.state().canSubmit());
QCOMPARE(controller.accountModel()->count(), 0);
QTRY_COMPARE(provider.snapshotCalls, 1);
QVERIFY(provider.lastForceRefresh);
QCOMPARE(controller.state().syncStatus, QStringLiteral("syncing"));
provider.finishSnapshot();
QCOMPARE(controller.state().syncStatus, QStringLiteral("ready"));
QVERIFY(controller.state().canSubmit());
QCOMPARE(controller.accountModel()->count(), 1);
settings.clear();
}
void LogosWalletProviderTest::controllerSeedsDefaultWalletConfigWithConfiguredEndpoint()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString walletHome = directory.filePath(QStringLiteral("wallet"));
ScopedEnvironment walletHomeEnvironment(
QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit());
const QString settingsApplication = QStringLiteral("WalletDefaultEndpointTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
WalletController controller(provider, settingsApplication);
controller.setDefaultSequencerAddress(QStringLiteral("https://testnet.lez.logos.co/"));
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
const QString configPath = walletHome + QStringLiteral("/wallet_config.json");
QCOMPARE(provider.lastPaths.config, configPath);
QFile config(configPath);
QVERIFY(config.open(QIODevice::ReadOnly));
const QJsonDocument document = QJsonDocument::fromJson(config.readAll());
QVERIFY(document.isObject());
const QJsonObject values = document.object();
QCOMPARE(values.value(QStringLiteral("sequencer_addr")).toString(),
QStringLiteral("https://testnet.lez.logos.co/"));
QCOMPARE(values.value(QStringLiteral("seq_poll_timeout")).toString(),
QStringLiteral("12s"));
QCOMPARE(values.value(QStringLiteral("seq_tx_poll_max_blocks")).toInt(), 5);
QCOMPARE(values.value(QStringLiteral("seq_poll_max_retries")).toInt(), 5);
QCOMPARE(values.value(QStringLiteral("seq_block_poll_max_amount")).toInt(), 100);
settings.clear();
}
void LogosWalletProviderTest::controllerPreservesExistingDefaultWalletConfig()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString walletHome = directory.filePath(QStringLiteral("wallet"));
QVERIFY(QDir().mkpath(walletHome));
const QString configPath = walletHome + QStringLiteral("/wallet_config.json");
const QByteArray existingConfig = QByteArrayLiteral(
"{\"sequencer_addr\":\"http://127.0.0.1:3040/\",\"custom\":true}");
QFile config(configPath);
QVERIFY(config.open(QIODevice::WriteOnly));
QCOMPARE(config.write(existingConfig), qint64(existingConfig.size()));
config.close();
ScopedEnvironment walletHomeEnvironment(
QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit());
const QString settingsApplication = QStringLiteral("WalletExistingEndpointTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
WalletController controller(provider, settingsApplication);
controller.setDefaultSequencerAddress(QStringLiteral("https://testnet.lez.logos.co/"));
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
QVERIFY(config.open(QIODevice::ReadOnly));
QCOMPARE(config.readAll(), existingConfig);
settings.clear();
}
void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect() void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect()
{ {
const QString settingsApplication = QStringLiteral("WalletReachabilityTest"); const QString settingsApplication = QStringLiteral("WalletReachabilityTest");
@@ -434,19 +880,164 @@ void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect()
QVERIFY(controller.open()); QVERIFY(controller.open());
QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000); QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000);
controller.disconnect();
finished.clear();
auto* timer = controller.findChild<QTimer*>(); auto* timer = controller.findChild<QTimer*>();
QVERIFY(timer); QVERIFY(timer);
QVERIFY(timer->isActive());
controller.disconnect();
QVERIFY(!timer->isActive());
finished.clear();
timer->setInterval(1); timer->setInterval(1);
controller.start(); controller.start();
QTest::qWait(50); QTest::qWait(50);
QVERIFY(!timer->isActive());
QCOMPARE(finished.count(), 0); QCOMPARE(finished.count(), 0);
settings.clear(); settings.clear();
} }
void LogosWalletProviderTest::completedAsyncSnapshotReleasesCallback()
{
LogosModules modules;
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
LogosWalletProvider provider(&modules);
QVERIFY(provider.connect({}).ok());
bool completed = false;
std::weak_ptr<int> callbackLifetime;
{
auto lifetime = std::make_shared<int>(1);
callbackLifetime = lifetime;
provider.snapshotAsync(true,
[lifetime = std::move(lifetime), &completed](WalletSnapshot snapshot) {
QVERIFY(snapshot.ok());
completed = true;
});
}
QVERIFY(completed);
QVERIFY(callbackLifetime.expired());
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
}
void LogosWalletProviderTest::deferredCallbacksIgnoreDestroyedController()
{
const QString settingsApplication = QStringLiteral("WalletDestroyedCallbackTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.deferAsync = true;
{
auto controller = std::make_unique<WalletController>(provider, settingsApplication);
QVERIFY(controller->open());
}
provider.finishConnect();
provider.deferAsync = false;
{
auto controller = std::make_unique<WalletController>(provider, settingsApplication);
QVERIFY(controller->open());
provider.deferAsync = true;
controller->refresh();
}
provider.finishSnapshot();
settings.clear();
}
void LogosWalletProviderTest::newerReachabilityResultWins()
{
const QString settingsApplication = QStringLiteral("WalletReachabilityOrderTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
QTcpServer firstServer;
QTcpServer secondServer;
QVERIFY(firstServer.listen(QHostAddress::LocalHost));
QVERIFY(secondServer.listen(QHostAddress::LocalHost));
const QString firstEndpoint = QStringLiteral("http://127.0.0.1:%1")
.arg(firstServer.serverPort());
const QString secondEndpoint = QStringLiteral("http://127.0.0.1:%1")
.arg(secondServer.serverPort());
FakeWalletProvider provider;
provider.connectResult.snapshot.sequencerAddress = firstEndpoint;
WalletController controller(provider, settingsApplication);
auto* network = controller.findChild<QNetworkAccessManager*>();
QVERIFY(network);
QSignalSpy finished(network, &QNetworkAccessManager::finished);
QVERIFY(controller.open());
QTRY_VERIFY(firstServer.hasPendingConnections());
QTcpSocket* first = firstServer.nextPendingConnection();
QVERIFY(first);
provider.createAccountResult.accountId = ACCOUNT_B;
provider.createAccountResult.snapshot.sequencerAddress = secondEndpoint;
QCOMPARE(controller.createAccount(true), ACCOUNT_B);
QTRY_VERIFY(secondServer.hasPendingConnections());
QTcpSocket* second = secondServer.nextPendingConnection();
QVERIFY(second);
second->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
second->disconnectFromHost();
QTRY_COMPARE(finished.size(), 1);
QVERIFY(controller.state().sequencerReachable);
first->disconnectFromHost();
QTRY_COMPARE(finished.size(), 2);
QVERIFY(controller.state().sequencerReachable);
settings.clear();
}
void LogosWalletProviderTest::coalescesReachabilityChecksForSameEndpoint()
{
const QString settingsApplication = QStringLiteral("WalletReachabilityCoalesceTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
QTcpServer server;
QVERIFY(server.listen(QHostAddress::LocalHost));
const QString endpoint = QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort());
FakeWalletProvider provider;
provider.connectResult.snapshot.sequencerAddress = endpoint;
WalletController controller(provider, settingsApplication);
QVERIFY(controller.open());
QTRY_VERIFY(server.hasPendingConnections());
QTcpSocket* request = server.nextPendingConnection();
QVERIFY(request);
provider.createAccountResult.accountId = ACCOUNT_B;
provider.createAccountResult.snapshot.sequencerAddress = endpoint;
QCOMPARE(controller.createAccount(true), ACCOUNT_B);
QTest::qWait(50);
QVERIFY(!server.hasPendingConnections());
request->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
request->disconnectFromHost();
settings.clear();
}
void LogosWalletProviderTest::controllerReportsPartialWalletCreation()
{
const QString settingsApplication = QStringLiteral("WalletPartialCreationTest");
QSettings settings(QStringLiteral("Logos"), settingsApplication);
settings.clear();
FakeWalletProvider provider;
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
provider.createWalletResult.failure = WalletFailure::SaveFailed;
WalletController controller(provider, settingsApplication);
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
provider.createWalletResult.mnemonic);
QVERIFY(!controller.state().isWalletOpen);
QCOMPARE(controller.state().syncStatus, QStringLiteral("error"));
QCOMPARE(controller.state().syncError, QStringLiteral("save_failed"));
settings.clear();
}
QTEST_GUILESS_MAIN(LogosWalletProviderTest) QTEST_GUILESS_MAIN(LogosWalletProviderTest)
#include "LogosWalletProviderTest.moc" #include "LogosWalletProviderTest.moc"
@@ -0,0 +1,328 @@
#include "SequencerIdentityProbe.h"
#include <QHostAddress>
#include <QHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QList>
#include <QPointer>
#include <QQueue>
#include <QSignalSpy>
#include <QTcpServer>
#include <QTcpSocket>
#include <QUrl>
#include <QtTest>
#include <utility>
namespace {
const QString IDENTITY(64, QLatin1Char('a'));
const QString OTHER_IDENTITY(64, QLatin1Char('b'));
SequencerNetworkContext::Configuration networkConfiguration()
{
return {
QStringLiteral("testnet"),
IDENTITY,
QStringLiteral("checkpoint:"),
};
}
QByteArray jsonRpcResult(const QString& identity)
{
return QJsonDocument(QJsonObject {
{ QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
{ QStringLiteral("id"), 1 },
{ QStringLiteral("result"), identity },
}).toJson(QJsonDocument::Compact);
}
class RpcServer final : public QObject {
public:
RpcServer()
{
m_server.listen(QHostAddress::LocalHost);
connect(&m_server, &QTcpServer::newConnection, this, [this]() {
while (QTcpSocket* socket = m_server.nextPendingConnection())
attach(socket);
});
}
QUrl endpoint() const
{
return QUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()));
}
bool isListening() const { return m_server.isListening(); }
void enqueueResponse(int status, QByteArray body)
{
m_responses.enqueue({ status, std::move(body) });
}
void holdNextResponse()
{
m_responses.enqueue({ 0, {} });
}
void respondHeld(int status, QByteArray body)
{
while (!m_held.isEmpty()) {
const QPointer<QTcpSocket> socket = m_held.dequeue();
if (socket)
sendResponse(socket, status, std::move(body));
return;
}
}
int requestCount() const { return m_requests.size(); }
QByteArray lastRequest() const { return m_requests.isEmpty() ? QByteArray() : m_requests.last(); }
private:
struct Response {
int status;
QByteArray body;
};
void attach(QTcpSocket* socket)
{
socket->setParent(this);
connect(socket, &QTcpSocket::readyRead, this, [this, socket]() {
QByteArray& request = m_partialRequests[socket];
request.append(socket->readAll());
const qsizetype headerEnd = request.indexOf("\r\n\r\n");
if (headerEnd < 0)
return;
const QByteArray headers = request.left(headerEnd);
qsizetype contentLength = 0;
for (const QByteArray& line : headers.split('\n')) {
const qsizetype separator = line.indexOf(':');
if (separator < 0)
continue;
if (line.left(separator).trimmed().compare("content-length",
Qt::CaseInsensitive) == 0) {
contentLength = line.mid(separator + 1).trimmed().toLongLong();
break;
}
}
if (request.size() < headerEnd + 4 + contentLength)
return;
m_requests.append(request);
m_partialRequests.remove(socket);
if (m_responses.isEmpty()) {
m_held.enqueue(socket);
return;
}
const Response response = m_responses.dequeue();
if (response.status == 0) {
m_held.enqueue(socket);
return;
}
sendResponse(socket, response.status, response.body);
});
connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater);
}
static void sendResponse(QTcpSocket* socket, int status, QByteArray body)
{
const QByteArray statusText = status >= 200 && status < 300
? QByteArrayLiteral("OK") : QByteArrayLiteral("Service Unavailable");
QByteArray response = QByteArrayLiteral("HTTP/1.1 ") + QByteArray::number(status)
+ QByteArrayLiteral(" ") + statusText
+ QByteArrayLiteral("\r\nContent-Type: application/json\r\nContent-Length: ")
+ QByteArray::number(body.size())
+ QByteArrayLiteral("\r\nConnection: close\r\n\r\n") + body;
socket->write(response);
socket->flush();
socket->disconnectFromHost();
}
QTcpServer m_server;
QHash<QTcpSocket*, QByteArray> m_partialRequests;
QQueue<Response> m_responses;
QQueue<QPointer<QTcpSocket>> m_held;
QList<QByteArray> m_requests;
};
SequencerIdentityProbe::Request requestFor(const QUrl& endpoint)
{
return {
endpoint,
QStringLiteral("getChannelId"),
QJsonArray { 10 },
SequencerIdentityProbe::stringIdentity,
};
}
}
class SequencerIdentityProbeTest final : public QObject {
Q_OBJECT
private slots:
void sendsConfiguredRequestAndAcceptsIdentity();
void waitsForEndpointBeforeProbing();
void retriesRejectedResponses_data();
void retriesRejectedResponses();
void supersedesEndpointReply();
void abortsReplyWhenReachabilityIsLost();
void retriesTransportFailureAfterEndpointChanges();
void extractsCheckpointBlockHash();
};
void SequencerIdentityProbeTest::sendsConfiguredRequestAndAcceptsIdentity()
{
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
QCOMPARE(probe.snapshot().fingerprint, QStringLiteral("checkpoint:") + IDENTITY);
QCOMPARE(server.requestCount(), 1);
QVERIFY(server.lastRequest().contains("\"method\":\"getChannelId\""));
QVERIFY(server.lastRequest().contains("\"params\":[10]"));
}
void SequencerIdentityProbeTest::waitsForEndpointBeforeProbing()
{
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(QUrl())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QCOMPARE(probe.snapshot().status, QStringLiteral("network_unknown"));
QCOMPARE(server.requestCount(), 0);
QVERIFY(probe.setEndpoint(server.endpoint()));
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
QCOMPARE(server.requestCount(), 1);
}
void SequencerIdentityProbeTest::retriesRejectedResponses_data()
{
QTest::addColumn<int>("status");
QTest::addColumn<QByteArray>("body");
QTest::addColumn<QString>("failure");
QTest::newRow("http") << 503 << QByteArrayLiteral("{}")
<< QStringLiteral("http_status");
QTest::newRow("malformed") << 200 << QByteArrayLiteral("not-json")
<< QStringLiteral("malformed_response");
QTest::newRow("json-rpc-error") << 200
<< QByteArrayLiteral("{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-1}}")
<< QStringLiteral("json_rpc_error");
}
void SequencerIdentityProbeTest::retriesRejectedResponses()
{
QFETCH(int, status);
QFETCH(QByteArray, body);
QFETCH(QString, failure);
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(status, body);
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QSignalSpy failures(&probe, &SequencerIdentityProbe::probeFailed);
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(server.requestCount(), 2);
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
QVERIFY(!failures.isEmpty());
QCOMPARE(failures.first().at(0).toString(), failure);
}
void SequencerIdentityProbeTest::supersedesEndpointReply()
{
RpcServer first;
QVERIFY(first.isListening());
first.holdNextResponse();
RpcServer second;
QVERIFY(second.isListening());
second.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(first.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(first.requestCount(), 1);
QVERIFY(probe.setEndpoint(second.endpoint()));
QTRY_COMPARE(second.requestCount(), 1);
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
first.respondHeld(200, jsonRpcResult(OTHER_IDENTITY));
QTest::qWait(50);
QCOMPARE(probe.snapshot().status, QStringLiteral("ready"));
}
void SequencerIdentityProbeTest::abortsReplyWhenReachabilityIsLost()
{
RpcServer server;
QVERIFY(server.isListening());
server.holdNextResponse();
SequencerIdentityProbe probe;
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_COMPARE(server.requestCount(), 1);
probe.setReachable(false);
server.respondHeld(200, jsonRpcResult(IDENTITY));
QTest::qWait(50);
QCOMPARE(probe.snapshot().status, QStringLiteral("network_unknown"));
}
void SequencerIdentityProbeTest::retriesTransportFailureAfterEndpointChanges()
{
QTcpServer unavailable;
QVERIFY(unavailable.listen(QHostAddress::LocalHost));
const QUrl unavailableEndpoint(
QStringLiteral("http://127.0.0.1:%1").arg(unavailable.serverPort()));
unavailable.close();
RpcServer server;
QVERIFY(server.isListening());
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
SequencerIdentityProbe probe;
QSignalSpy failures(&probe, &SequencerIdentityProbe::probeFailed);
QVERIFY(probe.configure(networkConfiguration(), requestFor(unavailableEndpoint)));
probe.setSequencerAvailable(true);
probe.setReachable(true);
QTRY_VERIFY(!failures.isEmpty());
QCOMPARE(failures.first().at(0).toString(), QStringLiteral("transport_error"));
QVERIFY(probe.setEndpoint(server.endpoint()));
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
}
void SequencerIdentityProbeTest::extractsCheckpointBlockHash()
{
QByteArray block(72, '\0');
const QByteArray expected(32, static_cast<char>(0xab));
block.replace(40, expected.size(), expected);
QCOMPARE(SequencerIdentityProbe::checkpointBlockHash(
QJsonValue(QString::fromLatin1(block.toBase64()))),
QString::fromLatin1(expected.toHex()));
QVERIFY(SequencerIdentityProbe::checkpointBlockHash(QJsonValue(QStringLiteral("bad"))).isEmpty());
}
QTEST_MAIN(SequencerIdentityProbeTest)
#include "SequencerIdentityProbeTest.moc"
@@ -0,0 +1,96 @@
#include <QtTest>
#include "SequencerNetworkContext.h"
namespace {
const QString NETWORK_ID = QStringLiteral("testnet");
const QString IDENTITY(64, QLatin1Char('a'));
SequencerNetworkContext::Configuration configuration()
{
return {
NETWORK_ID,
IDENTITY,
QStringLiteral("checkpoint:"),
};
}
}
class SequencerNetworkContextTest final : public QObject {
Q_OBJECT
private slots:
void acceptsMatchingIdentity();
void rejectsLateReplyAfterReachabilityLoss();
void rejectsSupersededProbe();
void rejectsInvalidConfiguration();
};
void SequencerNetworkContextTest::acceptsMatchingIdentity()
{
SequencerNetworkContext context;
QVERIFY(context.configure(configuration()));
context.setSequencerAvailable(true);
context.setReachable(true);
const std::optional<quint64> probe = context.beginIdentityProbe();
QVERIFY(probe.has_value());
QVERIFY(context.finishIdentityProbe(*probe, IDENTITY));
QCOMPARE(context.snapshot().id, NETWORK_ID);
QCOMPARE(context.snapshot().status, QStringLiteral("ready"));
QCOMPARE(context.snapshot().fingerprint, QStringLiteral("checkpoint:") + IDENTITY);
}
void SequencerNetworkContextTest::rejectsLateReplyAfterReachabilityLoss()
{
SequencerNetworkContext context;
QVERIFY(context.configure(configuration()));
context.setSequencerAvailable(true);
context.setReachable(true);
const std::optional<quint64> probe = context.beginIdentityProbe();
QVERIFY(probe.has_value());
context.setReachable(false);
QVERIFY(!context.finishIdentityProbe(*probe, IDENTITY));
QCOMPARE(context.snapshot().status, QStringLiteral("network_unknown"));
QVERIFY(context.snapshot().fingerprint.isEmpty());
}
void SequencerNetworkContextTest::rejectsSupersededProbe()
{
SequencerNetworkContext context;
QVERIFY(context.configure(configuration()));
context.setSequencerAvailable(true);
context.setReachable(true);
const std::optional<quint64> firstProbe = context.beginIdentityProbe();
QVERIFY(firstProbe.has_value());
context.setReachable(false);
context.setReachable(true);
const std::optional<quint64> secondProbe = context.beginIdentityProbe();
QVERIFY(secondProbe.has_value());
QVERIFY(!context.finishIdentityProbe(*firstProbe, IDENTITY));
QVERIFY(context.finishIdentityProbe(*secondProbe, IDENTITY));
QCOMPARE(context.snapshot().status, QStringLiteral("ready"));
}
void SequencerNetworkContextTest::rejectsInvalidConfiguration()
{
SequencerNetworkContext context;
SequencerNetworkContext::Configuration invalid = configuration();
invalid.expectedIdentity = QString(64, QLatin1Char('A'));
QVERIFY(!context.configure(invalid));
QVERIFY(!context.isConfigured());
QCOMPARE(context.snapshot().id, NETWORK_ID);
QCOMPARE(context.snapshot().status, QStringLiteral("config_missing"));
}
QTEST_MAIN(SequencerNetworkContextTest)
#include "SequencerNetworkContextTest.moc"
@@ -0,0 +1,66 @@
#include "SequencerNetworkSettings.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryFile>
#include <QtTest>
class SequencerNetworkSettingsTest : public QObject {
Q_OBJECT
private slots:
void loadsBundledTestnetIdentity();
void loadsDevnetChannelIdentity();
void rejectsInvalidDevnetIdentity();
};
void SequencerNetworkSettingsTest::loadsBundledTestnetIdentity()
{
const auto settings = SequencerNetworkSettingsLoader::load(
QStringLiteral("testnet"), {});
QVERIFY(settings);
QCOMPARE(settings->context.id, QStringLiteral("testnet"));
QCOMPARE(settings->context.expectedIdentity,
QStringLiteral("0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a"));
QCOMPARE(settings->context.fingerprintPrefix, QStringLiteral("block10:"));
QCOMPARE(settings->identityMethod, SequencerIdentityMethod::CheckpointBlock);
}
void SequencerNetworkSettingsTest::loadsDevnetChannelIdentity()
{
const QString identity(64, QLatin1Char('a'));
QTemporaryFile config;
QVERIFY(config.open());
const QByteArray contents = QJsonDocument(QJsonObject {
{ QStringLiteral("channelId"), identity },
}).toJson(QJsonDocument::Compact);
QCOMPARE(config.write(contents), qint64(contents.size()));
config.flush();
const auto settings = SequencerNetworkSettingsLoader::load(
QStringLiteral("devnet"), config.fileName());
QVERIFY(settings);
QCOMPARE(settings->context.id, QStringLiteral("devnet"));
QCOMPARE(settings->context.expectedIdentity, identity);
QCOMPARE(settings->context.fingerprintPrefix, QStringLiteral("channel:"));
QCOMPARE(settings->identityMethod, SequencerIdentityMethod::ChannelId);
}
void SequencerNetworkSettingsTest::rejectsInvalidDevnetIdentity()
{
QTemporaryFile config;
QVERIFY(config.open());
const QByteArray contents = QJsonDocument(QJsonObject {
{ QStringLiteral("channelId"), QStringLiteral("not-an-identity") },
}).toJson(QJsonDocument::Compact);
QCOMPARE(config.write(contents), qint64(contents.size()));
config.flush();
QVERIFY(!SequencerNetworkSettingsLoader::load(
QStringLiteral("devnet"), config.fileName()));
}
QTEST_GUILESS_MAIN(SequencerNetworkSettingsTest)
#include "SequencerNetworkSettingsTest.moc"
@@ -0,0 +1,22 @@
#include "WalletIdlDecoder.h"
#include <QtTest>
class WalletIdlDecoderLinkTest final : public QObject {
Q_OBJECT
private slots:
void linksDefaultDecoder();
};
void WalletIdlDecoderLinkTest::linksDefaultDecoder()
{
const WalletDecodeResult result = WalletIdlDecoder::decode(
QByteArrayLiteral("not-json"), {});
QCOMPARE(result.status, QStringLiteral("error"));
QCOMPARE(result.error, QStringLiteral("invalid_idl"));
}
QTEST_GUILESS_MAIN(WalletIdlDecoderLinkTest)
#include "WalletIdlDecoderLinkTest.moc"
@@ -0,0 +1,206 @@
#include "WalletPortfolioService.h"
#include <QtTest>
#include <utility>
// The service normally links this symbol from wallet-idl-decoder. Every test
// injects a decoder, so keep this target independent from the Rust FFI library.
WalletDecodeResult WalletIdlDecoder::decode(const QByteArray&,
const QVector<WalletAccountRead>&)
{
return {
QStringLiteral("error"),
QStringLiteral("unexpected_default_decoder"),
{},
};
}
namespace {
const QString DEFINITION_ID(64, QLatin1Char('a'));
const QString DEFINITION_BASE58 = QStringLiteral("base58-definition");
const QString HOLDING_ID(64, QLatin1Char('b'));
const QString TOKEN_PROGRAM_ID(64, QLatin1Char('c'));
const QString AMM_ACCOUNT_ID(64, QLatin1Char('d'));
const QString AMM_PROGRAM_ID(64, QLatin1Char('e'));
WalletAccountRead read(const QString& accountId,
const QString& programOwner,
const QString& data)
{
WalletAccountRead result;
result.accountId = accountId;
result.status = QStringLiteral("ok");
result.programOwner = programOwner;
result.dataHex = data;
return result;
}
WalletPortfolioRequest request(const QString& name = QStringLiteral("Test token"))
{
WalletSnapshot snapshot;
snapshot.publicAccountReads = {
read(DEFINITION_ID, TOKEN_PROGRAM_ID, QStringLiteral("definition")),
read(HOLDING_ID, TOKEN_PROGRAM_ID, QStringLiteral("holding")),
read(AMM_ACCOUNT_ID, AMM_PROGRAM_ID, QStringLiteral("amm")),
};
WalletPortfolioRequest result(snapshot);
result.tokenDefinitionIds = { DEFINITION_BASE58 };
result.tokens = { QVariantMap {
{ QStringLiteral("definitionId"), DEFINITION_BASE58 },
{ QStringLiteral("definitionIdHex"), DEFINITION_ID },
{ QStringLiteral("name"), name },
} };
result.tokenProgramId = TOKEN_PROGRAM_ID;
result.tokenIdl = QByteArrayLiteral("token-idl");
return result;
}
WalletDecodedAccount tokenDefinition()
{
WalletDecodedAccount account;
account.id = DEFINITION_ID;
account.status = QStringLiteral("decoded");
account.typeName = QStringLiteral("TokenDefinition");
account.value = QJsonObject {
{ QStringLiteral("Fungible"), QJsonObject {
{ QStringLiteral("name"), QStringLiteral("Test token") },
} },
};
return account;
}
WalletDecodedAccount tokenHolding(bool decoded = true)
{
WalletDecodedAccount account;
account.id = HOLDING_ID;
account.status = decoded ? QStringLiteral("decoded") : QStringLiteral("error");
account.typeName = QStringLiteral("TokenHolding");
account.value = QJsonObject {
{ QStringLiteral("Fungible"), QJsonObject {
{ QStringLiteral("definition_id"), QStringLiteral("definition") },
{ QStringLiteral("balance"), QStringLiteral("25") },
} },
};
account.accountIds.insert(QStringLiteral("definition"), DEFINITION_ID);
return account;
}
WalletDecodedAccount ammAccount()
{
WalletDecodedAccount account;
account.id = AMM_ACCOUNT_ID;
account.status = QStringLiteral("decoded");
account.typeName = QStringLiteral("Pool");
account.value = QJsonObject {
{ QStringLiteral("Pool"), QJsonObject {} },
};
return account;
}
WalletPortfolioService::Decoder decoder(int* calls, bool failHolding = false)
{
return [calls, failHolding](const QByteArray&, const QVector<WalletAccountRead>& reads) {
++*calls;
WalletDecodeResult result;
result.status = QStringLiteral("ok");
for (const WalletAccountRead& item : reads) {
if (item.accountId == DEFINITION_ID)
result.accounts.append(tokenDefinition());
else if (item.accountId == HOLDING_ID)
result.accounts.append(tokenHolding(!failHolding));
else if (item.accountId == AMM_ACCOUNT_ID)
result.accounts.append(ammAccount());
}
return result;
};
}
}
class WalletPortfolioServiceTest : public QObject {
Q_OBJECT
private slots:
void acceptsBase58DefinitionsAndReusesUnchangedDecodes();
void exposesHoldingDecodeFailureWithoutZeroBalance();
void exposesUnreadPublicAccountWithoutZeroBalance();
void exposesUnresolvedDefinitions();
};
void WalletPortfolioServiceTest::acceptsBase58DefinitionsAndReusesUnchangedDecodes()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls));
service.registerProgram(AMM_PROGRAM_ID, QStringLiteral("AMM"), QByteArrayLiteral("amm-idl"));
const WalletPortfolioResult first = service.refresh(request());
QCOMPARE(first.status, QStringLiteral("ready"));
QCOMPARE(first.assets.size(), 1);
const QVariantMap asset = first.assets.first().toMap();
QCOMPARE(asset.value(QStringLiteral("definitionId")).toString(), DEFINITION_ID);
QCOMPARE(asset.value(QStringLiteral("displayDefinitionId")).toString(), DEFINITION_BASE58);
QCOMPARE(asset.value(QStringLiteral("balance")).toString(), QStringLiteral("25"));
QCOMPARE(first.presentations.size(), 3);
const int firstDecodeCalls = decodeCalls;
QVERIFY(firstDecodeCalls > 0);
const WalletPortfolioResult renamed = service.refresh(request(QStringLiteral("Renamed")));
QCOMPARE(decodeCalls, firstDecodeCalls);
QCOMPARE(renamed.status, QStringLiteral("ready"));
QCOMPARE(renamed.assets.first().toMap().value(QStringLiteral("name")).toString(),
QStringLiteral("Renamed"));
}
void WalletPortfolioServiceTest::exposesHoldingDecodeFailureWithoutZeroBalance()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls, true));
const WalletPortfolioResult result = service.refresh(request());
QCOMPARE(result.status, QStringLiteral("partial"));
QCOMPARE(result.error, QStringLiteral("holding_decode_failed"));
const QVariantMap asset = result.assets.first().toMap();
QCOMPARE(asset.value(QStringLiteral("status")).toString(), QStringLiteral("unavailable"));
QVERIFY(asset.value(QStringLiteral("balance")).toString().isEmpty());
}
void WalletPortfolioServiceTest::exposesUnreadPublicAccountWithoutZeroBalance()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls));
WalletPortfolioRequest input = request();
for (WalletAccountRead& account : input.publicAccountReads) {
if (account.accountId == HOLDING_ID)
account = WalletAccountRead { HOLDING_ID };
}
const WalletPortfolioResult result = service.refresh(input);
QCOMPARE(result.status, QStringLiteral("partial"));
QCOMPARE(result.error, QStringLiteral("public_account_read_failed"));
const QVariantMap asset = result.assets.first().toMap();
QCOMPARE(asset.value(QStringLiteral("status")).toString(), QStringLiteral("unavailable"));
QVERIFY(asset.value(QStringLiteral("balance")).toString().isEmpty());
}
void WalletPortfolioServiceTest::exposesUnresolvedDefinitions()
{
int decodeCalls = 0;
WalletPortfolioService service(decoder(&decodeCalls));
WalletPortfolioRequest input = request();
input.tokens.clear();
const WalletPortfolioResult result = service.refresh(input);
QCOMPARE(result.status, QStringLiteral("error"));
QCOMPARE(result.error, QStringLiteral("definitions_unavailable"));
QCOMPARE(result.assets.size(), 1);
QCOMPARE(result.assets.first().toMap().value(QStringLiteral("status")).toString(),
QStringLiteral("unavailable"));
}
QTEST_GUILESS_MAIN(WalletPortfolioServiceTest)
#include "WalletPortfolioServiceTest.moc"
@@ -6,6 +6,8 @@
#include <QVariant> #include <QVariant>
#include <QVariantList> #include <QVariantList>
#include <functional>
class LogosAPI; class LogosAPI;
class FakeExecutionZone { class FakeExecutionZone {
@@ -48,6 +50,13 @@ public:
return openResult; 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, QString create_new(const QString& config,
const QString& storage, const QString& storage,
const QString& password) const QString& password)
@@ -66,34 +75,69 @@ public:
QString create_account_public() { return publicAccountId; } QString create_account_public() { return publicAccountId; }
QString create_account_private() { return privateAccountId; } QString create_account_private() { return privateAccountId; }
QString account_id_to_base58(const QString& accountId) const
{
return QStringLiteral("base58-") + accountId;
}
int get_last_synced_block() const { return lastSyncedBlock; } int get_last_synced_block() const { return lastSyncedBlock; }
int get_current_block_height() const { return currentBlockHeight; } 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) int sync_to_block(quint64)
{ {
++syncCalls; ++syncCalls;
return syncResult; 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; } QString get_sequencer_addr() const { return sequencerAddress; }
void get_sequencer_addrAsync(std::function<void(QString)> callback)
{
callback(get_sequencer_addr());
}
QVariantList list_accounts() QVariantList list_accounts()
{ {
++listCalls; ++listCalls;
return accounts; return accounts;
} }
void list_accountsAsync(std::function<void(QVariantList)> callback)
{
callback(list_accounts());
}
QString get_account_public(const QString& accountId) QString get_account_public(const QString& accountId)
{ {
++publicReadCalls; ++publicReadCalls;
return publicAccounts.value(accountId); 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 QString get_balance(const QString& accountId, bool) const
{ {
return balances.value(accountId); 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( QString send_generic_public_transaction(
const QStringList& accountIds, const QStringList& accountIds,
@@ -108,6 +152,7 @@ public:
submittedProgramId = programId; submittedProgramId = programId;
return transactionResponse; return transactionResponse;
} }
}; };
struct LogosModules { struct LogosModules {
@@ -0,0 +1,47 @@
import QtQuick
import QtTest
import Logos.Wallet as Wallet
Item {
id: root
width: 360
height: 240
Component {
id: copyButtonComponent
Wallet.CopyButton {}
}
Component {
id: clipboardSinkComponent
TextEdit {}
}
TestCase {
name: "CopyButton"
when: windowShown
function test_copiesText() {
const value = "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"
const copyButton = createTemporaryObject(copyButtonComponent, root, {
"copyText": value,
"copyLabel": "Copy address"
})
const sink = createTemporaryObject(clipboardSinkComponent, root)
verify(copyButton, "Copy button exists")
verify(sink, "Clipboard sink exists")
compare(copyButton.implicitWidth, 36)
compare(copyButton.implicitHeight, 36)
copyButton.click()
verify(copyButton.copied)
sink.paste()
tryCompare(sink, "text", value)
}
}
}
@@ -103,6 +103,38 @@ Item {
tryCompare(dialog, "opened", false) tryCompare(dialog, "opened", false)
} }
function test_activityStateDoesNotBlockCancellation() {
const dialog = createTemporaryObject(dialogComponent, root)
verify(dialog, "Dialog exists")
dialog.openWithSnapshot({ amount: "5" })
tryCompare(dialog, "opened", true)
dialog.activityBusy = true
const cancelButton = findChild(dialog, "transactionCancelButton")
const confirmButton = findChild(dialog, "transactionConfirmButton")
verify(cancelButton.enabled)
verify(!confirmButton.enabled)
dialog.cancel()
tryCompare(dialog, "opened", false)
}
function test_cancelUsesTheConfirmationButtonShape() {
const dialog = createTemporaryObject(dialogComponent, root)
verify(dialog, "Dialog exists")
dialog.roundedCancelButton = true
dialog.openWithSnapshot({ amount: "5" })
tryCompare(dialog, "opened", true)
const cancelButtonLoader = findChild(dialog, "transactionCancelButtonLoader")
const confirmButton = findChild(dialog, "transactionConfirmButton")
verify(cancelButtonLoader)
tryVerify(function() {
return cancelButtonLoader.item
&& cancelButtonLoader.item.background.radius === confirmButton.background.radius
})
}
function test_keepsActionsInsideShortViewport() { function test_keepsActionsInsideShortViewport() {
const viewport = createTemporaryObject(viewportComponent, root) const viewport = createTemporaryObject(viewportComponent, root)
verify(viewport, "Short viewport exists") verify(viewport, "Short viewport exists")
@@ -13,32 +13,65 @@ Item {
QtObject { QtObject {
property bool isWalletOpen: false property bool isWalletOpen: false
property bool walletExists: true 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 walletHome: "/wallet"
property string walletSyncStatus: "closed"
property string walletSyncError: ""
property bool deferOpen: false
property int openCalls: 0 property int openCalls: 0
property int createCalls: 0 property int createCalls: 0
property int publicAccountCalls: 0 property int publicAccountCalls: 0
property int privateAccountCalls: 0 property int privateAccountCalls: 0
property int disconnectCalls: 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() { function openExisting() {
openCalls++ openCalls++
isWalletOpen = true if (deferOpen) {
walletSyncStatus = "opening"
} else {
isWalletOpen = true
walletSyncStatus = "ready"
}
return true return true
} }
function createNewDefault(_password) { function createNewDefault(_password) {
createCalls++ createCalls++
if (createWalletFails)
return ""
isWalletOpen = true isWalletOpen = true
walletSyncStatus = createWalletRefreshFails ? "error" : "ready"
walletSyncError = createWalletRefreshFails ? "read_failed" : ""
return "alpha beta gamma" return "alpha beta gamma"
} }
function createAccountPublic() { function createAccountPublic() {
publicAccountCalls++ publicAccountCalls++
if (accountRefreshFails) {
walletSyncStatus = "error"
walletSyncError = "read_failed"
}
return "a".repeat(64) return "a".repeat(64)
} }
function createAccountPrivate() { function createAccountPrivate() {
privateAccountCalls++ privateAccountCalls++
if (accountRefreshFails) {
walletSyncStatus = "error"
walletSyncError = "read_failed"
}
return "b".repeat(64) return "b".repeat(64)
} }
@@ -46,6 +79,17 @@ Item {
disconnectCalls++ disconnectCalls++
isWalletOpen = false isWalletOpen = false
} }
function setPrimaryAccount(address) {
primaryAccountCalls++
primaryAccountAddress = address
return true
}
function setAccountAlias(_address, _alias) {
aliasCalls++
return true
}
} }
} }
@@ -54,6 +98,25 @@ Item {
ListModel { } ListModel { }
} }
Component {
id: portfolioComponent
QtObject {
property string assetStatus: "ready"
property string assetError: ""
property var assets: []
}
}
Component {
id: networkComponent
QtObject {
property string activeNetwork: ""
property string networkStatus: "ready"
}
}
Component { Component {
id: controlComponent id: controlComponent
Wallet.WalletControl { Wallet.WalletControl {
@@ -115,7 +178,7 @@ Item {
const model = createTemporaryObject(modelComponent, root) const model = createTemporaryObject(modelComponent, root)
verify(model, "Account model exists") verify(model, "Account model exists")
for (const account of accounts || []) for (const account of accounts || [])
model.append(account) model.append(accountData(account))
const control = createTemporaryObject(controlComponent, root, { const control = createTemporaryObject(controlComponent, root, {
wallet: backend, wallet: backend,
accountModel: model accountModel: model
@@ -124,6 +187,25 @@ Item {
return { backend, model, control } 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 || "",
decodedData: account.decodedData || "",
visibility: account.visibility || (account.isPublic === false ? "private" : "public"),
canBePrimary: account.canBePrimary === undefined ? true : account.canBePrimary,
isPrimary: account.isPrimary === true
}
}
function test_opensExistingWallet() { function test_opensExistingWallet() {
const fixture = createControl({ walletExists: true }, []) const fixture = createControl({ walletExists: true }, [])
const connectButton = findChild(fixture.control, "walletConnectButton") const connectButton = findChild(fixture.control, "walletConnectButton")
@@ -133,6 +215,19 @@ Item {
tryCompare(fixture.control, "connected", true) tryCompare(fixture.control, "connected", true)
} }
function test_surfacesDeferredOpenFailure() {
const fixture = createControl({ walletExists: true, deferOpen: true }, [])
mouseClick(findChild(fixture.control, "walletConnectButton"))
compare(fixture.backend.openCalls, 1)
compare(fixture.control.syncStatus, "opening")
fixture.backend.walletSyncStatus = "error"
fixture.backend.walletSyncError = "open_failed"
const dialog = findChild(fixture.control, "walletMessageDialog")
tryCompare(dialog, "opened", true)
verify(dialog.message.includes("open_failed"))
}
function test_requiresSeedBackupAcknowledgement() { function test_requiresSeedBackupAcknowledgement() {
const fixture = createControl({ walletExists: false }, []) const fixture = createControl({ walletExists: false }, [])
mouseClick(findChild(fixture.control, "walletConnectButton")) mouseClick(findChild(fixture.control, "walletConnectButton"))
@@ -165,6 +260,42 @@ Item {
tryCompare(dialog, "opened", false) 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() { function test_clampsSelectionAndDisconnectsLocally() {
const fixture = createControl({ isWalletOpen: true }, [ const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, { name: "One", address: "a".repeat(64), balance: "10", isPublic: true },
@@ -173,12 +304,12 @@ Item {
fixture.control.selectedIndex = 1 fixture.control.selectedIndex = 1
compare(fixture.control.selectedAddress, "b".repeat(64)) compare(fixture.control.selectedAddress, "b".repeat(64))
fixture.model.clear() fixture.model.clear()
tryCompare(fixture.control, "selectedIndex", 0) tryCompare(fixture.control, "selectedIndex", -1)
compare(fixture.control.selectedAddress, "") compare(fixture.control.selectedAddress, "")
fixture.model.append({ fixture.model.append(accountData({
name: "One", address: "a".repeat(64), balance: "10", isPublic: true name: "One", address: "a".repeat(64), balance: "10", isPublic: true
}) }))
mouseClick(findChild(fixture.control, "walletAccountButton")) mouseClick(findChild(fixture.control, "walletAccountButton"))
const disconnectButton = findChild(fixture.control, "walletDisconnectButton") const disconnectButton = findChild(fixture.control, "walletDisconnectButton")
tryVerify(function() { return disconnectButton.visible }) tryVerify(function() { return disconnectButton.visible })
@@ -187,6 +318,31 @@ Item {
tryCompare(fixture.control, "connected", false) tryCompare(fixture.control, "connected", false)
} }
function test_waitsForPrimaryDelegateBeforeShowingAccountType() {
const address = "a".repeat(64)
const fixture = createControl({
isWalletOpen: true,
primaryAccountAddress: address,
primaryAccountName: "Primary"
}, [])
mouseClick(findChild(fixture.control, "walletAccountButton"))
const accountType = findChild(fixture.control, "walletPrimaryAccountType")
verify(accountType, "Primary account type exists")
verify(!accountType.visible, "Account type waits for its selected delegate")
fixture.model.append(accountData({
name: "Primary",
address: address,
balance: "10",
isPublic: true,
isPrimary: true
}))
tryCompare(fixture.control, "selectedAddress", address)
tryCompare(accountType, "visible", true)
compare(accountType.text, "Public user account")
}
function test_connectedButtonClosesOpenMenu() { function test_connectedButtonClosesOpenMenu() {
const fixture = createControl({ isWalletOpen: true }, [ const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true } { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
@@ -200,6 +356,53 @@ Item {
tryCompare(menu, "opened", false) tryCompare(menu, "opened", false)
} }
function test_walletMenuClosesWithEscape() {
const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
])
const accountButton = findChild(fixture.control, "walletAccountButton")
const menu = findChild(fixture.control, "walletMenu")
mouseClick(accountButton)
tryCompare(menu, "opened", true)
keyClick(Qt.Key_Escape)
tryCompare(menu, "opened", false)
}
function test_createAccountDialogOwnsKeyboardFocus() {
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 backButton = findChild(fixture.control, "walletAccountsBackButton")
const addButton = findChild(fixture.control, "walletAddAccountButton")
verify(backButton && addButton, "Account controls exist")
mouseClick(addButton)
const dialog = findChild(fixture.control, "createAccountDialog")
const privateSwitch = findChild(dialog, "privateAccountSwitch")
tryCompare(dialog, "opened", true)
tryVerify(function() { return privateSwitch.activeFocus })
for (let index = 0; index < 6; ++index) {
keyClick(Qt.Key_Tab)
verify(!backButton.activeFocus, "Focus remains inside the dialog")
}
keyClick(Qt.Key_Escape)
tryCompare(dialog, "opened", false)
}
function test_walletMessageDialogClosesWithEscape() {
const fixture = createControl({ isWalletOpen: true }, [])
const dialog = findChild(fixture.control, "walletMessageDialog")
dialog.open()
tryCompare(dialog, "opened", true)
keyClick(Qt.Key_Escape)
tryCompare(dialog, "opened", false)
}
function test_openMenuTracksControlMovement() { function test_openMenuTracksControlMovement() {
const fixture = createControl({ isWalletOpen: true }, [ const fixture = createControl({ isWalletOpen: true }, [
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true } { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
@@ -226,8 +429,20 @@ Item {
function test_selectsAccount() { function test_selectsAccount() {
const fixture = createControl({ isWalletOpen: true }, [ 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")) mouseClick(findChild(fixture.control, "walletAccountButton"))
const accountsButton = findChild(fixture.control, "walletAccountsButton") const accountsButton = findChild(fixture.control, "walletAccountsButton")
@@ -238,9 +453,292 @@ Item {
tryCompare(accountList, "count", 2) tryCompare(accountList, "count", 2)
tryVerify(function() { return accountList.itemAtIndex(1) !== null }) tryVerify(function() { return accountList.itemAtIndex(1) !== null })
const secondAccount = accountList.itemAtIndex(1) const secondAccount = accountList.itemAtIndex(1)
secondAccount.clicked() mouseClick(secondAccount)
tryCompare(fixture.control, "selectedIndex", 1) tryCompare(fixture.control, "selectedIndex", 1)
compare(fixture.backend.primaryAccountAddress, "b".repeat(64))
compare(fixture.control.selectedAddress, "b".repeat(64)) compare(fixture.control.selectedAddress, "b".repeat(64))
compare(fixture.control.selectedDisplayAddress, "base58-two")
}
function test_flatWalletActionsUseReadableForeground() {
const fixture = createControl({
isWalletOpen: true,
assets: [{
name: "Available",
balance: "0",
definitionId: "c".repeat(64),
displayDefinitionId: "base58-available",
status: "ready",
section: "available"
}]
}, [
{
name: "One",
address: "a".repeat(64),
balance: "10",
isPublic: true,
isPrimary: true
},
{
name: "Two",
address: "b".repeat(64),
balance: "20",
isPublic: true
}
])
mouseClick(findChild(fixture.control, "walletAccountButton"))
const available = findChild(fixture.control, "walletAvailableAssetsButton")
tryVerify(function() { return available && available.visible })
compare(available.flat, true)
compare(available.palette.windowText, "#d4d4d8")
compare(available.contentItem.color, "#d4d4d8")
mouseClick(available)
tryCompare(fixture.control, "availableExpanded", true)
mouseClick(findChild(fixture.control, "walletAccountsButton"))
const advanced = findChild(fixture.control, "walletAdvancedAccountsButton")
tryVerify(function() { return advanced && advanced.visible })
compare(advanced.flat, true)
compare(advanced.palette.windowText, "#d4d4d8")
compare(advanced.contentItem.color, "#d4d4d8")
const accountList = findChild(fixture.control, "walletAccountList")
tryVerify(function() { return accountList.itemAtIndex(1) !== null })
const secondAccount = accountList.itemAtIndex(1)
const rename = findChild(secondAccount, "walletRenameButton")
const makePrimary = findChild(secondAccount, "walletMakePrimaryButton")
verify(rename && makePrimary, "Account action buttons exist")
for (const action of [rename, makePrimary]) {
compare(action.flat, true)
compare(action.palette.windowText, "#d4d4d8")
compare(action.contentItem.color, "#d4d4d8")
}
}
function test_tokenAssetsRenderInBoxes() {
const fixture = createControl({
isWalletOpen: true,
assets: [
{
name: "Held token",
balance: "42",
definitionId: "c".repeat(64),
displayDefinitionId: "base58-held-token",
status: "ready",
section: "assets"
},
{
name: "Available token",
balance: "0",
definitionId: "d".repeat(64),
displayDefinitionId: "base58-available-token",
status: "ready",
section: "available"
}
]
}, [])
mouseClick(findChild(fixture.control, "walletAccountButton"))
const heldRepeater = findChild(fixture.control, "walletAssetRepeater")
verify(heldRepeater, "Held token repeater exists")
let held = null
tryVerify(function() {
held = heldRepeater.itemAt(0)
return held !== null
})
verify(held, "Held token box exists")
tryCompare(held, "visible", true)
compare(held.implicitHeight, 68)
compare(held.radius, 10)
compare(held.border.width, 1)
compare(held.border.color, "#3f3f46")
const availableRepeater = findChild(fixture.control, "walletAvailableAssetRepeater")
verify(availableRepeater, "Available token repeater exists")
let available = null
tryVerify(function() {
available = availableRepeater.itemAt(1)
return available !== null
})
verify(available, "Available token box exists")
compare(available.visible, false)
mouseClick(findChild(fixture.control, "walletAvailableAssetsButton"))
tryCompare(available, "visible", true)
compare(available.implicitHeight, 64)
compare(available.radius, 10)
compare(available.border.width, 1)
compare(available.border.color, "#3f3f46")
}
function test_usesExplicitPortfolioAndNetworkProviders() {
const fixture = createControl({
isWalletOpen: true,
activeNetwork: "wallet network",
networkStatus: "error",
assetStatus: "ready",
assets: [{
name: "Wallet available token",
balance: "0",
definitionId: "a".repeat(64),
status: "ready",
section: "available"
}]
}, [])
const portfolio = createTemporaryObject(portfolioComponent, root, {
assetStatus: "loading",
assets: [{
name: "Portfolio token",
balance: "42",
definitionId: "b".repeat(64),
status: "ready",
section: "assets"
}]
})
const network = createTemporaryObject(networkComponent, root, {
activeNetwork: "shared testnet",
networkStatus: "loading"
})
verify(portfolio && network, "Shared providers exist")
fixture.control.portfolio = portfolio
fixture.control.network = network
compare(fixture.control.portfolioProvider, portfolio)
compare(fixture.control.networkProvider, network)
compare(fixture.control.walletAssets[0].name, "Portfolio token")
compare(fixture.control.assetStatus, "loading")
compare(fixture.control.activeNetwork, "shared testnet")
compare(fixture.control.networkStatus, "loading")
mouseClick(findChild(fixture.control, "walletAccountButton"))
const indicator = findChild(fixture.control, "walletNetworkStatusIndicator")
const networkName = findChild(fixture.control, "walletNetworkName")
const loading = findChild(fixture.control, "walletAssetsLoadingLabel")
const heldAssets = findChild(fixture.control, "walletAssetRepeater")
verify(indicator && networkName && loading && heldAssets, "Provider UI exists")
tryCompare(indicator, "color", "#f59e0b")
tryCompare(networkName, "text", "shared testnet")
tryCompare(loading, "visible", true)
tryCompare(heldAssets, "count", 1)
tryCompare(heldAssets.itemAt(0), "visible", true)
}
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 })
mouseClick(list.itemAtIndex(1))
compare(fixture.backend.primaryAccountCalls, 0)
compare(fixture.control.selectedAddress, userAddress)
}
function test_advancedShowsProgramAndDecodedData() {
const decodedData = "{\n \"name\": \"Test token\"\n}"
const fixture = createControl({ isWalletOpen: true }, [
{
name: "Token definition",
address: "c".repeat(64),
balance: "0",
isPublic: true,
kind: "token_definition",
section: "advanced",
programName: "Token",
accountType: "TokenDefinition",
decodedData: decodedData,
canBePrimary: false
}
])
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(0) !== null })
const program = findChild(list.itemAtIndex(0), "walletProgramName")
const decoded = findChild(list.itemAtIndex(0), "walletDecodedData")
verify(program && decoded, "Advanced details exist")
tryCompare(program, "visible", true)
compare(program.text, "Program: Token")
tryCompare(decoded, "visible", true)
compare(decoded.text, decodedData)
}
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() { function test_createsAccount() {
@@ -262,6 +760,47 @@ Item {
tryCompare(dialog, "opened", false) 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() { function test_compactLayoutHasStableWidth() {
const fixture = createControl({ isWalletOpen: false }, []) const fixture = createControl({ isWalletOpen: false }, [])
fixture.control.viewportWidth = 480 fixture.control.viewportWidth = 480
@@ -300,12 +839,12 @@ Item {
const model = createTemporaryObject(modelComponent, root) const model = createTemporaryObject(modelComponent, root)
verify(backend && model, "Wallet fixture exists") verify(backend && model, "Wallet fixture exists")
for (let index = 0; index < 10; ++index) { for (let index = 0; index < 10; ++index) {
model.append({ model.append(accountData({
name: "Account " + index, name: "Account " + index,
address: String(index).repeat(64), address: String(index).repeat(64),
balance: String(index), balance: String(index),
isPublic: true isPublic: true
}) }))
} }
const window = createTemporaryObject(compactWindowComponent, root) const window = createTemporaryObject(compactWindowComponent, root)
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include <utility>
#include "WalletProvider.h" #include "WalletProvider.h"
class FakeWalletProvider final : public WalletProvider { class FakeWalletProvider final : public WalletProvider {
@@ -23,6 +25,9 @@ public:
bool lastAccountWasPublic = false; bool lastAccountWasPublic = false;
WalletPaths lastPaths; WalletPaths lastPaths;
WalletTransaction lastTransaction; WalletTransaction lastTransaction;
bool deferAsync = false;
SessionCallback pendingConnectCallback;
SnapshotCallback pendingSnapshotCallback;
WalletSession connect(const WalletPaths& paths) override WalletSession connect(const WalletPaths& paths) override
{ {
@@ -31,6 +36,16 @@ public:
return connectResult; return connectResult;
} }
void connectAsync(const WalletPaths& paths, SessionCallback callback) override
{
++connectCalls;
lastPaths = paths;
if (deferAsync)
pendingConnectCallback = std::move(callback);
else
callback(connectResult);
}
WalletCreation createWallet(const WalletPaths& paths, WalletCreation createWallet(const WalletPaths& paths,
const QString&) override const QString&) override
{ {
@@ -46,6 +61,16 @@ public:
return snapshotResult; return snapshotResult;
} }
void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override
{
++snapshotCalls;
lastForceRefresh = forceRefresh;
if (deferAsync)
pendingSnapshotCallback = std::move(callback);
else
callback(snapshotResult);
}
void clearSnapshot() override { ++clearCalls; } void clearSnapshot() override { ++clearCalls; }
WalletAccountCreation createAccount(bool isPublic) override WalletAccountCreation createAccount(bool isPublic) override
@@ -72,4 +97,19 @@ public:
} }
void disconnect() override { ++disconnectCalls; } void disconnect() override { ++disconnectCalls; }
void finishConnect()
{
SessionCallback callback = std::move(pendingConnectCallback);
if (callback)
callback(connectResult);
}
void finishSnapshot()
{
SnapshotCallback callback = std::move(pendingSnapshotCallback);
if (callback)
callback(snapshotResult);
}
}; };
+38 -10
View File
@@ -11,10 +11,8 @@
}; };
# The AMM QML UI module (apps/amm) is built from this same flake so it can # The AMM QML UI module (apps/amm) is built from this same flake so it can
# reference the amm_ffi crate package via `self` — no filesystem # reference the amm_ffi crate package via `self` — no filesystem path or
# path or git-remote reference to this repo is needed (see apps/amm/flake.nix # git-remote reference to this repo is needed.
# history: a `git+file://` URL pointing at a local checkout is
# machine-specific and not portable).
logos-module-builder.url = "github:logos-co/logos-module-builder"; logos-module-builder.url = "github:logos-co/logos-module-builder";
# Core wallet module (the LEZ wallet FFI Qt plugin). The input name must # Core wallet module (the LEZ wallet FFI Qt plugin). The input name must
@@ -110,11 +108,36 @@
sourceDir = "modules/token/ffi"; sourceDir = "modules/token/ffi";
header = "token_ffi.h"; header = "token_ffi.h";
}; };
walletDecoderArgs = {
inherit src;
strictDeps = true;
pname = "wallet-idl-decoder";
version = "0.1.0";
cargoExtraArgs = "-p wallet-idl-decoder";
doCheck = false;
};
walletDecoder = craneLib.buildPackage (
walletDecoderArgs
// {
cargoArtifacts = craneLib.buildDepsOnly walletDecoderArgs;
postInstall =
''
mkdir -p $out/include
cp tools/wallet-idl-decoder/include/wallet_idl_decoder.h $out/include/
''
+ pkgs.lib.optionalString pkgs.stdenv.isDarwin ''
if [ -f $out/lib/libwallet_idl_decoder.dylib ]; then
install_name_tool -id "$out/lib/libwallet_idl_decoder.dylib" $out/lib/libwallet_idl_decoder.dylib
fi
'';
}
);
in in
{ {
packages.default = ammFfi; packages.default = ammFfi;
packages.amm_ffi = ammFfi; packages.amm_ffi = ammFfi;
packages.token_ffi = tokenFfi; packages.token_ffi = tokenFfi;
packages.wallet_idl_decoder = walletDecoder;
} }
); );
@@ -135,15 +158,19 @@
flakeInputs = inputs // { amm_module = ammModuleOutputs; }; flakeInputs = inputs // { amm_module = ammModuleOutputs; };
# The UI links no external lib of its own — the AMM brain (amm_ffi) is # The UI links no external lib of its own — the AMM brain (amm_ffi) is
# linked by amm_module, which the UI reaches via modules().amm_module. # linked by amm_module, which the UI reaches via modules().amm_module.
externalLibInputs = { }; externalLibInputs = {
wallet_idl_decoder = {
input = self;
packages.default = "wallet_idl_decoder";
};
};
# The AMM UI links the shared C++ wallet access lib and bundles the # The AMM UI links the shared C++ wallet access lib and bundles the
# Logos.Wallet QML module (apps/shared/wallet). apps/amm/flake.nix wires # Logos.Wallet QML module (apps/shared/wallet). Point CMake at the
# these via its `shared_wallet` input; when built from this root flake # in-tree source and stage the built QML module with the app.
# the source lives in-tree, so point CMake straight at it and stage the
# built QML module the same way. Keep in sync with apps/amm/flake.nix.
preConfigure = '' preConfigure = ''
cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${./apps/shared/wallet}") cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${./apps/shared/wallet}")
cmakeFlagsArray+=("-DLOGOS_WALLET_GENERATED_DIR=$PWD/generated_code/include") cmakeFlagsArray+=("-DLOGOS_WALLET_GENERATED_DIR=$PWD/generated_code/include")
cmakeFlagsArray+=("-DLEZ_IDL_ARTIFACTS_DIR=${./artifacts}")
''; '';
postInstall = '' postInstall = ''
walletQmlDescriptor="$(find "$PWD" -type f -path '*/shared-wallet/qml/Logos/Wallet/qmldir' -print -quit)" walletQmlDescriptor="$(find "$PWD" -type f -path '*/shared-wallet/qml/Logos/Wallet/qmldir' -print -quit)"
@@ -318,10 +345,11 @@
pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; }; pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; };
ammFfi = crateOutputs.packages.${system}.amm_ffi; ammFfi = crateOutputs.packages.${system}.amm_ffi;
moduleDir = appPkgs.${system}.default; moduleDir = appPkgs.${system}.default;
walletDecoder = crateOutputs.packages.${system}.wallet_idl_decoder;
in in
app // { app // {
program = "${pkgs.writeShellScript "run-amm-ui" '' program = "${pkgs.writeShellScript "run-amm-ui" ''
export DYLD_FALLBACK_LIBRARY_PATH="${ammFfi}/lib''${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" export DYLD_FALLBACK_LIBRARY_PATH="${ammFfi}/lib:${walletDecoder}/lib''${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}"
export QML_IMPORT_PATH="${moduleDir}/lib''${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" export QML_IMPORT_PATH="${moduleDir}/lib''${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}"
exec ${app.program} "$@" exec ${app.program} "$@"
''}"; ''}";
+6 -3
View File
@@ -1448,8 +1448,11 @@ LogosList AmmModuleImpl::resolveTokens(const LogosMap& request, bool wallet_open
LogosList out = LogosList::array(); LogosList out = LogosList::array();
const auto it = result.value.find("tokens"); const auto it = result.value.find("tokens");
if (it != result.value.end() && it->is_array()) if (it != result.value.end() && it->is_array()) {
for (const auto& row : *it) out.push_back(row); for (auto row : *it) {
row["definitionIdHex"] = normalizeAccountId(jStr(row, "definitionId"));
out.push_back(std::move(row));
}
}
return out; return out;
} }
+2 -1
View File
@@ -241,7 +241,8 @@ public:
/// normalized to hex here) — the app owns the set: its configured tokens plus any /// normalized to hex here) — the app owns the set: its configured tokens plus any
/// custom/pasted ids it remembers (held-but-unlisted tokens are not auto-added by /// custom/pasted ids it remembers (held-but-unlisted tokens are not auto-added by
/// the app). Reads each definition and (when `wallet_open`) the wallet, then returns /// the app). Reads each definition and (when `wallet_open`) the wallet, then returns
/// `[{ definitionId (base58), name, totalSupply, holdingId, balance }]`. Every /// `[{ definitionId (base58), definitionIdHex, name, totalSupply, holdingId,
/// balance }]`. Every
/// row has the same fields — a token the wallet doesn't hold gets `holdingId:""` /// row has the same fields — a token the wallet doesn't hold gets `holdingId:""`
/// and `balance:"0"` — held tokens first. A requested id whose definition is /// and `balance:"0"` — held tokens first. A requested id whose definition is
/// unreadable / non-fungible is omitted (the app treats a missing row as /// unreadable / non-fungible is omitted (the app treats a missing row as
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "wallet-idl-decoder"
version = "0.1.0"
edition = "2021"
[lints]
workspace = true
[lib]
name = "wallet_idl_decoder"
crate-type = ["cdylib", "rlib"]
[dependencies]
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
+287
View File
@@ -0,0 +1,287 @@
use std::{
collections::BTreeMap,
ffi::{CStr, CString},
os::raw::c_char,
panic::{catch_unwind, AssertUnwindSafe},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use spel_framework_core::{decode::decode_account_data_try_all, idl::SpelIdl, pda::parse_bytes32};
#[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) => {
if !encoded.starts_with("Public/") {
return;
}
if let Ok(bytes) = parse_bytes32(encoded) {
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)
}
/// Decodes a request from caller-owned C memory.
///
/// # Safety
/// `request_json` must be null or point to a readable NUL-terminated C string.
#[expect(
unsafe_code,
reason = "C ABI input requires reading a caller-owned C string"
)]
unsafe 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`].
///
/// # Safety
///
/// `request_json` must be null or point to a readable NUL-terminated C string
/// that remains valid for this call.
#[no_mangle]
#[expect(unsafe_code, reason = "C ABI requires a stable exported symbol")]
pub unsafe extern "C" fn wallet_idl_decode_accounts(request_json: *const c_char) -> *mut c_char {
let response = catch_unwind(AssertUnwindSafe(|| unsafe {
// SAFETY: Caller upholds this function's pointer contract.
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 = unsafe { 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) };
}
}