Skip to content

Commit ad5f51a

Browse files
fix: subscription error codes, input validation, OnboardPlanUser guards
- Added SUBSCRIBE_FAILED, SUBSCRIPTION_NOT_FOUND, SHARE_FAILED to ErrorCodes with severity mapping and user messages - Guarded ulong.Parse(subscriptionId) with TryParse in OnboardPlanUserAsync - Changed default feeSpendLimit from 500k to 5M udvpn (5 P2P, ~125 sessions) - Added input validation (null checks, >0 guards) to CancelSubscription, RenewSubscription, ShareSubscription, UpdateSubscription builders - Added FeeGrantTests test file Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 919fb9f commit ad5f51a

8 files changed

Lines changed: 537 additions & 3 deletions

File tree

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

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
namespace Sentinel.SDK.Core;
44

55
/// <summary>
6-
/// ChainClient partial — fee grant queries, issued-grant lookups, and expiry monitoring.
6+
/// ChainClient partial — fee grant queries, issued-grant lookups, expiry monitoring,
7+
/// and operator workflow helpers (grantPlanSubscribers, renewExpiringGrants, monitorFeeGrants).
78
/// </summary>
89
public sealed partial class ChainClient
910
{
@@ -167,4 +168,107 @@ public async Task<IReadOnlyList<ExpiringGrant>> GetExpiringGrantsAsync(
167168

168169
return null;
169170
}
171+
172+
// ─── Operator Workflow Functions ───
173+
174+
/// <summary>
175+
/// Build fee grant messages for all subscribers of a plan.
176+
/// Queries all subscribers, filters out the granter and already-granted addresses,
177+
/// and returns batch MsgGrantAllowance messages ready for broadcast.
178+
/// </summary>
179+
/// <param name="planId">Plan ID whose subscribers should receive grants.</param>
180+
/// <param name="granterAddress">Granter address (sent1...) — plan operator who pays gas.</param>
181+
/// <param name="spendLimitUdvpn">Optional spend limit per grant in udvpn.</param>
182+
/// <param name="expiration">Optional expiry date for each grant.</param>
183+
/// <param name="ct">Cancellation token.</param>
184+
/// <returns>Array of grant messages (empty if all subscribers already have grants).</returns>
185+
public async Task<SentinelMessage[]> BuildGrantPlanSubscribersAsync(
186+
int planId,
187+
string granterAddress,
188+
long? spendLimitUdvpn = null,
189+
DateTime? expiration = null,
190+
CancellationToken ct = default)
191+
{
192+
ArgumentException.ThrowIfNullOrWhiteSpace(granterAddress);
193+
if (planId <= 0)
194+
throw new ArgumentOutOfRangeException(nameof(planId), "Must be > 0");
195+
196+
// Get all subscribers, excluding the operator
197+
var subscribers = await QueryPlanSubscribersAsync(planId, excludeAddress: granterAddress, ct: ct);
198+
if (subscribers.Count == 0)
199+
return [];
200+
201+
// Get existing grants issued by this granter to filter out already-granted
202+
var existingGrants = await QueryFeeGrantsIssuedAsync(granterAddress, ct);
203+
var alreadyGranted = new HashSet<string>(existingGrants.Select(g => g.Grantee));
204+
205+
var messages = new List<SentinelMessage>();
206+
foreach (var sub in subscribers)
207+
{
208+
if (alreadyGranted.Contains(sub.Address))
209+
continue;
210+
211+
messages.Add(MessageBuilder.GrantFeeAllowance(
212+
granterAddress, sub.Address, spendLimitUdvpn, expiration));
213+
}
214+
215+
return messages.ToArray();
216+
}
217+
218+
/// <summary>
219+
/// Build messages to renew fee grants expiring within a given window.
220+
/// For each expiring grant: revokes the old one, creates a new one with fresh expiration.
221+
/// </summary>
222+
/// <param name="granterAddress">Granter address (sent1...).</param>
223+
/// <param name="withinDays">Days ahead to check for expiring grants (default: 7).</param>
224+
/// <param name="newSpendLimitUdvpn">Optional spend limit for renewed grants.</param>
225+
/// <param name="newExpiration">Optional new expiration for renewed grants.</param>
226+
/// <param name="ct">Cancellation token.</param>
227+
/// <returns>Array of revoke+grant message pairs (empty if nothing is expiring).</returns>
228+
public async Task<SentinelMessage[]> BuildRenewExpiringGrantsAsync(
229+
string granterAddress,
230+
int withinDays = 7,
231+
long? newSpendLimitUdvpn = null,
232+
DateTime? newExpiration = null,
233+
CancellationToken ct = default)
234+
{
235+
ArgumentException.ThrowIfNullOrWhiteSpace(granterAddress);
236+
237+
var expiring = await GetExpiringGrantsAsync(granterAddress, withinDays, role: "granter", ct: ct);
238+
if (expiring.Count == 0)
239+
return [];
240+
241+
var messages = new List<SentinelMessage>();
242+
foreach (var grant in expiring)
243+
{
244+
// Revoke old grant
245+
messages.Add(MessageBuilder.RevokeFeeAllowance(granterAddress, grant.Grantee));
246+
// Re-grant with fresh expiration
247+
messages.Add(MessageBuilder.GrantFeeAllowance(
248+
granterAddress, grant.Grantee, newSpendLimitUdvpn, newExpiration));
249+
}
250+
251+
return messages.ToArray();
252+
}
253+
254+
/// <summary>
255+
/// Find an existing active session between a wallet and a specific node.
256+
/// Prevents double-allocation by checking before starting a new session.
257+
/// </summary>
258+
/// <param name="walletAddress">Account address (sent1...).</param>
259+
/// <param name="nodeAddress">Node address (sentnode1...).</param>
260+
/// <param name="ct">Cancellation token.</param>
261+
/// <returns>The existing session, or null if none found.</returns>
262+
public async Task<ChainSession?> FindExistingSessionAsync(
263+
string walletAddress,
264+
string nodeAddress,
265+
CancellationToken ct = default)
266+
{
267+
ArgumentException.ThrowIfNullOrWhiteSpace(walletAddress);
268+
ArgumentException.ThrowIfNullOrWhiteSpace(nodeAddress);
269+
270+
var sessions = await GetSessionsAsync(walletAddress, status: "1", ct: ct);
271+
return sessions.FirstOrDefault(s =>
272+
string.Equals(s.NodeAddress, nodeAddress, StringComparison.OrdinalIgnoreCase));
273+
}
170274
}

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,54 @@ public async Task<IReadOnlyList<ActiveSession>> QueryActiveSessionsForAddressAsy
395395
return null;
396396
}
397397

