Skip to content

Commit de4bf0c

Browse files
feat: split disconnect into soft/hard + RPC query polish
- Disconnect path split: DisconnectAsync() (soft, preserves on-chain session for reuse) vs DisconnectAndEndSessionAsync() (hard, broadcasts MsgCancelSession and refunds the unused deposit). Replaces the prior DisconnectAsync(bool endSession=true) whose hidden default always ended the session and hid the reuse path. - ChainClient.Queries / ChainClient.FeeGrants / RpcClient / ProtobufReader: follow-on cleanups to the RPC-first migration landed in 493cfb2 / 772c5de. - SentinelVpnClient.Connect / SentinelVpnService / SentinelVpnClient: align with the new disconnect contract. Build: Release clean (0 warn, 0 err).
1 parent 493cfb2 commit de4bf0c

8 files changed

Lines changed: 315 additions & 49 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ public async Task<List<FeeGrant>> QueryFeeGrantsAsync(string grantee, Cancellati
2222
// RPC-first
2323
try
2424
{
25-
return await _rpcClient.QueryFeeGrantsAsync(grantee, ct: ct);
25+
var rpcGrants = await _rpcClient.QueryFeeGrantsAsync(grantee, ct: ct);
26+
if (rpcGrants.Count > 0) return rpcGrants;
27+
_logger?.Debug("RPC returned no fee grants; falling back to LCD to verify.");
2628
}
2729
catch (Exception ex) { _logger?.Debug($"RPC QueryFeeGrants failed, falling back to LCD: {ex.Message}"); }
2830

@@ -58,7 +60,9 @@ public async Task<IReadOnlyList<FeeGrant>> QueryFeeGrantsIssuedAsync(string gran
5860
// RPC-first
5961
try
6062
{
61-
return await _rpcClient.QueryFeeGrantsIssuedAsync(granter, ct: ct);
63+
var rpcGrants = await _rpcClient.QueryFeeGrantsIssuedAsync(granter, ct: ct);
64+
if (rpcGrants.Count > 0) return rpcGrants;
65+
_logger?.Debug("RPC returned no issued fee grants; falling back to LCD to verify.");
6266
}
6367
catch (Exception ex) { _logger?.Debug($"RPC QueryFeeGrantsIssued failed, falling back to LCD: {ex.Message}"); }
6468

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

Lines changed: 84 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ public async Task<List<ChainNode>> GetActiveNodesAsync(int limit = 500, Cancella
5454
// RPC-first
5555
try
5656
{
57-
return await _rpcClient.QueryNodesAsync(1, limit, ct);
57+
var rpcNodes = await _rpcClient.QueryNodesAsync(1, limit, ct);
58+
if (rpcNodes.Count > 0) return rpcNodes;
59+
_logger?.Debug("RPC returned no active nodes; falling back to LCD to verify.");
5860
}
5961
catch (Exception ex) { _logger?.Debug($"RPC GetActiveNodes failed, falling back to LCD: {ex.Message}"); }
6062

@@ -111,7 +113,9 @@ public async Task<List<Subscription>> GetSubscriptionsAsync(string address, Canc
111113
// RPC-first
112114
try
113115
{
114-
return await _rpcClient.QuerySubscriptionsForAccountAsync(address, ct: ct);
116+
var rpcSubs = await _rpcClient.QuerySubscriptionsForAccountAsync(address, ct: ct);
117+
if (rpcSubs.Count > 0) return rpcSubs;
118+
_logger?.Debug("RPC returned no subscriptions; falling back to LCD to verify.");
115119
}
116120
catch (Exception ex) { _logger?.Debug($"RPC GetSubscriptions failed, falling back to LCD: {ex.Message}"); }
117121

@@ -135,10 +139,15 @@ public async Task<List<ChainSession>> GetSessionsAsync(string address, string st
135139
try
136140
{
137141
var sessions = await _rpcClient.QuerySessionsForAccountAsync(address, ct: ct);
138-
// Filter by status if specified (RPC returns all statuses)
139-
if (status == "1")
140-
return sessions.Where(s => s.Status == "active" || s.Status == "1").ToList();
141-
return sessions;
142+
if (sessions.Count > 0)
143+
{
144+
// Filter by status if specified (RPC returns all statuses)
145+
var filtered = status == "1"
146+
? sessions.Where(s => s.Status == "active" || s.Status == "1").ToList()
147+
: sessions;
148+
if (filtered.Count > 0) return filtered;
149+
}
150+
_logger?.Debug("RPC returned no sessions; falling back to LCD to verify.");
142151
}
143152
catch (Exception ex) { _logger?.Debug($"RPC GetSessions failed, falling back to LCD: {ex.Message}"); }
144153

@@ -159,7 +168,9 @@ public async Task<List<ChainNode>> GetPlanNodesAsync(int planId, CancellationTok
159168
// RPC-first
160169
try
161170
{
162-
return await _rpcClient.QueryNodesForPlanAsync((ulong)planId, 1, 5000, ct);
171+
var rpcNodes = await _rpcClient.QueryNodesForPlanAsync((ulong)planId, 1, 5000, ct);
172+
if (rpcNodes.Count > 0) return rpcNodes;
173+
_logger?.Debug("RPC returned no plan nodes; falling back to LCD to verify.");
163174
}
164175
catch (Exception ex) { _logger?.Debug($"RPC GetPlanNodes failed, falling back to LCD: {ex.Message}"); }
165176

@@ -266,11 +277,23 @@ public async Task<List<DiscoveredPlan>> DiscoverPlansAsync(int maxId = 100, Canc
266277

267278
/// <summary>
268279
/// Get account number and sequence for transaction signing.
280+
/// RPC-first (cosmos.auth.v1beta1.Query/Account) with LCD fallback.
269281
/// </summary>
270282
/// <param name="address">Account address (sent1...).</param>
271283
/// <returns>Tuple of (accountNumber, sequence).</returns>
272284
internal async Task<(ulong AccountNumber, ulong Sequence)> GetAccountInfoAsync(string address, CancellationToken ct = default)
273285
{
286+
// RPC-first: faster, no LCD rate-limit exposure.
287+
try
288+
{
289+
var rpcResult = await _rpcClient.QueryAccountAsync(address, ct);
290+
if (rpcResult is not null)
291+
return rpcResult.Value;
292+
_logger?.Debug("RPC QueryAccount returned null; falling back to LCD.");
293+
}
294+
catch (Exception ex) { _logger?.Debug($"RPC QueryAccount failed, falling back to LCD: {ex.Message}"); }
295+
296+
// LCD fallback
274297
var path = $"/cosmos/auth/v1beta1/accounts/{address}";
275298
var json = await LcdGetAsync(path, ct);
276299

@@ -554,17 +577,23 @@ public async Task<IReadOnlyList<ActiveSession>> QueryActiveSessionsForAddressAsy
554577
{
555578
ArgumentNullException.ThrowIfNull(walletAddress);
556579

557-
// RPC-first
580+
// RPC-first. Treat empty result as suspicious and fall through to LCD — a silent
581+
// empty return used to mask decoder bugs (see suggestions/2026-04-21-rpc-session-decoder-any-and-field-numbers.md).
558582
try
559583
{
560584
var rpcSessions = await _rpcClient.QuerySessionsForAccountAsync(walletAddress, ct: ct);
561-
return rpcSessions
562-
.Where(s => s.Status == "active" || s.Status == "1")
563-
.Select(s => new ActiveSession(
564-
ulong.TryParse(s.Id, out var sid) ? sid : 0,
565-
s.NodeAddress,
566-
SessionStatus.Active))
567-
.ToList();
585+
if (rpcSessions.Count > 0)
586+
{
587+
var active = rpcSessions
588+
.Where(s => s.Status == "active" || s.Status == "1")
589+
.Select(s => new ActiveSession(
590+
ulong.TryParse(s.Id, out var sid) ? sid : 0,
591+
s.NodeAddress,
592+
SessionStatus.Active))
593+
.ToList();
594+
if (active.Count > 0) return active;
595+
}
596+
_logger?.Debug("RPC QueryActiveSessions returned no active sessions; falling back to LCD to verify.");
568597
}
569598
catch (Exception ex) { _logger?.Debug($"RPC QueryActiveSessions failed, falling back to LCD: {ex.Message}"); }
570599

@@ -605,7 +634,18 @@ public async Task<IReadOnlyList<ActiveSession>> QueryActiveSessionsForAddressAsy
605634

606635
// LCD fallback
607636
var path = $"/sentinel/session/v3/sessions/{sessionId}/allocations";
608-
var json = await LcdGetAsync(path, ct);
637+
JsonElement json;
638+
try
639+
{
640+
json = await LcdGetAsync(path, ct);
641+
}
642+
catch (SentinelException ex) when (ex.Code == "CLIENT_HTTP_404")
643+
{
644+
// Fresh session with no bandwidth reported yet (node hasn't called back to chain).
645+
// Treat as "allocation unknown" rather than failing session reuse — see
646+
// suggestions/2026-04-19-connect-widen-reuse-handshake-retry.md.
647+
return null;
648+
}
609649

610650
if (json.TryGetProperty("allocations", out var arr) && arr.ValueKind == JsonValueKind.Array)
611651
{
@@ -640,7 +680,9 @@ public async Task<List<SubscriptionAllocation>> QuerySubscriptionAllocationsAsyn
640680
// RPC-first (v2 — v3 returns 501)
641681
try
642682
{
643-
return await _rpcClient.QuerySubscriptionAllocationsAsync(subscriptionId, ct: ct);
683+
var rpcAllocs = await _rpcClient.QuerySubscriptionAllocationsAsync(subscriptionId, ct: ct);
684+
if (rpcAllocs.Count > 0) return rpcAllocs;
685+
_logger?.Debug("RPC returned no subscription allocations; falling back to LCD to verify.");
644686
}
645687
catch (Exception ex) { _logger?.Debug($"RPC QuerySubAllocations failed, falling back to LCD: {ex.Message}"); }
646688

@@ -691,7 +733,9 @@ public async Task<List<ChainNode>> QueryPlanNodesAsync(int planId, CancellationT
691733
// RPC-first
692734
try
693735
{
694-
return await _rpcClient.QueryNodesForPlanAsync((ulong)planId, 1, 5000, ct);
736+
var rpcNodes = await _rpcClient.QueryNodesForPlanAsync((ulong)planId, 1, 5000, ct);
737+
if (rpcNodes.Count > 0) return rpcNodes;
738+
_logger?.Debug("RPC returned no plan nodes; falling back to LCD to verify.");
695739
}
696740
catch (Exception ex) { _logger?.Debug($"RPC QueryPlanNodes failed, falling back to LCD: {ex.Message}"); }
697741

@@ -867,6 +911,7 @@ public async Task<IReadOnlyList<ChainNode>> GetAvailableNodesAsync(string wallet
867911

868912
/// <summary>
869913
/// Query all subscribers of a plan.
914+
/// RPC-first (verified wire format 2026-04-21); falls through to LCD on empty or exception.
870915
/// Optionally exclude an address (e.g. the plan owner) from the results.
871916
/// </summary>
872917
/// <param name="planId">Plan ID.</param>
@@ -878,14 +923,31 @@ public async Task<IReadOnlyList<PlanSubscriber>> QueryPlanSubscribersAsync(
878923
string? excludeAddress = null,
879924
CancellationToken ct = default)
880925
{
926+
// RPC-first (QuerySubscriptionsForPlan — direct PlanSubscription, not Any-wrapped).
927+
try
928+
{
929+
var rpcSubs = await _rpcClient.QuerySubscriptionsForPlanAsync((ulong)planId, ct: ct);
930+
if (rpcSubs.Count > 0)
931+
{
932+
var filtered = excludeAddress is null
933+
? rpcSubs
934+
: rpcSubs.Where(s => !string.Equals(s.Address, excludeAddress, StringComparison.OrdinalIgnoreCase)).ToList();
935+
return filtered;
936+
}
937+
_logger?.Debug("RPC QueryPlanSubscribers returned no results; falling back to LCD to verify.");
938+
}
939+
catch (Exception ex) { _logger?.Debug($"RPC QueryPlanSubscribers failed, falling back to LCD: {ex.Message}"); }
940+
941+
// LCD fallback
881942
var path = $"/sentinel/subscription/v3/plans/{planId}/subscriptions";
882943
var items = await LcdPaginatedAsync(path, "subscriptions", ct);
883944

884945
var subscribers = new List<PlanSubscriber>();
885946

886947
foreach (var item in items)
887948
{
888-
var address = item.TryGetProperty("address", out var a) ? a.GetString() ?? "" : "";
949+
var address = item.TryGetProperty("acc_address", out var a) ? a.GetString() ?? ""
950+
: item.TryGetProperty("address", out var a2) ? a2.GetString() ?? "" : "";
889951
if (string.IsNullOrEmpty(address) && item.TryGetProperty("subscriber", out var sub))
890952
{
891953
address = sub.GetString() ?? "";
@@ -1079,7 +1141,9 @@ public async Task<IReadOnlyList<AuthzGrant>> QueryAuthzGrantsAsync(
10791141
// RPC-first
10801142
try
10811143
{
1082-
return await _rpcClient.QueryAuthzGrantsAsync(granter, grantee, ct: ct);
1144+
var rpcGrants = await _rpcClient.QueryAuthzGrantsAsync(granter, grantee, ct: ct);
1145+
if (rpcGrants.Count > 0) return rpcGrants;
1146+
_logger?.Debug("RPC returned no authz grants; falling back to LCD to verify.");
10831147
}
10841148
catch (Exception ex) { _logger?.Debug($"RPC QueryAuthzGrants failed, falling back to LCD: {ex.Message}"); }
10851149

src/Sentinel.SDK.Core/ProtobufReader.cs

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -96,28 +96,53 @@ internal static PriceEntry DecodePrice(List<ProtoField> fields)
9696
return new PriceEntry(denom, baseVal, quoteVal);
9797
}
9898

99+
/// <summary>
100+
/// Unwrap a google.protobuf.Any (type_url=1, value=2) containing a Session message and decode it.
101+
/// RPC responses wrap repeated Session entries in Any; LCD returns the Session directly.
102+
/// </summary>
103+
internal static ChainSession DecodeSessionFromAny(List<ProtoField> anyFields)
104+
{
105+
// Any.value (field 2) contains the serialized Session message bytes.
106+
var valueField = GetField(anyFields, 2);
107+
if (valueField is null) return DecodeSession(anyFields); // Fallback — not Any-wrapped
108+
var sessionFields = DecodeEmbedded(valueField);
109+
return DecodeSession(sessionFields);
110+
}
111+
112+
/// <summary>
113+
/// Unwrap a google.protobuf.Any (type_url=1, value=2) containing a Subscription message and decode it.
114+
/// </summary>
115+
internal static Subscription DecodeSubscriptionFromAny(List<ProtoField> anyFields)
116+
{
117+
var valueField = GetField(anyFields, 2);
118+
if (valueField is null) return DecodeSubscription(anyFields);
119+
var subFields = DecodeEmbedded(valueField);
120+
return DecodeSubscription(subFields);
121+
}
122+
99123
/// <summary>
100124
/// Decode a ChainSession from protobuf fields.
101-
/// Session v3 wraps in base_session (field 1). Base session proto:
102-
/// id=1, acc_address=2, node_address=3, download_bytes=7(varint), upload_bytes=8(varint),
103-
/// max_bytes=9(varint), duration=10(string), max_duration=11(string), status=14(varint),
104-
/// inactive_at=15(embedded timestamp), start_at=16(embedded timestamp)
125+
/// Session v3 wraps in base_session (field 1). Verified base_session wire layout:
126+
/// id=1(varint), acc_address=2(string), node_address=3(string),
127+
/// download_bytes=4(string), upload_bytes=5(string), max_bytes=6(string),
128+
/// duration=7(string), max_duration=8(string), status=9(varint),
129+
/// inactive_at=10(timestamp), status_at=11(timestamp), start_at=12(timestamp).
105130
/// </summary>
106131
internal static ChainSession DecodeSession(List<ProtoField> outerFields)
107132
{
108-
// Unwrap base_session (field 1) if present
133+
// Unwrap base_session (field 1) if present and embedded
109134
var baseField = GetField(outerFields, 1);
110-
var fields = baseField is not null ? DecodeEmbedded(baseField) : outerFields;
135+
var fields = (baseField is not null && baseField.WireType == 2) ? DecodeEmbedded(baseField) : outerFields;
111136

112137
var id = GetField(fields, 1) is { } f1 ? f1.Varint.ToString() : "0";
113138
var accAddr = GetField(fields, 2) is { } f2 ? DecodeString(f2) : "";
114139
var nodeAddr = GetField(fields, 3) is { } f3 ? DecodeString(f3) : "";
115-
var download = GetField(fields, 7) is { } f7 ? f7.Varint.ToString() : "0";
116-
var upload = GetField(fields, 8) is { } f8 ? f8.Varint.ToString() : "0";
117-
var maxBytes = GetField(fields, 9) is { } f9 ? f9.Varint.ToString() : "0";
118-
var duration = GetField(fields, 10) is { } f10 ? DecodeString(f10) : null;
119-
var maxDuration = GetField(fields, 11) is { } f11 ? DecodeString(f11) : null;
120-
var status = GetField(fields, 14) is { } f14 ? (int)f14.Varint : 0;
140+
var download = GetField(fields, 4) is { } f4 ? DecodeString(f4) : "0";
141+
var upload = GetField(fields, 5) is { } f5 ? DecodeString(f5) : "0";
142+
var maxBytes = GetField(fields, 6) is { } f6 ? DecodeString(f6) : "0";
143+
var duration = GetField(fields, 7) is { } f7 ? DecodeString(f7) : null;
144+
var maxDuration = GetField(fields, 8) is { } f8 ? DecodeString(f8) : null;
145+
var status = GetField(fields, 9) is { } f9 ? (int)f9.Varint : 0;
121146
// status: 1=active, 2=inactive_pending, 3=inactive
122147
var statusStr = status switch { 1 => "active", 2 => "inactive_pending", 3 => "inactive", _ => status.ToString() };
123148

@@ -167,6 +192,26 @@ internal static Provider DecodeProvider(List<ProtoField> fields)
167192
return new Provider(address, name, identity, website, description, status);
168193
}
169194

195+
/// <summary>
196+
/// Decode a PlanSubscription from protobuf fields.
197+
/// Verified wire layout from live RPC probe (2026-04-21, plan 42):
198+
/// id=1(varint), acc_address=2(string), plan_id=3(varint),
199+
/// price=4(embedded: denom=1/string, base_value=2/string, quote_value=3/string),
200+
/// status=6(varint: 1=active, 2=inactive_pending, 3=inactive),
201+
/// inactive_at=7(timestamp), start_at=8(timestamp), status_at=9(timestamp).
202+
/// NOTE: outer response field 1 is NOT Any-wrapped — direct PlanSubscription bytes.
203+
/// </summary>
204+
internal static PlanSubscriber DecodePlanSubscription(List<ProtoField> fields)
205+
{
206+
var id = GetField(fields, 1) is { } f1 ? f1.Varint.ToString() : "0";
207+
var accAddr = GetField(fields, 2) is { } f2 ? DecodeString(f2) : "";
208+
// plan_id is field 3 (varint) on PlanSubscription — not base_subscription field 4
209+
var status = GetField(fields, 6) is { } f6 ? (int)f6.Varint : 0;
210+
var statusStr = status switch { 1 => "active", 2 => "inactive_pending", 3 => "inactive", _ => status.ToString() };
211+
212+
return new PlanSubscriber(accAddr, status, id);
213+
}
214+
170215
/// <summary>
171216
/// Decode a ChainNode from protobuf fields.
172217
/// Node proto: address=1, gigabyte_prices=2, hourly_prices=3, remote_addrs=4, status=6

0 commit comments

Comments
 (0)