Skip to content

Commit 493cfb2

Browse files
fix: silent truncation in QueryNodesAsync and QueryNodesForPlanAsync
Sentinel v3 caps node queries at limit and never emits next_key, causing standard pagination loops to terminate on the first call. Plan 36 has 803 active nodes but limit=500 returned exactly 500. Defaults raised to 10000 with <remarks> explaining the chain behavior. Additional work in this commit: BroadcastTxAsync via Tendermint RPC with LCD fallback, SessionModeTracker for per-session payment-mode persistence, DiskCache generic helper, AppTypeHelpers for white-label vs direct app modes, Consumer/Operator entrypoints in SDK.Node, event parser improvements, and expanded ExhaustiveChainTests coverage.
1 parent 772c5de commit 493cfb2

11 files changed

Lines changed: 1291 additions & 43 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
namespace Sentinel.SDK.Core;
2+
3+
// ─── App Type Validation Result ───
4+
5+
/// <summary>
6+
/// Result of validating an app configuration against its type requirements.
7+
/// Ported from js-sdk/app-types.js line 188-230.
8+
/// </summary>
9+
/// <param name="Valid">True when no errors were found.</param>
10+
/// <param name="Errors">Hard validation failures (missing required config, unknown type).</param>
11+
/// <param name="Warnings">Soft issues (misaligned options, missing optional feeGranter).</param>
12+
/// <param name="TypeDescription">The matched app type description, or null when unknown.</param>
13+
public record AppConfigValidation(
14+
bool Valid,
15+
IReadOnlyList<string> Errors,
16+
IReadOnlyList<string> Warnings,
17+
string? TypeDescription);
18+
19+
// ─── App Configuration ───
20+
21+
/// <summary>
22+
/// App-level configuration used by <see cref="AppTypeHelpers.ValidateAppConfig"/>
23+
/// and <see cref="AppTypeHelpers.GetConnectDefaults"/>. Mirrors the JS config object.
24+
/// </summary>
25+
public sealed class AppConfig
26+
{
27+
/// <summary>Wallet mnemonic (required for all app types).</summary>
28+
public string? Mnemonic { get; init; }
29+
30+
/// <summary>Plan ID (required for <see cref="Constants.AppTypes.WhiteLabel"/>).</summary>
31+
public long? PlanId { get; init; }
32+
33+
/// <summary>Fee granter address (recommended for white-label so users don't pay gas).</summary>
34+
public string? FeeGranter { get; init; }
35+
36+
/// <summary>DNS preset name or "handshake" (default).</summary>
37+
public string? Dns { get; init; }
38+
39+
/// <summary>Default gigabytes for direct P2P sessions (default: 1).</summary>
40+
public int? DefaultGigabytes { get; init; }
41+
42+
/// <summary>Prefer hourly pricing when both GB and hourly are available.</summary>
43+
public bool PreferHourly { get; init; }
44+
45+
/// <summary>Route all traffic through VPN (default: true).</summary>
46+
public bool? FullTunnel { get; init; }
47+
48+
/// <summary>Enable kill switch (default: false).</summary>
49+
public bool KillSwitch { get; init; }
50+
51+
/// <summary>Optional country filter for auto-connect.</summary>
52+
public string[]? Countries { get; init; }
53+
}
54+
55+
// ─── Connect Defaults ───
56+
57+
/// <summary>
58+
/// Recommended connect options for an app type. Spread into your connect call.
59+
/// Ported from js-sdk/app-types.js line 242-267.
60+
/// </summary>
61+
public sealed class ConnectDefaults
62+
{
63+
/// <summary>DNS preset name.</summary>
64+
public string Dns { get; init; } = "handshake";
65+
66+
/// <summary>Route all traffic through VPN.</summary>
67+
public bool FullTunnel { get; init; } = true;
68+
69+
/// <summary>Kill switch enabled.</summary>
70+
public bool KillSwitch { get; init; }
71+
72+
/// <summary>Plan ID (white-label only).</summary>
73+
public long? PlanId { get; init; }
74+
75+
/// <summary>Fee granter address (white-label only).</summary>
76+
public string? FeeGranter { get; init; }
77+
78+
/// <summary>Gigabytes to purchase (direct-p2p only).</summary>
79+
public int? Gigabytes { get; init; }
80+
81+
/// <summary>Prefer hourly pricing (direct-p2p only).</summary>
82+
public bool PreferHourly { get; init; }
83+
}
84+
85+
// ─── App Type Helpers ───
86+
87+
/// <summary>
88+
/// Validation and defaults helpers for the three Sentinel app types.
89+
/// Ported from js-sdk/app-types.js (validateAppConfig + getConnectDefaults, lines 181-267).
90+
/// </summary>
91+
public static class AppTypeHelpers
92+
{
93+
/// <summary>
94+
/// Validate an app's configuration against its type requirements.
95+
/// Call at app startup to catch misconfigurations early.
96+
/// Ported from js-sdk/app-types.js line 195-230.
97+
/// </summary>
98+
public static AppConfigValidation ValidateAppConfig(string appType, AppConfig? config = null)
99+
{
100+
if (!Constants.AppTypes.All.Contains(appType))
101+
{
102+
return new AppConfigValidation(
103+
Valid: false,
104+
Errors: new[] { $"Unknown app type: \"{appType}\". Use: {string.Join(", ", Constants.AppTypes.All)}" },
105+
Warnings: Array.Empty<string>(),
106+
TypeDescription: null);
107+
}
108+
109+
config ??= new AppConfig();
110+
var errors = new List<string>();
111+
var warnings = new List<string>();
112+
113+
// All app types require a mnemonic.
114+
if (string.IsNullOrEmpty(config.Mnemonic))
115+
errors.Add($"Missing required config: \"mnemonic\" (required for {appType} apps)");
116+
117+
if (appType == Constants.AppTypes.WhiteLabel)
118+
{
119+
if (!config.PlanId.HasValue || config.PlanId.Value <= 0)
120+
errors.Add("White-label apps MUST have a planId configured");
121+
if (string.IsNullOrEmpty(config.FeeGranter))
122+
warnings.Add("White-label apps should have a feeGranter so users don't pay gas. Without it, users need P2P tokens for gas fees.");
123+
}
124+
125+
if (appType == Constants.AppTypes.DirectP2P && config.PlanId.HasValue && config.PlanId.Value > 0)
126+
{
127+
warnings.Add("planId is set but app type is direct_p2p — plan functions won't be used. Did you mean all_in_one?");
128+
}
129+
130+
return new AppConfigValidation(
131+
Valid: errors.Count == 0,
132+
Errors: errors,
133+
Warnings: warnings,
134+
TypeDescription: DescribeType(appType));
135+
}
136+
137+
/// <summary>
138+
/// Get the recommended connect options for an app type.
139+
/// Returns a base config that callers can customize per connection.
140+
/// Ported from js-sdk/app-types.js line 242-267.
141+
/// </summary>
142+
public static ConnectDefaults GetConnectDefaults(string appType, AppConfig? appConfig = null)
143+
{
144+
appConfig ??= new AppConfig();
145+
146+
var dns = string.IsNullOrEmpty(appConfig.Dns) ? "handshake" : appConfig.Dns;
147+
var fullTunnel = appConfig.FullTunnel ?? true;
148+
149+
if (appType == Constants.AppTypes.WhiteLabel)
150+
{
151+
return new ConnectDefaults
152+
{
153+
Dns = dns,
154+
FullTunnel = fullTunnel,
155+
KillSwitch = appConfig.KillSwitch,
156+
PlanId = appConfig.PlanId,
157+
FeeGranter = string.IsNullOrEmpty(appConfig.FeeGranter) ? null : appConfig.FeeGranter,
158+
};
159+
}
160+
161+
if (appType == Constants.AppTypes.DirectP2P)
162+
{
163+
return new ConnectDefaults
164+
{
165+
Dns = dns,
166+
FullTunnel = fullTunnel,
167+
KillSwitch = appConfig.KillSwitch,
168+
Gigabytes = appConfig.DefaultGigabytes ?? 1,
169+
PreferHourly = appConfig.PreferHourly,
170+
};
171+
}
172+
173+
// ALL_IN_ONE or unknown — caller decides per-connection.
174+
return new ConnectDefaults
175+
{
176+
Dns = dns,
177+
FullTunnel = fullTunnel,
178+
KillSwitch = appConfig.KillSwitch,
179+
};
180+
}
181+
182+
private static string DescribeType(string appType) => appType switch
183+
{
184+
Constants.AppTypes.WhiteLabel => "White-label dVPN — branded app with pre-loaded plan + fee grant",
185+
Constants.AppTypes.DirectP2P => "Direct P2P — users pay nodes directly per-GB or per-hour",
186+
Constants.AppTypes.AllInOne => "All-in-one — plan subscriptions + direct P2P in one app",
187+
_ => appType,
188+
};
189+
}

