diff --git a/cspell.yaml b/cspell.yaml
index fccfea9..7891f26 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
@@ -71,3 +74,4 @@ words:
- powershell
- renderable
- solana
+ - tkna
diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs
index 9bbb8ce..86c3999 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,74 @@ 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());
+
+ ///
+ /// 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);
///
- /// 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 +108,26 @@ 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);
+
+ ///
+ /// 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/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..41bb048 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,101 @@ 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. 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.")]
+ 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;
+ }
+
+ if (addNewRepresentatives && IsAdoptableUrl(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;
+ }
+
+ ///
+ /// 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.
+ ///
+ /// 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 +730,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 +761,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 +781,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 +801,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 +820,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 +848,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 +857,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 +868,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);
+
+ 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
+ {
+ 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);
+ }
+ }
- string? quote = response.Quote?.Binary;
- if (string.IsNullOrEmpty(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())
{
- throw new KeetaException("VOTE_DECLINED", "the node returned no vote quote");
+ quotesByIssuer[quote.IssuerApiUrl] = quote;
}
- return Convert.FromBase64String(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 +1029,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 +1085,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 +1096,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 +1181,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 +1218,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 +1239,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 +1258,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 +1284,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 +1307,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 +1341,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 +1386,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 +1395,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 +1412,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..fddc463 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.
+ /// Gets the full state of the operating account.
public Task GetState(CancellationToken cancellationToken = default) =>
- _client.GetAccountState(Account, cancellationToken);
+ _client.GetAccountInfo(Account, cancellationToken);
- /// The settled balance of held by the operating account.
+ /// Gets the settled balance of held by the operating account.
public Task GetBalance(Crypto.Account token, CancellationToken cancellationToken = default) =>
- _client.GetAccountBalance(Account, token, cancellationToken);
+ _client.GetBalance(Account, token, cancellationToken);
- /// Every token balance held by the operating account.
+ /// Gets every token balance held by the operating account.
public Task> GetAllBalances(CancellationToken cancellationToken = default) =>
- _client.GetAccountBalances(Account, cancellationToken);
+ _client.GetAllBalances(Account, cancellationToken);
- /// The certificates published by the operating account.
+ /// Gets the certificates published by the operating account.
public Task> GetAllCertificates(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
+ /// .
///
+ /// The record, or null when the account never published it.
public Task GetCertificateByHash(
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 GetHead(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.
+ /// Gets the next pending (unreceived) block for the operating account, if any.
public Task GetPendingBlock(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.
+ /// Gets one 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);
+ _client.GetChain(Account, query, cancellationToken);
- /// A page of the operating account's committed staple history.
+ /// Gets one page of the operating account's committed staple history.
public Task GetHistory(HistoryQuery? query = null, CancellationToken cancellationToken = default) =>
- _client.GetAccountHistory(Account, query, cancellationToken);
+ _client.GetHistory(Account, query, cancellationToken);
+
+ /// Lists the ACL entries where the operating account is the principal.
+ public Task> ListAclsByPrincipal(CancellationToken cancellationToken = default) =>
+ _client.ListAclsByPrincipal(Account, cancellationToken);
- /// ACL entries where the operating account is the principal.
- public Task> GetAcls(CancellationToken cancellationToken = default) =>
- _client.GetAclsByPrincipal(Account, cancellationToken);
+ /// Lists the ACL entries granted to the operating account as an entity.
+ public Task> ListAclsByEntity(CancellationToken cancellationToken = default) =>
+ _client.ListAclsByEntity(Account, cancellationToken);
- /// ACL entries granted to the operating account as an entity.
- public Task> GetAclsByEntity(CancellationToken cancellationToken = default) =>
- _client.GetAclsByEntity(Account, cancellationToken);
+ ///
+ /// 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);
///
- /// 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.
+ /// 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,17 +209,18 @@ 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);
@@ -172,10 +233,10 @@ 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,
@@ -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,329 @@ 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);
+ }
- /// Publish the operating account's one-operation block.
+ ///
+ /// 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)
+ {
+ 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);
+ 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);
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ return;
+ }
+
+ 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);
+ }
+ }
+ }
+
+ ///
+ /// 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 GetState(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;
+ }
+
+ 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 +703,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 +717,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 +731,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 +742,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/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/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..1247ead 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,9 +156,9 @@ 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);
@@ -215,7 +215,221 @@ 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(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 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()
+ {
+ 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();
}
@@ -256,7 +470,7 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode()
// 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,7 +483,7 @@ 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);
Assert.Equal(head.Hash, Assert.Single(newest.Blocks).Hash);
@@ -291,7 +505,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);
@@ -306,29 +520,32 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode()
// 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,10 +554,10 @@ 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,
@@ -356,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()
{
@@ -369,8 +655,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);
@@ -391,7 +677,7 @@ public async Task CertificateWritesRoundTripAgainstTheLiveNode()
Assert.Single(record.Intermediates);
Assert.NotNull(await user.GetCertificateByHash(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));
@@ -434,16 +720,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 +745,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/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
new file mode 100644
index 0000000..4cd2e43
--- /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.GetBalance(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()
{
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()