398+
// ─── Subscription Allocation Queries ───
399+
400+
/// <summary>
401+
/// Query all bandwidth allocations for a subscription.
402+
/// Used to verify that a user has been granted access to a plan subscription via sharing.
403+
/// NOTE: Uses v2 endpoint because v3 returns 501 Not Implemented.
404+
/// </summary>
405+
/// <param name="subscriptionId">Subscription ID on chain.</param>
406+
/// <param name="ct">Cancellation token.</param>
407+
/// <returns>List of allocations for the subscription.</returns>
408+
public async Task<List<SubscriptionAllocation>> QuerySubscriptionAllocationsAsync(
409+
ulong subscriptionId, CancellationToken ct = default)
410+
{
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}.
413+
var path = $"/sentinel/subscription/v2/subscriptions/{subscriptionId}/allocations";
414+
var allocations = new List<SubscriptionAllocation>();
415+
416+
try
417+
{
418+
var json = await LcdGetAsync(path, ct);
419+
420+
if (json.TryGetProperty("allocations", out var arr) &&
421+
arr.ValueKind == JsonValueKind.Array)
422+
{
423+
foreach (var a in arr.EnumerateArray())
424+
{
425+
var id = a.TryGetProperty("id", out var idEl)
426+
? idEl.GetString() ?? idEl.ToString() : "0";
427+
var address = a.TryGetProperty("address", out var addrEl)
428+
? addrEl.GetString() ?? "" : "";
429+
var granted = a.TryGetProperty("granted_bytes", out var gEl)
430+
? gEl.GetString() ?? "0" : "0";
431+
var utilised = a.TryGetProperty("utilised_bytes", out var uEl)
432+
? uEl.GetString() ?? "0" : "0";
433+
434+
allocations.Add(new SubscriptionAllocation(id, address, granted, utilised));
435+
}
436+
}
437+
}
438+
catch (SentinelException ex) when (ex.Code == "CLIENT_HTTP_404")
439+
{
440+
// No allocations found — return empty list
441+
}
442+
443+
return allocations;
444+
}
445+
398446
// ─── Additional Query Methods ───
399447

400448
/// <summary>