src/Sentinel.SDK.Core/ChainClient.Queries.cs

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,12 +309,27 @@ public async Task<List<DiscoveredPlan>> DiscoverPlansAsync(int maxId = 100, Canc
309309
}
310310

311311
/// <summary>
312-
/// Broadcast a raw transaction to the chain via LCD.
312+
/// Broadcast a raw transaction to the chain. Tries Tendermint RPC
313+
/// <c>broadcast_tx_sync</c> first; falls back to LCD <c>/cosmos/tx/v1beta1/txs</c> on
314+
/// failure or unrecognizable RPC response.
313315
/// </summary>
314316
/// <param name="txBytes">Serialized TxRaw bytes.</param>
317+
/// <param name="ct">Cancellation token.</param>
315318
/// <returns>Transaction result.</returns>
316319
internal async Task<TxResult> BroadcastTxAsync(byte[] txBytes, CancellationToken ct = default)
317320
{
321+
// RPC-first: broadcast_tx_sync is lower-latency than LCD and avoids REST overhead.
322+
try
323+
{
324+
var rpcResult = await _rpcClient.BroadcastTxAsync(txBytes, ct);
325+
if (rpcResult is not null) return rpcResult;
326+
}
327+
catch (Exception ex)
328+
{
329+
_logger?.Debug($"RPC broadcast failed, falling back to LCD: {ex.Message}");
330+
}
331+
332+
// LCD fallback
318333
var base64Tx = Convert.ToBase64String(txBytes);
319334
var payload = new { tx_bytes = base64Tx, mode = "BROADCAST_MODE_SYNC" };
320335

@@ -356,17 +371,30 @@ internal async Task<TxResult> BroadcastTxAsync(byte[] txBytes, CancellationToken
356371
}
357372

358373
throw new SentinelException("CLIENT_BROADCAST_FAILED",
359-
$"All LCD endpoints failed to broadcast: {lastException?.Message}", lastException!);
374+
$"All RPC and LCD endpoints failed to broadcast: {lastException?.Message}", lastException!);
360375
}
361376

