From 18812b39847217106dc0b7cd7ed750be727b642b Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Mon, 3 Aug 2026 17:27:29 -0700 Subject: [PATCH 1/7] feat!: better alignment --- .../Interop/WasmRuntime.Surface.cs | 71 +- .../Services/Node/ChangeListenerOptions.cs | 16 + .../Services/Node/KeetaClient.cs | 801 +++++++++++++----- .../Services/Node/KeetaNetwork.cs | 103 ++- .../Services/Node/NodeModels.cs | 104 ++- .../Services/Node/TransmitOptions.cs | 10 +- .../Services/Node/UserClient.cs | 550 ++++++++++-- .../KeetaNet.Anchor.E2eTests/KycFlowTests.cs | 14 +- .../KeetaNet.Anchor.E2eTests/NodeFlowTests.cs | 177 ++-- .../KeetaNet.Anchor.E2eTests/TestnetTests.cs | 60 ++ tests/KeetaNet.Anchor.Tests/BlockTests.cs | 32 +- tests/KeetaNet.Anchor.Tests/NetworkTests.cs | 32 +- 12 files changed, 1515 insertions(+), 455 deletions(-) create mode 100644 src/KeetaNet.Anchor/Services/Node/ChangeListenerOptions.cs create mode 100644 tests/KeetaNet.Anchor.E2eTests/TestnetTests.cs diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs index 9bbb8ce..97ec5a8 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs @@ -3,16 +3,19 @@ namespace KeetaNet.Anchor; /// -/// The runtime's public creation surface: domain factories for handle-backed -/// crypto objects, and creation methods for the networked clients. Everything -/// created here is owned by this runtime and must be disposed before it. +/// The runtime's public creation surface. /// +/// +/// The surface exposes domain factories for handle-backed crypto objects and +/// creation methods for the networked clients. This runtime owns everything +/// created here, and callers must dispose those objects before the runtime. +/// public sealed partial class WasmRuntime { - /// Creates accounts: signers from key material, read-only accounts from addresses or public keys. + /// Creates signers from key material and read-only accounts from addresses or public keys. public AccountFactory Accounts { get; } - /// Parses base X.509 certificates: provider CAs, trust roots, intermediates. + /// Parses base X.509 certificates, such as provider CAs, trust roots, and intermediates. public CertificateFactory Certificates { get; } /// Parses and issues KYC leaf certificates. @@ -28,47 +31,60 @@ public sealed partial class WasmRuntime public BlockFactory Blocks { get; } /// - /// Create a KYC anchor client signed by , resolving - /// providers from 's on-chain service metadata read via - /// the node API at . + /// Creates a KYC anchor client signed by . /// + /// + /// The client resolves providers from the on-chain service metadata of + /// through the node API at . + /// public KycClient CreateKycClient(string nodeUrl, string root, Account account) => KycClient.WithAccount(this, nodeUrl, root, account); /// - /// Create an asset-movement anchor client signed by , - /// resolving providers from 's on-chain service metadata - /// read via the node API at . + /// Creates an asset-movement anchor client signed by . /// + /// + /// The client resolves providers from the on-chain service metadata of + /// through the node API at . + /// public AssetMovementClient CreateAssetMovementClient(string nodeUrl, string root, Account account) => AssetMovementClient.WithAccount(this, nodeUrl, root, account); /// - /// Create the base client for the node API at . + /// Creates the base client for the node API at . + /// + /// /// An injected (for example from - /// IHttpClientFactory) is borrowed, not disposed. Absent one the - /// client owns its own. Binding enables the - /// write path ( + /// IHttpClientFactory) is borrowed and never disposed. Without one + /// the client owns its own. Binding enables + /// the write path + /// ( /// and fee blocks). A client without one stays read-only. - /// + /// public KeetaClient CreateKeetaClient(string nodeUrl, HttpClient? httpClient = null, long? network = null) => new(this, nodeUrl, httpClient, network); /// - /// Create the base client for a well-known , - /// the reference fromNetwork: its first representative's endpoint - /// and its network id, so the write path is enabled. + /// Creates the base client for a well-known . /// + /// + /// The client binds the network's default representative set and its + /// network id, so the write path is enabled. Votes fan out to every + /// representative. Reads go to the representative with the highest weight. + /// public KeetaClient CreateKeetaClient(KeetaNetwork network, HttpClient? httpClient = null) => - new(this, network.RepresentativeApiUrl(), httpClient, network.Id()); + new(this, network.Representatives(), httpClient, network.Id()); /// - /// Create a client bound to (null for a - /// read-only client), operating as when given - /// and as the signer itself otherwise. Both accounts are borrowed, not + /// Creates a client bound to , or a read-only + /// client when the signer is null. + /// + /// + /// The client operates as when given, or as + /// the signer itself otherwise. Both accounts are borrowed and never /// disposed. See /// for the remaining parameters. - /// + /// public UserClient CreateUserClient( string nodeUrl, Account? signer, @@ -78,14 +94,13 @@ public UserClient CreateUserClient( new(this, nodeUrl, httpClient, network, signer, account); /// - /// Create a signer-bound client for a well-known - /// , the reference UserClient.fromNetwork. - /// See the URL overload for the remaining parameters. + /// Creates a signer-bound client for a well-known . /// + /// See the URL overload for the remaining parameters. public UserClient CreateUserClient( KeetaNetwork network, Account? signer, HttpClient? httpClient = null, Account? account = null) => - new(this, network.RepresentativeApiUrl(), httpClient, network.Id(), signer, account); + new(this, CreateKeetaClient(network, httpClient), signer, account); } diff --git a/src/KeetaNet.Anchor/Services/Node/ChangeListenerOptions.cs b/src/KeetaNet.Anchor/Services/Node/ChangeListenerOptions.cs new file mode 100644 index 0000000..4b27f26 --- /dev/null +++ b/src/KeetaNet.Anchor/Services/Node/ChangeListenerOptions.cs @@ -0,0 +1,16 @@ +namespace KeetaNet.Anchor; + +/// +/// Tuning for . +/// +/// +/// The WebSocket path reacts to a change immediately. The fallback poll +/// finds the updates that the socket missed. +/// +public sealed class ChangeListenerOptions +{ + /// + /// How often the fallback poll re-reads the account. The default is one minute. + /// + public TimeSpan FallbackFrequency { get; set; } = TimeSpan.FromMinutes(1); +} diff --git a/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs b/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs index bb2dc6d..54c0da4 100644 --- a/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs @@ -14,34 +14,93 @@ namespace KeetaNet.Anchor; /// -/// The base client for the KeetaNet node API: ledger reads and the two-round -/// transmit flow, over the transport generated from the canonical OpenAPI -/// spec. Account-bound conveniences live on . +/// The base client for the KeetaNet node API. /// +/// +/// The client serves ledger reads and the two-round transmit flow over the +/// transport generated from the canonical OpenAPI spec. Account-bound +/// conveniences live on . This type ports the +/// reference multi-representative Client. +/// public sealed class KeetaClient : IDisposable { - /// The block version the reference clients build. + /// The block version that the reference clients build. internal const int BlockVersion = 2; + /// The maximum age of a representative-weight snapshot before a refresh. + private static readonly TimeSpan RepresentativeRefreshInterval = TimeSpan.FromMinutes(5); + private readonly WasmRuntime _runtime; /// The client-owned transport. Null when an injected one is borrowed. private readonly HttpClient? _ownedHttp; - private readonly NodeApi _api; + /// The transport shared by every representative's generated API. + private readonly HttpClient _representativeHttp; + + /// The representative set in registry order. Each entry holds its own transport. + private readonly List _representatives; + + /// Guards . A transmit can run concurrently with a refresh. + private readonly object _representativesLock = new(); + + /// The time of the last representative-weight refresh. Null until the first refresh. + private DateTimeOffset? _representativesRefreshedAt; + + /// The weight refresh started at construction. Voting rounds await it first. + private readonly Task? _initialRefresh; private readonly long? _network; - /// The network's base token; derived only when a network is bound. + /// The base token of the bound network. Null when no network is bound. private readonly Crypto.Account? _baseToken; + /// One representative with its registry entry, its transport, and its last-seen voting weight. + private sealed class Representative + { + public Representative(RepresentativeEndpoint endpoint, NodeApi api) + { + Endpoint = endpoint; + Api = api; + } + + public RepresentativeEndpoint Endpoint { get; } + + public NodeApi Api { get; } + + public BigInteger? Weight { get; set; } + } + + /// A vote paired with the representative that issued it. + private readonly record struct RepresentativeVote(Representative Issuer, string VoteBase64); + /// - /// A client for the node API at . An injected - /// (for example from IHttpClientFactory) is - /// borrowed, not disposed. A bound enables the - /// write path; without one the client stays read-only. + /// Creates a client for the node API at . /// + /// + /// The client treats the node as a single-representative network. An + /// injected (for example from + /// IHttpClientFactory) is borrowed and never disposed. A bound + /// enables the write path. Without one the + /// client stays read-only. + /// internal KeetaClient(WasmRuntime runtime, string nodeUrl, HttpClient? http = null, long? network = null) + : this(runtime, new[] { new RepresentativeEndpoint(null, nodeUrl, null) }, http, network) + { + } + + /// + /// Creates a client over . + /// + /// + /// Votes fan out to every representative. Reads go to the representative + /// with the highest known weight. + /// + internal KeetaClient( + WasmRuntime runtime, + IReadOnlyList representatives, + HttpClient? http = null, + long? network = null) { _runtime = runtime; _network = network; @@ -56,47 +115,106 @@ internal KeetaClient(WasmRuntime runtime, string nodeUrl, HttpClient? http = nul http = _ownedHttp; } - _api = new NodeApi(http) { BaseUrl = nodeUrl }; + _representativeHttp = http; + _representatives = representatives + .Select(endpoint => new Representative(endpoint, new NodeApi(http) { BaseUrl = endpoint.ApiUrl })) + .ToList(); + + // The reference client refreshes weights at construction so the first + // transmit already orders by weight. A lone representative needs no + // ordering. + if (_representatives.Count > 1) + { + _initialRefresh = InitialRefresh(); + } + } + + /// + /// Runs the construction-time weight refresh. + /// + /// + /// The refresh is best-effort, as in the reference client. A failure + /// leaves the registry order in place, and the next voting round retries. + /// + private async Task InitialRefresh() + { + try + { + await UpdateReps(addNewRepresentatives: false, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception) + { + // Stale weights only affect ordering. The client stays usable. + } + } + + /// Returns a point-in-time copy of the representative set that is safe to enumerate. + private Representative[] SnapshotRepresentatives() + { + lock (_representativesLock) + { + return _representatives.ToArray(); + } } + /// + /// The representative for single-target requests. This is the + /// representative with the highest known weight, or the first one before + /// any refresh. + /// + private Representative Primary => + SnapshotRepresentatives().OrderByDescending(rep => rep.Weight ?? BigInteger.MinusOne).First(); + + /// The transport of the primary representative. Every single-target read uses it. + private NodeApi Api => Primary.Api; + + /// The advertised P2P endpoint of the primary representative, if any. + internal string? PrimaryP2pUrl => Primary.Endpoint.P2pUrl; + /// The bound network id, or null for a read-only client. public long? Network => _network; /// - /// The bound network's base token (the implicit fee currency), or null for - /// a read-only client. Owned by this client; do not dispose it. + /// The base token of the bound network, or null for a read-only client. /// + /// + /// The base token is the implicit fee currency. The client owns the + /// account. Do not dispose it. + /// public Crypto.Account? BaseToken => _baseToken; - /// The node software version string. - public async Task GetNodeVersion(CancellationToken cancellationToken = default) + /// Gets the node software version string. + public async Task GetVersion(CancellationToken cancellationToken = default) { - GetNodeVersionResponse response = await Attempt(() => _api.GetNodeVersionAsync(cancellationToken)).ConfigureAwait(false); + GetNodeVersionResponse response = await Attempt(() => Api.GetNodeVersionAsync(cancellationToken)).ConfigureAwait(false); return response.Node ?? ""; } /// - /// The ledger state of : head block, delegated - /// representative, published info, and token balances. + /// Gets the ledger state of . /// - public async Task GetAccountState( + /// + /// The head block, the delegated representative, the published info, and + /// the token balances. + /// + public async Task GetAccountInfo( Crypto.Account account, CancellationToken cancellationToken = default) { - GetAccountStateResponse state = await Attempt(() => _api.GetAccountStateAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + GetAccountStateResponse state = await Attempt(() => Api.GetAccountStateAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); return DecodeState(state.CurrentHeadBlock, state.CurrentHeadBlockHeight, state.Representative, state.Info, state.Balances); } /// - /// The ledger state of several in one call, - /// one entry per account in request order. + /// Gets the ledger state of several in one call. /// - public async Task> GetAccountStates( + /// One entry per account in request order. + public async Task> GetAccountsInfo( IReadOnlyList accounts, CancellationToken cancellationToken = default) { string joined = string.Join(",", accounts.Select(account => account.PublicKeyString)); - ICollection states = await Attempt(() => _api.GetAccountStatesAsync(joined, cancellationToken)).ConfigureAwait(false); + ICollection states = await Attempt(() => Api.GetAccountStatesAsync(joined, cancellationToken)).ConfigureAwait(false); return states .Select(item => DecodeState(item.CurrentHeadBlock, item.CurrentHeadBlockHeight, item.Representative, item.Info, item.Balances)) @@ -104,21 +222,21 @@ public async Task> GetAccountStates( } /// - /// The total supply of , read from its account - /// state. Null for an account that is not a token. + /// Gets the total supply of from its account state. /// + /// The supply, or null for an account that is not a token. public async Task GetTokenSupply( Crypto.Account token, CancellationToken cancellationToken = default) { - AccountState state = await GetAccountState(token, cancellationToken).ConfigureAwait(false); + AccountState state = await GetAccountInfo(token, cancellationToken).ConfigureAwait(false); return state.Info?.Supply; } - /// The point-in-time XOR checksum of the node's ledger. + /// Gets the point-in-time XOR checksum of the node's ledger. public async Task GetLedgerChecksum(CancellationToken cancellationToken = default) { - GetLedgerChecksumResponse checksum = await Attempt(() => _api.GetLedgerChecksumAsync(cancellationToken)).ConfigureAwait(false); + GetLedgerChecksumResponse checksum = await Attempt(() => Api.GetLedgerChecksumAsync(cancellationToken)).ConfigureAwait(false); DateTimeOffset? moment = null; if (!string.IsNullOrEmpty(checksum.Moment)) @@ -132,83 +250,88 @@ public async Task GetLedgerChecksum(CancellationToken cancellati checksum.MomentRange); } - /// The node's own representative. - public async Task GetNodeRepresentative(CancellationToken cancellationToken = default) - { - GeneratedRepresentative representative = await Attempt(() => _api.GetNodeRepresentativeAsync(cancellationToken)).ConfigureAwait(false); - return DecodeRepresentative(representative); - } - - /// The named and its voting weight. - public async Task GetRepresentative( - Crypto.Account representative, + /// + /// Gets the named and its voting weight. + /// + /// + /// When is omitted, the method returns + /// the contacted node's own representative. + /// + public async Task GetRepresentativeInfo( + Crypto.Account? representative = null, CancellationToken cancellationToken = default) { - GeneratedRepresentative named = await Attempt(() => _api.GetRepresentativeAsync(representative.PublicKeyString, cancellationToken)).ConfigureAwait(false); + if (representative is null) + { + GeneratedRepresentative own = await Attempt(() => Api.GetNodeRepresentativeAsync(cancellationToken)).ConfigureAwait(false); + return DecodeRepresentative(own); + } + + GeneratedRepresentative named = await Attempt(() => Api.GetRepresentativeAsync(representative.PublicKeyString, cancellationToken)).ConfigureAwait(false); return DecodeRepresentative(named); } - /// Every representative the node knows, with advertised endpoints. - public async Task> GetAllRepresentatives(CancellationToken cancellationToken = default) + /// Gets every representative that the node knows, with the advertised endpoints. + public async Task> GetAllRepresentativeInfo(CancellationToken cancellationToken = default) { - GetAllRepresentativesResponse response = await Attempt(() => _api.GetAllRepresentativesAsync(cancellationToken)).ConfigureAwait(false); + GetAllRepresentativesResponse response = await Attempt(() => Api.GetAllRepresentativesAsync(cancellationToken)).ConfigureAwait(false); ICollection representatives = response.Representatives ?? Array.Empty(); return representatives.Select(DecodeRepresentative).ToArray(); } - /// Node statistics, as the opaque JSON the reference reports. + /// Gets the node statistics as opaque JSON. public async Task GetNodeStats(CancellationToken cancellationToken = default) { - object stats = await Attempt(() => _api.GetNodeStatsAsync(cancellationToken)).ConfigureAwait(false); + object stats = await Attempt(() => Api.GetNodeStatsAsync(cancellationToken)).ConfigureAwait(false); return (JsonElement)stats; } - /// Connected peers, as the opaque JSON the reference reports. - public async Task GetNodePeers(CancellationToken cancellationToken = default) + /// Gets the connected peers as opaque JSON. + public async Task GetPeers(CancellationToken cancellationToken = default) { - object peers = await Attempt(() => _api.GetPeersAsync(cancellationToken)).ConfigureAwait(false); + object peers = await Attempt(() => Api.GetPeersAsync(cancellationToken)).ConfigureAwait(false); return (JsonElement)peers; } - /// Every token balance holds. - public async Task> GetAccountBalances( + /// Gets every token balance that holds. + public async Task> GetAllBalances( Crypto.Account account, CancellationToken cancellationToken = default) { - GetAccountBalancesResponse response = await Attempt(() => _api.GetAccountBalancesAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + GetAccountBalancesResponse response = await Attempt(() => Api.GetAccountBalancesAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); return DecodeBalances(response.Balances); } - /// The settled balance of in base units. - public async Task GetAccountBalance( + /// Gets the settled balance of in base units of . + public async Task GetBalance( Crypto.Account account, Crypto.Account token, CancellationToken cancellationToken = default) { - GetAccountBalanceResponse response = await Attempt(() => _api.GetAccountBalanceAsync(account.PublicKeyString, token.PublicKeyString, cancellationToken)).ConfigureAwait(false); + GetAccountBalanceResponse response = await Attempt(() => Api.GetAccountBalanceAsync(account.PublicKeyString, token.PublicKeyString, cancellationToken)).ConfigureAwait(false); return OptionalHexAmount(response.Balance) ?? BigInteger.Zero; } - /// The head block of 's chain, or null for a never-used account. + /// Gets the head block of the chain of , or null for a never-used account. public async Task GetHeadBlock( Crypto.Account account, CancellationToken cancellationToken = default) { - GetAccountHeadResponse response = await Attempt(() => _api.GetAccountHeadAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + GetAccountHeadResponse response = await Attempt(() => Api.GetAccountHeadAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); return DecodeBlock(response.Block); } - /// The next pending (unreceived) block for , if any. + /// Gets the next pending (unreceived) block for , if any. public async Task GetPendingBlock( Crypto.Account account, CancellationToken cancellationToken = default) { - GetPendingBlockResponse response = await Attempt(() => _api.GetPendingBlockAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + GetPendingBlockResponse response = await Attempt(() => Api.GetPendingBlockAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); return DecodeBlock(response.Block); } - /// The block identified by on the given , if present. + /// Gets the block identified by on the given , if present. public async Task GetBlock( Crypto.BlockHash blockHash, LedgerSide? side = null, @@ -221,24 +344,27 @@ public async Task GetAccountBalance( _ => null, }; - GetBlockResponse response = await Attempt(() => _api.GetBlockAsync(blockHash.ToString(), generated, cancellationToken)).ConfigureAwait(false); + GetBlockResponse response = await Attempt(() => Api.GetBlockAsync(blockHash.ToString(), generated, cancellationToken)).ConfigureAwait(false); return DecodeBlock(response.Block); } - /// The block following , if one exists. + /// Gets the block that follows , if one exists. public async Task GetSuccessorBlock( Crypto.BlockHash blockHash, CancellationToken cancellationToken = default) { - GetSuccessorBlockResponse response = await Attempt(() => _api.GetSuccessorBlockAsync(blockHash.ToString(), cancellationToken)).ConfigureAwait(false); + GetSuccessorBlockResponse response = await Attempt(() => Api.GetSuccessorBlockAsync(blockHash.ToString(), cancellationToken)).ConfigureAwait(false); return DecodeBlock(response.SuccessorBlock); } /// - /// The block produced by for the idempotent - /// , if any, searching the given - /// (the main ledger when omitted). + /// Gets the block that produced for the + /// idempotent , if any. /// + /// + /// The search covers the given , or the main + /// ledger when omitted. + /// public async Task GetBlockFromIdempotent( Crypto.Account account, string key, @@ -252,22 +378,23 @@ public async Task GetAccountBalance( _ => null, }; - GetBlockFromIdempotentResponse response = await Attempt(() => _api.GetBlockFromIdempotentAsync(account.PublicKeyString, key, generated, cancellationToken)).ConfigureAwait(false); + GetBlockFromIdempotentResponse response = await Attempt(() => Api.GetBlockFromIdempotentAsync(account.PublicKeyString, key, generated, cancellationToken)).ConfigureAwait(false); return DecodeBlock(response.Block); } /// - /// The verified votes the node holds for on - /// , or null when it holds none. The caller owns - /// the votes and must dispose them. + /// Gets the verified votes that the node holds for + /// on . /// + /// The votes, or null when the node holds none. + /// The caller owns the votes and must dispose them. public async Task?> GetBlockVotes( Crypto.BlockHash blockHash, LedgerSide side = LedgerSide.Main, CancellationToken cancellationToken = default) { Side generated = side == LedgerSide.Side ? Side.Side : Side.Main; - GetBlockVotesResponse response = await Attempt(() => _api.GetBlockVotesAsync(blockHash.ToString(), generated, cancellationToken)).ConfigureAwait(false); + GetBlockVotesResponse response = await Attempt(() => Api.GetBlockVotesAsync(blockHash.ToString(), generated, cancellationToken)).ConfigureAwait(false); if (response.Votes is null) { return null; @@ -295,17 +422,18 @@ public async Task GetAccountBalance( } /// - /// A single page of 's block chain (most recent - /// first), bounded by , with the cursor for the - /// next page. The caller owns the blocks and must dispose them. + /// Gets one page of the block chain of , most + /// recent first, bounded by . /// - public async Task GetAccountChain( + /// The page and the cursor for the next page. + /// The caller owns the blocks and must dispose them. + public async Task GetChain( Crypto.Account account, ChainQuery? query = null, CancellationToken cancellationToken = default) { ChainQuery bounds = query ?? new ChainQuery(); - GetAccountChainResponse response = await Attempt(() => _api.GetAccountChainAsync( + GetAccountChainResponse response = await Attempt(() => Api.GetAccountChainAsync( account.PublicKeyString, bounds.Start?.ToString(), bounds.End?.ToString(), @@ -338,16 +466,17 @@ public async Task GetAccountChain( } /// - /// A single page of 's committed staple history, - /// bounded by , with the cursor for the next page. + /// Gets one page of the committed staple history of + /// , bounded by . /// - public async Task GetAccountHistory( + /// The page and the cursor for the next page. + public async Task GetHistory( Crypto.Account account, HistoryQuery? query = null, CancellationToken cancellationToken = default) { HistoryQuery bounds = query ?? new HistoryQuery(); - GetAccountHistoryResponse response = await Attempt(() => _api.GetAccountHistoryAsync( + GetAccountHistoryResponse response = await Attempt(() => Api.GetAccountHistoryAsync( account.PublicKeyString, bounds.Start?.ToString(), bounds.Limit, @@ -357,15 +486,16 @@ public async Task GetAccountHistory( } /// - /// A single page of the node's global staple history, bounded by - /// , with the cursor for the next page. + /// Gets one page of the node's global staple history, bounded by + /// . /// + /// The page and the cursor for the next page. public async Task GetGlobalHistory( HistoryQuery? query = null, CancellationToken cancellationToken = default) { HistoryQuery bounds = query ?? new HistoryQuery(); - GetGlobalHistoryResponse response = await Attempt(() => _api.GetGlobalHistoryAsync( + GetGlobalHistoryResponse response = await Attempt(() => Api.GetGlobalHistoryAsync( bounds.Start?.ToString(), bounds.Limit, cancellationToken)).ConfigureAwait(false); @@ -374,36 +504,38 @@ public async Task GetGlobalHistory( } /// - /// ACL entries where is the principal. The - /// caller owns the returned accounts and permission sets. + /// Lists the ACL entries where is the principal. /// - public async Task> GetAclsByPrincipal( + /// The caller owns the returned accounts and permission sets. + public async Task> ListAclsByPrincipal( Crypto.Account account, CancellationToken cancellationToken = default) { - ListAclsByPrincipalResponse response = await Attempt(() => _api.ListAclsByPrincipalAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + ListAclsByPrincipalResponse response = await Attempt(() => Api.ListAclsByPrincipalAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); return DecodeAcls(response.Permissions); } /// - /// ACL entries granted to as an entity. The - /// caller owns the returned accounts and permission sets. + /// Lists the ACL entries granted to as an entity. /// - public async Task> GetAclsByEntity( + /// The caller owns the returned accounts and permission sets. + public async Task> ListAclsByEntity( Crypto.Account account, CancellationToken cancellationToken = default) { - ListAclsByEntityResponse response = await Attempt(() => _api.ListAclsByEntityAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + ListAclsByEntityResponse response = await Attempt(() => Api.ListAclsByEntityAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); return DecodeAcls(response.Permissions); } /// - /// A builder pre-set with the reference block version, the bound network, - /// as originator, - /// (the account itself when null) signing, and the current moment. The - /// caller positions it, appends operations, and builds. Requires a bound - /// network. + /// Creates a builder pre-set with the block version, the bound network, + /// as the originator, and the current moment. /// + /// + /// signs, or the account itself when null. The + /// caller positions the builder, appends operations, and builds. The + /// method requires a bound network. + /// internal Crypto.BlockBuilder InitBuilder(Crypto.Account account, Crypto.Account? signer = null) { (long network, _) = RequireNetwork(); @@ -416,7 +548,7 @@ internal Crypto.BlockBuilder InitBuilder(Crypto.Account account, Crypto.Account? .WithDate(DateTimeOffset.UtcNow); } - /// Publish one signed block as its own staple. See the list overload. + /// Publishes one signed block as its own staple. See the list overload. public Task Transmit( Crypto.Block block, TransmitOptions? options = null, @@ -424,29 +556,32 @@ public Task Transmit( Transmit(new[] { block }, options, cancellationToken); /// - /// Publish as one atomic staple, the port of the - /// reference two-round transmit. When the temporary round's votes require - /// a fee, the factory in is invoked with that - /// round and its block joins the permanent round and the staple. - /// Requires a bound network. + /// Publishes as one atomic staple through the + /// two-round vote flow. /// + /// + /// The temporary round fans out to every representative. When its votes + /// require a fee, the factory in receives that + /// round and its block joins the permanent round and the staple. The + /// permanent round contacts only the representatives that issued a + /// temporary vote. The staple carries every permanent vote. + /// public async Task Transmit( IReadOnlyList blocks, TransmitOptions? options = null, CancellationToken cancellationToken = default) { - // The whole write path is gated, not just fee construction, so an - // unbound client keeps its documented read-only guarantee. - _ = RequireNetwork(); - TransmitOptions resolved = options ?? new TransmitOptions(); List encoded = blocks.Select(EncodeBlock).ToList(); - string temporary = await RequestVote(encoded, priorVote: null, resolved.Quote, cancellationToken).ConfigureAwait(false); + + await RefreshRepresentatives(cancellationToken).ConfigureAwait(false); + IReadOnlyList temporary = + await RequestVotes(encoded, priorVotes: null, resolved.Quotes.ToArray(), cancellationToken).ConfigureAwait(false); Crypto.Block? feeBlock = null; try { - if (VoteRequiresFee(temporary)) + if (VotesRequireFee(temporary)) { feeBlock = await FeeBlockFor(blocks, temporary, resolved, cancellationToken).ConfigureAwait(false); } @@ -461,7 +596,8 @@ public async Task Transmit( encoded.Add(EncodeBlock(feeBlock)); } - string permanent = await RequestVote(encoded, temporary, quote: null, cancellationToken).ConfigureAwait(false); + IReadOnlyList permanent = + await RequestVotes(encoded, temporary, quotes: null, cancellationToken).ConfigureAwait(false); return await PublishStaple(all, permanent, cancellationToken).ConfigureAwait(false); } finally @@ -471,12 +607,94 @@ public async Task Transmit( } /// - /// Build and sign the fee block 's votes require: - /// 's balance pays, - /// signs (distinct under delegated signing). Chains atop the account's - /// block in the staple, else its ledger head, so the payer need not appear - /// in the round. Null when no fee is owed. Requires a bound network. + /// Refreshes the voting weights of the known representatives from the ledger. + /// + /// + /// Unknown representatives join the set when + /// is set. Reads and votes + /// prefer the representatives with higher weights afterward. + /// + [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", + Justification = "GetAllRepresentativeInfo transfers ownership of the returned accounts to the caller; this method is that caller.")] + public async Task UpdateReps(bool addNewRepresentatives = false, CancellationToken cancellationToken = default) + { + IReadOnlyList known = await GetAllRepresentativeInfo(cancellationToken).ConfigureAwait(false); + foreach (NodeRepresentative info in known) + { + using (info.Account) + { + string key = info.Account.PublicKeyString; + lock (_representativesLock) + { + // A URL-only client has no key for its representative, so + // the advertised API URL identifies it instead. + Representative? match = _representatives.Find(rep => + rep.Endpoint.Key == key + || (rep.Endpoint.Key is null && rep.Endpoint.ApiUrl == info.ApiUrl)); + if (match is not null) + { + match.Weight = info.Weight; + continue; + } + + // Two entries with one URL would double-contact the same + // node, so an already-known endpoint never joins again. + bool knownUrl = _representatives.Exists(rep => rep.Endpoint.ApiUrl == info.ApiUrl); + if (addNewRepresentatives && !knownUrl && !string.IsNullOrEmpty(info.ApiUrl)) + { + var endpoint = new RepresentativeEndpoint(key, info.ApiUrl, null); + _representatives.Add(new Representative(endpoint, new NodeApi(_representativeHttp) { BaseUrl = info.ApiUrl }) + { + Weight = info.Weight, + }); + } + } + } + } + + _representativesRefreshedAt = DateTimeOffset.UtcNow; + } + + /// + /// Ensures that the representative weights are fresh before a voting round. + /// + /// A failed refresh leaves the previous snapshot in place. + private async Task RefreshRepresentatives(CancellationToken cancellationToken) + { + if (_initialRefresh is { } initial) + { + await initial.ConfigureAwait(false); + } + + bool fresh = _representativesRefreshedAt is { } at + && DateTimeOffset.UtcNow - at < RepresentativeRefreshInterval; + if (fresh || SnapshotRepresentatives().Length == 1) + { + return; + } + + try + { + await UpdateReps(addNewRepresentatives: false, cancellationToken).ConfigureAwait(false); + } + catch (KeetaException) + { + // Stale weights only affect ordering. Voting proceeds regardless. + } + } + + /// + /// Builds and signs the fee block that the votes in + /// require. /// + /// The signed fee block, or null when no fee is owed. + /// + /// The balance of pays the fee, and + /// signs. The two differ under delegated + /// signing. The block chains atop the account's block in the staple, or + /// atop its ledger head, so the payer need not appear in the round. The + /// method requires a bound network. + /// public async Task BuildFeeBlock( Crypto.VoteStaple staple, Crypto.Account account, @@ -505,7 +723,7 @@ public async Task Transmit( string? previous = _runtime.StapleTipFor(staple.Handle, account.Handle); if (previous is null) { - AccountState state = await GetAccountState(account, cancellationToken).ConfigureAwait(false); + AccountState state = await GetAccountInfo(account, cancellationToken).ConfigureAwait(false); previous = state.HeadBlock?.ToString(); } @@ -536,19 +754,19 @@ public async Task Transmit( } /// - /// Every certificate has published on-chain, each - /// with the intermediates recorded alongside it. An account with no published - /// certificates yields an empty list. + /// Gets every certificate that has published + /// on-chain, each with its recorded intermediates. /// + /// An empty list for an account with no published certificates. public async Task> GetAllCertificates( Crypto.Account account, CancellationToken cancellationToken = default) { - GetAccountCertificatesResponse response = await Attempt(() => _api.GetAccountCertificatesAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + GetAccountCertificatesResponse response = await Attempt(() => Api.GetAccountCertificatesAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); ICollection records = response.Certificates ?? Array.Empty(); // A record with no certificate body is the node's "not found" shape. - // Drop it rather than surface an empty entry, as the reference does. + // Drop it rather than surface an empty entry. return records .Where(record => record.Certificate1 is not null) .Select(DecodeCertificate) @@ -556,16 +774,17 @@ public async Task> GetAllCertificates( } /// - /// The certificate published under - /// (its ), - /// with its recorded intermediates. Null when the account never published it. + /// Gets the certificate that published under + /// (its + /// ), with its recorded intermediates. /// + /// The record, or null when the account never published it. public async Task GetCertificateByHash( Crypto.Account account, Crypto.CertificateHash certificateHash, CancellationToken cancellationToken = default) { - GetCertificateByHashResponse record = await Attempt(() => _api.GetCertificateByHashAsync(account.PublicKeyString, certificateHash.ToString(), cancellationToken)).ConfigureAwait(false); + GetCertificateByHashResponse record = await Attempt(() => Api.GetCertificateByHashAsync(account.PublicKeyString, certificateHash.ToString(), cancellationToken)).ConfigureAwait(false); if (record.Certificate1 is null) { return null; @@ -575,12 +794,14 @@ public async Task> GetAllCertificates( } /// - /// Read 's published certificates and evaluate - /// them against at , - /// the port of the reference verifyAccountCertificateChain. The - /// issuers are the only trust anchors. A record's own intermediates just - /// help bridge the chain. + /// Reads the published certificates of and + /// evaluates them against at + /// . /// + /// + /// The issuers are the only trust anchors. A record's own intermediates + /// only help complete the chain. + /// public async Task VerifyAccountCertificateChain( Crypto.Account account, IReadOnlyList trustedIssuers, @@ -592,13 +813,15 @@ public async Task VerifyAccountCertificateChain( } /// - /// Evaluate already-fetched against + /// Evaluates already-fetched against /// at . - /// A record that does not parse is skipped, never trusted. Skipped records - /// still count as published, so an account whose every record is malformed - /// reports , not - /// . /// + /// + /// A record that does not parse is skipped and never trusted. Skipped + /// records still count as published, so an account whose every record is + /// malformed reports , not + /// . + /// public CertificateChainStatus EvaluateCertificateChain( IReadOnlyList records, IReadOnlyList trustedIssuers, @@ -618,8 +841,8 @@ public CertificateChainStatus EvaluateCertificateChain( } /// - /// Release the resources the client owns: its base token account and, when - /// not injected, its . + /// Releases the base token account and, when not injected, the owned + /// . /// public void Dispose() { @@ -627,7 +850,7 @@ public void Dispose() _ownedHttp?.Dispose(); } - /// The bound network and its base token, required by the write path. + /// Returns the bound network and its base token, which the write path requires. private (long Network, Crypto.Account BaseToken) RequireNetwork() { if (_network is not { } network || _baseToken is null) @@ -638,46 +861,160 @@ public void Dispose() return (network, _baseToken); } - /// A block's transport bytes in the base64 form the vote endpoint carries. + /// Returns the block's transport bytes in the base64 form that the vote endpoint carries. private static string EncodeBlock(Crypto.Block block) => Convert.ToBase64String(block.ToBytes()); /// - /// Request a non-binding vote quote for , locking - /// in the fee the node would charge. Attach it to a transmit through - /// . + /// Requests non-binding vote quotes for from + /// every representative. /// - public async Task GetVoteQuote( + /// One quote per representative that answered. + /// + /// Each quote locks in the fee that its issuer would charge. Attach the + /// quotes to a transmit through . + /// Individual failures are tolerated. When no representative answers, + /// the failure of the highest-weight one surfaces. + /// + public async Task> GetVoteQuotes( IReadOnlyList blocks, CancellationToken cancellationToken = default) { + await RefreshRepresentatives(cancellationToken).ConfigureAwait(false); + var body = new Body2 { Blocks = blocks.Select(EncodeBlock).ToList() }; - CreateVoteQuoteResponse response = await Attempt(() => _api.CreateVoteQuoteAsync(body, cancellationToken)).ConfigureAwait(false); + var requests = SnapshotRepresentatives() + .Select(rep => RequestQuoteFrom(rep, body, cancellationToken)) + .ToArray(); + (Representative Rep, byte[]? Value, KeetaException? Error)[] outcomes = + await Task.WhenAll(requests).ConfigureAwait(false); - string? quote = response.Quote?.Binary; - if (string.IsNullOrEmpty(quote)) + return SuccessesOrThrow(outcomes, "no representative returned a vote quote") + .Select(success => new VoteQuote(success.Value, success.Rep.Endpoint.ApiUrl)) + .ToArray(); + } + + /// Requests one representative's quote and captures its failure rather than throwing. + private static async Task<(Representative Rep, byte[]? Value, KeetaException? Error)> RequestQuoteFrom( + Representative representative, + Body2 body, + CancellationToken cancellationToken) + { + try { - throw new KeetaException("VOTE_DECLINED", "the node returned no vote quote"); + CreateVoteQuoteResponse response = await Attempt(() => representative.Api.CreateVoteQuoteAsync(body, cancellationToken)).ConfigureAwait(false); + string? quote = response.Quote?.Binary; + if (string.IsNullOrEmpty(quote)) + { + throw new KeetaException("VOTE_DECLINED", "the node returned no vote quote"); + } + + return (representative, Convert.FromBase64String(quote), null); } + catch (KeetaException error) + { + return (representative, null, error); + } + } - return Convert.FromBase64String(quote); + /// + /// Requests votes over from the + /// representative set concurrently. + /// + /// + /// Round one leaves null and fans out to + /// every representative. Round two attaches the full temporary set and + /// contacts only its issuers, which the node requires to escalate. + /// Individual failures are tolerated. When no representative votes, the + /// failure of the highest-weight one surfaces. Each quote in + /// goes only to the representative that issued + /// it, matched by its API URL. + /// + private async Task> RequestVotes( + IReadOnlyList blocksBase64, + IReadOnlyList? priorVotes, + IReadOnlyList? quotes, + CancellationToken cancellationToken) + { + IReadOnlyList targets = priorVotes is null + ? SnapshotRepresentatives() + : priorVotes.Select(prior => prior.Issuer).ToArray(); + + var quotesByIssuer = new Dictionary(); + foreach (VoteQuote quote in quotes ?? Array.Empty()) + { + quotesByIssuer[quote.IssuerApiUrl] = quote; + } + + var requests = targets + .Select(rep => RequestVoteFrom( + rep, + blocksBase64, + priorVotes, + quotesByIssuer.GetValueOrDefault(rep.Endpoint.ApiUrl)?.Bytes, + cancellationToken)) + .ToArray(); + (Representative Rep, string? Value, KeetaException? Error)[] outcomes = + await Task.WhenAll(requests).ConfigureAwait(false); + + return SuccessesOrThrow(outcomes, "no representative returned a vote") + .Select(success => new RepresentativeVote(success.Rep, success.Value)) + .ToArray(); } /// - /// Request one vote over . Round one leaves - /// null so the body omits votes entirely, - /// and may attach a pre-fetched . Round two attaches - /// the temporary vote so the representative escalates it. + /// Splits fan-out outcomes into their successes. /// - private async Task RequestVote( + /// + /// When every request failed, the failure of the highest-weight + /// representative surfaces, or a VOTE_DECLINED with + /// when no failure was captured. + /// + private static List<(Representative Rep, T Value)> SuccessesOrThrow( + IReadOnlyList<(Representative Rep, T? Value, KeetaException? Error)> outcomes, + string emptyMessage) + where T : class + { + var successes = new List<(Representative, T)>(outcomes.Count); + KeetaException? highestError = null; + BigInteger highestErrorWeight = BigInteger.MinusOne; + foreach ((Representative rep, T? value, KeetaException? error) in outcomes) + { + if (value is not null) + { + successes.Add((rep, value)); + continue; + } + + BigInteger weight = rep.Weight ?? BigInteger.MinusOne; + if (error is not null && (highestError is null || weight > highestErrorWeight)) + { + highestError = error; + highestErrorWeight = weight; + } + } + + if (successes.Count == 0) + { + throw highestError ?? new KeetaException("VOTE_DECLINED", emptyMessage); + } + + return successes; + } + + /// Requests one representative's vote and captures its failure rather than throwing. + private static async Task<(Representative Rep, string? Vote, KeetaException? Error)> RequestVoteFrom( + Representative representative, IReadOnlyList blocksBase64, - string? priorVote, + IReadOnlyList? priorVotes, byte[]? quote, CancellationToken cancellationToken) { var body = new Body { Blocks = blocksBase64.ToList() }; - if (priorVote is not null) + if (priorVotes is not null) { - body.Votes = new List { priorVote }; + // Every contacted representative receives the full temporary set, + // including its own vote, which the node requires to escalate. + body.Votes = priorVotes.Select(prior => prior.VoteBase64).ToList(); } if (quote is not null) @@ -685,35 +1022,53 @@ private async Task RequestVote( body.Quote = Convert.ToBase64String(quote); } - CreateVoteResponse response = await Attempt(() => _api.CreateVoteAsync(body, cancellationToken)).ConfigureAwait(false); - string? vote = response.Vote?.Binary; - if (string.IsNullOrEmpty(vote)) + try { - throw new KeetaException("VOTE_DECLINED", "the node returned no vote"); - } + CreateVoteResponse response = await Attempt(() => representative.Api.CreateVoteAsync(body, cancellationToken)).ConfigureAwait(false); + string? vote = response.Vote?.Binary; + if (string.IsNullOrEmpty(vote)) + { + throw new KeetaException("VOTE_DECLINED", "the node returned no vote"); + } - return vote; + return (representative, vote, null); + } + catch (KeetaException error) + { + return (representative, null, error); + } } - /// Materialize a base64 vote from the vote endpoint. + /// Materializes a base64 vote from the vote endpoint. private Crypto.Vote DecodeVote(string voteBase64) => new(_runtime, _runtime.VoteFromBytes(Convert.FromBase64String(voteBase64))); - /// Whether the base64 vote obliges a fee block. - private bool VoteRequiresFee(string voteBase64) + /// + /// Returns whether any of the round's votes obliges a fee block. A vote + /// with a zero-amount option does not. + /// + private bool VotesRequireFee(IReadOnlyList votes) { - using Crypto.Vote vote = DecodeVote(voteBase64); - return vote.RequiresFee; + foreach (RepresentativeVote entry in votes) + { + using Crypto.Vote vote = DecodeVote(entry.VoteBase64); + if (vote.RequiresFee) + { + return true; + } + } + + return false; } /// - /// Produce the fee block the temporary round requires through the - /// caller's factory, handing it the validated staple over - /// and . + /// Produces the fee block that the temporary round requires through the + /// caller's factory. The factory receives the validated staple over + /// and the round's . /// private async Task FeeBlockFor( IReadOnlyList blocks, - string temporaryVote, + IReadOnlyList temporaryVotes, TransmitOptions options, CancellationToken cancellationToken) { @@ -723,7 +1078,7 @@ private bool VoteRequiresFee(string voteBase64) } IReadOnlyList priority = options.FeeTokenPriority.ToArray(); - using Crypto.VoteStaple staple = StapleFor(blocks, temporaryVote); + using Crypto.VoteStaple staple = StapleFor(blocks, temporaryVotes); Crypto.Block? feeBlock = await factory(this, staple, priority, cancellationToken).ConfigureAwait(false); if (feeBlock is null) { @@ -734,44 +1089,78 @@ private bool VoteRequiresFee(string voteBase64) } /// - /// A validated staple over and the base64 vote - /// endorsing them, enforcing the staple invariants. + /// Builds a validated staple over and the + /// base64 votes that endorse them. The build enforces the staple invariants. /// - private Crypto.VoteStaple StapleFor(IReadOnlyList blocks, string voteBase64) + private Crypto.VoteStaple StapleFor(IReadOnlyList blocks, IReadOnlyList votes) { - using Crypto.Vote vote = DecodeVote(voteBase64); int[] blockHandles = blocks.Select(block => block.Handle).ToArray(); long moment = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - return new Crypto.VoteStaple(_runtime, _runtime.VoteStapleNew(blockHandles, new[] { vote.Handle }, moment)); + var decoded = new List(votes.Count); + try + { + foreach (RepresentativeVote entry in votes) + { + decoded.Add(DecodeVote(entry.VoteBase64)); + } + + int[] voteHandles = decoded.Select(vote => vote.Handle).ToArray(); + return new Crypto.VoteStaple(_runtime, _runtime.VoteStapleNew(blockHandles, voteHandles, moment)); + } + finally + { + foreach (Crypto.Vote vote in decoded) + { + vote.Dispose(); + } + } } - /// Assemble the staple over plus the permanent vote, and post it. + /// + /// Assembles the staple over plus every + /// permanent vote, and posts it to the representative with the highest + /// weight. + /// private async Task PublishStaple( IReadOnlyList blocks, - string permanentVoteBase64, + IReadOnlyList permanentVotes, CancellationToken cancellationToken) { byte[] stapleBytes; - using (Crypto.Vote vote = DecodeVote(permanentVoteBase64)) + var decoded = new List(permanentVotes.Count); + try { + foreach (RepresentativeVote entry in permanentVotes) + { + decoded.Add(DecodeVote(entry.VoteBase64)); + } + int[] blockHandles = blocks.Select(block => block.Handle).ToArray(); + int[] voteHandles = decoded.Select(vote => vote.Handle).ToArray(); long moment = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - stapleBytes = _runtime.VoteStapleBuild(blockHandles, new[] { vote.Handle }, moment); + stapleBytes = _runtime.VoteStapleBuild(blockHandles, voteHandles, moment); + } + finally + { + foreach (Crypto.Vote vote in decoded) + { + vote.Dispose(); + } } var body = new Body3 { VotesAndBlocks = Convert.ToBase64String(stapleBytes) }; - await Attempt(() => _api.PublishVoteStapleAsync(body, cancellationToken)).ConfigureAwait(false); + await Attempt(() => Api.PublishVoteStapleAsync(body, cancellationToken)).ConfigureAwait(false); - // A fulfilled publish means the node accepted the staple. Its - // `publish` flag only reports whether the node also voted on it, so - // the reference clients ignore it and so do we. + // A fulfilled publish means the node accepted the staple. The + // response's `publish` flag only reports whether the node also voted + // on it, so the client ignores the flag. return true; } /// - /// Position atop , or - /// as an opening block when the account has no chain yet. + /// Positions atop , + /// or as an opening block when the account has no chain yet. /// internal static void PositionAfter(Crypto.BlockBuilder builder, string? previous) { @@ -785,8 +1174,8 @@ internal static void PositionAfter(Crypto.BlockBuilder builder, string? previous } /// - /// Whether one published record chains to a trusted issuer at the moment. - /// A malformed certificate or intermediate makes the record fail closed. + /// Returns whether one published record chains to a trusted issuer at the + /// moment. A malformed certificate or intermediate makes the record fail closed. /// private bool RecordChainsToRoot( Certificate record, @@ -822,9 +1211,9 @@ private bool RecordChainsToRoot( } /// - /// Run one generated transport call, projecting its failure to a + /// Runs one generated transport call and projects its failure to a /// . A node error envelope surfaces its own - /// code (for example LEDGER_SUCCESSOR_VOTE_EXISTS); anything else + /// code (for example LEDGER_SUCCESSOR_VOTE_EXISTS). Anything else /// collapses to the stable NODE_STATUS code. /// private static async Task Attempt(Func> operation) @@ -843,12 +1232,12 @@ private static async Task Attempt(Func> operation) } } - /// Map a generated certificate record to the SDK's shared record. + /// Maps a generated certificate record to the SDK's shared record. private static Certificate DecodeCertificate(GeneratedCertificate record) => new(record.Certificate1, record.Intermediates?.ToArray() ?? Array.Empty()); /// - /// Materialize a transport block (base64 $binary) inside the core. + /// Materializes a transport block (base64 $binary) inside the core. /// An absent block field is the node's "none" shape. /// private Crypto.Block? DecodeBlock(GeneratedBlock? block) @@ -862,7 +1251,7 @@ private static Certificate DecodeCertificate(GeneratedCertificate record) => return _runtime.Blocks.ParseHex(hex); } - /// Map generated history entries and the paging cursor to the typed page. + /// Maps generated history entries and the paging cursor to the typed page. private static HistoryPage DecodeHistoryPage(ICollection? history, string? nextKey) { ICollection items = history ?? Array.Empty(); @@ -888,9 +1277,9 @@ private static HistoryPage DecodeHistoryPage(ICollection? } /// - /// Map generated ACL rows to typed entries: each principal by its declared - /// type, the entity/target accounts, and the [base, external] - /// permission bitmaps decoded through the core. + /// Maps generated ACL rows to typed entries. Each entry carries the + /// principal by its declared type, the entity and target accounts, and + /// the [base, external] permission bitmaps decoded through the core. /// [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP001:Dispose created", Justification = "Ownership of the permission set transfers to the returned Acl entry; the caller disposes it with the entry's accounts, as with every model carrying live handles.")] @@ -911,9 +1300,9 @@ private Acl[] DecodeAcls(ICollection? rows) => .ToArray(); /// - /// Decode an ACL principal from its wire shape: an account address string - /// when the type is ACCOUNT, or an object carrying the issuing - /// certificate hash and its anchor account when CERTIFICATE. + /// Decodes an ACL principal from its wire shape. The shape is an account + /// address string when the type is ACCOUNT, or an object with the + /// issuing certificate hash and its anchor account when CERTIFICATE. /// private AclPrincipal? DecodeAclPrincipal(ACLRowPrincipalType kind, object? principal) { @@ -945,17 +1334,17 @@ private Acl[] DecodeAcls(ICollection? rows) => return new AclAccountPrincipal(_runtime.Accounts.FromPublicKeyString(address)); } - /// Parse an optional account address field, null when absent. + /// Parses an optional account address field. Returns null when the field is absent. private Crypto.Account? OptionalAccount(string? address) => string.IsNullOrEmpty(address) ? null : _runtime.Accounts.FromPublicKeyString(address); - /// Parse an optional hex hash field, null when absent. + /// Parses an optional hex hash field. Returns null when the field is absent. private static Crypto.BlockHash? OptionalBlockHash(string? hex) => string.IsNullOrEmpty(hex) ? null : Crypto.BlockHash.Parse(hex); /// - /// Map one account's generated state fields to the typed - /// , shared by the single and batch reads. + /// Maps one account's generated state fields to the typed + /// . The single and batch reads share this path. /// private AccountState DecodeState( string? headBlock, @@ -990,8 +1379,8 @@ private AccountState DecodeState( } /// - /// Map a generated representative to the typed model. The plural endpoint - /// advertises endpoints; the singular lookup does not. + /// Maps a generated representative to the typed model. The plural + /// endpoint advertises endpoints. The singular lookup does not. /// private NodeRepresentative DecodeRepresentative(GeneratedRepresentative representative) => new( @@ -999,7 +1388,7 @@ private NodeRepresentative DecodeRepresentative(GeneratedRepresentative represen OptionalHexAmount(representative.Weight) ?? BigInteger.Zero, representative.Endpoints?.Api); - /// Map generated balance entries, treating absent amounts as zero. + /// Maps generated balance entries and treats absent amounts as zero. private TokenBalance[] DecodeBalances(ICollection? balances) { if (balances is null) @@ -1016,7 +1405,7 @@ private TokenBalance[] DecodeBalances(ICollection? balances) .ToArray(); } - /// Parse a node amount (a 0x-prefixed hexadecimal BigInt), null when absent. + /// Parses a node amount (a 0x-prefixed hexadecimal BigInt). Returns null when the value is absent. private static BigInteger? OptionalHexAmount(string? value) { if (string.IsNullOrEmpty(value)) diff --git a/src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs b/src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs index 5d8c491..a026946 100644 --- a/src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs +++ b/src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs @@ -1,10 +1,12 @@ namespace KeetaNet.Anchor; /// -/// A well-known KeetaNet network, the port of the reference network registry. -/// Feeds the FromNetwork-style client factories with the network id and -/// its first representative's API endpoint. +/// A well-known KeetaNet network from the network registry. /// +/// +/// The FromNetwork-style client factories read the network id and the +/// default representative set from this enum. +/// public enum KeetaNetwork { /// The production network. @@ -17,9 +19,44 @@ public enum KeetaNetwork Dev, } -/// The reference registry values for each . +/// +/// One entry of the representative registry. +/// +/// +/// The entry carries the representative's account address and its advertised +/// API and P2P endpoints. The address is null when the network derives it at +/// runtime, as dev does. +/// +public sealed record RepresentativeEndpoint(string? Key, string ApiUrl, string? P2pUrl); + +/// The registry values for each . public static class KeetaNetworkExtensions { + /// The registered representative addresses of the production network. + private static readonly string[] MainRepresentativeKeys = + { + "keeta_aabwip6zeo2fnzfxp5hssrrqtascs2277w2zk7vqd6d3k3m4dkt2flcbca2mqki", + "keeta_aabvmwxttv4q56gbfveighwfwp3yvitlrdfsacic3ckqc7lqelsspvmhc7oldmq", + "keeta_aabwqf5fnta4t2v2atieis545b3rqoq6z7x5w3geugiilqlz5jdsb5og2rmxvdq", + "keeta_aablpogflko72eusdhuuqgsto2rwcvy2m5mo5snmvrmbacz3qczwjtwpmzf5ufq", + }; + + private static readonly string[] StagingRepresentativeKeys = + { + "keeta_aabaagdrwrwnkzox4u3qh6uukre6lckax6kb5fwyxd4vtpua6vrjc6nuhb75fji", + "keeta_aabgizanf4agmioyrswbg4wsl7nmjlrakwd4piuks7cqagfccnxc2fscm25hw7i", + "keeta_aab2gw2zmtazqgtromyfmhjn5h67ep23676zq62obgtqaw65x5l5krn252w57ma", + "keeta_aabue4mdj22i5o6774tlszcxy2sxyvpninbm54nfhxn6dkmsvtryd7oha4bzh2i", + }; + + private static readonly string[] TestRepresentativeKeys = + { + "keeta_aabi4bd3f7jrt67mxcq44ozj65bh4bp2mygmrkedxggu2rxwn2ztuw3b6exivbq", + "keeta_aabf7dz5asq2n2lrldct33x2ww65cophxp7egfiixbb7tbyat5r3kcbcez7ftpi", + "keeta_aab3cxegizwhtim3zlyuwjhiqd5ikkhxg42smhwc3wx6yn7ep2t6lwo6emvw4wa", + "keeta_aabznoicrzvte6ql5rxbgugmfrjqubbnjuo5l6ivopowy4rpkqgs5fco3oaezcq", + }; + /// The network identifier stamped onto blocks for this network. public static long Id(this KeetaNetwork network) => network switch @@ -41,18 +78,64 @@ public static string Alias(this KeetaNetwork network) => }; /// - /// The API endpoint of representative - /// (numbered from one). Production networks carry a network infix; - /// dev does not. + /// Returns the API endpoint of representative + /// , numbered from one. + /// + /// + /// Production network URLs carry a network infix. The dev + /// URLs do not. + /// + public static string RepresentativeApiUrl(this KeetaNetwork network, int representative = 1) => + network.RepresentativeUrl("https", "api", representative); + + /// + /// Returns the P2P (WebSocket) endpoint of representative + /// , numbered from one. /// - public static string RepresentativeApiUrl(this KeetaNetwork network, int representative = 1) + public static string RepresentativeP2pUrl(this KeetaNetwork network, int representative = 1) => + network.RepresentativeUrl("wss", "p2p", representative); + + /// + /// Returns the default representative set for this network. + /// + /// + /// Each network registers four representatives, each with its account + /// address and advertised endpoints. The dev network derives its + /// representative accounts from a deterministic seed at runtime, so its + /// entries carry no key. + /// + public static IReadOnlyList Representatives(this KeetaNetwork network) + { + string[]? keys = network switch + { + KeetaNetwork.Main => MainRepresentativeKeys, + KeetaNetwork.Staging => StagingRepresentativeKeys, + KeetaNetwork.Test => TestRepresentativeKeys, + _ => null, + }; + + var endpoints = new RepresentativeEndpoint[4]; + for (int index = 0; index < endpoints.Length; index++) + { + int representative = index + 1; + endpoints[index] = new RepresentativeEndpoint( + keys?[index], + network.RepresentativeApiUrl(representative), + network.RepresentativeP2pUrl(representative)); + } + + return endpoints; + } + + /// Builds one representative endpoint URL. The API and P2P forms share this path. + private static string RepresentativeUrl(this KeetaNetwork network, string scheme, string path, int representative) { string alias = network.Alias(); if (network == KeetaNetwork.Dev) { - return $"https://rep{representative}.{alias}.api.keeta.com/api"; + return $"{scheme}://rep{representative}.{alias}.api.keeta.com/{path}"; } - return $"https://rep{representative}.{alias}.network.api.keeta.com/api"; + return $"{scheme}://rep{representative}.{alias}.network.api.keeta.com/{path}"; } } diff --git a/src/KeetaNet.Anchor/Services/Node/NodeModels.cs b/src/KeetaNet.Anchor/Services/Node/NodeModels.cs index ae86f6e..aba6c68 100644 --- a/src/KeetaNet.Anchor/Services/Node/NodeModels.cs +++ b/src/KeetaNet.Anchor/Services/Node/NodeModels.cs @@ -5,7 +5,7 @@ namespace KeetaNet.Anchor; /// -/// Outcome of evaluating an account's published certificates against a trust set. +/// The outcome of evaluating an account's published certificates against a trust set. /// public enum CertificateChainStatus { @@ -20,17 +20,20 @@ public enum CertificateChainStatus } /// An account's balance in one token, in that token's base units. -/// is the not-yet-settled amount, zero when none. +/// is the not-yet-settled amount. It is zero when none is pending. public sealed record TokenBalance(Account Token, BigInteger Balance, BigInteger Pending); /// On-chain account info. is present only for token accounts. public sealed record NodeAccountInfo(string? Name, string? Description, string? Metadata, BigInteger? Supply); /// -/// The ledger state of an account: its head block hash and height, delegated -/// representative, published , and token balances. -/// A never-used account reads back with null head and empty balances. +/// The ledger state of an account. /// +/// +/// The state carries the head block hash and height, the delegated +/// representative, the published , and the token +/// balances. A never-used account reads back with a null head and empty balances. +/// public sealed record AccountState( BlockHash? HeadBlock, BigInteger? HeadHeight, @@ -39,17 +42,34 @@ public sealed record AccountState( IReadOnlyList Balances); /// -/// A representative and its on-ledger voting weight. is -/// the REST endpoint the node advertises for it: the all-representatives read -/// includes it, the singular lookups do not. +/// A representative and its on-ledger voting weight. /// +/// +/// is the REST endpoint that the node advertises for the +/// representative. The all-representatives read includes it. The singular +/// lookups do not. +/// public sealed record NodeRepresentative(Account Account, BigInteger Weight, string? ApiUrl); /// -/// A point-in-time XOR checksum over the node's ledger, with the approximate -/// it was taken and half the measurement window -/// (, milliseconds). +/// A non-binding vote quote from one representative. /// +/// +/// The quote locks in the fee that the issuing representative would charge +/// for the quoted blocks. Attach quotes to a transmit through +/// . The vote round returns each quote +/// only to the representative that issued it, matched by +/// . +/// +public sealed record VoteQuote(byte[] Bytes, string IssuerApiUrl); + +/// +/// A point-in-time XOR checksum over the node's ledger. +/// +/// +/// is the approximate time of the measurement. +/// is half the measurement window, in milliseconds. +/// public sealed record LedgerChecksum(BigInteger Checksum, DateTimeOffset? Moment, double MomentRangeMs); /// Which ledger a block lookup searches. @@ -62,43 +82,55 @@ public enum LedgerSide } /// -/// Pagination/range bounds for . -/// / are block-hash cursors; -/// caps the page size (the node applies its own default -/// and maximum). +/// The pagination and range bounds for . /// +/// +/// and are block-hash cursors. +/// caps the page size, and the node applies its own +/// default and maximum. +/// public sealed record ChainQuery(BlockHash? Start = null, BlockHash? End = null, int? Limit = null); /// -/// A single page of an account's chain (most recent first) together with the -/// cursor for the next page: pass as the next query's -/// ; null once the chain is exhausted. The -/// caller owns the blocks and must dispose them. +/// One page of an account's chain, most recent first, with the cursor for +/// the next page. /// +/// +/// Pass as the next query's +/// . The cursor is null once the chain is +/// exhausted. The caller owns the blocks and must dispose them. +/// public sealed record ChainPage(IReadOnlyList Blocks, BlockHash? NextKey); /// -/// Pagination bounds for . -/// is the previous page's last staple id; -/// caps the page size. +/// The pagination bounds for . /// +/// +/// is the previous page's last staple id. +/// caps the page size. +/// public sealed record HistoryQuery(BlockHash? Start = null, int? Limit = null); /// -/// One committed vote staple in an account's history: its transport bytes, -/// its id (the hash over the block hashes it covers), and the moment it was -/// committed. +/// One committed vote staple in an account's history. /// +/// +/// The entry carries the staple's transport bytes, its id (the hash over the +/// block hashes it covers), and the moment of the commit. +/// public sealed record NodeHistoryEntry(byte[] StapleBytes, BlockHash? Id, DateTimeOffset? Timestamp); /// -/// A single page of history together with the cursor for the next page: pass -/// as the next query's ; -/// null once the history is exhausted. +/// One page of history with the cursor for the next page. /// +/// +/// Pass as the next query's +/// . The cursor is null once the history is +/// exhausted. +/// public sealed record HistoryPage(IReadOnlyList Entries, BlockHash? NextKey); -/// The principal an ACL entry grants permissions to. +/// The principal that an ACL entry grants permissions to. public abstract record AclPrincipal { private protected AclPrincipal() @@ -110,15 +142,19 @@ private protected AclPrincipal() public sealed record AclAccountPrincipal(Account Account) : AclPrincipal; /// -/// A certificate principal: any account presenting a certificate issued by -/// the certificate with , anchored to . +/// A certificate principal. It matches any account that presents a +/// certificate issued by the certificate with , anchored +/// to . /// public sealed record AclCertificatePrincipal(CertificateHash Hash, Account Account) : AclPrincipal; /// -/// An access-control entry granting the +/// An access-control entry that grants the /// permissions over , keyed under -/// . Carries live accounts and a permission set the -/// caller must dispose, like every other model carrying handles. +/// . /// +/// +/// The entry carries live accounts and a permission set that the caller must +/// dispose, like every other model carrying handles. +/// public sealed record Acl(AclPrincipal? Principal, Account? Entity, Account? Target, Permissions Granted); diff --git a/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs b/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs index 48d5ddd..4e6e264 100644 --- a/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs +++ b/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs @@ -25,10 +25,14 @@ public sealed class TransmitOptions public IList FeeTokenPriority { get; } = new List(); /// - /// A pre-fetched vote quote (from ) - /// to attach to the temporary round, locking in the quoted fee. + /// Pre-fetched vote quotes (from ) + /// to attach to the temporary round. /// - public byte[]? Quote { get; set; } + /// + /// Each quote locks in the fee that its issuing representative charges. + /// The round returns each quote only to its issuer. + /// + public IList Quotes { get; } = new List(); /// The fee-block factory, or null to pay no fee. public GenerateFeeBlock? FeeBlockFactory { get; set; } diff --git a/src/KeetaNet.Anchor/Services/Node/UserClient.cs b/src/KeetaNet.Anchor/Services/Node/UserClient.cs index 9d8961a..a4f11b5 100644 --- a/src/KeetaNet.Anchor/Services/Node/UserClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/UserClient.cs @@ -1,17 +1,25 @@ +using System.Globalization; +using System.Net.WebSockets; using System.Numerics; +using System.Text; +using System.Text.Json; namespace KeetaNet.Anchor; /// -/// A bound to an operating account: reads imply the -/// account, writes originate from it and are signed by the bound signer, which -/// also pays any required fee by default. Without a signer the client is -/// read-only and writes throw SIGNER_REQUIRED. +/// A bound to an operating account. /// +/// +/// Reads imply the account. Writes originate from it, and the bound signer +/// signs them and pays any required fee by default. Without a signer the +/// client is read-only and writes throw SIGNER_REQUIRED. +/// public sealed class UserClient : IDisposable { private readonly WasmRuntime _runtime; + [System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP008:Don't assign member with injected and created disposables", + Justification = "Both constructors transfer ownership of the client to this instance; Dispose releases it.")] private readonly KeetaClient _client; /// The operating account when it differs from the signer. @@ -19,12 +27,29 @@ public sealed class UserClient : IDisposable private readonly Crypto.Account? _signer; + /// The registered change handlers, keyed by subscription. The map is its own lock. + private readonly Dictionary> _changeHandlers = new(); + + /// Serializes change detection so that the socket and the poll never race. + private readonly SemaphoreSlim _changeGate = new(1, 1); + + /// The fallback poll. It runs while any change handler is registered. + private Timer? _changeTimer; + + /// Cancels the WebSocket loop and any in-flight change detection. + private CancellationTokenSource? _changeCancellation; + + /// The fingerprint of the last emitted account state. + private string? _previousChangeFingerprint; + /// - /// An owned for - /// bound to , operating as - /// when given and as the signer itself - /// otherwise. Both accounts are borrowed, not disposed. + /// Creates an owned for + /// bound to . /// + /// + /// The client operates as when given, or as + /// the signer itself otherwise. Both accounts are borrowed and never disposed. + /// internal UserClient( WasmRuntime runtime, string nodeUrl, @@ -32,14 +57,31 @@ internal UserClient( long? network, Crypto.Account? signer, Crypto.Account? account) + : this(runtime, new KeetaClient(runtime, nodeUrl, http, network), signer, account) + { + } + + /// + /// Adopts bound to . + /// + /// + /// This instance owns the adopted client and disposes it. The client + /// operates as when given, or as the signer + /// itself otherwise. Both accounts are borrowed and never disposed. + /// + internal UserClient( + WasmRuntime runtime, + KeetaClient client, + Crypto.Account? signer, + Crypto.Account? account) { _runtime = runtime; - _client = new KeetaClient(runtime, nodeUrl, http, network); + _client = client; _signer = signer; _account = account; } - /// The underlying client, for reads beyond the operating account. + /// The underlying client for reads beyond the operating account. public KeetaClient Client => _client; /// The bound signer, if any. @@ -49,49 +91,54 @@ internal UserClient( public bool IsReadOnly => _signer is null; /// - /// The operating account: the configured account, then the signer. - /// Throws SIGNER_REQUIRED when neither is bound. + /// The operating account. This is the configured account, or the signer + /// when none is configured. /// + /// SIGNER_REQUIRED when neither is bound. public Crypto.Account Account => _account ?? _signer ?? throw new KeetaException("SIGNER_REQUIRED", "bind a signer or an operating account to the user client"); - /// The full state of the operating account. - public Task GetState(CancellationToken cancellationToken = default) => - _client.GetAccountState(Account, cancellationToken); + /// Gets the full state of the operating account. + public Task State(CancellationToken cancellationToken = default) => + _client.GetAccountInfo(Account, cancellationToken); - /// The settled balance of held by the operating account. - public Task GetBalance(Crypto.Account token, CancellationToken cancellationToken = default) => - _client.GetAccountBalance(Account, token, cancellationToken); + /// Gets the settled balance of held by the operating account. + public Task Balance(Crypto.Account token, CancellationToken cancellationToken = default) => + _client.GetBalance(Account, token, cancellationToken); - /// Every token balance held by the operating account. - public Task> GetAllBalances(CancellationToken cancellationToken = default) => - _client.GetAccountBalances(Account, cancellationToken); + /// Gets every token balance held by the operating account. + public Task> AllBalances(CancellationToken cancellationToken = default) => + _client.GetAllBalances(Account, cancellationToken); - /// The certificates published by the operating account. - public Task> GetAllCertificates(CancellationToken cancellationToken = default) => + /// Gets the certificates published by the operating account. + public Task> GetCertificates(CancellationToken cancellationToken = default) => _client.GetAllCertificates(Account, cancellationToken); /// - /// The certificate the operating account published under - /// , or null when it never did. + /// Gets the certificate that the operating account published under + /// . /// - public Task GetCertificateByHash( + /// The record, or null when the account never published it. + public Task GetCertificates( Crypto.CertificateHash certificateHash, CancellationToken cancellationToken = default) => _client.GetCertificateByHash(Account, certificateHash, cancellationToken); - /// The head block of the operating account's chain, or null for a fresh account. - public Task GetHeadBlock(CancellationToken cancellationToken = default) => - _client.GetHeadBlock(Account, cancellationToken); + /// Gets the hash of the operating account's head block, or null for a fresh account. + public async Task Head(CancellationToken cancellationToken = default) + { + using Crypto.Block? head = await _client.GetHeadBlock(Account, cancellationToken).ConfigureAwait(false); + return head?.Hash; + } - /// The next pending (unreceived) block for the operating account, if any. - public Task GetPendingBlock(CancellationToken cancellationToken = default) => + /// Gets the next pending (unreceived) block for the operating account, if any. + public Task PendingBlock(CancellationToken cancellationToken = default) => _client.GetPendingBlock(Account, cancellationToken); /// - /// The block the operating account produced for the idempotent + /// Gets the block that the operating account produced for the idempotent /// , if any. /// public Task GetBlockFromIdempotent( @@ -100,32 +147,45 @@ public Task> GetAllCertificates(CancellationToken can CancellationToken cancellationToken = default) => _client.GetBlockFromIdempotent(Account, key, side, cancellationToken); - /// A page of the operating account's block chain, most recent first. - public Task GetChain(ChainQuery? query = null, CancellationToken cancellationToken = default) => - _client.GetAccountChain(Account, query, cancellationToken); + /// Gets one page of the operating account's block chain, most recent first. + public Task Chain(ChainQuery? query = null, CancellationToken cancellationToken = default) => + _client.GetChain(Account, query, cancellationToken); - /// A page of the operating account's committed staple history. - public Task GetHistory(HistoryQuery? query = null, CancellationToken cancellationToken = default) => - _client.GetAccountHistory(Account, query, cancellationToken); + /// Gets one page of the operating account's committed staple history. + public Task History(HistoryQuery? query = null, CancellationToken cancellationToken = default) => + _client.GetHistory(Account, query, cancellationToken); - /// ACL entries where the operating account is the principal. - public Task> GetAcls(CancellationToken cancellationToken = default) => - _client.GetAclsByPrincipal(Account, cancellationToken); + /// Lists the ACL entries where the operating account is the principal. + public Task> ListAclsByPrincipal(CancellationToken cancellationToken = default) => + _client.ListAclsByPrincipal(Account, cancellationToken); - /// ACL entries granted to the operating account as an entity. - public Task> GetAclsByEntity(CancellationToken cancellationToken = default) => - _client.GetAclsByEntity(Account, cancellationToken); + /// Lists the ACL entries granted to the operating account as an entity. + public Task> ListAclsByEntity(CancellationToken cancellationToken = default) => + _client.ListAclsByEntity(Account, cancellationToken); /// - /// A builder for the operating account, signed by the bound signer and - /// pre-set with the client's defaults. The caller positions it, appends - /// operations, and builds. Requires a signer and a bound network. + /// Requests non-binding vote quotes for from + /// every representative. /// + /// Attach the quotes to a transmit through . + public Task> GetQuotes( + IReadOnlyList blocks, + CancellationToken cancellationToken = default) => + _client.GetVoteQuotes(blocks, cancellationToken); + + /// + /// Creates a builder for the operating account, signed by the bound + /// signer and pre-set with the client's defaults. + /// + /// + /// The caller positions the builder, appends operations, and builds. The + /// method requires a signer and a bound network. + /// public Crypto.BlockBuilder InitBuilder() => _client.InitBuilder(Account, RequireSigner()); /// - /// Publish one signed block, paying any required fee with the bound - /// signer unless carries a fee-block factory. + /// Publishes one signed block. The bound signer pays any required fee + /// unless carries a fee-block factory. /// public Task Transmit( Crypto.Block block, @@ -134,9 +194,9 @@ public Task Transmit( Transmit(new[] { block }, options, cancellationToken); /// - /// Publish as one atomic staple, paying any - /// required fee with the bound signer unless - /// carries a fee-block factory. + /// Publishes as one atomic staple. The bound + /// signer pays any required fee unless carries + /// a fee-block factory. /// public Task Transmit( IReadOnlyList blocks, @@ -149,21 +209,22 @@ public Task Transmit( } /// - /// Position atop the operating account's - /// ledger head (opening a fresh chain when it has none), build its block, - /// and transmit it, the reference publishBuilder. The builder must - /// not carry a position of its own. + /// Positions atop the operating account's + /// ledger head, builds its block, and transmits it. /// - public async Task Publish( + /// + /// A fresh account opens a new chain. The builder must not carry a + /// position of its own. + /// + public async Task PublishBuilder( Crypto.BlockBuilder builder, TransmitOptions? options = null, CancellationToken cancellationToken = default) { - // Require a signer to publish a block _ = RequireSigner(); TransmitOptions resolved = OrDefaultFeePayer(options); - AccountState state = await GetState(cancellationToken).ConfigureAwait(false); + AccountState state = await State(cancellationToken).ConfigureAwait(false); KeetaClient.PositionAfter(builder, state.HeadBlock?.ToString()); using Crypto.Block block = builder.Build(); @@ -172,17 +233,17 @@ public async Task Publish( } /// - /// Create a identifier under the operating account - /// and publish the creating block, returning the derived account. The - /// caller owns the returned account. + /// Creates a identifier under the operating + /// account and publishes the creating block. /// + /// The derived account. The caller owns it. public async Task GenerateIdentifier( Crypto.IdentifierKind kind, TransmitOptions? options = null, CancellationToken cancellationToken = default) { TransmitOptions resolved = OrDefaultFeePayer(options); - AccountState state = await GetState(cancellationToken).ConfigureAwait(false); + AccountState state = await State(cancellationToken).ConfigureAwait(false); Crypto.Account identifier = Account.GenerateIdentifier(kind, state.HeadBlock); try @@ -203,8 +264,8 @@ public async Task Publish( } /// - /// Send of to - /// , carrying an optional + /// Sends of to + /// with an optional /// reference. /// public async Task Send( @@ -219,7 +280,7 @@ public async Task Send( return await BuildAndTransmit(send, options, cancellationToken).ConfigureAwait(false); } - /// Set the operating account's representative to . + /// Sets the operating account's representative to . public async Task SetRep( Crypto.Account representative, TransmitOptions? options = null, @@ -230,11 +291,13 @@ public async Task SetRep( } /// - /// Add or remove on the operating account, - /// the reference modifyCertificate. An add records - /// alongside the certificate; a - /// subtract retires it by its hash and ignores them. + /// Adds or removes on the operating account. /// + /// + /// An add records alongside the + /// certificate. A subtract retires the certificate by its hash and + /// ignores the intermediates. + /// public async Task ModifyCertificate( Crypto.AdjustMethod method, Crypto.Certificate certificate, @@ -254,10 +317,13 @@ public async Task ModifyCertificate( } /// - /// Remove the operating account's published certificate addressed by - /// . Only - /// applies: an add needs the certificate itself. + /// Removes the operating account's published certificate addressed by + /// . /// + /// + /// Only applies. An add needs + /// the certificate itself. + /// public async Task ModifyCertificate( Crypto.AdjustMethod method, Crypto.CertificateHash hash, @@ -271,9 +337,9 @@ public async Task ModifyCertificate( } /// - /// Publish the operating account's on-chain info. - /// is required for identifier accounts. + /// Publishes the operating account's on-chain info. /// + /// is required for identifier accounts. public async Task SetInfo( string name, string description, @@ -287,10 +353,13 @@ public async Task SetInfo( } /// - /// Apply to - /// with , optionally scoped to - /// (the operating account when omitted). + /// Applies to + /// with . /// + /// + /// The grant scopes to , or to the operating + /// account when omitted. + /// public async Task UpdatePermissions( Crypto.Account principal, Crypto.Permissions permissions, @@ -303,10 +372,315 @@ public async Task UpdatePermissions( return await BuildAndTransmit(modify, options, cancellationToken).ConfigureAwait(false); } - /// Release the owned ; the bound accounts stay with the caller. - public void Dispose() => _client.Dispose(); + /// + /// Registers for changes to the operating account. + /// + /// + /// A WebSocket filtered to the operating account reacts to a new staple + /// immediately. A fallback poll (see + /// ) finds the + /// updates that the socket missed. Either path re-reads the account and + /// invokes the handlers only when its state changed. The delivered state + /// is valid only for the duration of the callback. Dispose the returned + /// subscription to unregister. The last disposal stops the socket and the poll. + /// + public IDisposable OnChange(Action handler, ChangeListenerOptions? options = null) + { + ChangeListenerOptions resolved = options ?? new ChangeListenerOptions(); + var id = Guid.NewGuid(); + + lock (_changeHandlers) + { + _changeHandlers.Add(id, handler); + if (_changeHandlers.Count == 1) + { + StartChangeListener(resolved); + } + } + + return new ChangeSubscription(this, id); + } + + /// Starts the poll and, when the representative advertises one, the socket. + [System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP003:Dispose previous before re-assigning", + Justification = "Only called under the handler lock when no listener runs; StopChangeListener disposed and nulled the previous instances.")] + private void StartChangeListener(ChangeListenerOptions options) + { + var cancellation = new CancellationTokenSource(); + _changeCancellation = cancellation; + _changeTimer = new Timer( + _ => _ = EmitIfChanged(cancellation.Token), + state: null, + options.FallbackFrequency, + options.FallbackFrequency); + + if (_client.PrimaryP2pUrl is { } p2pUrl) + { + _ = RunChangeSocket(p2pUrl, cancellation.Token); + } + } + + /// Unregisters one subscription. The last removal stops the listener. + private void RemoveChangeHandler(Guid id) + { + lock (_changeHandlers) + { + if (!_changeHandlers.Remove(id) || _changeHandlers.Count > 0) + { + return; + } + + StopChangeListener(); + } + } + + /// Stops the poll and the socket loop. Callers hold the handler lock. + private void StopChangeListener() + { + _changeCancellation?.Cancel(); + _changeCancellation?.Dispose(); + _changeCancellation = null; + _changeTimer?.Dispose(); + _changeTimer = null; + _previousChangeFingerprint = null; + } + + /// + /// Runs the socket loop against the representative's P2P endpoint. + /// + /// + /// The loop greets as a participant filtered to the operating account and + /// re-checks the account whenever a staple lands. It reconnects with + /// exponential backoff. + /// + private async Task RunChangeSocket(string p2pUrl, CancellationToken cancellationToken) + { + int attempts = 0; + while (!cancellationToken.IsCancellationRequested) + { + try + { + using var socket = new ClientWebSocket(); + await socket.ConnectAsync(new Uri(p2pUrl), cancellationToken).ConfigureAwait(false); + await GreetParticipant(socket, cancellationToken).ConfigureAwait(false); + attempts = 0; + + await ListenForStaples(socket, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception exception) when (exception is WebSocketException or JsonException or IOException) + { + // Fall through to the reconnect delay. The poll still covers changes. + } + + attempts++; + TimeSpan backoff = TimeSpan.FromSeconds(Math.Pow(2, Math.Min(attempts, 6))); + try + { + await Task.Delay(backoff, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + } + } + + /// Sends the participant greeting filtered to the operating account. + private async Task GreetParticipant(ClientWebSocket socket, CancellationToken cancellationToken) + { + string greeting = JsonSerializer.Serialize(new + { + id = Guid.NewGuid().ToString(), + greeting = new + { + kind = 0, + filter = Account.PublicKeyString, + }, + }); + + await socket.SendAsync( + Encoding.UTF8.GetBytes(greeting), + WebSocketMessageType.Text, + endOfMessage: true, + cancellationToken).ConfigureAwait(false); + } + + /// Consumes socket messages until the socket closes and reacts to add notifications. + private async Task ListenForStaples(ClientWebSocket socket, CancellationToken cancellationToken) + { + byte[] buffer = new byte[64 * 1024]; + while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) + { + using var message = new MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + message.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + + if (result.MessageType == WebSocketMessageType.Close) + { + return; + } + + using JsonDocument document = JsonDocument.Parse(Encoding.UTF8.GetString(message.ToArray())); + if (document.RootElement.TryGetProperty("add", out _)) + { + await EmitIfChanged(cancellationToken).ConfigureAwait(false); + } + } + } + + /// + /// Re-reads the operating account and invokes the handlers when its state + /// differs from the last emission. The state's accounts are released once + /// the handlers return. + /// + private async Task EmitIfChanged(CancellationToken cancellationToken) + { + try + { + await _changeGate.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + try + { + AccountState state = await State(cancellationToken).ConfigureAwait(false); + try + { + string fingerprint = FingerprintOf(state); + Action[] handlers; + lock (_changeHandlers) + { + if (_previousChangeFingerprint == fingerprint) + { + return; + } + + _previousChangeFingerprint = fingerprint; + handlers = _changeHandlers.Values.ToArray(); + } + + foreach (Action handler in handlers) + { + handler(state); + } + } + finally + { + ReleaseState(state); + } + } + catch (OperationCanceledException) + { + // Torn down while reading. Nothing to emit. + } + catch (Exception failure) when (failure is KeetaException or HttpRequestException) + { + // A failed poll emits nothing. The next tick retries. + } + finally + { + try + { + _changeGate.Release(); + } + catch (ObjectDisposedException) + { + // Dispose raced an in-flight check. The gate is gone with it. + } + } + } + + /// Returns a stable digest of the state fields used for change detection. + private static string FingerprintOf(AccountState state) + { + var digest = new StringBuilder(); + digest.Append(state.HeadBlock?.ToString() ?? "-"); + digest.Append('|').Append(state.HeadHeight?.ToString(CultureInfo.InvariantCulture) ?? "-"); + digest.Append('|').Append(state.Representative?.PublicKeyString ?? "-"); + digest.Append('|').Append(state.Info?.Name ?? "-"); + digest.Append('|').Append(state.Info?.Description ?? "-"); + digest.Append('|').Append(state.Info?.Metadata ?? "-"); + + foreach (TokenBalance balance in state.Balances.OrderBy(entry => entry.Token.PublicKeyString, StringComparer.Ordinal)) + { + digest.Append('|').Append(balance.Token.PublicKeyString) + .Append(':').Append(balance.Balance.ToString(CultureInfo.InvariantCulture)) + .Append(':').Append(balance.Pending.ToString(CultureInfo.InvariantCulture)); + } + + return digest.ToString(); + } + + /// Releases the disposable accounts that a delivered state carries. + [System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", + Justification = "The change listener owns the states it reads; handlers only borrow them for the callback.")] + private static void ReleaseState(AccountState state) + { + state.Representative?.Dispose(); + foreach (TokenBalance balance in state.Balances) + { + balance.Token.Dispose(); + } + } + + /// One registered change handler. Disposing it unregisters the handler. + private sealed class ChangeSubscription : IDisposable + { + private readonly UserClient _owner; + + private readonly Guid _id; + + private bool _disposed; + + public ChangeSubscription(UserClient owner, Guid id) + { + _owner = owner; + _id = id; + } - /// Publish the operating account's one-operation block. + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _owner.RemoveChangeHandler(_id); + } + } + + /// + /// Stops any change listener and releases the owned + /// . The bound accounts stay with the caller. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", + Justification = "The adopting constructor transfers ownership of the client to this instance.")] + public void Dispose() + { + lock (_changeHandlers) + { + _changeHandlers.Clear(); + StopChangeListener(); + } + + _changeGate.Dispose(); + _client.Dispose(); + } + + /// Publishes the operating account's one-operation block. private async Task BuildAndTransmit( Crypto.BlockOperation operation, TransmitOptions? options, @@ -315,10 +689,10 @@ private async Task BuildAndTransmit( using Crypto.BlockBuilder builder = InitBuilder(); builder.AddOperation(operation); - return await Publish(builder, options, cancellationToken).ConfigureAwait(false); + return await PublishBuilder(builder, options, cancellationToken).ConfigureAwait(false); } - /// Absent a fee-block factory, the bound signer pays any required fee itself. + /// Defaults the fee payer to the bound signer when no fee-block factory is set. private TransmitOptions OrDefaultFeePayer(TransmitOptions? options) { if (options?.FeeBlockFactory is not null) @@ -329,7 +703,11 @@ private TransmitOptions OrDefaultFeePayer(TransmitOptions? options) TransmitOptions resolved = TransmitOptions.WithFeeSigner(RequireSigner()); if (options is not null) { - resolved.Quote = options.Quote; + foreach (VoteQuote quote in options.Quotes) + { + resolved.Quotes.Add(quote); + } + foreach (Crypto.Account token in options.FeeTokenPriority) { resolved.FeeTokenPriority.Add(token); @@ -339,7 +717,7 @@ private TransmitOptions OrDefaultFeePayer(TransmitOptions? options) return resolved; } - /// Reject any certificate adjust method other than . + /// Rejects any certificate adjust method other than . private static void RequireAdjust(Crypto.AdjustMethod method, Crypto.AdjustMethod expected) { if (method != expected) @@ -350,7 +728,7 @@ private static void RequireAdjust(Crypto.AdjustMethod method, Crypto.AdjustMetho } } - /// The bound signer, required by every write. + /// Returns the bound signer, which every write requires. private Crypto.Account RequireSigner() => _signer ?? throw new KeetaException("SIGNER_REQUIRED", "bind a signer to the user client to build or transmit blocks"); } diff --git a/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs index f206629..31287d2 100644 --- a/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs @@ -90,7 +90,7 @@ public async Task LedgerReadServesEveryPublishedCertificateRecord() using KeetaClient client = runtime.CreateKeetaClient(anchor.NodeApi); // An account that never published anything reads back as an empty list. - // The Account overload resolves the address itself, as the reference does. + // The Account overload resolves the address itself. IReadOnlyList none = await client.GetAllCertificates(observer, cancellationToken); Assert.Empty(none); @@ -116,8 +116,8 @@ public async Task LedgerReadServesEveryPublishedCertificateRecord() using CryptoCertificate publishedLeaf = runtime.Certificates.Parse(chain.Leaf); Assert.Equal(CertificateHash.Parse(chain.LeafHash), publishedLeaf.Hash); - // The leaf is individually addressable by its hash, intermediates - // intact. An unpublished hash resolves to null, as the reference does. + // The leaf is individually addressable by its hash, with its + // intermediates intact. An unpublished hash resolves to null. Certificate? byHash = await client.GetCertificateByHash(holder, publishedLeaf.Hash, cancellationToken); Assert.NotNull(byHash); AssertSameCertificate(runtime, chain.Leaf, byHash!.Value); @@ -140,7 +140,7 @@ public async Task BasicLedgerReadsReportTheHolderStateAndBalances() using var runtime = WasmRuntime.Load(); using KeetaClient client = runtime.CreateKeetaClient(anchor.NodeApi); - string version = await client.GetNodeVersion(cancellationToken); + string version = await client.GetVersion(cancellationToken); Assert.NotEmpty(version); // The chain holder was funded with base tokens before publishing (fees @@ -149,19 +149,19 @@ public async Task BasicLedgerReadsReportTheHolderStateAndBalances() PublishedChain chain = PublishedChain.Publish(harness); using Account holder = runtime.Accounts.FromPublicKeyString(chain.Account); - AccountState state = await client.GetAccountState(holder, cancellationToken); + AccountState state = await client.GetAccountInfo(holder, cancellationToken); Assert.NotNull(state.HeadBlock); TokenBalance funding = Assert.Single(state.Balances); Assert.True(funding.Balance > BigInteger.Zero); - IReadOnlyList balances = await client.GetAccountBalances(holder, cancellationToken); + IReadOnlyList balances = await client.GetAllBalances(holder, cancellationToken); TokenBalance listed = Assert.Single(balances); Assert.Equal(funding.Token.PublicKeyString, listed.Token.PublicKeyString); Assert.Equal(funding.Balance, listed.Balance); // The token account the state read returned round-trips as the typed // argument of the direct balance read. - BigInteger direct = await client.GetAccountBalance(holder, funding.Token, cancellationToken); + BigInteger direct = await client.GetBalance(holder, funding.Token, cancellationToken); Assert.Equal(funding.Balance, direct); harness.Shutdown(); diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs index ce841a7..ee2a22e 100644 --- a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs @@ -29,13 +29,13 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode() using KeetaClient client = runtime.CreateKeetaClient(node.Api); using Account baseToken = runtime.Accounts.FromPublicKeyString(node.BaseToken); - string version = await client.GetNodeVersion(cancellationToken); + string version = await client.GetVersion(cancellationToken); Assert.NotEmpty(version); // An account the ledger has never seen reads back empty: no head, no // representative, no balances, and an info envelope with blank fields. using Account observer = runtime.Accounts.FromSeed(E2eSeeds.Caller, 0, E2eSeeds.Secp256k1); - AccountState empty = await client.GetAccountState(observer, cancellationToken); + AccountState empty = await client.GetAccountInfo(observer, cancellationToken); Assert.Null(empty.HeadBlock); Assert.Null(empty.Representative); Assert.NotNull(empty.Info); @@ -44,8 +44,8 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode() Assert.True(string.IsNullOrEmpty(empty.Info.Metadata)); Assert.Null(empty.Info.Supply); Assert.Empty(empty.Balances); - Assert.Empty(await client.GetAccountBalances(observer, cancellationToken)); - Assert.Equal(BigInteger.Zero, await client.GetAccountBalance(observer, baseToken, cancellationToken)); + Assert.Empty(await client.GetAllBalances(observer, cancellationToken)); + Assert.Equal(BigInteger.Zero, await client.GetBalance(observer, baseToken, cancellationToken)); // The C#-derived holder must be the address the harness funds - the // interop anchor proving both sides derive the same account. @@ -55,7 +55,7 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode() // Before the holder publishes anything, the balance is the exact // funded amount (the sender paid the transfer fee). - BigInteger initial = await client.GetAccountBalance(holder, baseToken, cancellationToken); + BigInteger initial = await client.GetBalance(holder, baseToken, cancellationToken); Assert.Equal(new BigInteger(Funding), initial); // Publish info and delegate weight through the reference client, then @@ -65,7 +65,7 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode() using Account expectedRep = runtime.Accounts.FromSeed(E2eSeeds.Recipient, 0, E2eSeeds.Secp256k1); Assert.Equal(expectedRep.PublicKeyString, representative); - AccountState state = await client.GetAccountState(holder, cancellationToken); + AccountState state = await client.GetAccountInfo(holder, cancellationToken); Assert.NotNull(state.Info); Assert.Equal("TREASURY", state.Info!.Name); Assert.Equal("Primary holder account", state.Info.Description); @@ -90,13 +90,13 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode() Assert.True(settled.Balance > BigInteger.Zero); Assert.True(settled.Balance <= new BigInteger(Funding)); - BigInteger direct = await client.GetAccountBalance(holder, baseToken, cancellationToken); + BigInteger direct = await client.GetBalance(holder, baseToken, cancellationToken); Assert.Equal(settled.Balance, direct); // The token account's own state carries the chain-initialized supply, // and the supply convenience serves the same value. A non-token // account reports no supply at all. - AccountState tokenState = await client.GetAccountState(baseToken, cancellationToken); + AccountState tokenState = await client.GetAccountInfo(baseToken, cancellationToken); Assert.NotNull(tokenState.Info); Assert.NotNull(tokenState.Info!.Supply); Assert.True(tokenState.Info.Supply > BigInteger.Zero); @@ -107,7 +107,7 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode() // The batch read returns one state per account in request order, // agreeing with the individual reads. - IReadOnlyList states = await client.GetAccountStates(new[] { holder, observer }, cancellationToken); + IReadOnlyList states = await client.GetAccountsInfo(new[] { holder, observer }, cancellationToken); Assert.Equal(2, states.Count); Assert.Equal(state.HeadBlock, states[0].HeadBlock); Assert.Equal(settled.Balance, Assert.Single(states[0].Balances).Balance); @@ -121,7 +121,7 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode() // failure, with the transport error preserved as its cause. using KeetaClient misRouted = runtime.CreateKeetaClient(node.Api + "/bogus"); KeetaException failure = await Assert.ThrowsAsync( - () => misRouted.GetNodeVersion(cancellationToken)); + () => misRouted.GetVersion(cancellationToken)); Assert.Equal("NODE_STATUS", failure.Code); Assert.NotNull(failure.InnerException); @@ -156,17 +156,17 @@ public async Task FeeBearingSendTransmitsAgainstTheLiveNode() const long Amount = 12_345; Assert.True(await user.Send(recipient, Amount, baseToken, cancellationToken: cancellationToken)); - // The recipient gains exactly the amount; the holder also paid the + // The recipient gains exactly the amount. The holder also paid the // round's flat fee. - BigInteger credited = await client.GetAccountBalance(recipient, baseToken, cancellationToken); + BigInteger credited = await client.GetBalance(recipient, baseToken, cancellationToken); Assert.Equal(new BigInteger(Amount), credited); - BigInteger remaining = await user.GetBalance(baseToken, cancellationToken); + BigInteger remaining = await user.Balance(baseToken, cancellationToken); Assert.Equal(new BigInteger(Funding) - Amount - RoundFee, remaining); // The fee block chained atop the send, so the holder's head advanced // past the send block and must match the reference client's. - AccountState state = await user.GetState(cancellationToken); + AccountState state = await user.State(cancellationToken); Assert.NotNull(state.HeadBlock); string? referenceHead = node.Head(holder.PublicKeyString); @@ -176,10 +176,10 @@ public async Task FeeBearingSendTransmitsAgainstTheLiveNode() // The SET_REP chains atop the advanced head and costs one more fee. Assert.True(await user.SetRep(recipient, cancellationToken: cancellationToken)); - state = await user.GetState(cancellationToken); + state = await user.State(cancellationToken); Assert.Equal(recipient.PublicKeyString, state.Representative!.PublicKeyString); remaining -= RoundFee; - Assert.Equal(remaining, await user.GetBalance(baseToken, cancellationToken)); + Assert.Equal(remaining, await user.Balance(baseToken, cancellationToken)); // A fee-less transmit against the fee-enforcing node refuses with the // typed FEE_REQUIRED before anything is published. The refusal leaves @@ -214,8 +214,86 @@ public async Task FeeBearingSendTransmitsAgainstTheLiveNode() } // No refusal advanced either chain. - Assert.Equal(remaining, await user.GetBalance(baseToken, cancellationToken)); - Assert.Equal(new BigInteger(Amount), await client.GetAccountBalance(recipient, baseToken, cancellationToken)); + Assert.Equal(remaining, await user.Balance(baseToken, cancellationToken)); + Assert.Equal(new BigInteger(Amount), await client.GetBalance(recipient, baseToken, cancellationToken)); + + harness.Shutdown(); + } + + [Fact] + public async Task ChangeListenersReactToLedgerMutations() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("node"); + LedgerNode node = LedgerNode.Start(harness); + + using var runtime = WasmRuntime.Load(); + using Account holder = runtime.Accounts.FromSeed(E2eSeeds.Subject, 0, E2eSeeds.Secp256k1); + using Account recipient = runtime.Accounts.FromSeed(E2eSeeds.Recipient, 0, E2eSeeds.Secp256k1); + using UserClient user = runtime.CreateUserClient(node.Api, holder, network: node.Network); + Account baseToken = user.Client.BaseToken!; + + node.Fund(E2eSeeds.Subject, Funding); + + // The test node advertises no P2P endpoint, so the fallback poll is + // the delivery path. A tight frequency keeps the test fast. + var heads = new System.Collections.Concurrent.ConcurrentQueue(); + using var delivered = new SemaphoreSlim(0); + var options = new ChangeListenerOptions { FallbackFrequency = TimeSpan.FromMilliseconds(200) }; + + using (user.OnChange( + state => + { + heads.Enqueue(state.HeadBlock?.ToString() ?? ""); + delivered.Release(); + }, + options)) + { + // The first poll emits the funded state. + Assert.True(await delivered.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken)); + + // A send advances the head, and only that change emits again. + Assert.True(await user.Send(recipient, 7, baseToken, cancellationToken: cancellationToken)); + Assert.True(await delivered.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken)); + } + + Assert.True(heads.Count >= 2); + string[] observed = heads.ToArray(); + Assert.NotEqual(observed[0], observed[^1]); + + harness.Shutdown(); + } + + [Fact] + public async Task UpdateRepsRefreshesWeightsFromTheLiveNode() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("node"); + LedgerNode node = LedgerNode.Start(harness); + + using var runtime = WasmRuntime.Load(); + using Account holder = runtime.Accounts.FromSeed(E2eSeeds.Subject, 0, E2eSeeds.Secp256k1); + using Account recipient = runtime.Accounts.FromSeed(E2eSeeds.Recipient, 0, E2eSeeds.Secp256k1); + using UserClient user = runtime.CreateUserClient(node.Api, holder, network: node.Network); + KeetaClient client = user.Client; + Account baseToken = client.BaseToken!; + + node.Fund(E2eSeeds.Subject, Funding); + + // The ledger names the harness node as its sole weighted + // representative. + IReadOnlyList ledger = await client.GetAllRepresentativeInfo(cancellationToken); + NodeRepresentative sole = Assert.Single(ledger); + Assert.True(sole.Weight > BigInteger.Zero); + + // The refresh adopts the ledger weights. The discovery variant must + // not duplicate the node the client already contacts. + await client.UpdateReps(cancellationToken: cancellationToken); + await client.UpdateReps(addNewRepresentatives: true, cancellationToken: cancellationToken); + + // The refreshed set still carries the write path end to end. + Assert.True(await user.Send(recipient, 5, baseToken, cancellationToken: cancellationToken)); + Assert.Equal(new BigInteger(5), await client.GetBalance(recipient, baseToken, cancellationToken)); harness.Shutdown(); } @@ -249,14 +327,14 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() using Permissions access = runtime.Blocks.PermissionsFromFlags(AccessFlag); Assert.True(await user.UpdatePermissions(recipient, access, cancellationToken: cancellationToken)); - AccountState state = await user.GetState(cancellationToken); + AccountState state = await user.State(cancellationToken); Assert.Equal("HOLDER", state.Info!.Name); Assert.NotNull(state.HeadBlock); // The head reads back as a live block originated by the holder, and // fetching it by hash yields the identical block. An unknown hash is // the node's "none" shape, not a failure. - using Block? head = await user.GetHeadBlock(cancellationToken); + using Block? head = await client.GetHeadBlock(holder, cancellationToken); Assert.NotNull(head); Assert.Equal(state.HeadBlock!.Value, head!.Hash); @@ -269,13 +347,13 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() Assert.Equal(head.Hash, byHash!.Hash); Assert.Null(await client.GetBlock(BlockHash.Parse(new string('0', 64)), cancellationToken: cancellationToken)); - // The chain lists most recent first; a limit of one pages with a + // The chain lists most recent first. A limit of one pages with a // cursor, and the block behind the head names the head as successor. - ChainPage newest = await user.GetChain(new ChainQuery(Limit: 1), cancellationToken); + ChainPage newest = await user.Chain(new ChainQuery(Limit: 1), cancellationToken); Assert.Equal(head.Hash, Assert.Single(newest.Blocks).Hash); Assert.NotNull(newest.NextKey); - ChainPage chain = await user.GetChain(cancellationToken: cancellationToken); + ChainPage chain = await user.Chain(cancellationToken: cancellationToken); Assert.True(chain.Blocks.Count >= 2); Assert.Equal(head.Hash, chain.Blocks[0].Hash); @@ -283,7 +361,7 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() Assert.Equal(head.Hash, successor!.Hash); // Account and global history both carry the committed staples. - HistoryPage history = await user.GetHistory(cancellationToken: cancellationToken); + HistoryPage history = await user.History(cancellationToken: cancellationToken); Assert.NotEmpty(history.Entries); Assert.All(history.Entries, entry => Assert.NotEmpty(entry.StapleBytes)); Assert.All(history.Entries, entry => Assert.NotNull(entry.Timestamp)); @@ -291,7 +369,7 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() HistoryPage global = await client.GetGlobalHistory(cancellationToken: cancellationToken); Assert.NotEmpty(global.Entries); - // The settled head retains its votes; nothing is pending and an + // The settled head retains its votes. Nothing is pending, and an // unknown idempotent key resolves to no block. IReadOnlyList? votes = await client.GetBlockVotes(head.Hash, cancellationToken: cancellationToken); Assert.NotNull(votes); @@ -301,34 +379,37 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() vote.Dispose(); } - Assert.Null(await user.GetPendingBlock(cancellationToken)); + Assert.Null(await user.PendingBlock(cancellationToken)); Assert.Null(await user.GetBlockFromIdempotent(Guid.NewGuid().ToString("N"), cancellationToken: cancellationToken)); // The grant reads back typed from both directions: the recipient as // principal, the holder as entity, carrying the access flag. - IReadOnlyList granted = await client.GetAclsByPrincipal(recipient, cancellationToken); + IReadOnlyList granted = await client.ListAclsByPrincipal(recipient, cancellationToken); Acl grant = Assert.Single(granted); AclAccountPrincipal principal = Assert.IsType(grant.Principal); Assert.Equal(recipient.PublicKeyString, principal.Account.PublicKeyString); Assert.Equal(holder.PublicKeyString, grant.Entity!.PublicKeyString); Assert.Contains(BaseFlag.Access, grant.Granted.Flags); - IReadOnlyList byEntity = await client.GetAclsByEntity(holder, cancellationToken); + IReadOnlyList byEntity = await client.ListAclsByEntity(holder, cancellationToken); Assert.Contains(byEntity, entry => entry.Principal is AclAccountPrincipal account && account.Account.PublicKeyString == recipient.PublicKeyString); - // A pre-fetched vote quote rides the transmit's temporary round. + // Pre-fetched vote quotes ride the transmit's temporary round, each + // routed back to the representative that issued it. using (Block quoted = BuildSend(runtime, user, recipient, Amount, state.HeadBlock)) { - byte[] quote = await client.GetVoteQuote(new[] { quoted }, cancellationToken); - Assert.NotEmpty(quote); + IReadOnlyList quotes = await user.GetQuotes(new[] { quoted }, cancellationToken); + VoteQuote quote = Assert.Single(quotes); + Assert.NotEmpty(quote.Bytes); + Assert.Equal(node.Api, quote.IssuerApiUrl); TransmitOptions options = TransmitOptions.WithFeeSigner(holder); - options.Quote = quote; + options.Quotes.Add(quote); Assert.True(await client.Transmit(quoted, options, cancellationToken)); } - BigInteger credited = await client.GetAccountBalance(recipient, baseToken, cancellationToken); + BigInteger credited = await client.GetBalance(recipient, baseToken, cancellationToken); Assert.Equal(new BigInteger(Amount * 2), credited); // A builder without a position publishes through the one-call path: @@ -337,20 +418,20 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() using (BlockBuilder builder = user.InitBuilder()) { builder.AddOperation(send); - Assert.True(await user.Publish(builder, cancellationToken: cancellationToken)); + Assert.True(await user.PublishBuilder(builder, cancellationToken: cancellationToken)); } - credited = await client.GetAccountBalance(recipient, baseToken, cancellationToken); + credited = await client.GetBalance(recipient, baseToken, cancellationToken); Assert.Equal(new BigInteger(Amount * 3), credited); // The one-call identifier claim derives against the pre-claim head, // publishes the CREATE_IDENTIFIER block, and returns the account. - AccountState beforeClaim = await user.GetState(cancellationToken); + AccountState beforeClaim = await user.State(cancellationToken); using Account tokenId = await user.GenerateIdentifier(IdentifierKind.Token, cancellationToken: cancellationToken); using Account expectedId = holder.GenerateIdentifier(IdentifierKind.Token, beforeClaim.HeadBlock); Assert.Equal(expectedId.PublicKeyString, tokenId.PublicKeyString); - AccountState afterClaim = await user.GetState(cancellationToken); + AccountState afterClaim = await user.State(cancellationToken); Assert.NotEqual(beforeClaim.HeadBlock, afterClaim.HeadBlock); harness.Shutdown(); @@ -369,8 +450,8 @@ public async Task CertificateWritesRoundTripAgainstTheLiveNode() node.Fund(E2eSeeds.Subject, Funding); - // A reference-issued chain for the holder: the node's graph check - // demands the CA extensions only the reference builder emits. + // The TypeScript harness issues the chain for the holder because the + // node's graph check demands CA extensions that only its builder emits. IssuedChain issued = node.IssueChain(E2eSeeds.Subject); using CryptoCertificate leaf = runtime.Certificates.Parse(issued.Leaf); using CryptoCertificate authority = runtime.Certificates.Parse(issued.Ca); @@ -381,7 +462,7 @@ public async Task CertificateWritesRoundTripAgainstTheLiveNode() Assert.True(await user.ModifyCertificate( AdjustMethod.Add, leaf, new[] { authority }, cancellationToken: cancellationToken)); - IReadOnlyList published = await user.GetAllCertificates(cancellationToken); + IReadOnlyList published = await user.GetCertificates(cancellationToken); Certificate record = Assert.Single(published); using (CryptoCertificate readBack = runtime.Certificates.Parse(record.Value)) { @@ -389,14 +470,14 @@ public async Task CertificateWritesRoundTripAgainstTheLiveNode() } Assert.Single(record.Intermediates); - Assert.NotNull(await user.GetCertificateByHash(leaf.Hash, cancellationToken)); + Assert.NotNull(await user.GetCertificates(leaf.Hash, cancellationToken)); - // The subtract retires the leaf by its hash; the reads empty out. + // The subtract retires the leaf by its hash. The reads empty out. Assert.True(await user.ModifyCertificate( AdjustMethod.Subtract, leaf, cancellationToken: cancellationToken)); - Assert.Empty(await user.GetAllCertificates(cancellationToken)); - Assert.Null(await user.GetCertificateByHash(leaf.Hash, cancellationToken)); + Assert.Empty(await user.GetCertificates(cancellationToken)); + Assert.Null(await user.GetCertificates(leaf.Hash, cancellationToken)); harness.Shutdown(); } @@ -434,16 +515,16 @@ private static Block BuildSend( /// private static async Task AssertRepresentativeReads(KeetaClient client, LedgerNode node, CancellationToken cancellationToken) { - NodeRepresentative own = await client.GetNodeRepresentative(cancellationToken); + NodeRepresentative own = await client.GetRepresentativeInfo(cancellationToken: cancellationToken); Assert.Equal(node.Representative, own.Account.PublicKeyString); Assert.True(own.Weight > BigInteger.Zero); - NodeRepresentative named = await client.GetRepresentative(own.Account, cancellationToken); + NodeRepresentative named = await client.GetRepresentativeInfo(own.Account, cancellationToken); Assert.Equal(node.Representative, named.Account.PublicKeyString); Assert.Equal(own.Weight, named.Weight); // Only the plural read advertises the REST endpoint. - IReadOnlyList all = await client.GetAllRepresentatives(cancellationToken); + IReadOnlyList all = await client.GetAllRepresentativeInfo(cancellationToken); NodeRepresentative advertised = Assert.Single(all, entry => entry.Account.PublicKeyString == node.Representative); Assert.NotNull(advertised.ApiUrl); Assert.NotEmpty(advertised.ApiUrl!); @@ -459,7 +540,7 @@ private static async Task AssertNodeDiagnostics(KeetaClient client, Cancellation JsonElement stats = await client.GetNodeStats(cancellationToken); Assert.Equal(JsonValueKind.Object, stats.ValueKind); - JsonElement peers = await client.GetNodePeers(cancellationToken); + JsonElement peers = await client.GetPeers(cancellationToken); Assert.Equal(JsonValueKind.Object, peers.ValueKind); } } diff --git a/tests/KeetaNet.Anchor.E2eTests/TestnetTests.cs b/tests/KeetaNet.Anchor.E2eTests/TestnetTests.cs new file mode 100644 index 0000000..60f500c --- /dev/null +++ b/tests/KeetaNet.Anchor.E2eTests/TestnetTests.cs @@ -0,0 +1,60 @@ +using System.Numerics; + +using KeetaNet.Anchor.Crypto; + +using Xunit; + +namespace KeetaNet.Anchor.E2eTests; + +/// +/// Opt-in tests against the live public test network. +/// +/// +/// These tests exercise the multi-representative transmit path that no local +/// test node can, because a local node runs a single representative that +/// holds all the weight. Set KEETA_TESTNET_SEED to a funded secp256k1 +/// seed to enable them. CI leaves them skipped. +/// +public sealed class TestnetTests +{ + /// The environment variable that carries the funded seed. + private const string SeedVariable = "KEETA_TESTNET_SEED"; + + [Fact] + public async Task SendRoundTripsAgainstTheLiveTestnet() + { + string seed = RequireSeed(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + + using var runtime = WasmRuntime.Load(); + using Account holder = runtime.Accounts.FromSeed(seed, 0, "ecdsa_secp256k1"); + using Account recipient = runtime.Accounts.FromSeed(seed, 1, "ecdsa_secp256k1"); + using UserClient user = runtime.CreateUserClient(KeetaNetwork.Test, holder); + KeetaClient client = user.Client; + + // The registry set answers reads and refreshes voting weights. + string version = await client.GetVersion(cancellationToken); + Assert.NotEmpty(version); + await client.UpdateReps(cancellationToken: cancellationToken); + + Account baseToken = client.BaseToken!; + BigInteger before = await user.Balance(baseToken, cancellationToken); + Assert.True(before > BigInteger.Zero, $"fund {holder.PublicKeyString} on the testnet first"); + + // The send must gather votes across representatives. No single + // testnet representative holds quorum weight on its own. + BigInteger sent = await client.GetBalance(recipient, baseToken, cancellationToken); + Assert.True(await user.Send(recipient, 1, baseToken, cancellationToken: cancellationToken)); + + BigInteger credited = await client.GetBalance(recipient, baseToken, cancellationToken); + Assert.Equal(sent + 1, credited); + } + + /// Returns the configured seed and skips the test when it is absent. + private static string RequireSeed() + { + string? seed = Environment.GetEnvironmentVariable(SeedVariable); + Assert.SkipWhen(string.IsNullOrEmpty(seed), $"set {SeedVariable} to a funded testnet seed to run"); + return seed!; + } +} diff --git a/tests/KeetaNet.Anchor.Tests/BlockTests.cs b/tests/KeetaNet.Anchor.Tests/BlockTests.cs index cd7d1c5..83d69a6 100644 --- a/tests/KeetaNet.Anchor.Tests/BlockTests.cs +++ b/tests/KeetaNet.Anchor.Tests/BlockTests.cs @@ -13,7 +13,7 @@ namespace KeetaNet.Anchor.Tests; /// public sealed class BlockTests { - /// The reference TEST network id; signing rejects unknown networks. + /// The TEST network id. Signing rejects unknown networks. private const long Network = 0x5445_5354; /// A neighboring known network (DEV), for the derivation contrast. @@ -178,34 +178,6 @@ public void IdentifierOperationsDeriveDeterministicIdentifierAccounts() using BlockOperation supply = runtime.Blocks.TokenAdminSupply(1_000, AdjustMethod.Add); } - [Fact] - public async Task TransmitRefusesAClientWithoutABoundNetwork() - { - using var runtime = WasmRuntime.Load(); - using Account sender = runtime.Accounts.FromSeed(TestSeeds.Subject, 0, TestSeeds.DefaultAlgorithm); - using Account recipient = runtime.Accounts.FromSeed(TestSeeds.Recipient, 0, TestSeeds.DefaultAlgorithm); - using Account token = runtime.Blocks.NetworkBaseToken(Network); - - using BlockOperation send = runtime.Blocks.Send(recipient, 42, token); - using var builder = runtime.Blocks.NewBuilder(); - builder - .WithVersion(2) - .WithNetwork(Network) - .WithAccount(sender) - .WithSigner(sender) - .WithDate(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000)) - .AsOpening() - .AddOperation(send); - using Block block = builder.Build(); - - // The anchor URL is non-routable, so reaching the transport would - // surface NODE_STATUS instead: the gate must trip first. - using KeetaClient client = runtime.CreateKeetaClient(TestSeeds.NonRoutableAnchor); - KeetaException refused = await Assert.ThrowsAsync( - () => client.Transmit(block, cancellationToken: TestContext.Current.CancellationToken)); - Assert.Equal("NETWORK_REQUIRED", refused.Code); - } - [Fact] public async Task TransmitRefusesAReadOnlyUserClientEvenWithAFeeFactory() { @@ -249,7 +221,7 @@ public async Task TransmitRefusesAReadOnlyUserClientEvenWithAFeeFactory() .WithDate(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000)); KeetaException refusedPublish = await Assert.ThrowsAsync( - () => readOnly.Publish(external, options, TestContext.Current.CancellationToken)); + () => readOnly.PublishBuilder(external, options, TestContext.Current.CancellationToken)); Assert.Equal("SIGNER_REQUIRED", refusedPublish.Code); } diff --git a/tests/KeetaNet.Anchor.Tests/NetworkTests.cs b/tests/KeetaNet.Anchor.Tests/NetworkTests.cs index e8627ad..9bf8fbb 100644 --- a/tests/KeetaNet.Anchor.Tests/NetworkTests.cs +++ b/tests/KeetaNet.Anchor.Tests/NetworkTests.cs @@ -3,9 +3,9 @@ namespace KeetaNet.Anchor.Tests; /// -/// The well-known network registry: ids, aliases, and representative -/// endpoints must match the reference registry verbatim, and the -/// fromNetwork-style factories must bind them. +/// The well-known network registry. The ids, the aliases, and the +/// representative endpoints must match the reference registry exactly, and +/// the fromNetwork-style factories must bind them. /// public sealed class NetworkTests { @@ -21,6 +21,32 @@ public void TheRegistryMatchesTheReferenceValues(KeetaNetwork network, long id, Assert.Equal(apiUrl, network.RepresentativeApiUrl()); } + [Theory] + [InlineData(KeetaNetwork.Main, "keeta_aabwip6zeo2fnzfxp5hssrrqtascs2277w2zk7vqd6d3k3m4dkt2flcbca2mqki")] + [InlineData(KeetaNetwork.Staging, "keeta_aabaagdrwrwnkzox4u3qh6uukre6lckax6kb5fwyxd4vtpua6vrjc6nuhb75fji")] + [InlineData(KeetaNetwork.Test, "keeta_aabi4bd3f7jrt67mxcq44ozj65bh4bp2mygmrkedxggu2rxwn2ztuw3b6exivbq")] + public void TheRegistryCarriesFourKeyedRepresentativesPerNetwork(KeetaNetwork network, string firstKey) + { + IReadOnlyList representatives = network.Representatives(); + + Assert.Equal(4, representatives.Count); + Assert.Equal(firstKey, representatives[0].Key); + Assert.All(representatives, entry => Assert.NotNull(entry.Key)); + Assert.Equal(network.RepresentativeApiUrl(2), representatives[1].ApiUrl); + Assert.Equal(network.RepresentativeP2pUrl(3), representatives[2].P2pUrl); + Assert.StartsWith("wss://", representatives[0].P2pUrl!, StringComparison.Ordinal); + } + + [Fact] + public void TheDevRegistryDerivesItsRepresentativesAtRuntime() + { + IReadOnlyList representatives = KeetaNetwork.Dev.Representatives(); + + Assert.Equal(4, representatives.Count); + Assert.All(representatives, entry => Assert.Null(entry.Key)); + Assert.Equal("https://rep1.dev.api.keeta.com/api", representatives[0].ApiUrl); + } + [Fact] public void TheNetworkFactoriesBindTheNetworkAndDeriveItsBaseToken() { From f896fdf3665c48c322a921805d3e4b08577b006f Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Mon, 3 Aug 2026 17:38:44 -0700 Subject: [PATCH 2/7] fix: bind `ListenForStaples` --- src/KeetaNet.Anchor/Services/Node/UserClient.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/KeetaNet.Anchor/Services/Node/UserClient.cs b/src/KeetaNet.Anchor/Services/Node/UserClient.cs index a4f11b5..9ebdb34 100644 --- a/src/KeetaNet.Anchor/Services/Node/UserClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/UserClient.cs @@ -509,6 +509,12 @@ await socket.SendAsync( cancellationToken).ConfigureAwait(false); } + /// + /// The largest socket message the listener accepts. The notifications are + /// small JSON objects, so anything larger is a misbehaving peer. + /// + private const int MaxSocketMessageBytes = 1024 * 1024; + /// Consumes socket messages until the socket closes and reacts to add notifications. private async Task ListenForStaples(ClientWebSocket socket, CancellationToken cancellationToken) { @@ -520,6 +526,13 @@ private async Task ListenForStaples(ClientWebSocket socket, CancellationToken ca do { result = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + if (message.Length + result.Count > MaxSocketMessageBytes) + { + // Surfacing this as a socket failure drops the connection + // and re-enters the backoff loop, bounding memory. + throw new WebSocketException("the peer sent an oversized message"); + } + message.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); @@ -529,7 +542,8 @@ private async Task ListenForStaples(ClientWebSocket socket, CancellationToken ca return; } - using JsonDocument document = JsonDocument.Parse(Encoding.UTF8.GetString(message.ToArray())); + var payload = new ReadOnlyMemory(message.GetBuffer(), 0, (int)message.Length); + using JsonDocument document = JsonDocument.Parse(payload); if (document.RootElement.TryGetProperty("add", out _)) { await EmitIfChanged(cancellationToken).ConfigureAwait(false); From 2ab310c9ea0571cec70c4d0a4300b5a787e25228 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Mon, 3 Aug 2026 17:38:53 -0700 Subject: [PATCH 3/7] chore: lint --- cspell.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cspell.yaml b/cspell.yaml index fccfea9..43f928b 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -10,6 +10,9 @@ ignorePaths: - tests/node-harness/node_modules/** - tests/node-harness/dist/** - tests/node-harness/package-lock.json +ignoreRegExpList: + # KeetaNet account addresses (base32 payloads). + - "keeta_[a-z0-9]+" words: - keeta - keetanet From ca6610695419c1925e451eb6b6e8098a4118973e Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Mon, 3 Aug 2026 18:07:29 -0700 Subject: [PATCH 4/7] fix: harden update reps --- .../Services/Node/KeetaClient.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs b/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs index 54c0da4..41bb048 100644 --- a/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs @@ -612,7 +612,9 @@ public async Task Transmit( /// /// Unknown representatives join the set when /// is set. Reads and votes - /// prefer the representatives with higher weights afterward. + /// prefer the representatives with higher weights afterward. The + /// contacted node advertises the endpoints and the client sends requests + /// to them, so enable discovery only against a trusted network. /// [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", Justification = "GetAllRepresentativeInfo transfers ownership of the returned accounts to the caller; this method is that caller.")] @@ -637,10 +639,7 @@ public async Task UpdateReps(bool addNewRepresentatives = false, CancellationTok continue; } - // Two entries with one URL would double-contact the same - // node, so an already-known endpoint never joins again. - bool knownUrl = _representatives.Exists(rep => rep.Endpoint.ApiUrl == info.ApiUrl); - if (addNewRepresentatives && !knownUrl && !string.IsNullOrEmpty(info.ApiUrl)) + if (addNewRepresentatives && IsAdoptableUrl(info.ApiUrl)) { var endpoint = new RepresentativeEndpoint(key, info.ApiUrl, null); _representatives.Add(new Representative(endpoint, new NodeApi(_representativeHttp) { BaseUrl = info.ApiUrl }) @@ -655,6 +654,14 @@ public async Task UpdateReps(bool addNewRepresentatives = false, CancellationTok _representativesRefreshedAt = DateTimeOffset.UtcNow; } + /// + /// Returns whether an advertised endpoint may join the representative + /// set. Only absolute HTTP and HTTPS URLs qualify. + /// + private static bool IsAdoptableUrl([NotNullWhen(true)] string? url) => + Uri.TryCreate(url, UriKind.Absolute, out Uri? parsed) + && (parsed.Scheme == Uri.UriSchemeHttps || parsed.Scheme == Uri.UriSchemeHttp); + /// /// Ensures that the representative weights are fresh before a voting round. /// From 0bcea9415a9572ea2dbfd9c790548e342cbc5dba Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 4 Aug 2026 09:45:32 -0700 Subject: [PATCH 5/7] test: improve coverage --- .../Interop/WasmRuntime.Surface.cs | 27 +++ .../Services/Node/UserClient.cs | 24 +-- tests/KeetaNet.Anchor.E2eTests/Anchors.cs | 5 +- .../KeetaNet.Anchor.E2eTests.csproj | 5 + .../KeetaNet.Anchor.E2eTests/NodeFlowTests.cs | 168 ++++++++++++++++-- .../ScriptedP2pNode.cs | 139 +++++++++++++++ .../KeetaNet.Anchor.E2eTests/TestnetTests.cs | 2 +- tests/node-harness/src/chain.ts | 5 +- tests/node-harness/src/node.ts | 1 + 9 files changed, 345 insertions(+), 31 deletions(-) create mode 100644 tests/KeetaNet.Anchor.E2eTests/ScriptedP2pNode.cs diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs index 97ec5a8..86c3999 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs @@ -75,6 +75,20 @@ public KeetaClient CreateKeetaClient(string nodeUrl, HttpClient? httpClient = nu public KeetaClient CreateKeetaClient(KeetaNetwork network, HttpClient? httpClient = null) => new(this, network.Representatives(), httpClient, network.Id()); + /// + /// Creates the base client over a custom set. + /// + /// + /// This is the custom-configuration path for self-hosted networks. See + /// the overload for the fan-out behavior and + /// the URL overload for the remaining parameters. + /// + public KeetaClient CreateKeetaClient( + IReadOnlyList representatives, + HttpClient? httpClient = null, + long? network = null) => + new(this, representatives, httpClient, network); + /// /// Creates a client bound to , or a read-only /// client when the signer is null. @@ -103,4 +117,17 @@ public UserClient CreateUserClient( HttpClient? httpClient = null, Account? account = null) => new(this, CreateKeetaClient(network, httpClient), signer, account); + + /// + /// Creates a signer-bound client over a custom + /// set. + /// + /// See the URL overload for the remaining parameters. + public UserClient CreateUserClient( + IReadOnlyList representatives, + Account? signer, + HttpClient? httpClient = null, + long? network = null, + Account? account = null) => + new(this, CreateKeetaClient(representatives, httpClient, network), signer, account); } diff --git a/src/KeetaNet.Anchor/Services/Node/UserClient.cs b/src/KeetaNet.Anchor/Services/Node/UserClient.cs index 9ebdb34..fddc463 100644 --- a/src/KeetaNet.Anchor/Services/Node/UserClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/UserClient.cs @@ -101,19 +101,19 @@ internal UserClient( ?? throw new KeetaException("SIGNER_REQUIRED", "bind a signer or an operating account to the user client"); /// Gets the full state of the operating account. - public Task State(CancellationToken cancellationToken = default) => + public Task GetState(CancellationToken cancellationToken = default) => _client.GetAccountInfo(Account, cancellationToken); /// Gets the settled balance of held by the operating account. - public Task Balance(Crypto.Account token, CancellationToken cancellationToken = default) => + public Task GetBalance(Crypto.Account token, CancellationToken cancellationToken = default) => _client.GetBalance(Account, token, cancellationToken); /// Gets every token balance held by the operating account. - public Task> AllBalances(CancellationToken cancellationToken = default) => + public Task> GetAllBalances(CancellationToken cancellationToken = default) => _client.GetAllBalances(Account, cancellationToken); /// Gets the certificates published by the operating account. - public Task> GetCertificates(CancellationToken cancellationToken = default) => + public Task> GetAllCertificates(CancellationToken cancellationToken = default) => _client.GetAllCertificates(Account, cancellationToken); /// @@ -121,20 +121,20 @@ public Task> GetCertificates(CancellationToken cancel /// . /// /// The record, or null when the account never published it. - public Task GetCertificates( + public Task GetCertificateByHash( Crypto.CertificateHash certificateHash, CancellationToken cancellationToken = default) => _client.GetCertificateByHash(Account, certificateHash, cancellationToken); /// Gets the hash of the operating account's head block, or null for a fresh account. - public async Task Head(CancellationToken cancellationToken = default) + public async Task GetHead(CancellationToken cancellationToken = default) { using Crypto.Block? head = await _client.GetHeadBlock(Account, cancellationToken).ConfigureAwait(false); return head?.Hash; } /// Gets the next pending (unreceived) block for the operating account, if any. - public Task PendingBlock(CancellationToken cancellationToken = default) => + public Task GetPendingBlock(CancellationToken cancellationToken = default) => _client.GetPendingBlock(Account, cancellationToken); /// @@ -148,11 +148,11 @@ public Task> GetCertificates(CancellationToken cancel _client.GetBlockFromIdempotent(Account, key, side, cancellationToken); /// Gets one page of the operating account's block chain, most recent first. - public Task Chain(ChainQuery? query = null, CancellationToken cancellationToken = default) => + public Task GetChain(ChainQuery? query = null, CancellationToken cancellationToken = default) => _client.GetChain(Account, query, cancellationToken); /// Gets one page of the operating account's committed staple history. - public Task History(HistoryQuery? query = null, CancellationToken cancellationToken = default) => + public Task GetHistory(HistoryQuery? query = null, CancellationToken cancellationToken = default) => _client.GetHistory(Account, query, cancellationToken); /// Lists the ACL entries where the operating account is the principal. @@ -224,7 +224,7 @@ public async Task PublishBuilder( _ = RequireSigner(); TransmitOptions resolved = OrDefaultFeePayer(options); - AccountState state = await State(cancellationToken).ConfigureAwait(false); + AccountState state = await GetState(cancellationToken).ConfigureAwait(false); KeetaClient.PositionAfter(builder, state.HeadBlock?.ToString()); using Crypto.Block block = builder.Build(); @@ -243,7 +243,7 @@ public async Task PublishBuilder( CancellationToken cancellationToken = default) { TransmitOptions resolved = OrDefaultFeePayer(options); - AccountState state = await State(cancellationToken).ConfigureAwait(false); + AccountState state = await GetState(cancellationToken).ConfigureAwait(false); Crypto.Account identifier = Account.GenerateIdentifier(kind, state.HeadBlock); try @@ -569,7 +569,7 @@ private async Task EmitIfChanged(CancellationToken cancellationToken) try { - AccountState state = await State(cancellationToken).ConfigureAwait(false); + AccountState state = await GetState(cancellationToken).ConfigureAwait(false); try { string fingerprint = FingerprintOf(state); diff --git a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs index 2957768..839edea 100644 --- a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs +++ b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs @@ -61,14 +61,16 @@ internal sealed class LedgerNode private readonly NodeHarness _harness; public string Api { get; } + public string P2p { get; } public string BaseToken { get; } public string Representative { get; } public long Network { get; } - private LedgerNode(NodeHarness harness, string api, string baseToken, string representative, long network) + private LedgerNode(NodeHarness harness, string api, string p2p, string baseToken, string representative, long network) { _harness = harness; Api = api; + P2p = p2p; BaseToken = baseToken; Representative = representative; Network = network; @@ -82,6 +84,7 @@ public static LedgerNode Start(NodeHarness harness) return new LedgerNode( harness, started.GetProperty("api").GetString()!, + started.GetProperty("p2p").GetString()!, started.GetProperty("baseToken").GetString()!, started.GetProperty("representative").GetString()!, long.Parse(started.GetProperty("network").GetString()!, System.Globalization.CultureInfo.InvariantCulture)); diff --git a/tests/KeetaNet.Anchor.E2eTests/KeetaNet.Anchor.E2eTests.csproj b/tests/KeetaNet.Anchor.E2eTests/KeetaNet.Anchor.E2eTests.csproj index dff3e0f..6f5a8cd 100644 --- a/tests/KeetaNet.Anchor.E2eTests/KeetaNet.Anchor.E2eTests.csproj +++ b/tests/KeetaNet.Anchor.E2eTests/KeetaNet.Anchor.E2eTests.csproj @@ -13,6 +13,11 @@ + + + + + diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs index ee2a22e..df054fb 100644 --- a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs @@ -161,12 +161,12 @@ public async Task FeeBearingSendTransmitsAgainstTheLiveNode() BigInteger credited = await client.GetBalance(recipient, baseToken, cancellationToken); Assert.Equal(new BigInteger(Amount), credited); - BigInteger remaining = await user.Balance(baseToken, cancellationToken); + BigInteger remaining = await user.GetBalance(baseToken, cancellationToken); Assert.Equal(new BigInteger(Funding) - Amount - RoundFee, remaining); // The fee block chained atop the send, so the holder's head advanced // past the send block and must match the reference client's. - AccountState state = await user.State(cancellationToken); + AccountState state = await user.GetState(cancellationToken); Assert.NotNull(state.HeadBlock); string? referenceHead = node.Head(holder.PublicKeyString); @@ -176,10 +176,10 @@ public async Task FeeBearingSendTransmitsAgainstTheLiveNode() // The SET_REP chains atop the advanced head and costs one more fee. Assert.True(await user.SetRep(recipient, cancellationToken: cancellationToken)); - state = await user.State(cancellationToken); + state = await user.GetState(cancellationToken); Assert.Equal(recipient.PublicKeyString, state.Representative!.PublicKeyString); remaining -= RoundFee; - Assert.Equal(remaining, await user.Balance(baseToken, cancellationToken)); + Assert.Equal(remaining, await user.GetBalance(baseToken, cancellationToken)); // A fee-less transmit against the fee-enforcing node refuses with the // typed FEE_REQUIRED before anything is published. The refusal leaves @@ -214,7 +214,7 @@ public async Task FeeBearingSendTransmitsAgainstTheLiveNode() } // No refusal advanced either chain. - Assert.Equal(remaining, await user.Balance(baseToken, cancellationToken)); + Assert.Equal(remaining, await user.GetBalance(baseToken, cancellationToken)); Assert.Equal(new BigInteger(Amount), await client.GetBalance(recipient, baseToken, cancellationToken)); harness.Shutdown(); @@ -264,6 +264,142 @@ public async Task ChangeListenersReactToLedgerMutations() harness.Shutdown(); } + [Fact] + public async Task ChangeSocketReactsToTheLiveNodeBroadcast() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("node"); + LedgerNode node = LedgerNode.Start(harness); + + using var runtime = WasmRuntime.Load(); + using Account holder = runtime.Accounts.FromSeed(E2eSeeds.Subject, 0, E2eSeeds.Secp256k1); + using Account recipient = runtime.Accounts.FromSeed(E2eSeeds.Recipient, 0, E2eSeeds.Secp256k1); + + // The reference node's real P2P socket serves this client, so the + // greeting and the staple broadcasts cross implementations. + var endpoints = new[] { new RepresentativeEndpoint(null, node.Api, node.P2p) }; + using UserClient user = runtime.CreateUserClient(endpoints, holder, network: node.Network); + Account baseToken = user.Client.BaseToken!; + + node.Fund(E2eSeeds.Subject, Funding); + + // A long fallback keeps the poll out of the test. Every emission + // below must arrive through the socket. + var heads = new System.Collections.Concurrent.ConcurrentQueue(); + using var delivered = new SemaphoreSlim(0); + var options = new ChangeListenerOptions { FallbackFrequency = TimeSpan.FromMinutes(10) }; + + using (user.OnChange( + state => + { + heads.Enqueue(state.HeadBlock?.ToString() ?? ""); + delivered.Release(); + }, + options)) + { + // The socket needs a moment to connect and greet, or the node + // broadcasts the first staple before this participant registers. + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); + + // The node broadcasts each published staple to the greeted + // socket, and the listener re-reads the account and emits. + Assert.True(await user.Send(recipient, 7, baseToken, cancellationToken: cancellationToken)); + Assert.True(await delivered.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken)); + Assert.True(await user.Send(recipient, 9, baseToken, cancellationToken: cancellationToken)); + Assert.True(await delivered.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken)); + } + + // Each broadcast delivered a fresh head, and the final head matches + // the reference client's own view of the chain. + Assert.True(heads.Count >= 2); + string[] observed = heads.ToArray(); + Assert.NotEqual(observed[0], observed[^1]); + Assert.Equal(BlockHash.Parse(node.Head(holder.PublicKeyString)!), BlockHash.Parse(observed[^1])); + + harness.Shutdown(); + } + + [Fact] + public async Task ChangeSocketDropsOversizedFramesAndReconnects() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("node"); + LedgerNode node = LedgerNode.Start(harness); + + await using ScriptedP2pNode p2p = await ScriptedP2pNode.Start(); + + using var runtime = WasmRuntime.Load(); + using Account holder = runtime.Accounts.FromSeed(E2eSeeds.Subject, 0, E2eSeeds.Secp256k1); + using Account recipient = runtime.Accounts.FromSeed(E2eSeeds.Recipient, 0, E2eSeeds.Secp256k1); + + // The scripted endpoint stands in for the P2P socket, so the test + // can send the hostile frames the reference node never produces. + var endpoints = new[] { new RepresentativeEndpoint(null, node.Api, p2p.WsUrl) }; + using UserClient user = runtime.CreateUserClient(endpoints, holder, network: node.Network); + Account baseToken = user.Client.BaseToken!; + + node.Fund(E2eSeeds.Subject, Funding); + + // A long fallback keeps the poll out of the test. Every emission + // below must arrive through the socket. + var heads = new System.Collections.Concurrent.ConcurrentQueue(); + using var delivered = new SemaphoreSlim(0); + var options = new ChangeListenerOptions { FallbackFrequency = TimeSpan.FromMinutes(10) }; + + using (user.OnChange( + state => + { + heads.Enqueue(state.HeadBlock?.ToString() ?? ""); + delivered.Release(); + }, + options)) + { + // The client greets as a participant filtered to its account. + ScriptedP2pConnection first = await p2p.NextConnection(cancellationToken); + JsonElement greeting = first.Greeting.GetProperty("greeting"); + Assert.Equal(0, greeting.GetProperty("kind").GetInt32()); + Assert.Equal(holder.PublicKeyString, greeting.GetProperty("filter").GetString()); + Assert.False(string.IsNullOrEmpty(first.Greeting.GetProperty("id").GetString())); + + // An `add` notification makes the client re-read the account. + await first.Send("{\"add\":{}}"); + Assert.True(await delivered.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken)); + + // Advance the ledger, then trip the oversize guard. + Assert.True(await user.Send(recipient, 7, baseToken, cancellationToken: cancellationToken)); + try + { + await first.Send("{\"pad\":\"" + new string('x', 2 * 1024 * 1024) + "\"}"); + } + catch (System.Net.WebSockets.WebSocketException) + { + // The client aborts mid-frame once the guard trips, so the + // send may observe the closed connection. + } + + first.Complete(); + + // The client reconnects with backoff and greets again. The next + // add delivers the advanced head. + ScriptedP2pConnection second = await p2p.NextConnection(cancellationToken); + Assert.Equal( + holder.PublicKeyString, + second.Greeting.GetProperty("greeting").GetProperty("filter").GetString()); + + await second.Send("{\"add\":{}}"); + Assert.True(await delivered.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken)); + second.Complete(); + } + + Assert.Equal(2, heads.Count); + + string[] observed = heads.ToArray(); + Assert.NotEqual(observed[0], observed[1]); + Assert.NotEmpty(observed[1]); + + harness.Shutdown(); + } + [Fact] public async Task UpdateRepsRefreshesWeightsFromTheLiveNode() { @@ -327,7 +463,7 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() using Permissions access = runtime.Blocks.PermissionsFromFlags(AccessFlag); Assert.True(await user.UpdatePermissions(recipient, access, cancellationToken: cancellationToken)); - AccountState state = await user.State(cancellationToken); + AccountState state = await user.GetState(cancellationToken); Assert.Equal("HOLDER", state.Info!.Name); Assert.NotNull(state.HeadBlock); @@ -349,11 +485,11 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() // The chain lists most recent first. A limit of one pages with a // cursor, and the block behind the head names the head as successor. - ChainPage newest = await user.Chain(new ChainQuery(Limit: 1), cancellationToken); + ChainPage newest = await user.GetChain(new ChainQuery(Limit: 1), cancellationToken); Assert.Equal(head.Hash, Assert.Single(newest.Blocks).Hash); Assert.NotNull(newest.NextKey); - ChainPage chain = await user.Chain(cancellationToken: cancellationToken); + ChainPage chain = await user.GetChain(cancellationToken: cancellationToken); Assert.True(chain.Blocks.Count >= 2); Assert.Equal(head.Hash, chain.Blocks[0].Hash); @@ -361,7 +497,7 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() Assert.Equal(head.Hash, successor!.Hash); // Account and global history both carry the committed staples. - HistoryPage history = await user.History(cancellationToken: cancellationToken); + HistoryPage history = await user.GetHistory(cancellationToken: cancellationToken); Assert.NotEmpty(history.Entries); Assert.All(history.Entries, entry => Assert.NotEmpty(entry.StapleBytes)); Assert.All(history.Entries, entry => Assert.NotNull(entry.Timestamp)); @@ -379,7 +515,7 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() vote.Dispose(); } - Assert.Null(await user.PendingBlock(cancellationToken)); + Assert.Null(await user.GetPendingBlock(cancellationToken)); Assert.Null(await user.GetBlockFromIdempotent(Guid.NewGuid().ToString("N"), cancellationToken: cancellationToken)); // The grant reads back typed from both directions: the recipient as @@ -426,12 +562,12 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() // The one-call identifier claim derives against the pre-claim head, // publishes the CREATE_IDENTIFIER block, and returns the account. - AccountState beforeClaim = await user.State(cancellationToken); + AccountState beforeClaim = await user.GetState(cancellationToken); using Account tokenId = await user.GenerateIdentifier(IdentifierKind.Token, cancellationToken: cancellationToken); using Account expectedId = holder.GenerateIdentifier(IdentifierKind.Token, beforeClaim.HeadBlock); Assert.Equal(expectedId.PublicKeyString, tokenId.PublicKeyString); - AccountState afterClaim = await user.State(cancellationToken); + AccountState afterClaim = await user.GetState(cancellationToken); Assert.NotEqual(beforeClaim.HeadBlock, afterClaim.HeadBlock); harness.Shutdown(); @@ -462,7 +598,7 @@ public async Task CertificateWritesRoundTripAgainstTheLiveNode() Assert.True(await user.ModifyCertificate( AdjustMethod.Add, leaf, new[] { authority }, cancellationToken: cancellationToken)); - IReadOnlyList published = await user.GetCertificates(cancellationToken); + IReadOnlyList published = await user.GetAllCertificates(cancellationToken); Certificate record = Assert.Single(published); using (CryptoCertificate readBack = runtime.Certificates.Parse(record.Value)) { @@ -470,14 +606,14 @@ public async Task CertificateWritesRoundTripAgainstTheLiveNode() } Assert.Single(record.Intermediates); - Assert.NotNull(await user.GetCertificates(leaf.Hash, cancellationToken)); + Assert.NotNull(await user.GetCertificateByHash(leaf.Hash, cancellationToken)); // The subtract retires the leaf by its hash. The reads empty out. Assert.True(await user.ModifyCertificate( AdjustMethod.Subtract, leaf, cancellationToken: cancellationToken)); - Assert.Empty(await user.GetCertificates(cancellationToken)); - Assert.Null(await user.GetCertificates(leaf.Hash, cancellationToken)); + Assert.Empty(await user.GetAllCertificates(cancellationToken)); + Assert.Null(await user.GetCertificateByHash(leaf.Hash, cancellationToken)); harness.Shutdown(); } diff --git a/tests/KeetaNet.Anchor.E2eTests/ScriptedP2pNode.cs b/tests/KeetaNet.Anchor.E2eTests/ScriptedP2pNode.cs new file mode 100644 index 0000000..c34c2de --- /dev/null +++ b/tests/KeetaNet.Anchor.E2eTests/ScriptedP2pNode.cs @@ -0,0 +1,139 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace KeetaNet.Anchor.E2eTests; + +/// +/// A test-controlled WebSocket endpoint for adversarial P2P cases. It hands each +/// accepted connection to the test, which scripts the protocol. +/// +internal sealed class ScriptedP2pNode : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly Channel _connections = + Channel.CreateUnbounded(); + + private ScriptedP2pNode(WebApplication app) + { + _app = app; + } + + /// The advertised ws:// endpoint. + public string WsUrl { get; private set; } = ""; + + public static async Task Start() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + + WebApplication app = builder.Build(); + var node = new ScriptedP2pNode(app); + + app.UseWebSockets(); + app.Run(async context => + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync(); + var connection = new ScriptedP2pConnection(socket); + try + { + await connection.ReadGreeting(); + await node._connections.Writer.WriteAsync(connection); + await connection.Completion; + } + catch (Exception exception) when (exception is WebSocketException or IOException or OperationCanceledException) + { + // The client dropped the connection. The handler just ends. + } + }); + + await app.StartAsync(); + string url = app.Urls.First(); + node.WsUrl = url.Replace("http://", "ws://", StringComparison.Ordinal); + return node; + } + + /// Waits for the next client connection, greeting already read. + public async Task NextConnection(CancellationToken cancellationToken) + { + return await _connections.Reader.ReadAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + while (_connections.Reader.TryRead(out ScriptedP2pConnection? connection)) + { + connection.Complete(); + } + + await _app.StopAsync(); + await _app.DisposeAsync(); + } +} + +/// +/// One accepted P2P connection. The test sends protocol messages through it +/// and completes it when done, which lets the server handler end. +/// +internal sealed class ScriptedP2pConnection +{ + private readonly WebSocket _socket; + private readonly TaskCompletionSource _done = new(TaskCreationOptions.RunContinuationsAsynchronously); + + internal ScriptedP2pConnection(WebSocket socket) + { + _socket = socket; + } + + /// The client's parsed greeting message. + public JsonElement Greeting { get; private set; } + + /// Held open until the test calls . + internal Task Completion => _done.Task; + + /// Reads the first (greeting) text message from the client. + internal async Task ReadGreeting() + { + byte[] buffer = new byte[64 * 1024]; + using var message = new MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await _socket.ReceiveAsync(buffer, CancellationToken.None); + message.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + + using JsonDocument document = JsonDocument.Parse(message.ToArray()); + Greeting = document.RootElement.Clone(); + } + + /// Sends one text message to the client. + public async Task Send(string json) + { + await _socket.SendAsync( + Encoding.UTF8.GetBytes(json), + WebSocketMessageType.Text, + endOfMessage: true, + CancellationToken.None); + } + + /// Releases the server handler, which closes the connection. + public void Complete() + { + _done.TrySetResult(); + } +} diff --git a/tests/KeetaNet.Anchor.E2eTests/TestnetTests.cs b/tests/KeetaNet.Anchor.E2eTests/TestnetTests.cs index 60f500c..4cd2e43 100644 --- a/tests/KeetaNet.Anchor.E2eTests/TestnetTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/TestnetTests.cs @@ -38,7 +38,7 @@ public async Task SendRoundTripsAgainstTheLiveTestnet() await client.UpdateReps(cancellationToken: cancellationToken); Account baseToken = client.BaseToken!; - BigInteger before = await user.Balance(baseToken, cancellationToken); + BigInteger before = await user.GetBalance(baseToken, cancellationToken); Assert.True(before > BigInteger.Zero, $"fund {holder.PublicKeyString} on the testnet first"); // The send must gather votes across representatives. No single diff --git a/tests/node-harness/src/chain.ts b/tests/node-harness/src/chain.ts index 187ef7f..76a0c98 100644 --- a/tests/node-harness/src/chain.ts +++ b/tests/node-harness/src/chain.ts @@ -41,6 +41,8 @@ export interface ChainNode { node: ReferenceNode; /* The node API base URL, e.g. `http://127.0.0.1:`. */ api: string; + /* The node P2P WebSocket URL, e.g. `ws://127.0.0.1:`. */ + p2p: string; /* A UserClient for the funded representative account. */ repClient: UserClient; /* Send `amount` of the base token to `account`. */ @@ -66,6 +68,7 @@ export async function bootChainNode(): Promise { const node = await nodeTesting.createTestNode(repNodeAccount, { createInitialVoteStaple: false, + enableP2P: true, nodeConfig: { nodeAlias: 'TEST' }, ledger: { computeFeeFromBlocks: function(_ignore_ledger, _ignore_blocks, _ignore_effects) { @@ -136,5 +139,5 @@ export async function bootChainNode(): Promise { return(rootAccount.publicKeyString.get()); }; - return({ node, api: endpoints.api, repClient, give, publish, clientFor }); + return({ node, api: endpoints.api, p2p: endpoints.p2p, repClient, give, publish, clientFor }); } diff --git a/tests/node-harness/src/node.ts b/tests/node-harness/src/node.ts index f942bf9..1b415fc 100644 --- a/tests/node-harness/src/node.ts +++ b/tests/node-harness/src/node.ts @@ -116,6 +116,7 @@ async function handleStartNode(): Promise { return({ event: 'node-started', api: chain.api, + p2p: chain.p2p, baseToken: chain.repClient.baseToken.publicKeyString.get(), representative: chain.repClient.account.publicKeyString.get(), network: chain.node.config.network.toString() From 280d56e515675171e05744d7f70f6c3e659d82f3 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 4 Aug 2026 10:27:18 -0700 Subject: [PATCH 6/7] test: add additional testing for supply --- .../KeetaNet.Anchor.E2eTests/NodeFlowTests.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs index df054fb..1247ead 100644 --- a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs @@ -573,6 +573,75 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() harness.Shutdown(); } + [Fact] + public async Task TokenSetupSupplyAndSendRoundTripAgainstTheLiveNode() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("node"); + LedgerNode node = LedgerNode.Start(harness); + + using var runtime = WasmRuntime.Load(); + using Account holder = runtime.Accounts.FromSeed(E2eSeeds.Subject, 0, E2eSeeds.Secp256k1); + using UserClient user = runtime.CreateUserClient(node.Api, holder, network: node.Network); + KeetaClient client = user.Client; + + node.Fund(E2eSeeds.Subject, Funding); + + // The one-call claim creates the token identifier under the holder. + using Account token = await user.GenerateIdentifier(IdentifierKind.Token, cancellationToken: cancellationToken); + + // The setup block opens the token's own chain, signed by the owner: + // info with a public default permission, then the initial supply. + string metadata = Convert.ToBase64String("{\"decimalPlaces\":10}"u8.ToArray()); + using Permissions access = runtime.Blocks.PermissionsFromFlags(new[] { BaseFlag.Access }); + using BlockOperation setInfo = runtime.Blocks.SetInfo("TKNA", "Example Token", metadata, access); + using BlockOperation supply = runtime.Blocks.TokenAdminSupply(50_000, AdjustMethod.Add); + + using BlockBuilder setupBuilder = runtime.Blocks.NewBuilder(); + setupBuilder + .WithVersion(2) + .WithNetwork(node.Network) + .WithAccount(token) + .WithSigner(holder) + .WithDate(DateTimeOffset.UtcNow) + .AsOpening() + .AddOperation(setInfo) + .AddOperation(supply); + using Block setup = setupBuilder.Build(); + + // The send distributes part of the fresh supply from the token to the + // holder, chained atop the setup block. + using BlockOperation send = runtime.Blocks.Send(holder, 200, token); + using BlockBuilder sendBuilder = runtime.Blocks.NewBuilder(); + sendBuilder + .WithVersion(2) + .WithNetwork(node.Network) + .WithAccount(token) + .WithSigner(holder) + .WithDate(DateTimeOffset.UtcNow) + .WithPrevious(setup.Hash) + .AddOperation(send); + using Block distribute = sendBuilder.Build(); + + // Both blocks ride one transmit. The holder pays the demanded fee. + Assert.True(await client.Transmit( + new[] { setup, distribute }, + TransmitOptions.WithFeeSigner(holder), + cancellationToken)); + + // The reads confirm the info, the supply, and the distribution. + AccountState tokenState = await client.GetAccountInfo(token, cancellationToken); + Assert.Equal("TKNA", tokenState.Info?.Name); + Assert.Equal("Example Token", tokenState.Info?.Description); + Assert.Equal(metadata, tokenState.Info?.Metadata); + Assert.Equal(new BigInteger(50_000), tokenState.Info?.Supply); + + BigInteger distributed = await client.GetBalance(holder, token, cancellationToken); + Assert.Equal(new BigInteger(200), distributed); + + harness.Shutdown(); + } + [Fact] public async Task CertificateWritesRoundTripAgainstTheLiveNode() { From e9a440a29401b451994cf346ae22ac4172122fe8 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 4 Aug 2026 10:36:25 -0700 Subject: [PATCH 7/7] chore: lint --- cspell.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.yaml b/cspell.yaml index 43f928b..7891f26 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -74,3 +74,4 @@ words: - powershell - renderable - solana + - tkna