From 010b1c773bbadf99658d1ce6178efc3c9d11910c Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 28 Jul 2026 18:31:08 -0700 Subject: [PATCH 1/2] fix: better alignment --- ...etaNetAnchorServiceCollectionExtensions.cs | 8 +- src/KeetaNet.Anchor/Crypto/Certificate.cs | 2 +- .../Interop/WasmRuntime.Surface.cs | 23 ++- .../Node/{NodeClient.cs => KeetaClient.cs} | 33 +++- .../Services/Node/TransmitOptions.cs | 2 +- .../Services/Node/UserClient.cs | 185 ++++++++++++++++++ .../KeetaNet.Anchor.E2eTests/KycFlowTests.cs | 6 +- .../KeetaNet.Anchor.E2eTests/NodeFlowTests.cs | 111 +++++------ tests/KeetaNet.Anchor.Tests/BlockTests.cs | 31 +++ .../DependencyInjectionTests.cs | 10 +- tests/KeetaNet.Anchor.Tests/TrustTests.cs | 2 +- 11 files changed, 323 insertions(+), 90 deletions(-) rename src/KeetaNet.Anchor/Services/Node/{NodeClient.cs => KeetaClient.cs} (95%) create mode 100644 src/KeetaNet.Anchor/Services/Node/UserClient.cs 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 676304d..66804c5 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, @@ -510,7 +529,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..f137488 --- /dev/null +++ b/src/KeetaNet.Anchor/Services/Node/UserClient.cs @@ -0,0 +1,185 @@ +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) => + _client.Transmit(block, OrDefaultFeePayer(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) => + _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 1037500..5a1c0ff 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 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); From fcd2ecb657397ad68e704c20f88e5083a196d0f2 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Wed, 29 Jul 2026 12:32:31 -0700 Subject: [PATCH 2/2] fix: guard transmit --- .../Services/Node/UserClient.cs | 10 ++++-- tests/KeetaNet.Anchor.Tests/BlockTests.cs | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/KeetaNet.Anchor/Services/Node/UserClient.cs b/src/KeetaNet.Anchor/Services/Node/UserClient.cs index f137488..564afeb 100644 --- a/src/KeetaNet.Anchor/Services/Node/UserClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/UserClient.cs @@ -97,7 +97,7 @@ public Task Transmit( Crypto.Block block, TransmitOptions? options = null, CancellationToken cancellationToken = default) => - _client.Transmit(block, OrDefaultFeePayer(options), cancellationToken); + Transmit(new[] { block }, options, cancellationToken); /// /// Publish as one atomic staple, paying any @@ -107,8 +107,12 @@ public Task Transmit( public Task Transmit( IReadOnlyList blocks, TransmitOptions? options = null, - CancellationToken cancellationToken = default) => - _client.Transmit(blocks, OrDefaultFeePayer(options), cancellationToken); + CancellationToken cancellationToken = default) + { + _ = RequireSigner(); + + return _client.Transmit(blocks, OrDefaultFeePayer(options), cancellationToken); + } /// /// Send of to diff --git a/tests/KeetaNet.Anchor.Tests/BlockTests.cs b/tests/KeetaNet.Anchor.Tests/BlockTests.cs index 885b424..a4f777e 100644 --- a/tests/KeetaNet.Anchor.Tests/BlockTests.cs +++ b/tests/KeetaNet.Anchor.Tests/BlockTests.cs @@ -108,6 +108,40 @@ public async Task TransmitRefusesAClientWithoutABoundNetwork() 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() {