diff --git a/dotnet/EcencyApi.Tests/EngineOptionalLegTests.cs b/dotnet/EcencyApi.Tests/EngineOptionalLegTests.cs
new file mode 100644
index 00000000..68acb7a7
--- /dev/null
+++ b/dotnet/EcencyApi.Tests/EngineOptionalLegTests.cs
@@ -0,0 +1,78 @@
+using System.Text.Json.Nodes;
+using EcencyApi.Handlers;
+using EcencyApi.Infrastructure;
+using Xunit;
+
+namespace EcencyApi.Tests;
+
+///
+/// The engine layer is assembled from one required leg (balances) and three
+/// enrichment legs (token metadata, market metrics, unclaimed rewards). Losing
+/// enrichment must cost the decoration, not the balances: a metrics outage used
+/// to surface identically to a total Hive-Engine outage — no tokens at all.
+///
+public class EngineOptionalLegTests
+{
+ [Fact]
+ public async Task Optional_PassesThroughASuccessfulLeg()
+ {
+ var expected = new JsonArray { new JsonObject { ["symbol"] = "LEO" } };
+
+ var result = await WalletApi.Optional(Task.FromResult(expected), "engine tokens", 2000);
+
+ Assert.Single(result);
+ Assert.Equal("LEO", result[0]!["symbol"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Optional_DegradesAFailedLegToEmptyInsteadOfThrowing()
+ {
+ var failed = Task.FromException(new Exception("upstream down"));
+
+ var result = await WalletApi.Optional(failed, "engine metrics", 2000);
+
+ Assert.Empty(result);
+ }
+
+ // A stalling upstream, not a throwing one, is the outage that matters here:
+ // the engine node pool is walked at 2s per attempt and far outlasts the leg
+ // budget, so catching exceptions alone would leave the caller waiting until
+ // its own timeout returned an empty layer — losing the balances entirely.
+ [Fact]
+ public async Task Optional_BoundsAStallingLeg()
+ {
+ var stalled = new TaskCompletionSource();
+
+ var started = System.Diagnostics.Stopwatch.StartNew();
+ var result = await WalletApi.Optional(stalled.Task, "engine metrics", 150);
+ started.Stop();
+
+ Assert.Empty(result);
+ Assert.True(started.ElapsedMilliseconds < 2000,
+ $"should have given up near the timeout, took {started.ElapsedMilliseconds}ms");
+
+ // Completing late must not fault anything the caller already moved past.
+ stalled.SetException(new Exception("late failure"));
+ await Task.Delay(50);
+ }
+
+ // Enrichment legs must not be able to take the layer down between them: even
+ // with both token metadata and metrics failing, the balances still render
+ // (unpriced) rather than the wallet showing no engine tokens at all.
+ [Fact]
+ public async Task Optional_LetsBalancesSurviveLosingEveryEnrichmentLeg()
+ {
+ var tokens = WalletApi.Optional(
+ Task.FromException(new Exception("tokens down")), "engine tokens", 2000);
+ var metrics = WalletApi.Optional(
+ Task.FromException(new Exception("metrics down")), "engine metrics", 2000);
+
+ Assert.Empty(await tokens);
+ Assert.Empty(await metrics);
+
+ // ConvertEngineToken null-tolerates both, so a balance row still converts.
+ var converted = EcencyApi.Models.HiveEngine.ConvertEngineToken(
+ new JsonObject { ["symbol"] = "LEO", ["balance"] = "1.5" }, null, null, null);
+ Assert.Equal("LEO", JsVal.AsString(JsVal.Prop(converted, "symbol")));
+ }
+}
diff --git a/dotnet/EcencyApi/Handlers/WalletApi.Engine.cs b/dotnet/EcencyApi/Handlers/WalletApi.Engine.cs
index 595b0860..1efbfb92 100644
--- a/dotnet/EcencyApi/Handlers/WalletApi.Engine.cs
+++ b/dotnet/EcencyApi/Handlers/WalletApi.Engine.cs
@@ -154,6 +154,32 @@ public static async Task FetchEngineRewards(string username)
}
}
+ ///
+ /// Degrades an enrichment leg to an empty result instead of failing the caller.
+ /// The engine layer must survive losing decoration; only the balances leg is
+ /// load-bearing.
+ ///
+ /// Bounded as well as caught: a stalling upstream is the more common outage,
+ /// and EngineRpcClient.Find walks the whole node pool at 2s per attempt, which
+ /// far outlasts the engine leg's budget. Catching exceptions alone would leave
+ /// Task.WhenAll pending until the caller's own timeout fired and returned an
+ /// empty layer — discarding the balances this exists to preserve.
+ ///
+ internal static Task Optional(Task task, string label, int timeoutMs) =>
+ WithTimeout(Observed(task, label), timeoutMs, new JsonArray());
+
+ /// Never faults, so timing it out cannot leave an unobserved exception behind.
+ /// Logged once so a persistent upstream outage is still visible.
+ private static async Task Observed(Task task, string label)
+ {
+ try { return await task; }
+ catch (Exception err)
+ {
+ Console.WriteLine($"failed to get {label} {err.Message}");
+ return new JsonArray();
+ }
+ }
+
private static async Task FetchEngineTokensWithBalance(string username)
{
try
@@ -163,8 +189,15 @@ private static async Task FetchEngineTokensWithBalance(string usernam
var symbols = balances.Select(b => JsVal.AsString(JsVal.Prop(b, "symbol")) ?? JsVal.ToJsString(JsVal.Prop(b, "symbol")))
.Where(s => s != null).Select(s => s!).ToList();
- var tokensTask = FetchEngineTokens(symbols);
- var metricsTask = FetchEngineMetrics(symbols);
+ // Balances are the only required leg: they are the user's actual
+ // holdings. Tokens (name/precision/icon) and metrics (market price,
+ // used for fiat valuation) are enrichment — ConvertEngineToken already
+ // null-tolerates both. Letting either failure propagate would hit the
+ // catch below and blank the whole layer, so a metrics outage would look
+ // identical to a total Hive-Engine outage: no tokens in the wallet at
+ // all. Degrade to unpriced balances instead of showing nothing.
+ var tokensTask = Optional(FetchEngineTokens(symbols), "engine tokens", EngineEnrichmentTimeoutMs);
+ var metricsTask = Optional(FetchEngineMetrics(symbols), "engine metrics", EngineEnrichmentTimeoutMs);
// The rewards upstream allows 30s, but this whole fetch must fit
// the portfolioV2 engine leg budget (4.5s) — a slow rewards call
// would otherwise blank the entire engine layer. Rewards are
@@ -300,6 +333,9 @@ await Task.WhenAll(globalPropsTask, accountTask, marketTask, pointsTask, engineT
private const int FastLegTimeout = 3000;
private const int SlowLegTimeout = 4500;
private const int EngineRewardsTimeoutMs = 2000;
+ // Same budget as rewards: balances must complete first, then the enrichment
+ // legs run concurrently, and the whole engine fetch has to fit SlowLegTimeout.
+ private const int EngineEnrichmentTimeoutMs = 2000;
private static async Task WithTimeout(Task task, int ms, T fallback)
{