src/Sentinel.SDK.Core/IChainClient.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,23 @@ public interface IChainClient
168168
/// <returns>List of grants expiring within the specified window.</returns>
169169
Task<IReadOnlyList<ExpiringGrant>> GetExpiringGrantsAsync(string address, int withinDays = 7, string role = "grantee", CancellationToken ct = default);
170170

171+
// ─── Operator Workflow ───
172+
173+
/// <summary>
174+
/// Build fee grant messages for all subscribers of a plan.
175+
/// </summary>
176+
Task<SentinelMessage[]> BuildGrantPlanSubscribersAsync(int planId, string granterAddress, long? spendLimitUdvpn = null, DateTime? expiration = null, CancellationToken ct = default);
177+
178+
/// <summary>
179+
/// Build messages to renew fee grants expiring within a given window.
180+
/// </summary>
181+
Task<SentinelMessage[]> BuildRenewExpiringGrantsAsync(string granterAddress, int withinDays = 7, long? newSpendLimitUdvpn = null, DateTime? newExpiration = null, CancellationToken ct = default);
182+
183+
/// <summary>
184+
/// Find an existing active session between a wallet and a specific node.
185+
/// </summary>
186+
Task<ChainSession?> FindExistingSessionAsync(string walletAddress, string nodeAddress, CancellationToken ct = default);
187+
171188
// ─── Provider ───
172189

173190
/// <summary>

