From b71be34edfcbec612dc6988ce5d533eb76e41fe4 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 17 Jul 2026 21:39:09 -0300 Subject: [PATCH] perf(amm): cache verified token definitions --- apps/amm/CMakeLists.txt | 43 ++++ apps/amm/src/AmmUiBackend.cpp | 71 ++++- apps/amm/src/AmmUiBackend.h | 14 +- apps/amm/src/TokenDefinitionCache.cpp | 104 ++++++++ apps/amm/src/TokenDefinitionCache.h | 53 ++++ .../cpp/AmmUiBackendDefinitionCacheTest.cpp | 242 ++++++++++++++++++ .../tests/cpp/TokenDefinitionCacheTest.cpp | 185 +++++++++++++ .../wallet/tests/support/FakeWalletProvider.h | 47 +++- 8 files changed, 744 insertions(+), 15 deletions(-) create mode 100644 apps/amm/src/TokenDefinitionCache.cpp create mode 100644 apps/amm/src/TokenDefinitionCache.h create mode 100644 apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp create mode 100644 apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index d96a7f1..670ed7b 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -33,6 +33,8 @@ logos_module( src/AmmUiBackend.cpp src/ActiveNetwork.h src/ActiveNetwork.cpp + src/TokenDefinitionCache.h + src/TokenDefinitionCache.cpp src/WalletIdlDecoder.h src/WalletIdlDecoder.cpp FIND_PACKAGES @@ -74,4 +76,45 @@ if(BUILD_TESTING) target_include_directories(amm_active_network_test PRIVATE src) target_link_libraries(amm_active_network_test PRIVATE Qt6::Core Qt6::Test) add_test(NAME amm_active_network COMMAND amm_active_network_test) + + add_executable(amm_token_definition_cache_test + tests/cpp/TokenDefinitionCacheTest.cpp + src/TokenDefinitionCache.h + src/TokenDefinitionCache.cpp + ) + set_target_properties(amm_token_definition_cache_test PROPERTIES AUTOMOC ON) + target_compile_features(amm_token_definition_cache_test PRIVATE cxx_std_17) + target_include_directories(amm_token_definition_cache_test PRIVATE + src + "${LOGOS_WALLET_SOURCE_DIR}/src" + "${LOGOS_WALLET_SOURCE_DIR}/tests/support" + ) + target_link_libraries(amm_token_definition_cache_test PRIVATE Qt6::Core Qt6::Test) + add_test(NAME amm_token_definition_cache COMMAND amm_token_definition_cache_test) + + add_executable(amm_backend_definition_cache_test + tests/cpp/AmmUiBackendDefinitionCacheTest.cpp + ) + add_dependencies(amm_backend_definition_cache_test amm_ui_module_plugin) + set_target_properties(amm_backend_definition_cache_test PROPERTIES AUTOMOC ON) + target_compile_features(amm_backend_definition_cache_test PRIVATE cxx_std_17) + target_include_directories(amm_backend_definition_cache_test PRIVATE + src + "${CMAKE_CURRENT_BINARY_DIR}" + "${LOGOS_WALLET_SOURCE_DIR}/src" + "${LOGOS_WALLET_SOURCE_DIR}/tests/support" + ) + target_link_libraries(amm_backend_definition_cache_test PRIVATE + Qt6::Core + Qt6::Network + Qt6::RemoteObjects + Qt6::Test + amm_ui_module_plugin + ) + if(UNIX AND NOT APPLE) + target_link_options(amm_backend_definition_cache_test PRIVATE + "-Wl,--allow-shlib-undefined" + ) + endif() + add_test(NAME amm_backend_definition_cache COMMAND amm_backend_definition_cache_test) endif() diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 106d68f..53ed616 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -109,12 +109,33 @@ WalletAccountRead accountRead(const WalletAccount& account) AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), - m_wallet(std::make_unique(m_logosAPI)), + m_ownedWallet(std::make_unique(m_logosAPI)), + m_wallet(m_ownedWallet.get()), + m_definitionCache(*m_wallet), m_walletController(std::make_unique( *m_wallet, QStringLiteral("AmmUI"))), m_networkManager(new QNetworkAccessManager(this)), m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))), m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json"))) +{ + initialize(); +} + +AmmUiBackend::AmmUiBackend(WalletProvider& wallet, QObject* parent) + : AmmUiBackendSimpleSource(parent), + m_logosAPI(nullptr), + m_wallet(&wallet), + m_definitionCache(*m_wallet), + m_walletController(std::make_unique( + *m_wallet, QStringLiteral("AmmUI"))), + m_networkManager(new QNetworkAccessManager(this)), + m_tokenIdl(resource(QStringLiteral(":/amm/idl/token-idl.json"))), + m_ammIdl(resource(QStringLiteral(":/amm/idl/amm-idl.json"))) +{ + initialize(); +} + +void AmmUiBackend::initialize() { setAssets({}); setAssetStatus(QStringLiteral("idle")); @@ -198,6 +219,10 @@ bool AmmUiBackend::setPrimaryAccount(QString accountId) void AmmUiBackend::syncWalletState() { const WalletUiState& state = m_walletController->state(); + if (state.syncStatus == QStringLiteral("opening") + || state.syncStatus == QStringLiteral("syncing")) { + m_definitionCache.cancelPending(); + } const QString previousAddress = sequencerAddr(); const bool wasReachable = sequencerReachable(); setIsWalletOpen(state.isWalletOpen); @@ -274,41 +299,71 @@ void AmmUiBackend::refreshPortfolio() { const quint64 generation = ++m_portfolioGeneration; if (!m_walletController->state().isWalletOpen) { + m_definitionCache.cancelPending(); setAssets({}); setAssetStatus(QStringLiteral("idle")); setAssetError({}); return; } if (m_network.status() != QStringLiteral("ready")) { + invalidateDefinitionCache(); setAssets({}); setAssetStatus(QStringLiteral("blocked")); setAssetError(m_network.status()); return; } if (m_tokenIdl.isEmpty()) { + invalidateDefinitionCache(); setAssetStatus(QStringLiteral("error")); setAssetError(QStringLiteral("token_idl_missing")); return; } + const TokenDefinitionCacheKey key = definitionCacheKey(m_network.snapshot()); setAssetStatus(QStringLiteral("loading")); setAssetError({}); - m_wallet->readPublicAccountsAsync( - m_network.snapshot().tokenIds, - [this, generation](QVector reads) { - applyDefinitions(generation, reads); + if (m_appliedDefinitionKey && *m_appliedDefinitionKey == key + && m_definitionCache.contains(key)) { + applyWalletPortfolio(generation); + return; + } + m_definitionCache.read( + key, + [this, generation, key](QVector reads) { + applyDefinitions(generation, key, reads); }); } +TokenDefinitionCacheKey AmmUiBackend::definitionCacheKey( + const ActiveNetworkSnapshot& network) const +{ + return { + network.id, + network.fingerprint, + sequencerAddr(), + network.tokenIds, + }; +} + +void AmmUiBackend::invalidateDefinitionCache() +{ + m_definitionCache.clear(); + m_appliedDefinitionKey.reset(); +} + void AmmUiBackend::applyDefinitions( quint64 generation, + const TokenDefinitionCacheKey& key, const QVector& reads) { if (generation != m_portfolioGeneration) return; const ActiveNetworkSnapshot network = m_network.snapshot(); + if (!(key == definitionCacheKey(network))) + return; const WalletDecodeResult decoded = WalletIdlDecoder::decode(m_tokenIdl, reads); if (!decoded.ok() || reads.size() != network.tokenIds.size() || decoded.accounts.size() != reads.size()) { + invalidateDefinitionCache(); setAssetStatus(QStringLiteral("error")); setAssetError(decoded.error.isEmpty() ? QStringLiteral("definition_decode_failed") @@ -338,6 +393,7 @@ void AmmUiBackend::applyDefinitions( if (m_tokenProgramId.isEmpty()) m_tokenProgramId = read.programOwner; else if (m_tokenProgramId != read.programOwner) { + invalidateDefinitionCache(); setAssets({}); setAssetStatus(QStringLiteral("error")); setAssetError(QStringLiteral("token_program_mismatch")); @@ -349,6 +405,7 @@ void AmmUiBackend::applyDefinitions( m_tokens.append(std::move(token)); } if (m_tokenProgramId.isEmpty()) { + invalidateDefinitionCache(); setAssets({}); setAssetStatus(QStringLiteral("error")); setAssetError(QStringLiteral("definitions_unavailable")); @@ -356,6 +413,10 @@ void AmmUiBackend::applyDefinitions( } m_idlRegistry.registerProgram( m_tokenProgramId, QStringLiteral("Token"), m_tokenIdl); + if (unavailable > 0) + invalidateDefinitionCache(); + else + m_appliedDefinitionKey = key; setAssetError(unavailable > 0 ? QStringLiteral("some_definitions_unavailable") : QString()); diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 9b6d84d..32daa4c 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -2,6 +2,7 @@ #define AMM_UI_BACKEND_H #include +#include #include #include @@ -10,6 +11,7 @@ #include "rep_AmmUiBackend_source.h" #include "ActiveNetwork.h" +#include "TokenDefinitionCache.h" #include "WalletAccountModel.h" #include "WalletIdlDecoder.h" @@ -24,6 +26,8 @@ class AmmUiBackend : public AmmUiBackendSimpleSource { public: explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); + // The injected provider must outlive the backend. + explicit AmmUiBackend(WalletProvider& wallet, QObject* parent = nullptr); ~AmmUiBackend() override; WalletAccountModel* accountModel() const; @@ -51,14 +55,21 @@ private: void syncWalletState(); void publishNetworkState(); + void initialize(); void probeNetworkIdentity(); void refreshPortfolio(); + TokenDefinitionCacheKey definitionCacheKey( + const ActiveNetworkSnapshot& network) const; + void invalidateDefinitionCache(); void applyDefinitions(quint64 generation, + const TokenDefinitionCacheKey& key, const QVector& reads); void applyWalletPortfolio(quint64 generation); LogosAPI* m_logosAPI; - std::unique_ptr m_wallet; + std::unique_ptr m_ownedWallet; + WalletProvider* m_wallet; + TokenDefinitionCache m_definitionCache; std::unique_ptr m_walletController; QNetworkAccessManager* m_networkManager; ActiveNetwork m_network; @@ -67,6 +78,7 @@ private: WalletIdlRegistry m_idlRegistry; QVector m_tokens; QString m_tokenProgramId; + std::optional m_appliedDefinitionKey; bool m_identityProbeInFlight = false; quint64 m_portfolioGeneration = 0; }; diff --git a/apps/amm/src/TokenDefinitionCache.cpp b/apps/amm/src/TokenDefinitionCache.cpp new file mode 100644 index 0000000..c1b424f --- /dev/null +++ b/apps/amm/src/TokenDefinitionCache.cpp @@ -0,0 +1,104 @@ +#include "TokenDefinitionCache.h" + +#include + +bool TokenDefinitionCacheKey::isReusable() const +{ + return !networkId.isEmpty() + && !networkFingerprint.isEmpty() + && !sequencerAddress.isEmpty() + && !tokenIds.isEmpty(); +} + +bool TokenDefinitionCacheKey::operator==(const TokenDefinitionCacheKey& other) const +{ + return networkId == other.networkId + && networkFingerprint == other.networkFingerprint + && sequencerAddress == other.sequencerAddress + && tokenIds == other.tokenIds; +} + +TokenDefinitionCache::TokenDefinitionCache(WalletProvider& provider) + : m_provider(provider), + m_state(std::make_shared()) +{ +} + +TokenDefinitionCache::~TokenDefinitionCache() +{ + clear(); +} + +void TokenDefinitionCache::read(const TokenDefinitionCacheKey& key, Callback callback) +{ + if (contains(key)) { + callback(m_state->cachedReads); + return; + } + if (m_state->inFlight && m_state->inFlight->key == key) { + m_state->inFlight->callbacks.append(std::move(callback)); + return; + } + cancelPending(); + + const auto request = std::make_shared(); + request->key = key; + request->callbacks.append(std::move(callback)); + m_state->inFlight = request; + const std::weak_ptr state = m_state; + m_provider.readPublicAccountsAsync( + key.tokenIds, + [state, request](QVector reads) mutable { + const std::shared_ptr lockedState = state.lock(); + if (!lockedState || request->cancelled) + return; + if (lockedState->inFlight == request) { + lockedState->inFlight.reset(); + if (TokenDefinitionCache::isComplete(request->key, reads)) { + lockedState->cachedKey = request->key; + lockedState->cachedReads = reads; + } + } + + QVector callbacks = std::move(request->callbacks); + for (Callback& callback : callbacks) + callback(reads); + }); +} + +bool TokenDefinitionCache::contains(const TokenDefinitionCacheKey& key) const +{ + return m_state->cachedKey && *m_state->cachedKey == key; +} + +void TokenDefinitionCache::cancelPending() +{ + if (m_state->inFlight) { + m_state->inFlight->cancelled = true; + m_state->inFlight->callbacks.clear(); + } + m_state->inFlight.reset(); +} + +void TokenDefinitionCache::clear() +{ + m_state->cachedKey.reset(); + m_state->cachedReads.clear(); + cancelPending(); +} + +bool TokenDefinitionCache::isComplete( + const TokenDefinitionCacheKey& key, + const QVector& reads) +{ + if (!key.isReusable() || reads.size() != key.tokenIds.size()) + return false; + for (qsizetype index = 0; index < reads.size(); ++index) { + const WalletAccountRead& read = reads.at(index); + if (!read.ok() || read.accountId != key.tokenIds.at(index) + || read.programOwner.isEmpty() || read.dataHex.isEmpty()) { + return false; + } + } + return true; +} diff --git a/apps/amm/src/TokenDefinitionCache.h b/apps/amm/src/TokenDefinitionCache.h new file mode 100644 index 0000000..3cdf31b --- /dev/null +++ b/apps/amm/src/TokenDefinitionCache.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "WalletProvider.h" + +struct TokenDefinitionCacheKey { + QString networkId; + QString networkFingerprint; + QString sequencerAddress; + QStringList tokenIds; + + bool isReusable() const; + bool operator==(const TokenDefinitionCacheKey& other) const; +}; + +class TokenDefinitionCache final { +public: + using Callback = std::function)>; + + explicit TokenDefinitionCache(WalletProvider& provider); + ~TokenDefinitionCache(); + + void read(const TokenDefinitionCacheKey& key, Callback callback); + bool contains(const TokenDefinitionCacheKey& key) const; + void cancelPending(); + void clear(); + +private: + struct InFlight { + TokenDefinitionCacheKey key; + QVector callbacks; + bool cancelled = false; + }; + + struct State { + std::optional cachedKey; + QVector cachedReads; + std::shared_ptr inFlight; + }; + + static bool isComplete(const TokenDefinitionCacheKey& key, + const QVector& reads); + + WalletProvider& m_provider; + std::shared_ptr m_state; +}; diff --git a/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp b/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp new file mode 100644 index 0000000..28e1bcb --- /dev/null +++ b/apps/amm/tests/cpp/AmmUiBackendDefinitionCacheTest.cpp @@ -0,0 +1,242 @@ +#include "AmmUiBackend.h" +#include "FakeWalletProvider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { +class ScopedEnvironment final { +public: + ScopedEnvironment(QByteArray name, QByteArray value) + : m_name(std::move(name)), + m_hadValue(qEnvironmentVariableIsSet(m_name.constData())), + m_previous(qgetenv(m_name.constData())) + { + qputenv(m_name.constData(), value); + } + + ~ScopedEnvironment() + { + if (m_hadValue) + qputenv(m_name.constData(), m_previous); + else + qunsetenv(m_name.constData()); + } + +private: + QByteArray m_name; + bool m_hadValue; + QByteArray m_previous; +}; + +class LocalRpcServer final { +public: + explicit LocalRpcServer(QString channelId) + : m_channelId(std::move(channelId)) + { + QObject::connect(&m_server, &QTcpServer::newConnection, [&]() { + while (m_server.hasPendingConnections()) { + QTcpSocket* socket = m_server.nextPendingConnection(); + QObject::connect(socket, &QTcpSocket::readyRead, socket, + [this, socket]() { process(socket); }); + if (socket->bytesAvailable() > 0) + process(socket); + } + }); + } + + bool listen() + { + return m_server.listen(QHostAddress::LocalHost); + } + + QString endpoint() const + { + return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()); + } + +private: + void process(QTcpSocket* socket) + { + QByteArray& request = m_requests[socket]; + request.append(socket->readAll()); + const qsizetype headerEnd = request.indexOf("\r\n\r\n"); + if (headerEnd < 0) + return; + + qsizetype contentLength = 0; + for (QByteArray line : request.first(headerEnd).split('\n')) { + line = line.trimmed(); + if (line.toLower().startsWith("content-length:")) { + contentLength = line.mid(sizeof("content-length:") - 1) + .trimmed().toLongLong(); + } + } + if (request.size() - headerEnd - 4 < contentLength) + return; + + m_requests.remove(socket); + const QByteArray payload = QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("result"), m_channelId }, + }).toJson(QJsonDocument::Compact); + QByteArray response = QByteArrayLiteral( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: "); + response += QByteArray::number(payload.size()); + response += QByteArrayLiteral("\r\nConnection: close\r\n\r\n"); + response += payload; + socket->write(response); + socket->disconnectFromHost(); + } + + QTcpServer m_server; + QHash m_requests; + QString m_channelId; +}; + +QByteArray devnetConfig(const QString& channelId, + const QString& ammProgramId, + const QString& definitionId) +{ + return QJsonDocument(QJsonObject { + { QStringLiteral("channelId"), channelId }, + { QStringLiteral("ammProgramId"), ammProgramId }, + { QStringLiteral("tokenDefinitionIds"), QJsonArray { definitionId } }, + }).toJson(QJsonDocument::Compact); +} + +class BackendFixture final { +public: + BackendFixture() + : channelId(64, QLatin1Char('a')), + ammProgramId(64, QLatin1Char('b')), + definitionId(64, QLatin1Char('c')), + tokenProgramId(64, QLatin1Char('d')), + server(channelId) + { + } + + bool initialize(bool deferDefinitionReads = false) + { + if (!server.listen() || !directory.isValid()) + return false; + const QString walletHome = directory.filePath(QStringLiteral("wallet")); + if (!QDir().mkpath(walletHome)) + return false; + const QString configPath = directory.filePath(QStringLiteral("devnet.json")); + QFile config(configPath); + if (!config.open(QIODevice::WriteOnly)) + return false; + const QByteArray configData = devnetConfig(channelId, ammProgramId, definitionId); + if (config.write(configData) != qint64(configData.size())) + return false; + config.close(); + + network = std::make_unique( + QByteArrayLiteral("AMM_UI_NETWORK"), QByteArrayLiteral("devnet")); + devnetFile = std::make_unique( + QByteArrayLiteral("AMM_UI_DEVNET_FILE"), configPath.toLocal8Bit()); + walletHomeEnvironment = std::make_unique( + QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit()); + settingsHome = std::make_unique( + QByteArrayLiteral("XDG_CONFIG_HOME"), + directory.filePath(QStringLiteral("settings")).toLocal8Bit()); + QSettings settings(QStringLiteral("Logos"), QStringLiteral("AmmUI")); + settings.setValue(QStringLiteral("disconnected"), false); + settings.sync(); + + provider.connectResult.adopted = true; + provider.connectResult.snapshot.sequencerAddress = server.endpoint(); + provider.snapshotResult = provider.connectResult.snapshot; + provider.readResult.status = QStringLiteral("ok"); + provider.readResult.programOwner = tokenProgramId; + provider.readResult.dataHex = QStringLiteral( + "0004000000544553540a0000000000000000000000000000000000"); + provider.deferPublicAccountReads = deferDefinitionReads; + backend = std::make_unique(provider); + return true; + } + + QString channelId; + QString ammProgramId; + QString definitionId; + QString tokenProgramId; + LocalRpcServer server; + QTemporaryDir directory; + std::unique_ptr network; + std::unique_ptr devnetFile; + std::unique_ptr walletHomeEnvironment; + std::unique_ptr settingsHome; + FakeWalletProvider provider; + std::unique_ptr backend; +}; +} + +class AmmUiBackendDefinitionCacheTest : public QObject { + Q_OBJECT + +private slots: + void reusesDefinitionsAfterRefreshAndReopen(); + void restartsDefinitionReadAfterRefreshAndReopen(); +}; + +void AmmUiBackendDefinitionCacheTest::reusesDefinitionsAfterRefreshAndReopen() +{ + BackendFixture fixture; + QVERIFY(fixture.initialize()); + + QTRY_COMPARE(fixture.backend->networkStatus(), QStringLiteral("ready")); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 1); + QCOMPARE(fixture.provider.lastPublicAccountIds, + QStringList { fixture.definitionId }); + + fixture.backend->refreshBalances(); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 1); + + fixture.backend->disconnectWallet(); + QTRY_COMPARE(fixture.backend->walletSyncStatus(), QStringLiteral("closed")); + QVERIFY(fixture.backend->openExisting()); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 1); +} + +void AmmUiBackendDefinitionCacheTest::restartsDefinitionReadAfterRefreshAndReopen() +{ + BackendFixture fixture; + QVERIFY(fixture.initialize(true)); + + QTRY_COMPARE(fixture.backend->networkStatus(), QStringLiteral("ready")); + QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 1); + QCOMPARE(fixture.backend->assetStatus(), QStringLiteral("loading")); + + fixture.backend->refreshBalances(); + QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 2); + + fixture.backend->disconnectWallet(); + QTRY_COMPARE(fixture.backend->walletSyncStatus(), QStringLiteral("closed")); + QVERIFY(fixture.backend->openExisting()); + QTRY_COMPARE(fixture.provider.publicAccountReadCalls, 3); + + fixture.provider.completePendingPublicAccountReads(); + QTRY_COMPARE(fixture.backend->assetStatus(), QStringLiteral("ready")); + QCOMPARE(fixture.provider.publicAccountReadCalls, 3); +} + +QTEST_GUILESS_MAIN(AmmUiBackendDefinitionCacheTest) +#include "AmmUiBackendDefinitionCacheTest.moc" diff --git a/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp b/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp new file mode 100644 index 0000000..1eecc30 --- /dev/null +++ b/apps/amm/tests/cpp/TokenDefinitionCacheTest.cpp @@ -0,0 +1,185 @@ +#include "FakeWalletProvider.h" +#include "TokenDefinitionCache.h" + +#include + +#include + +namespace { +TokenDefinitionCacheKey cacheKey(const QString& fingerprint = QStringLiteral("channel:one")) +{ + return { + QStringLiteral("devnet"), + fingerprint, + QStringLiteral("http://127.0.0.1:8080"), + { + QString(64, QLatin1Char('a')), + QString(64, QLatin1Char('b')), + }, + }; +} + +void makeReadsReady(FakeWalletProvider& provider) +{ + provider.readResult.status = QStringLiteral("ok"); + provider.readResult.programOwner = QString(64, QLatin1Char('c')); + provider.readResult.dataHex = QStringLiteral("00"); +} +} + +class TokenDefinitionCacheTest : public QObject { + Q_OBJECT + +private slots: + void reusesCompleteReads(); + void retriesIncompleteReads(); + void separatesNetworkKeys(); + void coalescesInFlightReads(); + void restartsCancelledRead(); + void dropsPendingCallbackOnDestruction(); +}; + +void TokenDefinitionCacheTest::reusesCompleteReads() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + + QVector first; + cache.read(key, [&first](QVector reads) { + first = std::move(reads); + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + QCOMPARE(provider.lastPublicAccountIds, key.tokenIds); + QCOMPARE(first.size(), key.tokenIds.size()); + QVERIFY(cache.contains(key)); + + QVector second; + cache.read(key, [&second](QVector reads) { + second = std::move(reads); + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + QCOMPARE(second.size(), first.size()); + for (qsizetype index = 0; index < second.size(); ++index) { + QCOMPARE(second.at(index).accountId, first.at(index).accountId); + QCOMPARE(second.at(index).status, first.at(index).status); + } +} + +void TokenDefinitionCacheTest::retriesIncompleteReads() +{ + FakeWalletProvider provider; + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + + cache.read(key, [](QVector) {}); + cache.read(key, [](QVector) {}); + + QCOMPARE(provider.publicAccountReadCalls, 2); + QVERIFY(!cache.contains(key)); +} + +void TokenDefinitionCacheTest::separatesNetworkKeys() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey baseKey = cacheKey(); + TokenDefinitionCacheKey fingerprintKey = baseKey; + fingerprintKey.networkFingerprint = QStringLiteral("channel:two"); + TokenDefinitionCacheKey endpointKey = baseKey; + endpointKey.sequencerAddress = QStringLiteral("http://127.0.0.1:8081"); + TokenDefinitionCacheKey definitionsKey = baseKey; + definitionsKey.tokenIds = { + baseKey.tokenIds.at(1), + baseKey.tokenIds.at(0), + }; + TokenDefinitionCacheKey networkKey = baseKey; + networkKey.networkId = QStringLiteral("testnet"); + + cache.read(baseKey, [](QVector) {}); + cache.read(fingerprintKey, [](QVector) {}); + cache.read(endpointKey, [](QVector) {}); + cache.read(definitionsKey, [](QVector) {}); + cache.read(networkKey, [](QVector) {}); + + QCOMPARE(provider.publicAccountReadCalls, 5); + QVERIFY(!cache.contains(baseKey)); + QVERIFY(cache.contains(networkKey)); +} + +void TokenDefinitionCacheTest::coalescesInFlightReads() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + provider.deferPublicAccountReads = true; + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + bool firstCalled = false; + bool secondCalled = false; + + cache.read(key, [&firstCalled](QVector) { + firstCalled = true; + }); + cache.read(key, [&secondCalled](QVector) { + secondCalled = true; + }); + + QCOMPARE(provider.publicAccountReadCalls, 1); + provider.completePendingPublicAccountReads(); + + QVERIFY(firstCalled); + QVERIFY(secondCalled); + QVERIFY(cache.contains(key)); +} + +void TokenDefinitionCacheTest::restartsCancelledRead() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + provider.deferPublicAccountReads = true; + TokenDefinitionCache cache(provider); + const TokenDefinitionCacheKey key = cacheKey(); + bool cancelledCallback = false; + bool retryCallback = false; + + cache.read(key, [&cancelledCallback](QVector) { + cancelledCallback = true; + }); + cache.cancelPending(); + cache.read(key, [&retryCallback](QVector) { + retryCallback = true; + }); + + QCOMPARE(provider.publicAccountReadCalls, 2); + provider.completePendingPublicAccountReads(); + + QVERIFY(!cancelledCallback); + QVERIFY(retryCallback); + QVERIFY(cache.contains(key)); +} + +void TokenDefinitionCacheTest::dropsPendingCallbackOnDestruction() +{ + FakeWalletProvider provider; + makeReadsReady(provider); + provider.deferPublicAccountReads = true; + const TokenDefinitionCacheKey key = cacheKey(); + bool callbackCalled = false; + + { + TokenDefinitionCache cache(provider); + cache.read(key, [&callbackCalled](QVector) { + callbackCalled = true; + }); + } + provider.completePendingPublicAccountReads(); + + QVERIFY(!callbackCalled); +} + +QTEST_GUILESS_MAIN(TokenDefinitionCacheTest) +#include "TokenDefinitionCacheTest.moc" diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h index 477b725..1beb671 100644 --- a/apps/shared/wallet/tests/support/FakeWalletProvider.h +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -20,12 +20,21 @@ public: int clearCalls = 0; int createAccountCalls = 0; mutable int readCalls = 0; + int publicAccountReadCalls = 0; int submitCalls = 0; int disconnectCalls = 0; bool lastForceRefresh = false; bool lastAccountWasPublic = false; WalletPaths lastPaths; WalletTransaction lastTransaction; + QStringList lastPublicAccountIds; + bool deferPublicAccountReads = false; + + struct PendingPublicAccountRead { + QStringList accountIds; + AccountReadsCallback callback; + }; + QVector pendingPublicAccountReads; WalletSession connect(const WalletPaths& paths) override { @@ -83,16 +92,21 @@ public: void readPublicAccountsAsync(const QStringList& accountIds, AccountReadsCallback callback) override { - QVector results = readResults; - if (results.isEmpty()) { - results.reserve(accountIds.size()); - for (const QString& accountId : accountIds) { - WalletAccountRead result = readResult; - result.accountId = accountId; - results.append(std::move(result)); - } + ++publicAccountReadCalls; + lastPublicAccountIds = accountIds; + if (deferPublicAccountReads) { + pendingPublicAccountReads.append({ accountIds, std::move(callback) }); + return; } - callback(std::move(results)); + callback(accountReads(accountIds)); + } + + void completePendingPublicAccountReads() + { + QVector pending; + pending.swap(pendingPublicAccountReads); + for (PendingPublicAccountRead& read : pending) + read.callback(accountReads(read.accountIds)); } WalletSubmission submitPublicTransaction( @@ -104,4 +118,19 @@ public: } void disconnect() override { ++disconnectCalls; } + +private: + QVector accountReads(const QStringList& accountIds) const + { + QVector results = readResults; + if (results.isEmpty()) { + results.reserve(accountIds.size()); + for (const QString& accountId : accountIds) { + WalletAccountRead result = readResult; + result.accountId = accountId; + results.append(std::move(result)); + } + } + return results; + } };