fix(lite): gateway refresh degrades gracefully on a failed command

LiteWalletGateway::refresh() aborted the entire refresh on the first command whose bridge
call or parse failed — which turned a single real-backend shape mismatch (e.g. syncstatus)
into a total, empty-everything refresh. Since the balance/addresses/list real shapes are
still unverified and we've already hit shape drift twice, make refresh resilient:

- Run every planned command; assembleLiteWalletRefreshBundle already skips failed results.
- result.ok = any usable data came back (bundle.complete still reflects all-succeeded).
- One command's failure now degrades gracefully — the other sections still populate.

testLiteWalletGatewayRefreshSkipsFailedCommand (fake balance returns invalid JSON) asserts
the refresh still succeeds with addresses/transactions/info populated and balance skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 06:57:10 -05:00
parent 3119440cd9
commit 268eba6321
4 changed files with 50 additions and 13 deletions

View File

@@ -33,6 +33,7 @@ inline bool g_liteFakeWalletExists = true;
inline bool g_liteFakeServerOnline = true;
inline bool g_liteFakeShutdownCalled = false;
inline std::atomic<bool> g_liteFakeSyncBlock{false}; // when true, the "sync" command blocks
inline std::atomic<bool> g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
inline void resetLiteFakeCounters()
{
@@ -76,6 +77,8 @@ inline char* liteFakeExecute(const char* command, const char*)
}
if (std::strcmp(c, "syncstatus") == 0) // real backend shape: "syncing" is a string
return liteFakeDup("{\"syncing\":\"true\",\"synced_blocks\":1000,\"total_blocks\":1000}");
if (std::strcmp(c, "balance") == 0 && g_liteFakeBadBalance.load())
return liteFakeDup("not-json"); // simulate a shape/parse failure for one command
if (std::strcmp(c, "balance") == 0)
return liteFakeDup("{\"tbalance\":100000000,\"zbalance\":200000000,\"unconfirmed\":50000000,"
"\"verified_zbalance\":180000000,\"spendable_zbalance\":170000000}");

View File

@@ -4687,6 +4687,29 @@ void testLiteSyncStatusParserRealShapes()
}
}
// Gateway hardening: one command's parse failure must not abort the whole refresh — the
// other commands still populate the bundle (graceful degradation against real-shape drift).
void testLiteWalletGatewayRefreshSkipsFailedCommand()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
const auto conn = defaultLiteConnectionSettings();
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
LiteWalletGateway gateway(caps, conn, &bridge, LiteWalletGatewayOptions{/*allowBridgeCalls*/ true});
dragonx::test::g_liteFakeBadBalance.store(true); // make only the balance command fail to parse
const auto result = gateway.refresh(LiteWalletRefreshRequest{});
dragonx::test::g_liteFakeBadBalance.store(false);
EXPECT_TRUE(result.ok); // partial success, not a total failure
EXPECT_FALSE(result.bundle.complete); // not all commands succeeded
EXPECT_FALSE(result.bundle.hasBalance); // the failed command is skipped
EXPECT_TRUE(result.bundle.hasAddresses); // the others still populate
EXPECT_TRUE(result.bundle.hasTransactions);
EXPECT_TRUE(result.bundle.hasInfo);
}
// M2b-3: opening a wallet auto-starts the background worker, which produces a refresh model
// the main thread can pick up via takeRefreshedModel() and apply to WalletState.
void testLiteWalletControllerWorkerProducesModel()
@@ -4788,6 +4811,7 @@ int main()
testLiteRefreshModelAppliesToWalletState();
testLiteSyncStatusParserRealShapes();
testLiteWalletControllerRefreshPopulatesState();
testLiteWalletGatewayRefreshSkipsFailedCommand();
testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerShutdownDoesNotHangDuringSync();
testLiteBridgeRuntimeShutdownIsIdempotent();