src/Sentinel.SDK.Core/MessageBuilder.Subscription.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ public static SentinelMessage SubStartSession(
7979
/// <summary>Cancel a subscription. Proto: sentinel.subscription.v3.MsgCancelSubscriptionRequest</summary>
8080
public static SentinelMessage CancelSubscription(string from, ulong subscriptionId)
8181
{
82+
ArgumentException.ThrowIfNullOrWhiteSpace(from);
83+
if (subscriptionId == 0)
84+
throw new ArgumentOutOfRangeException(nameof(subscriptionId), "Must be > 0");
85+
8286
using var s = new MemoryStream();
8387
WriteStringField(s, 1, from);
8488
WriteVarintField(s, 2, subscriptionId);
@@ -88,27 +92,43 @@ public static SentinelMessage CancelSubscription(string from, ulong subscription
8892
/// <summary>Renew an expiring subscription. Proto: sentinel.subscription.v3.MsgRenewSubscriptionRequest</summary>
8993
public static SentinelMessage RenewSubscription(string from, ulong subscriptionId, string denom = "udvpn")
9094
{
95+
ArgumentException.ThrowIfNullOrWhiteSpace(from);
96+
if (subscriptionId == 0)
97+
throw new ArgumentOutOfRangeException(nameof(subscriptionId), "Must be > 0");
98+
9199
using var s = new MemoryStream();
92100
WriteStringField(s, 1, from);
93101
WriteVarintField(s, 2, subscriptionId);
94102
WriteStringField(s, 3, denom);
95103
return new SentinelMessage("/sentinel.subscription.v3.MsgRenewSubscriptionRequest", s.ToArray());
96104
}
97105

98-
/// <summary>Share bandwidth with another address. Proto: sentinel.subscription.v3.MsgShareSubscriptionRequest</summary>
106+
/// <summary>Share bandwidth with another address. Proto: sentinel.subscription.v3.MsgShareSubscriptionRequest
107+
/// Field 4 (bytes) is cosmossdk.io/math.Int — proto string type (wire type 2), NOT varint.</summary>
99108
public static SentinelMessage ShareSubscription(string from, ulong subscriptionId, string accAddress, long bytes)
100109
{
110+
ArgumentException.ThrowIfNullOrWhiteSpace(from);
111+
if (subscriptionId == 0)
112+
throw new ArgumentOutOfRangeException(nameof(subscriptionId), "Must be > 0");
113+
ArgumentException.ThrowIfNullOrWhiteSpace(accAddress);
114+
if (bytes <= 0)
115+
throw new ArgumentOutOfRangeException(nameof(bytes), "Must be > 0");
116+
101117
using var s = new MemoryStream();
102118
WriteStringField(s, 1, from);
103119
WriteVarintField(s, 2, subscriptionId);
104120
WriteStringField(s, 3, accAddress);
105-
WriteVarintField(s, 4, (ulong)bytes);
121+
WriteStringField(s, 4, bytes.ToString());
106122
return new SentinelMessage("/sentinel.subscription.v3.MsgShareSubscriptionRequest", s.ToArray());
107123
}
108124

109125
/// <summary>Update subscription renewal policy. Proto: sentinel.subscription.v3.MsgUpdateSubscriptionRequest</summary>
110126
public static SentinelMessage UpdateSubscription(string from, ulong subscriptionId, int renewalPricePolicy)
111127
{
128+
ArgumentException.ThrowIfNullOrWhiteSpace(from);
129+
if (subscriptionId == 0)
130+
throw new ArgumentOutOfRangeException(nameof(subscriptionId), "Must be > 0");
131+
112132
using var s = new MemoryStream();
113133
WriteStringField(s, 1, from);
114134
WriteVarintField(s, 2, subscriptionId);

src/Sentinel.SDK.Core/SentinelErrors.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ public static class ErrorCodes
159159
public const string AlreadyConnected = "ALREADY_CONNECTED";
160160
public const string PartialConnectionFailed = "PARTIAL_CONNECTION_FAILED";
161161

162+
// ─── Subscription / Plan ───
163+
public const string SubscribeFailed = "SUBSCRIBE_FAILED";
164+
public const string SubscriptionNotFound = "SUBSCRIPTION_NOT_FOUND";
165+
public const string ShareFailed = "SHARE_FAILED";
166+
162167
// ─── C#-specific (extras not in JS — keep for backwards compat) ───
163168
public const string NotConnected = "NOT_CONNECTED";
164169
public const string HandshakeFailed = "HANDSHAKE_FAILED";
@@ -228,6 +233,9 @@ public static class ErrorSeverity
228233
ErrorCodes.PartialConnectionFailed => "recoverable",
229234
ErrorCodes.SessionExists => "recoverable",
230235
ErrorCodes.HandshakeFailed => "recoverable",
236+
ErrorCodes.SubscribeFailed => "retryable",
237+
ErrorCodes.SubscriptionNotFound => "retryable",
238+
ErrorCodes.ShareFailed => "retryable",
231239

232240
// Infrastructure — check system state
233241
ErrorCodes.TlsCertChanged => "infrastructure",
@@ -283,6 +291,9 @@ public static class ErrorSeverity
283291
ErrorCodes.SessionPoisoned => "Session is poisoned (previously failed). Start a new session.",
284292
ErrorCodes.PartialConnectionFailed => "Payment succeeded but connection failed. Use recovery to retry.",
285293
ErrorCodes.Aborted => "Connection was cancelled.",
294+
ErrorCodes.SubscribeFailed => "Failed to subscribe to the plan. Check your balance and try again.",
295+
ErrorCodes.SubscriptionNotFound => "Subscription not found after payment. Check chain state.",
296+
ErrorCodes.ShareFailed => "Failed to share subscription bandwidth. Try again.",
286297
ErrorCodes.InvalidMnemonic => "Invalid wallet phrase. Must be 12 or 24 words.",
287298
ErrorCodes.InvalidNodeAddress => "Invalid node address.",
288299
ErrorCodes.InvalidOptions => "Invalid connection options provided.",

src/Sentinel.SDK.Core/TransactionBuilder.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,107 @@ private static byte[] BuildTxRaw(byte[] txBodyBytes, byte[] authInfoBytes, byte[
502502
return stream.ToArray();
503503
}
504504

505+
// ─── High-Level Subscription Operations ───
506+
507+
/// <summary>
508+
/// Share a subscription's bandwidth with another address.
509+
/// Builds and broadcasts a MsgShareSubscriptionRequest.
510+
/// The bytes parameter specifies the bandwidth allowance (cosmossdk.io/math.Int).
511+
/// NOTE: Only bytes-based sharing is supported — the chain has no time/duration field.
512+
/// The operator must manage user expiry externally.
513+
/// </summary>
514+
/// <param name="subscriptionId">Subscription ID to share.</param>
515+
/// <param name="recipientAddress">Address to share with (sent1...).</param>
516+
/// <param name="bytes">Bytes of bandwidth to grant (e.g. 1073741824 for 1 GB).</param>
517+
/// <returns>Transaction result with hash and success status.</returns>
518+
public async Task<TxResult> ShareSubscriptionAsync(
519+
ulong subscriptionId, string recipientAddress, long bytes)
520+
{
521+
ArgumentException.ThrowIfNullOrWhiteSpace(recipientAddress);
522+
if (subscriptionId == 0)
523+
throw new ArgumentOutOfRangeException(nameof(subscriptionId), "Must be > 0");
524+
if (bytes <= 0)
525+
throw new ArgumentOutOfRangeException(nameof(bytes), "Must be > 0");
526+
527+
var msg = MessageBuilder.ShareSubscription(
528+
_wallet.Address, subscriptionId, recipientAddress, bytes);
529+
return await BroadcastAsync(msg);
530+
}
531+
532+
/// <summary>
533+
/// Complete "add user to plan" flow: subscribe to plan, share bandwidth with user,
534+
/// and optionally grant a fee allowance so the user doesn't pay gas.
535+
/// This is the primary operation for plan operators onboarding new users.
536+
/// </summary>
537+
/// <param name="planId">Plan ID to subscribe to.</param>
538+
/// <param name="userAddress">User address to share with (sent1...).</param>
539+
/// <param name="bytes">Bytes of bandwidth to grant the user.</param>
540+
/// <param name="denom">Payment denomination (default: "udvpn").</param>
541+
/// <param name="grantFee">If true, also grants a fee allowance to the user.</param>
542+
/// <param name="feeSpendLimit">Max fee allowance in udvpn (default: 5,000,000 = 5 P2P, enough for ~125 sessions).</param>
543+
/// <param name="feeExpiration">Fee grant expiration (default: null = no expiry).</param>
544+
/// <returns>Onboard result with subscription ID and all TX hashes.</returns>
545+
public async Task<OnboardResult> OnboardPlanUserAsync(
546+
ulong planId,
547+
string userAddress,
548+
long bytes,
549+
string denom = "udvpn",
550+
bool grantFee = false,
551+
long feeSpendLimit = 5_000_000,
552+
DateTime? feeExpiration = null)
553+
{
554+
ArgumentException.ThrowIfNullOrWhiteSpace(userAddress);
555+
if (planId == 0)
556+
throw new ArgumentOutOfRangeException(nameof(planId), "Must be > 0");
557+
if (bytes <= 0)
558+
throw new ArgumentOutOfRangeException(nameof(bytes), "Must be > 0");
559+
560+
// Step 1: Subscribe to plan
561+
var subMsg = MessageBuilder.StartSubscription(_wallet.Address, planId, denom);
562+
var subResult = await BroadcastAsync(subMsg);
563+
564+
if (!subResult.Success)
565+
throw new SentinelException("SUBSCRIBE_FAILED",
566+
$"Subscribe to plan {planId} failed: {subResult.RawLog}");
567+
568+
// Step 2: Query to find the subscription ID
569+
await Task.Delay(5000); // Wait for chain state to propagate
570+
var subs = await _client.GetSubscriptionsAsync(_wallet.Address);
571+
var match = subs
572+
.Where(s => s.PlanId == planId.ToString() &&
573+
s.Status.Contains("ACTIVE", StringComparison.OrdinalIgnoreCase))
574+
.OrderByDescending(s => s.Id)
575+
.FirstOrDefault();
576+
577+
var subscriptionId = match?.Id
578+
?? throw new SentinelException("SUBSCRIPTION_NOT_FOUND",
579+
$"Subscription for plan {planId} not found after successful TX");
580+
581+
// Step 3: Share subscription with user (bytes-based)
582+
if (!ulong.TryParse(subscriptionId, out var subId))
583+
throw new SentinelException(ErrorCodes.SubscriptionNotFound,
584+
$"Invalid subscription ID format: {subscriptionId}");
585+
var shareMsg = MessageBuilder.ShareSubscription(
586+
_wallet.Address, subId, userAddress, bytes);
587+
var shareResult = await BroadcastAsync(shareMsg);
588+
589+
if (!shareResult.Success)
590+
throw new SentinelException("SHARE_FAILED",
591+
$"Share subscription {subscriptionId} failed: {shareResult.RawLog}");
592+
593+
// Step 4: Optional fee grant
594+
string? grantTxHash = null;
595+
if (grantFee)
596+
{
597+
var grantMsg = MessageBuilder.GrantFeeAllowance(
598+
_wallet.Address, userAddress, feeSpendLimit, feeExpiration);
599+
var grantResult = await BroadcastAsync(grantMsg);
600+
grantTxHash = grantResult.TxHash;
601+
}
602+
603+
return new OnboardResult(subscriptionId, subResult.TxHash, shareResult.TxHash, grantTxHash);
604+
}
605+
505606
// ─── Gas Estimation ───
506607

507608
/// <summary>

0 commit comments

Comments
 (0)