diff --git a/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNetAnchorServiceCollectionExtensions.cs b/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNetAnchorServiceCollectionExtensions.cs
index 4467a05..06fb289 100644
--- a/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNetAnchorServiceCollectionExtensions.cs
+++ b/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNetAnchorServiceCollectionExtensions.cs
@@ -28,18 +28,18 @@ public static IServiceCollection AddKeetaNetAnchor(this IServiceCollection servi
}
///
- /// Register a for the node API at
+ /// Register a for the node API at
/// as a typed HTTP client, so its
/// comes from
/// IHttpClientFactory (pooled handlers, policy-friendly).
///
/// The same collection, for chaining.
- public static IServiceCollection AddKeetaNetAnchorNodeClient(this IServiceCollection services, string nodeUrl)
+ public static IServiceCollection AddKeetaNetAnchorKeetaClient(this IServiceCollection services, string nodeUrl)
{
services.AddKeetaNetAnchor();
- services.AddHttpClient(nameof(NodeClient))
+ services.AddHttpClient(nameof(KeetaClient))
.AddTypedClient((http, provider) =>
- provider.GetRequiredService().CreateNodeClient(nodeUrl, http));
+ provider.GetRequiredService().CreateKeetaClient(nodeUrl, http));
return services;
}
}
diff --git a/src/KeetaNet.Anchor/Crypto/Certificate.cs b/src/KeetaNet.Anchor/Crypto/Certificate.cs
index 9d5c543..82c5890 100644
--- a/src/KeetaNet.Anchor/Crypto/Certificate.cs
+++ b/src/KeetaNet.Anchor/Crypto/Certificate.cs
@@ -61,7 +61,7 @@ public DateTimeOffset NotAfter
///
/// The SHA3-256 of the certificate's DER: the key the ledger stores a
/// published certificate under, so it feeds
- /// .
+ /// .
///
public CertificateHash Hash => CertificateHash.Parse(Runtime.CertificateHash(Convert.ToHexString(ToDer())));
diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs
index c1f284b..fa38d6e 100644
--- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs
+++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs
@@ -44,13 +44,28 @@ public AssetMovementClient CreateAssetMovementClient(string nodeUrl, string root
AssetMovementClient.WithAccount(this, nodeUrl, root, account);
///
- /// Create a lite client for the node API at . An
- /// injected (for example from
+ /// Create 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 (
+ /// write path (
/// and fee blocks). A client without one stays read-only.
///
- public NodeClient CreateNodeClient(string nodeUrl, HttpClient? httpClient = null, long? network = null) =>
+ public KeetaClient CreateKeetaClient(string nodeUrl, HttpClient? httpClient = null, long? network = null) =>
new(this, nodeUrl, 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
+ /// disposed. See for the remaining
+ /// parameters.
+ ///
+ public UserClient CreateUserClient(
+ string nodeUrl,
+ Account? signer,
+ HttpClient? httpClient = null,
+ long? network = null,
+ Account? account = null) =>
+ new(this, nodeUrl, httpClient, network, signer, account);
}
diff --git a/src/KeetaNet.Anchor/Services/Node/NodeClient.cs b/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs
similarity index 95%
rename from src/KeetaNet.Anchor/Services/Node/NodeClient.cs
rename to src/KeetaNet.Anchor/Services/Node/KeetaClient.cs
index c690525..696aefc 100644
--- a/src/KeetaNet.Anchor/Services/Node/NodeClient.cs
+++ b/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs
@@ -10,14 +10,14 @@
namespace KeetaNet.Anchor;
///
-/// A lite, read-only client for the KeetaNet node API: the ledger reads the
-/// reference node client performs, over the transport generated from the
-/// canonical OpenAPI spec.
+/// 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 .
///
-public sealed class NodeClient : IDisposable
+public sealed class KeetaClient : IDisposable
{
/// The block version the reference clients build.
- private const int BlockVersion = 2;
+ internal const int BlockVersion = 2;
private readonly WasmRuntime _runtime;
@@ -37,7 +37,7 @@ public sealed class NodeClient : IDisposable
/// borrowed, not disposed. A bound enables the
/// write path; without one the client stays read-only.
///
- internal NodeClient(WasmRuntime runtime, string nodeUrl, HttpClient? http = null, long? network = null)
+ internal KeetaClient(WasmRuntime runtime, string nodeUrl, HttpClient? http = null, long? network = null)
{
_runtime = runtime;
_network = network;
@@ -186,6 +186,25 @@ public async Task GetAccountBalance(
return OptionalHexAmount(response.Balance) ?? BigInteger.Zero;
}
+ ///
+ /// 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.
+ ///
+ internal Crypto.BlockBuilder InitBuilder(Crypto.Account account, Crypto.Account? signer = null)
+ {
+ (long network, _) = RequireNetwork();
+
+ return _runtime.Blocks.NewBuilder()
+ .WithVersion(BlockVersion)
+ .WithNetwork(network)
+ .WithAccount(account)
+ .WithSigner(signer ?? account)
+ .WithDate(DateTimeOffset.UtcNow);
+ }
+
/// Publish one signed block as its own staple. See the list overload.
public Task Transmit(
Crypto.Block block,
@@ -515,7 +534,7 @@ private async Task PublishStaple(
/// Position atop , or
/// as an opening block when the account has no chain yet.
///
- private static void PositionAfter(Crypto.BlockBuilder builder, string? previous)
+ internal static void PositionAfter(Crypto.BlockBuilder builder, string? previous)
{
if (string.IsNullOrEmpty(previous))
{
diff --git a/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs b/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs
index 75c7969..6e49eb1 100644
--- a/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs
+++ b/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs
@@ -7,7 +7,7 @@ namespace KeetaNet.Anchor;
/// with FEE_REQUIRED before anything is published.
///
public delegate Task GenerateFeeBlock(
- NodeClient client,
+ KeetaClient client,
Crypto.VoteStaple staple,
IReadOnlyList feeTokenPriority,
CancellationToken cancellationToken);
diff --git a/src/KeetaNet.Anchor/Services/Node/UserClient.cs b/src/KeetaNet.Anchor/Services/Node/UserClient.cs
new file mode 100644
index 0000000..564afeb
--- /dev/null
+++ b/src/KeetaNet.Anchor/Services/Node/UserClient.cs
@@ -0,0 +1,189 @@
+using System.Numerics;
+
+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.
+///
+public sealed class UserClient : IDisposable
+{
+ private readonly WasmRuntime _runtime;
+
+ private readonly KeetaClient _client;
+
+ /// The operating account when it differs from the signer.
+ private readonly Crypto.Account? _account;
+
+ private readonly Crypto.Account? _signer;
+
+ ///
+ /// An owned for
+ /// bound to , operating as
+ /// when given and as the signer itself
+ /// otherwise. Both accounts are borrowed, not disposed.
+ ///
+ internal UserClient(
+ WasmRuntime runtime,
+ string nodeUrl,
+ HttpClient? http,
+ long? network,
+ Crypto.Account? signer,
+ Crypto.Account? account)
+ {
+ _runtime = runtime;
+ _client = new KeetaClient(runtime, nodeUrl, http, network);
+ _signer = signer;
+ _account = account;
+ }
+
+ /// The underlying client, for reads beyond the operating account.
+ public KeetaClient Client => _client;
+
+ /// The bound signer, if any.
+ public Crypto.Account? Signer => _signer;
+
+ /// Whether this client has no signer and therefore rejects writes.
+ public bool IsReadOnly => _signer is null;
+
+ ///
+ /// The operating account: the configured account, then the signer.
+ /// Throws SIGNER_REQUIRED when neither is bound.
+ ///
+ public Crypto.Account Account =>
+ _account
+ ?? _signer
+ ?? throw new KeetaException("SIGNER_REQUIRED", "bind a signer or an operating account to the user client");
+
+ /// The full state of the operating account.
+ public Task GetState(CancellationToken cancellationToken = default) =>
+ _client.GetAccountState(Account, cancellationToken);
+
+ /// The settled balance of held by the operating account.
+ public Task GetBalance(Crypto.Account token, CancellationToken cancellationToken = default) =>
+ _client.GetAccountBalance(Account, token, cancellationToken);
+
+ /// Every token balance held by the operating account.
+ public Task> GetAllBalances(CancellationToken cancellationToken = default) =>
+ _client.GetAccountBalances(Account, cancellationToken);
+
+ /// 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.
+ ///
+ public Task GetCertificateByHash(
+ Crypto.CertificateHash certificateHash,
+ CancellationToken cancellationToken = default) =>
+ _client.GetCertificateByHash(Account, certificateHash, 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.
+ ///
+ 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.
+ ///
+ public Task Transmit(
+ Crypto.Block block,
+ TransmitOptions? options = null,
+ CancellationToken cancellationToken = default) =>
+ Transmit(new[] { block }, options, cancellationToken);
+
+ ///
+ /// Publish as one atomic staple, paying any
+ /// required fee with the bound signer unless
+ /// carries a fee-block factory.
+ ///
+ public Task Transmit(
+ IReadOnlyList blocks,
+ TransmitOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ _ = RequireSigner();
+
+ return _client.Transmit(blocks, OrDefaultFeePayer(options), cancellationToken);
+ }
+
+ ///
+ /// Send of to
+ /// , carrying an optional
+ /// reference.
+ ///
+ public async Task Send(
+ Crypto.Account to,
+ BigInteger amount,
+ Crypto.Account token,
+ string? external = null,
+ TransmitOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ using Crypto.BlockOperation send = _runtime.Blocks.Send(to, amount, token, external);
+ return await BuildAndTransmit(send, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// Set the operating account's representative to .
+ public async Task SetRep(
+ Crypto.Account representative,
+ TransmitOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ using Crypto.BlockOperation setRep = _runtime.Blocks.SetRep(representative);
+ return await BuildAndTransmit(setRep, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// Release the owned ; the bound accounts stay with the caller.
+ public void Dispose() => _client.Dispose();
+
+ ///
+ /// Build the operating account's one-operation block against its ledger
+ /// head (opening a fresh chain when it has none) and transmit it.
+ ///
+ private async Task BuildAndTransmit(
+ Crypto.BlockOperation operation,
+ TransmitOptions? options,
+ CancellationToken cancellationToken)
+ {
+ TransmitOptions resolved = OrDefaultFeePayer(options);
+ AccountState state = await GetState(cancellationToken).ConfigureAwait(false);
+
+ using Crypto.BlockBuilder builder = InitBuilder();
+ KeetaClient.PositionAfter(builder, state.HeadBlock?.ToString());
+ using Crypto.Block block = builder.AddOperation(operation).Build();
+
+ return await _client.Transmit(block, resolved, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// Absent a fee-block factory, the bound signer pays any required fee itself.
+ private TransmitOptions OrDefaultFeePayer(TransmitOptions? options)
+ {
+ if (options?.FeeBlockFactory is not null)
+ {
+ return options;
+ }
+
+ TransmitOptions resolved = TransmitOptions.WithFeeSigner(RequireSigner());
+ if (options is not null)
+ {
+ foreach (Crypto.Account token in options.FeeTokenPriority)
+ {
+ resolved.FeeTokenPriority.Add(token);
+ }
+ }
+
+ return resolved;
+ }
+
+ /// The bound signer, required by every write.
+ private Crypto.Account RequireSigner() =>
+ _signer ?? throw new KeetaException("SIGNER_REQUIRED", "bind a signer to the user client to build or transmit blocks");
+}
diff --git a/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs
index 7be21ee..6d9b98f 100644
--- a/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs
+++ b/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs
@@ -87,7 +87,7 @@ public async Task LedgerReadServesEveryPublishedCertificateRecord()
using var runtime = WasmRuntime.Load();
using Account observer = runtime.Accounts.FromSeed(E2eSeeds.Caller, 0, E2eSeeds.Secp256k1);
- using NodeClient client = runtime.CreateNodeClient(anchor.NodeApi);
+ 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.
@@ -138,7 +138,7 @@ public async Task BasicLedgerReadsReportTheHolderStateAndBalances()
KycAnchor anchor = KycAnchor.Start(harness);
using var runtime = WasmRuntime.Load();
- using NodeClient client = runtime.CreateNodeClient(anchor.NodeApi);
+ using KeetaClient client = runtime.CreateKeetaClient(anchor.NodeApi);
string version = await client.GetNodeVersion(cancellationToken);
Assert.NotEmpty(version);
@@ -175,7 +175,7 @@ public async Task PublishedChainStatusMatchesTheTrustSet()
KycAnchor anchor = KycAnchor.Start(harness);
using var runtime = WasmRuntime.Load();
- using NodeClient client = runtime.CreateNodeClient(anchor.NodeApi);
+ using KeetaClient client = runtime.CreateKeetaClient(anchor.NodeApi);
PublishedChain chain = PublishedChain.Publish(harness);
using Account holder = runtime.Accounts.FromPublicKeyString(chain.Account);
diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs
index 9f145bb..bc79179 100644
--- a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs
+++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs
@@ -23,7 +23,7 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode()
LedgerNode node = LedgerNode.Start(harness);
using var runtime = WasmRuntime.Load();
- using NodeClient client = runtime.CreateNodeClient(node.Api);
+ using KeetaClient client = runtime.CreateKeetaClient(node.Api);
using Account baseToken = runtime.Accounts.FromPublicKeyString(node.BaseToken);
string version = await client.GetNodeVersion(cancellationToken);
@@ -116,7 +116,7 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode()
// A response from outside the node API surfaces as the stable typed
// failure, with the transport error preserved as its cause.
- using NodeClient misRouted = runtime.CreateNodeClient(node.Api + "/bogus");
+ using KeetaClient misRouted = runtime.CreateKeetaClient(node.Api + "/bogus");
KeetaException failure = await Assert.ThrowsAsync(
() => misRouted.GetNodeVersion(cancellationToken));
Assert.Equal("NODE_STATUS", failure.Code);
@@ -136,118 +136,101 @@ public async Task FeeBearingSendTransmitsAgainstTheLiveNode()
LedgerNode node = LedgerNode.Start(harness);
using var runtime = WasmRuntime.Load();
- using NodeClient client = runtime.CreateNodeClient(node.Api, network: node.Network);
+ 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;
// Both sides must derive the same base token from the network id.
Assert.NotNull(client.BaseToken);
Assert.Equal(node.BaseToken, client.BaseToken!.PublicKeyString);
+ Account baseToken = client.BaseToken;
- using Account holder = runtime.Accounts.FromSeed(E2eSeeds.Subject, 0, E2eSeeds.Secp256k1);
- using Account recipient = runtime.Accounts.FromSeed(E2eSeeds.Recipient, 0, E2eSeeds.Secp256k1);
node.Fund(E2eSeeds.Subject, Funding);
- // The temporary round demands a fee; the holder pays it, and the node
- // accepts the staple.
+ // The send opens the holder's chain and pays the demanded fee from
+ // the bound signer by default.
const long Amount = 12_345;
- using (Block send = BuildSend(runtime, client, holder, recipient, Amount, previous: null))
- {
- bool accepted = await client.Transmit(send, TransmitOptions.WithFeeSigner(holder), cancellationToken);
- Assert.True(accepted);
- }
+ Assert.True(await user.Send(recipient, Amount, baseToken, cancellationToken: cancellationToken));
// The recipient gains exactly the amount; the holder also paid the
// round's flat fee.
- BigInteger credited = await client.GetAccountBalance(recipient, client.BaseToken, cancellationToken);
+ BigInteger credited = await client.GetAccountBalance(recipient, baseToken, cancellationToken);
Assert.Equal(new BigInteger(Amount), credited);
- BigInteger remaining = await client.GetAccountBalance(holder, client.BaseToken, cancellationToken);
+ BigInteger remaining = await user.GetBalance(baseToken, cancellationToken);
Assert.Equal(new BigInteger(Funding) - Amount - RoundFee, remaining);
// The fee block chained atop the send, so the holder's head advanced
// past the send block and must match the reference client's.
- AccountState state = await client.GetAccountState(holder, cancellationToken);
+ AccountState state = await user.GetState(cancellationToken);
Assert.NotNull(state.HeadBlock);
string? referenceHead = node.Head(holder.PublicKeyString);
Assert.NotNull(referenceHead);
Assert.Equal(BlockHash.Parse(referenceHead!), state.HeadBlock!.Value);
- // A chained SET_REP delegates the holder's weight; the round costs
- // one more flat fee and the ledger reflects the delegation.
- using (BlockOperation toRep = runtime.Blocks.SetRep(recipient))
- using (Block setRep = BuildBlock(runtime, client, holder, state.HeadBlock, toRep))
- {
- Assert.True(await client.Transmit(setRep, TransmitOptions.WithFeeSigner(holder), cancellationToken));
- }
+ // The SET_REP chains atop the advanced head and costs one more fee.
+ Assert.True(await user.SetRep(recipient, cancellationToken: cancellationToken));
- state = await client.GetAccountState(holder, cancellationToken);
+ state = await user.GetState(cancellationToken);
Assert.Equal(recipient.PublicKeyString, state.Representative!.PublicKeyString);
remaining -= RoundFee;
- Assert.Equal(remaining, await client.GetAccountBalance(holder, client.BaseToken, cancellationToken));
+ Assert.Equal(remaining, await user.GetBalance(baseToken, cancellationToken));
// A fee-less transmit against the fee-enforcing node refuses with the
// typed FEE_REQUIRED before anything is published. The refusal leaves
// the representative's temporary vote behind, blocking the holder's
// height for the rest of the test - each refusal rides its own account.
- using (Block feeless = BuildSend(runtime, client, holder, recipient, Amount, state.HeadBlock))
+ using (Block feeless = BuildSend(runtime, user, recipient, Amount, state.HeadBlock))
{
KeetaException refused = await Assert.ThrowsAsync(
() => client.Transmit(feeless, cancellationToken: cancellationToken));
Assert.Equal("FEE_REQUIRED", refused.Code);
}
+ // A signer-less user client rejects writes outright.
+ using UserClient readOnly = runtime.CreateUserClient(node.Api, signer: null, network: node.Network);
+ Assert.True(readOnly.IsReadOnly);
+
+ KeetaException unsigned = await Assert.ThrowsAsync(
+ () => readOnly.Send(recipient, 1, baseToken, cancellationToken: cancellationToken));
+ Assert.Equal("SIGNER_REQUIRED", unsigned.Code);
+
// A client without a bound network cannot originate the fee block the
// round demands, so its transmit refuses before publishing anything.
- using NodeClient readOnly = runtime.CreateNodeClient(node.Api);
- Assert.Null(readOnly.BaseToken);
+ using KeetaClient unbound = runtime.CreateKeetaClient(node.Api);
+ Assert.Null(unbound.BaseToken);
- using (Block opening = BuildSend(runtime, client, recipient, holder, 1, previous: null))
+ using UserClient recipientUser = runtime.CreateUserClient(node.Api, recipient, network: node.Network);
+ using (Block opening = BuildSend(runtime, recipientUser, holder, 1, previous: null))
{
- KeetaException unbound = await Assert.ThrowsAsync(
- () => readOnly.Transmit(opening, TransmitOptions.WithFeeSigner(recipient), cancellationToken));
- Assert.Equal("NETWORK_REQUIRED", unbound.Code);
+ KeetaException refused = await Assert.ThrowsAsync(
+ () => unbound.Transmit(opening, TransmitOptions.WithFeeSigner(recipient), cancellationToken));
+ Assert.Equal("NETWORK_REQUIRED", refused.Code);
}
- // Neither refusal advanced either chain.
- Assert.Equal(remaining, await client.GetAccountBalance(holder, client.BaseToken, cancellationToken));
- Assert.Equal(new BigInteger(Amount), await client.GetAccountBalance(recipient, client.BaseToken, cancellationToken));
+ // No refusal advanced either chain.
+ Assert.Equal(remaining, await user.GetBalance(baseToken, cancellationToken));
+ Assert.Equal(new BigInteger(Amount), await client.GetAccountBalance(recipient, baseToken, cancellationToken));
harness.Shutdown();
}
- /// A signed base-token send from to .
+ ///
+ /// A signed base-token send from 's operating
+ /// account to , opening the chain when
+ /// is null.
+ ///
private static Block BuildSend(
WasmRuntime runtime,
- NodeClient client,
- Account from,
+ UserClient user,
Account to,
long amount,
BlockHash? previous)
{
- using BlockOperation send = runtime.Blocks.Send(to, amount, client.BaseToken!);
- return BuildBlock(runtime, client, from, previous, send);
- }
-
- ///
- /// A signed one-operation block for , opening its
- /// chain when is null and chaining atop it
- /// otherwise.
- ///
- private static Block BuildBlock(
- WasmRuntime runtime,
- NodeClient client,
- Account account,
- BlockHash? previous,
- BlockOperation operation)
- {
- using BlockBuilder builder = runtime.Blocks.NewBuilder();
- builder
- .WithVersion(2)
- .WithNetwork(client.Network!.Value)
- .WithAccount(account)
- .WithSigner(account)
- .WithDate(DateTimeOffset.UtcNow)
- .AddOperation(operation);
+ using BlockOperation send = runtime.Blocks.Send(to, amount, user.Client.BaseToken!);
+ using BlockBuilder builder = user.InitBuilder();
if (previous is { } hash)
{
@@ -258,14 +241,14 @@ private static Block BuildBlock(
builder.AsOpening();
}
- return builder.Build();
+ return builder.AddOperation(send).Build();
}
///
/// The three representative reads agree on the chain's one representative:
/// the node's own, the singular lookup, and the advertised set.
///
- private static async Task AssertRepresentativeReads(NodeClient client, LedgerNode node, CancellationToken cancellationToken)
+ private static async Task AssertRepresentativeReads(KeetaClient client, LedgerNode node, CancellationToken cancellationToken)
{
NodeRepresentative own = await client.GetNodeRepresentative(cancellationToken);
Assert.Equal(node.Representative, own.Account.PublicKeyString);
@@ -283,7 +266,7 @@ private static async Task AssertRepresentativeReads(NodeClient client, LedgerNod
}
/// The diagnostic reads: checksum with a moment, stats and peers as JSON objects.
- private static async Task AssertNodeDiagnostics(NodeClient client, CancellationToken cancellationToken)
+ private static async Task AssertNodeDiagnostics(KeetaClient client, CancellationToken cancellationToken)
{
LedgerChecksum checksum = await client.GetLedgerChecksum(cancellationToken);
Assert.NotEqual(BigInteger.Zero, checksum.Checksum);
diff --git a/tests/KeetaNet.Anchor.Tests/BlockTests.cs b/tests/KeetaNet.Anchor.Tests/BlockTests.cs
index 5726846..a4f777e 100644
--- a/tests/KeetaNet.Anchor.Tests/BlockTests.cs
+++ b/tests/KeetaNet.Anchor.Tests/BlockTests.cs
@@ -49,6 +49,37 @@ public void ASignedOpeningBlockRoundTripsAndConsumesItsBuilder()
Assert.Equal("BUILDER_CONSUMED", refused.Code);
}
+ [Fact]
+ public void TheUserBuilderPreSetsTheSigningDefaults()
+ {
+ 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 UserClient user = runtime.CreateUserClient(TestSeeds.NonRoutableAnchor, sender, network: Network);
+
+ using BlockOperation send = runtime.Blocks.Send(recipient, 42, user.Client.BaseToken!);
+ using BlockBuilder builder = user.InitBuilder();
+ using Block block = builder.AsOpening().AddOperation(send).Build();
+ Assert.NotEmpty(block.ToBytes());
+
+ // Without a bound network there is nothing to pre-set.
+ using UserClient unbound = runtime.CreateUserClient(TestSeeds.NonRoutableAnchor, sender);
+ KeetaException refused = Assert.Throws(() =>
+ {
+ using BlockBuilder unreachable = unbound.InitBuilder();
+ });
+ Assert.Equal("NETWORK_REQUIRED", refused.Code);
+
+ // Without a signer there is nothing to sign with.
+ using UserClient readOnly = runtime.CreateUserClient(TestSeeds.NonRoutableAnchor, signer: null, network: Network);
+ Assert.True(readOnly.IsReadOnly);
+ KeetaException unsigned = Assert.Throws(() =>
+ {
+ using BlockBuilder unreachable = readOnly.InitBuilder();
+ });
+ Assert.Equal("SIGNER_REQUIRED", unsigned.Code);
+ }
+
[Fact]
public async Task TransmitRefusesAClientWithoutABoundNetwork()
{
@@ -71,12 +102,46 @@ public async Task TransmitRefusesAClientWithoutABoundNetwork()
// The anchor URL is non-routable, so reaching the transport would
// surface NODE_STATUS instead: the gate must trip first.
- using NodeClient client = runtime.CreateNodeClient(TestSeeds.NonRoutableAnchor);
+ 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()
+ {
+ 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();
+
+ // A custom fee-block factory bypasses the default fee-payer branch,
+ // so the signer gate itself must refuse the prebuilt block.
+ var options = new TransmitOptions
+ {
+ FeeBlockFactory = (_, _, _, _) => Task.FromResult(null),
+ };
+
+ using UserClient readOnly = runtime.CreateUserClient(
+ TestSeeds.NonRoutableAnchor, signer: null, network: Network, account: sender);
+ KeetaException refused = await Assert.ThrowsAsync(
+ () => readOnly.Transmit(block, options, TestContext.Current.CancellationToken));
+ Assert.Equal("SIGNER_REQUIRED", refused.Code);
+ }
+
[Fact]
public void TheBaseTokenDerivesDeterministicallyFromTheNetwork()
{
diff --git a/tests/KeetaNet.Anchor.Tests/DependencyInjectionTests.cs b/tests/KeetaNet.Anchor.Tests/DependencyInjectionTests.cs
index ff0b66c..b630b64 100644
--- a/tests/KeetaNet.Anchor.Tests/DependencyInjectionTests.cs
+++ b/tests/KeetaNet.Anchor.Tests/DependencyInjectionTests.cs
@@ -48,17 +48,17 @@ public void RegistersTheRuntimeFactorySurfacesForDirectInjection()
}
[Fact]
- public void RegistersANodeClientBackedByTheHttpClientFactory()
+ public void RegistersAKeetaClientBackedByTheHttpClientFactory()
{
var services = new ServiceCollection();
- services.AddKeetaNetAnchorNodeClient("http://127.0.0.1:1/api/node");
+ services.AddKeetaNetAnchorKeetaClient("http://127.0.0.1:1/api/node");
using ServiceProvider provider = services.BuildServiceProvider();
- using NodeClient first = provider.GetRequiredService();
- using NodeClient second = provider.GetRequiredService();
+ using KeetaClient first = provider.GetRequiredService();
+ using KeetaClient second = provider.GetRequiredService();
Assert.NotSame(first, second);
- // The node-client registration also provides the shared runtime.
+ // The client registration also provides the shared runtime.
WasmRuntime runtime = provider.GetRequiredService();
Assert.False(runtime.IsDisposed);
}
diff --git a/tests/KeetaNet.Anchor.Tests/TrustTests.cs b/tests/KeetaNet.Anchor.Tests/TrustTests.cs
index 99cf657..f9cf30a 100644
--- a/tests/KeetaNet.Anchor.Tests/TrustTests.cs
+++ b/tests/KeetaNet.Anchor.Tests/TrustTests.cs
@@ -20,7 +20,7 @@ public sealed class TrustTests
public void ChainStatusMatchesTheTrustSet()
{
using var runtime = WasmRuntime.Load();
- using NodeClient client = runtime.CreateNodeClient(TestSeeds.NonRoutableAnchor);
+ using KeetaClient client = runtime.CreateKeetaClient(TestSeeds.NonRoutableAnchor);
using Account caAccount = runtime.Accounts.FromSeed(TestSeeds.Issuer, 0, TestSeeds.DefaultAlgorithm);
using Account subject = runtime.Accounts.FromSeed(TestSeeds.Subject, 0, TestSeeds.DefaultAlgorithm);