362377
/// <summary>
363-
/// Query a transaction by hash from the LCD.
378+
/// Query a transaction by hash. Tries Tendermint RPC <c>tx</c> method first; falls back
379+
/// to LCD <c>/cosmos/tx/v1beta1/txs/{hash}</c> if RPC returns nothing.
364380
/// </summary>
365381
/// <param name="txHash">Transaction hash (hex).</param>
366382
/// <param name="ct">Cancellation token.</param>
367383
/// <returns>Transaction result if found, or null if not found.</returns>
368384
internal async Task<TxResult?> QueryTxAsync(string txHash, CancellationToken ct = default)
369385
{
386+
// RPC-first: avoids LCD propagation lag for double-spend guard checks.
387+
try
388+
{
389+
var rpcResult = await _rpcClient.QueryTxAsync(txHash, ct);
390+
if (rpcResult is not null) return rpcResult;
391+
}
392+
catch (Exception ex)
393+
{
394+
_logger?.Debug($"RPC QueryTx failed for {txHash}, falling back to LCD: {ex.Message}");
395+
}
396+
397+
// LCD fallback
370398
try
371399
{
372400
var path = $"/cosmos/tx/v1beta1/txs/{txHash}";
@@ -391,6 +419,134 @@ internal async Task<TxResult> BroadcastTxAsync(byte[] txBytes, CancellationToken
391419
}
392420
}
393421

422+
/// <summary>
423+
/// Query a TX by hash and return the top-level <c>events</c> array as JSON text.
424+
/// Modern Cosmos SDK emits events directly on <c>tx_response.events</c>; raw_log can be
425+
/// empty. This method returns whichever is populated, normalized to the array format
426+
/// expected by <see cref="EventParser.FindEvent"/>.
427+
/// </summary>
428+
private async Task<string?> QueryTxEventsJsonAsync(string txHash, CancellationToken ct)
429+
{
430+
// RPC-first: Tendermint tx method returns events directly, no LCD propagation lag.
431+
try
432+
{
433+
var rpcEvents = await _rpcClient.QueryTxEventsJsonAsync(txHash, ct);
434+
if (!string.IsNullOrEmpty(rpcEvents)) return rpcEvents;
435+
}
436+
catch (Exception ex)
437+
{
438+
_logger?.Debug($"RPC tx events failed for {txHash}, falling back to LCD: {ex.Message}");
439+
}
440+
441+
try
442+
{
443+
var path = $"/cosmos/tx/v1beta1/txs/{txHash}";
444+
var json = await LcdGetAsync(path, ct);
445+
if (!json.TryGetProperty("tx_response", out var txResponse)) return null;
446+
447+
// Prefer tx_response.events (top-level, modern)
448+
if (txResponse.TryGetProperty("events", out var eventsTop) &&
449+
eventsTop.ValueKind == JsonValueKind.Array &&
450+
eventsTop.GetArrayLength() > 0)
451+
{
452+
return eventsTop.GetRawText();
453+
}
454+
455+
// Fallback: raw_log is a JSON string containing log entries with events
456+
if (txResponse.TryGetProperty("raw_log", out var rl))
457+
{
458+
var raw = rl.GetString();
459+
if (string.IsNullOrEmpty(raw)) return null;
460+
try
461+
{
462+
using var logDoc = JsonDocument.Parse(raw);
463+
if (logDoc.RootElement.ValueKind == JsonValueKind.Array)
464+
{
465+
var combined = new List<JsonElement>();
466+
foreach (var entry in logDoc.RootElement.EnumerateArray())
467+
{
468+
if (entry.TryGetProperty("events", out var evs) &&
469+
evs.ValueKind == JsonValueKind.Array)
470+
{
471+
foreach (var ev in evs.EnumerateArray()) combined.Add(ev);
472+
}
473+
}
474+
if (combined.Count == 0) return null;
475+
using var ms = new System.IO.MemoryStream();
476+
using (var writer = new Utf8JsonWriter(ms))
477+
{
478+
writer.WriteStartArray();
479+
foreach (var ev in combined) ev.WriteTo(writer);
480+
writer.WriteEndArray();
481+
}
482+
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
483+
}
484+
}
485+
catch { /* raw_log not JSON — return as-is for regex fallback */ }
486+
return raw;
487+
}
488+
return null;
489+
}
490+
catch (SentinelException ex) when (ex.Code == "CLIENT_HTTP_404") { return null; }
491+
}
492+
493+
/// <inheritdoc />
494+
public async Task<long?> ExtractPlanIdFromTxAsync(string txHash, int timeoutMs = 20000, CancellationToken ct = default)
495+
{
496+
ArgumentNullException.ThrowIfNull(txHash);
497+
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
498+
while (DateTime.UtcNow < deadline)
499+
{
500+
ct.ThrowIfCancellationRequested();
501+
var eventsJson = await QueryTxEventsJsonAsync(txHash, ct);
502+
if (!string.IsNullOrEmpty(eventsJson))
503+
{
504+
var id = EventParser.ExtractPlanIdFromEvents(eventsJson);
505+
if (id is > 0) return id;
506+
}
507+
await Task.Delay(2000, ct);
508+
}
509+
return null;
510+
}
511+
512+
/// <inheritdoc />
513+
public async Task<long?> ExtractSubscriptionIdFromTxAsync(string txHash, int timeoutMs = 20000, CancellationToken ct = default)
514+
{
515+
ArgumentNullException.ThrowIfNull(txHash);
516+
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
517+
while (DateTime.UtcNow < deadline)
518+
{
519+
ct.ThrowIfCancellationRequested();
520+
var eventsJson = await QueryTxEventsJsonAsync(txHash, ct);
521+
if (!string.IsNullOrEmpty(eventsJson))
522+
{
523+
var id = EventParser.ExtractSubscriptionIdFromEvents(eventsJson);
524+
if (id is > 0) return id;
525+
}
526+
await Task.Delay(2000, ct);
527+
}
528+
return null;
529+
}
530+
531+
/// <inheritdoc />
532+
public async Task<long?> ExtractSessionIdFromTxAsync(string txHash, int timeoutMs = 20000, CancellationToken ct = default)
533+
{
534+
ArgumentNullException.ThrowIfNull(txHash);
535+
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
536+
while (DateTime.UtcNow < deadline)
537+
{
538+
ct.ThrowIfCancellationRequested();
539+
var eventsJson = await QueryTxEventsJsonAsync(txHash, ct);
540+
if (!string.IsNullOrEmpty(eventsJson))
541+
{
542+
var id = EventParser.ExtractSessionId(eventsJson);
543+
if (id is > 0) return id;
544+
}
545+
await Task.Delay(2000, ct);
546+
}
547+
return null;
548+
}
549+
394550
// ─── IChainClient: Session Queries ───
395551

396552
/// <inheritdoc />

0 commit comments

Comments
 (0)