Skip to content

Commit 772c5de

Browse files
feat: RPC-first migration — 15 query methods with LCD fallback
RpcClient wired into ChainClient. Added typed query methods for sessions, subscriptions, provider, fee grants, authz grants, and allocations using ProtobufReader decoders. All ChainClient query methods now try RPC first and fall back to LCD on failure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 633f861 commit 772c5de

5 files changed

Lines changed: 406 additions & 16 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ public async Task<List<FeeGrant>> QueryFeeGrantsAsync(string grantee, Cancellati
1919
{
2020
ArgumentNullException.ThrowIfNull(grantee);
2121

22+
// RPC-first
23+
try
24+
{
25+
return await _rpcClient.QueryFeeGrantsAsync(grantee, ct: ct);
26+
}
27+
catch (Exception ex) { _logger?.Debug($"RPC QueryFeeGrants failed, falling back to LCD: {ex.Message}"); }
28+
29+
// LCD fallback
2230
var path = $"/cosmos/feegrant/v1beta1/allowances/{grantee}";
2331
var items = await LcdPaginatedAsync(path, "allowances", ct);
2432

@@ -47,6 +55,14 @@ public async Task<IReadOnlyList<FeeGrant>> QueryFeeGrantsIssuedAsync(string gran
4755
{
4856
ArgumentNullException.ThrowIfNull(granter);
4957

58+
// RPC-first
59+
try
60+
{
61+
return await _rpcClient.QueryFeeGrantsIssuedAsync(granter, ct: ct);
62+
}
63+
catch (Exception ex) { _logger?.Debug($"RPC QueryFeeGrantsIssued failed, falling back to LCD: {ex.Message}"); }
64+
65+
// LCD fallback
5066
var path = $"/cosmos/feegrant/v1beta1/issued/{granter}";
5167
var items = await LcdPaginatedAsync(path, "allowances", ct);
5268

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

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ public async Task<Balance> GetBalanceAsync(string address, CancellationToken ct
2020
{
2121
ArgumentNullException.ThrowIfNull(address);
2222

23+
// RPC-first
24+
try
25+
{
26+
return await _rpcClient.QueryBalanceAsync(address, Constants.Denom, ct);
27+
}
28+
catch (Exception ex) { _logger?.Debug($"RPC GetBalance failed, falling back to LCD: {ex.Message}"); }
29+
30+
// LCD fallback
2331
var path = $"/cosmos/bank/v1beta1/balances/{address}/by_denom?denom={Constants.Denom}";
2432
var json = await LcdGetAsync(path, ct);
2533

@@ -43,6 +51,14 @@ public async Task<Balance> GetBalanceAsync(string address, CancellationToken ct
4351
/// <returns>List of active chain nodes.</returns>
4452
public async Task<List<ChainNode>> GetActiveNodesAsync(int limit = 500, CancellationToken ct = default)
4553
{
54+
// RPC-first
55+
try
56+
{
57+
return await _rpcClient.QueryNodesAsync(1, limit, ct);
58+
}
59+
catch (Exception ex) { _logger?.Debug($"RPC GetActiveNodes failed, falling back to LCD: {ex.Message}"); }
60+
61+
// LCD fallback
4662
var path = $"/sentinel/node/v3/nodes?status=1&pagination.limit={limit}";
4763
var items = await LcdPaginatedAsync(path, "nodes", ct);
4864
return items.Select(ParseChainNode).ToList();
@@ -57,6 +73,14 @@ public async Task<List<ChainNode>> GetActiveNodesAsync(int limit = 500, Cancella
5773
{
5874
ArgumentNullException.ThrowIfNull(nodeAddress);
5975

76+
// RPC-first
77+
try
78+
{
79+
return await _rpcClient.QueryNodeAsync(nodeAddress, ct);
80+
}
81+
catch (Exception ex) { _logger?.Debug($"RPC GetNode failed, falling back to LCD: {ex.Message}"); }
82+
83+
// LCD fallback
6084
try
6185
{
6286
var path = $"/sentinel/node/v3/nodes/{nodeAddress}";
@@ -84,6 +108,14 @@ public async Task<List<Subscription>> GetSubscriptionsAsync(string address, Canc
84108
{
85109
ArgumentNullException.ThrowIfNull(address);
86110

111+
// RPC-first
112+
try
113+
{
114+
return await _rpcClient.QuerySubscriptionsForAccountAsync(address, ct: ct);
115+
}
116+
catch (Exception ex) { _logger?.Debug($"RPC GetSubscriptions failed, falling back to LCD: {ex.Message}"); }
117+
118+
// LCD fallback
87119
var path = $"/sentinel/subscription/v3/accounts/{address}/subscriptions";
88120
var items = await LcdPaginatedAsync(path, "subscriptions", ct);
89121
return items.Select(ParseSubscription).ToList();
@@ -99,7 +131,18 @@ public async Task<List<ChainSession>> GetSessionsAsync(string address, string st
99131
{
100132
ArgumentNullException.ThrowIfNull(address);
101133

102-
// MUST use /accounts/{addr}/sessions — query-param format returns ALL sessions unfiltered
134+
// RPC-first
135+
try
136+
{
137+
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+
}
143+
catch (Exception ex) { _logger?.Debug($"RPC GetSessions failed, falling back to LCD: {ex.Message}"); }
144+
145+
// LCD fallback — MUST use /accounts/{addr}/sessions path
103146
var path = $"/sentinel/session/v3/accounts/{address}/sessions?status={status}";
104147
var items = await LcdPaginatedAsync(path, "sessions", ct);
105148
return items.Select(ParseChainSession).ToList();
@@ -113,6 +156,14 @@ public async Task<List<ChainSession>> GetSessionsAsync(string address, string st
113156
/// <returns>List of nodes in the plan.</returns>
114157
public async Task<List<ChainNode>> GetPlanNodesAsync(int planId, CancellationToken ct = default)
115158
{
159+
// RPC-first
160+
try
161+
{
162+
return await _rpcClient.QueryNodesForPlanAsync((ulong)planId, 1, 5000, ct);
163+
}
164+
catch (Exception ex) { _logger?.Debug($"RPC GetPlanNodes failed, falling back to LCD: {ex.Message}"); }
165+
166+
// LCD fallback
116167
var path = $"/sentinel/node/v3/plans/{planId}/nodes?pagination.limit=5000";
117168
var items = await LcdPaginatedAsync(path, "nodes", ct);
118169
return items.Select(ParseChainNode).ToList();
@@ -347,14 +398,27 @@ public async Task<IReadOnlyList<ActiveSession>> QueryActiveSessionsForAddressAsy
347398
{
348399
ArgumentNullException.ThrowIfNull(walletAddress);
349400

350-
// MUST use /accounts/{addr}/sessions — the query-param format returns ALL sessions unfiltered
401+
// RPC-first
402+
try
403+
{
404+
var rpcSessions = await _rpcClient.QuerySessionsForAccountAsync(walletAddress, ct: ct);
405+
return rpcSessions
406+
.Where(s => s.Status == "active" || s.Status == "1")
407+
.Select(s => new ActiveSession(
408+
ulong.TryParse(s.Id, out var sid) ? sid : 0,
409+
s.NodeAddress,
410+
SessionStatus.Active))
411+
.ToList();
412+
}
413+
catch (Exception ex) { _logger?.Debug($"RPC QueryActiveSessions failed, falling back to LCD: {ex.Message}"); }
414+
415+
// LCD fallback
351416
var path = $"/sentinel/session/v3/accounts/{walletAddress}/sessions?status=1";
352417
var items = await LcdPaginatedAsync(path, "sessions", ct);
353418
var sessions = new List<ActiveSession>();
354419

355420
foreach (var s in items)
356421
{
357-
// v3 sessions wrap fields in base_session
358422
var bs = s.TryGetProperty("base_session", out var baseEl) ? baseEl : s;
359423
var idStr = bs.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? "0" : "0";
360424
var nodeAddr = bs.TryGetProperty("node_address", out var naEl) ? naEl.GetString() ?? "" : "";
@@ -375,6 +439,15 @@ public async Task<IReadOnlyList<ActiveSession>> QueryActiveSessionsForAddressAsy
375439
/// <inheritdoc />
376440
public async Task<RawSessionAllocation?> QuerySessionAllocationAsync(ulong sessionId, CancellationToken ct = default)
377441
{
442+
// RPC-first
443+
try
444+
{
445+
var result = await _rpcClient.QuerySessionAllocationAsync(sessionId, ct);
446+
if (result is not null) return result;
447+
}
448+
catch (Exception ex) { _logger?.Debug($"RPC QuerySessionAllocation failed, falling back to LCD: {ex.Message}"); }
449+
450+
// LCD fallback
378451
var path = $"/sentinel/session/v3/sessions/{sessionId}/allocations";
379452
var json = await LcdGetAsync(path, ct);
380453

@@ -408,8 +481,14 @@ public async Task<IReadOnlyList<ActiveSession>> QueryActiveSessionsForAddressAsy
408481
public async Task<List<SubscriptionAllocation>> QuerySubscriptionAllocationsAsync(
409482
ulong subscriptionId, CancellationToken ct = default)
410483
{
411-
// NOTE: v3 endpoint for this specific query hasn't been implemented yet (returns 501).
412-
// The v2 path works and returns the same allocation data. Same situation as /plan/v3/plans/{id}.
484+
// RPC-first (v2 — v3 returns 501)
485+
try
486+
{
487+
return await _rpcClient.QuerySubscriptionAllocationsAsync(subscriptionId, ct: ct);
488+
}
489+
catch (Exception ex) { _logger?.Debug($"RPC QuerySubAllocations failed, falling back to LCD: {ex.Message}"); }
490+
491+
// LCD fallback (v2 path — v3 returns 501)
413492
var path = $"/sentinel/subscription/v2/subscriptions/{subscriptionId}/allocations";
414493
var allocations = new List<SubscriptionAllocation>();
415494

@@ -453,6 +532,14 @@ public async Task<List<SubscriptionAllocation>> QuerySubscriptionAllocationsAsyn
453532
/// <returns>List of nodes in the plan.</returns>
454533
public async Task<List<ChainNode>> QueryPlanNodesAsync(int planId, CancellationToken ct = default)
455534
{
535+
// RPC-first
536+
try
537+
{
538+
return await _rpcClient.QueryNodesForPlanAsync((ulong)planId, 1, 5000, ct);
539+
}
540+
catch (Exception ex) { _logger?.Debug($"RPC QueryPlanNodes failed, falling back to LCD: {ex.Message}"); }
541+
542+
// LCD fallback
456543
var path = $"/sentinel/node/v3/plans/{planId}/nodes?pagination.limit=5000";
457544
var items = await LcdPaginatedAsync(path, "nodes", ct);
458545
return items.Select(ParseChainNode).ToList();
@@ -546,6 +633,15 @@ public async Task<IReadOnlyList<ChainNode>> GetAvailableNodesAsync(string wallet
546633
{
547634
ArgumentNullException.ThrowIfNull(provAddress);
548635

636+
// RPC-first (v2 — provider not migrated to v3)
637+
try
638+
{
639+
var result = await _rpcClient.QueryProviderAsync(provAddress, ct);
640+
if (result is not null) return result;
641+
}
642+
catch (Exception ex) { _logger?.Debug($"RPC GetProvider failed, falling back to LCD: {ex.Message}"); }
643+
644+
// LCD fallback
549645
try
550646
{
551647
var path = $"/sentinel/provider/v2/providers/{provAddress}";
@@ -584,6 +680,17 @@ public async Task<IReadOnlyList<ChainNode>> GetAvailableNodesAsync(string wallet
584680
{
585681
ArgumentNullException.ThrowIfNull(id);
586682

683+
// RPC-first
684+
if (ulong.TryParse(id, out var subId))
685+
{
686+
try
687+
{
688+
return await _rpcClient.QuerySubscriptionAsync(subId, ct);
689+
}
690+
catch (Exception ex) { _logger?.Debug($"RPC GetSubscription failed, falling back to LCD: {ex.Message}"); }
691+
}
692+
693+
// LCD fallback
587694
try
588695
{
589696
var path = $"/sentinel/subscription/v3/subscriptions/{id}";
@@ -813,6 +920,14 @@ public async Task<IReadOnlyList<AuthzGrant>> QueryAuthzGrantsAsync(
813920
ArgumentException.ThrowIfNullOrWhiteSpace(granter);
814921
ArgumentException.ThrowIfNullOrWhiteSpace(grantee);
815922

923+
// RPC-first
924+
try
925+
{
926+
return await _rpcClient.QueryAuthzGrantsAsync(granter, grantee, ct: ct);
927+
}
928+
catch (Exception ex) { _logger?.Debug($"RPC QueryAuthzGrants failed, falling back to LCD: {ex.Message}"); }
929+
930+
// LCD fallback
816931
var path = $"/cosmos/authz/v1beta1/grants?granter={granter}&grantee={grantee}";
817932
var grants = new List<AuthzGrant>();
818933

@@ -833,7 +948,6 @@ public async Task<IReadOnlyList<AuthzGrant>> QueryAuthzGrantsAsync(
833948
if (auth.TryGetProperty("@type", out var typeProp))
834949
msgTypeUrl = typeProp.GetString() ?? "";
835950

836-
// GenericAuthorization has msg field
837951
if (auth.TryGetProperty("msg", out var msgProp))
838952
msgTypeUrl = msgProp.GetString() ?? msgTypeUrl;
839953
}

src/Sentinel.SDK.Core/ChainClient.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public sealed partial class ChainClient : IChainClient, IDisposable
1515
private readonly HttpClient _publicHttpClient; // CA-validated for LCD/RPC
1616
private readonly ISdkLogger? _logger;
1717
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(15);
18+
private readonly RpcClient _rpcClient;
1819

1920
private static readonly JsonSerializerOptions JsonOptions = new()
2021
{
@@ -64,6 +65,9 @@ public ChainClient(string[]? lcdUrls = null, string[]? rpcUrls = null, ISdkLogge
6465
{
6566
Timeout = _timeout,
6667
};
68+
69+
// RPC client for protobuf/ABCI queries (~10x faster than LCD)
70+
_rpcClient = new RpcClient(_rpcUrls, _logger);
6771
}
6872

6973
// ─── Initialization ───
@@ -90,5 +94,6 @@ public void Dispose()
9094
{
9195
_httpClient.Dispose();
9296
_publicHttpClient.Dispose();
97+
_rpcClient.Dispose();
9398
}
9499
}

src/Sentinel.SDK.Core/ProtobufReader.cs

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

99+
/// <summary>
100+
/// 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)
105+
/// </summary>
106+
internal static ChainSession DecodeSession(List<ProtoField> outerFields)
107+
{
108+
// Unwrap base_session (field 1) if present
109+
var baseField = GetField(outerFields, 1);
110+
var fields = baseField is not null ? DecodeEmbedded(baseField) : outerFields;
111+
112+
var id = GetField(fields, 1) is { } f1 ? f1.Varint.ToString() : "0";
113+
var accAddr = GetField(fields, 2) is { } f2 ? DecodeString(f2) : "";
114+
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;
121+
// status: 1=active, 2=inactive_pending, 3=inactive
122+
var statusStr = status switch { 1 => "active", 2 => "inactive_pending", 3 => "inactive", _ => status.ToString() };
123+
124+
return new ChainSession(id, accAddr, nodeAddr, download, upload, maxBytes, duration, maxDuration, statusStr, null, null);
125+
}
126+
127+
/// <summary>
128+
/// Decode a Subscription from protobuf fields.
129+
/// Subscription v3 wraps in base_subscription (field 1). Base:
130+
/// id=1(varint), acc_address=2(string), plan_id=4(varint), status=7(varint),
131+
/// start_at=8(timestamp), inactive_at=9(timestamp)
132+
/// Price is on outer field 2.
133+
/// </summary>
134+
internal static Subscription DecodeSubscription(List<ProtoField> outerFields)
135+
{
136+
var baseField = GetField(outerFields, 1);
137+
var fields = baseField is not null ? DecodeEmbedded(baseField) : outerFields;
138+
139+
var id = GetField(fields, 1) is { } f1 ? f1.Varint.ToString() : "0";
140+
var accAddr = GetField(fields, 2) is { } f2 ? DecodeString(f2) : "";
141+
var planId = GetField(fields, 4) is { } f4 ? f4.Varint.ToString() : "0";
142+
var status = GetField(fields, 7) is { } f7 ? (int)f7.Varint : 0;
143+
var statusStr = status switch { 1 => "active", 2 => "inactive_pending", 3 => "inactive", _ => status.ToString() };
144+
145+
// Price is outer field 2 (on the subscription wrapper, not base)
146+
PriceEntry? price = null;
147+
if (GetField(outerFields, 2) is { } pf)
148+
{
149+
price = DecodePrice(DecodeEmbedded(pf));
150+
}
151+
152+
return new Subscription(id, accAddr, planId, price, statusStr, "", "");
153+
}
154+
155+
/// <summary>
156+
/// Decode a Provider from protobuf fields.
157+
/// Provider v2: address=1, name=2, identity=3, website=4, description=5, status=6(varint)
158+
/// </summary>
159+
internal static Provider DecodeProvider(List<ProtoField> fields)
160+
{
161+
var address = GetField(fields, 1) is { } f1 ? DecodeString(f1) : "";
162+
var name = GetField(fields, 2) is { } f2 ? DecodeString(f2) : "";
163+
var identity = GetField(fields, 3) is { } f3 ? DecodeString(f3) : "";
164+
var website = GetField(fields, 4) is { } f4 ? DecodeString(f4) : "";
165+
var description = GetField(fields, 5) is { } f5 ? DecodeString(f5) : "";
166+
var status = GetField(fields, 6) is { } f6 ? (int)f6.Varint : 0;
167+
return new Provider(address, name, identity, website, description, status);
168+
}
169+
99170
/// <summary>
100171
/// Decode a ChainNode from protobuf fields.
101172
/// Node proto: address=1, gigabyte_prices=2, hourly_prices=3, remote_addrs=4, status=6

0 commit comments

Comments
 (0)