diff --git a/src/KeetaNet.Anchor/Crypto/BlockFactory.cs b/src/KeetaNet.Anchor/Crypto/BlockFactory.cs
new file mode 100644
index 0000000..b86b717
--- /dev/null
+++ b/src/KeetaNet.Anchor/Crypto/BlockFactory.cs
@@ -0,0 +1,55 @@
+using System.Numerics;
+
+namespace KeetaNet.Anchor.Crypto;
+
+///
+/// Creates block builders, operations, and parsed blocks owned by one runtime.
+/// Reached through . Everything it creates must
+/// be disposed before that runtime.
+///
+public sealed class BlockFactory
+{
+ private readonly WasmRuntime _runtime;
+
+ internal BlockFactory(WasmRuntime runtime) => _runtime = runtime;
+
+ /// A fresh builder for one signed block.
+ public BlockBuilder NewBuilder() => new(_runtime);
+
+ ///
+ /// A SEND operation transferring base units
+ /// of to , with an optional
+ /// reference.
+ ///
+ public BlockOperation Send(Account to, BigInteger amount, Account token, string? external = null)
+ {
+ string value = amount.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ int handle = _runtime.OpSend(to.Handle, value, token.Handle, external ?? "");
+
+ return new BlockOperation(_runtime, handle);
+ }
+
+ /// A SET_REP operation delegating to .
+ public BlockOperation SetRep(Account representative)
+ {
+ int handle = _runtime.OpSetRep(representative.Handle);
+ return new BlockOperation(_runtime, handle);
+ }
+
+ /// Decode a signed block from its transport hex.
+ public Block ParseHex(string hex)
+ {
+ int handle = _runtime.BlockFromHex(hex);
+ return new Block(_runtime, handle);
+ }
+
+ ///
+ /// The base token account for (the implicit fee
+ /// currency), derived exactly as the reference does.
+ ///
+ public Account NetworkBaseToken(long network)
+ {
+ int handle = _runtime.NetworkBaseToken(network);
+ return new Account(_runtime, handle);
+ }
+}
diff --git a/src/KeetaNet.Anchor/Crypto/Blocks.cs b/src/KeetaNet.Anchor/Crypto/Blocks.cs
new file mode 100644
index 0000000..355a7a0
--- /dev/null
+++ b/src/KeetaNet.Anchor/Crypto/Blocks.cs
@@ -0,0 +1,185 @@
+namespace KeetaNet.Anchor.Crypto;
+
+///
+/// A signed, sealed block ready to transmit. Produced by
+/// or parsed from transport hex through
+/// . The block lives inside the wasm core.
+///
+public sealed class Block : WasmObject
+{
+ internal Block(WasmRuntime runtime, int handle)
+ : base(runtime, handle)
+ {
+ }
+
+ /// The block hash.
+ public BlockHash Hash => BlockHash.Parse(Runtime.BlockHashHex(Handle));
+
+ /// The block's raw transport bytes, as a vote request carries them.
+ public byte[] ToBytes() => Runtime.BlockToBytes(Handle);
+
+ private protected override void Release(WasmRuntime runtime, int handle) => runtime.BlockFree(handle);
+}
+
+///
+/// One ledger operation a block carries. Created through
+/// or handed out by the fee flow; appending it to a
+/// builder clones it, so one operation may feed several blocks.
+///
+public sealed class BlockOperation : WasmObject
+{
+ internal BlockOperation(WasmRuntime runtime, int handle)
+ : base(runtime, handle)
+ {
+ }
+
+ private protected override void Release(WasmRuntime runtime, int handle) => runtime.OpFree(handle);
+}
+
+///
+/// A representative vote decoded from its transport bytes. Internal: votes
+/// only ever pass through the transmit flow.
+///
+internal sealed class Vote : WasmObject
+{
+ internal Vote(WasmRuntime runtime, int handle)
+ : base(runtime, handle)
+ {
+ }
+
+ /// Whether this vote obliges a fee block (a required, non-optional fee schedule).
+ public bool RequiresFee => Runtime.VoteRequiresFee(Handle);
+
+ private protected override void Release(WasmRuntime runtime, int handle) => runtime.VoteFree(handle);
+}
+
+///
+/// A validated round of blocks and the votes endorsing them. The transmit flow
+/// hands one to the fee-block factory so it can read the fee the round owes
+/// and the payer's chaining tip.
+///
+public sealed class VoteStaple : WasmObject
+{
+ internal VoteStaple(WasmRuntime runtime, int handle)
+ : base(runtime, handle)
+ {
+ }
+
+ private protected override void Release(WasmRuntime runtime, int handle) => runtime.VoteStapleFree(handle);
+}
+
+/// The declared purpose of a block.
+public enum BlockPurpose
+{
+ /// An ordinary user block.
+ Generic,
+ /// A fee block paying for a vote round.
+ Fee,
+}
+
+///
+/// A fluent builder for one signed block. Each step consumes the core-side
+/// builder and rebinds it, so a failed step invalidates the builder. Reached
+/// through .
+///
+public sealed class BlockBuilder : IDisposable
+{
+ private readonly WasmRuntime _runtime;
+
+ /// The live core builder handle; zero once consumed or disposed.
+ private int _handle;
+
+ internal BlockBuilder(WasmRuntime runtime)
+ {
+ _runtime = runtime;
+ _handle = runtime.BuilderNew();
+ }
+
+ /// Set the block version (the reference builds version 2).
+ public BlockBuilder WithVersion(int version) => Step(handle => _runtime.BuilderWithVersion(handle, version));
+
+ /// Set the network id the block belongs to.
+ public BlockBuilder WithNetwork(long network) => Step(handle => _runtime.BuilderWithNetwork(handle, network));
+
+ /// Set the originating account.
+ public BlockBuilder WithAccount(Account account) => Step(handle => _runtime.BuilderWithAccount(handle, account.Handle));
+
+ /// Set the signing account (distinct from the originator under delegated signing).
+ public BlockBuilder WithSigner(Account signer) => Step(handle => _runtime.BuilderWithSigner(handle, signer.Handle));
+
+ /// Chain the block atop .
+ public BlockBuilder WithPrevious(BlockHash previous) => Step(handle => _runtime.BuilderWithPrevious(handle, previous.ToBytes()));
+
+ /// Mark the block as an account opening (no previous block).
+ public BlockBuilder AsOpening() => Step(_runtime.BuilderAsOpening);
+
+ /// Set the block timestamp.
+ public BlockBuilder WithDate(DateTimeOffset date) => Step(handle => _runtime.BuilderWithDate(handle, date.ToUnixTimeMilliseconds()));
+
+ /// Set the block purpose; unset defaults to .
+ public BlockBuilder WithPurpose(BlockPurpose purpose) => Step(handle => _runtime.BuilderWithPurpose(handle, PurposeName(purpose)));
+
+ /// Append (cloned; the caller keeps ownership).
+ public BlockBuilder AddOperation(BlockOperation operation) => Step(handle => _runtime.BuilderWithOperation(handle, operation.Handle));
+
+ ///
+ /// Build, validate, and sign the block, consuming the builder.
+ ///
+ public Block Build()
+ {
+ int handle = TakeCurrent();
+ int block = _runtime.BuilderSign(handle);
+
+ return new Block(_runtime, block);
+ }
+
+ /// Release the core builder when it was never consumed by .
+ public void Dispose()
+ {
+ if (_handle == 0)
+ {
+ return;
+ }
+
+ int handle = _handle;
+ _handle = 0;
+ _runtime.BuilderFree(handle);
+ }
+
+ ///
+ /// Run one consuming builder step. The current handle is cleared before
+ /// the call: the core frees it even on failure, so a throwing step must
+ /// not leave a stale handle behind for to double-free.
+ ///
+ private BlockBuilder Step(Func operation)
+ {
+ int handle = TakeCurrent();
+ _handle = operation(handle);
+
+ return this;
+ }
+
+ /// The live handle, cleared so no failure path can reuse it.
+ private int TakeCurrent()
+ {
+ if (_handle == 0)
+ {
+ throw new KeetaException("BUILDER_CONSUMED", "the block builder was already consumed or disposed");
+ }
+
+ int handle = _handle;
+ _handle = 0;
+
+ return handle;
+ }
+
+ private static string PurposeName(BlockPurpose purpose)
+ {
+ if (purpose == BlockPurpose.Fee)
+ {
+ return "fee";
+ }
+
+ return "generic";
+ }
+}
diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs
new file mode 100644
index 0000000..eb38a15
--- /dev/null
+++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs
@@ -0,0 +1,192 @@
+using System.Buffers.Binary;
+
+namespace KeetaNet.Anchor;
+
+///
+/// The block surface of the P1 core module: builders, operations, signed
+/// blocks, votes, and vote staples - the guest side of the fee-block and
+/// transmit flow. Every internal entry point dispatches onto the runtime's
+/// owner thread.
+///
+public sealed partial class WasmRuntime
+{
+ internal int BlockFromHex(string hex) => ParseText("keeta_block_from_hex", hex);
+
+ internal string BlockHashHex(int handle) => TextOf("keeta_block_hash", handle);
+
+ internal byte[] BlockToBytes(int handle) => BytesOf("keeta_block_to_bytes", handle);
+
+ internal void BlockFree(int handle) => RunFree("keeta_block_free", handle);
+
+ internal int OpSetRep(int to) =>
+ Run(() =>
+ {
+ int result = Invoke("keeta_op_set_rep", to);
+ return TakeHandle(result);
+ });
+
+ internal int OpSend(int to, string amount, int token, string external) =>
+ Run(() =>
+ {
+ using var arguments = new ArgumentScope(this);
+ Argument value = arguments.Write(amount);
+ Argument reference = arguments.Write(external);
+
+ int result = Invoke(
+ "keeta_op_send", to, value.Pointer, value.Length, token, reference.Pointer, reference.Length);
+ return TakeHandle(result);
+ });
+
+ internal void OpFree(int handle) => RunFree("keeta_op_free", handle);
+
+ internal int BuilderNew() =>
+ Run(() =>
+ {
+ int result = Invoke("keeta_builder_new");
+ return TakeHandle(result);
+ });
+
+ internal int BuilderWithVersion(int handle, int version) =>
+ Run(() => TakeHandle(Invoke("keeta_builder_with_version", handle, version)));
+
+ internal int BuilderWithNetwork(int handle, long network) =>
+ Run(() => TakeHandle(Invoke("keeta_builder_with_network", handle, network)));
+
+ internal int BuilderWithAccount(int handle, int account) =>
+ Run(() => TakeHandle(Invoke("keeta_builder_with_account", handle, account)));
+
+ internal int BuilderWithSigner(int handle, int signer) =>
+ Run(() => TakeHandle(Invoke("keeta_builder_with_signer", handle, signer)));
+
+ internal int BuilderWithPrevious(int handle, byte[] previous) =>
+ Run(() =>
+ {
+ using var arguments = new ArgumentScope(this);
+ Argument hash = arguments.WriteBytes(previous);
+
+ int result = Invoke("keeta_builder_with_previous", handle, hash.Pointer, hash.Length);
+ return TakeHandle(result);
+ });
+
+ internal int BuilderAsOpening(int handle) =>
+ Run(() => TakeHandle(Invoke("keeta_builder_as_opening", handle)));
+
+ internal int BuilderWithDate(int handle, long unixMillis) =>
+ Run(() => TakeHandle(Invoke("keeta_builder_with_date", handle, unixMillis)));
+
+ internal int BuilderWithPurpose(int handle, string purpose) =>
+ Run(() =>
+ {
+ using var arguments = new ArgumentScope(this);
+ Argument name = arguments.Write(purpose);
+
+ int result = Invoke("keeta_builder_with_purpose", handle, name.Pointer, name.Length);
+ return TakeHandle(result);
+ });
+
+ internal int BuilderWithOperation(int handle, int operation) =>
+ Run(() => TakeHandle(Invoke("keeta_builder_with_operation", handle, operation)));
+
+ ///
+ /// Build, validate, and sign the block in one dispatch, consuming the
+ /// builder. The intermediate unsigned handle never crosses the boundary,
+ /// so a signing failure cannot leak it.
+ ///
+ internal int BuilderSign(int handle) =>
+ Run(() =>
+ {
+ int unsigned = TakeHandle(Invoke("keeta_builder_build", handle));
+ return TakeHandle(Invoke("keeta_unsigned_sign", unsigned));
+ });
+
+ internal void BuilderFree(int handle) => RunFree("keeta_builder_free", handle);
+
+ internal int VoteFromBytes(byte[] bytes) => ParseBytes("keeta_vote_from_bytes", bytes);
+
+ internal void VoteFree(int handle) => RunFree("keeta_vote_free", handle);
+
+ internal bool VoteRequiresFee(int handle) =>
+ Run(() => Invoke("keeta_fees_required", handle) != 0);
+
+ internal int VoteStapleNew(int[] blocks, int[] votes, long momentMillis) =>
+ Run(() =>
+ {
+ using var arguments = new ArgumentScope(this);
+ Argument blockList = arguments.WriteHandles(blocks);
+ Argument voteList = arguments.WriteHandles(votes);
+
+ int result = Invoke(
+ "keeta_vote_staple_new",
+ blockList.Pointer, blockList.Length,
+ voteList.Pointer, voteList.Length,
+ momentMillis);
+ return TakeHandle(result);
+ });
+
+ internal byte[] VoteStapleBuild(int[] blocks, int[] votes, long momentMillis) =>
+ Run(() =>
+ {
+ using var arguments = new ArgumentScope(this);
+ Argument blockList = arguments.WriteHandles(blocks);
+ Argument voteList = arguments.WriteHandles(votes);
+
+ int result = Invoke(
+ "keeta_vote_staple_build",
+ blockList.Pointer, blockList.Length,
+ voteList.Pointer, voteList.Length,
+ momentMillis);
+ return TakeBytes(result);
+ });
+
+ internal void VoteStapleFree(int handle) => RunFree("keeta_vote_staple_free", handle);
+
+ ///
+ /// The fee-paying operation handles the staple's votes require. Zero from
+ /// the core means no fee is owed, decoded here as an empty list.
+ ///
+ internal int[] StapleFeeSends(int staple, int baseToken, int[] priority) =>
+ Run(() =>
+ {
+ using var arguments = new ArgumentScope(this);
+ Argument tokens = arguments.WriteHandles(priority);
+
+ int result = Invoke(
+ "keeta_staple_fee_sends", staple, baseToken, tokens.Pointer, tokens.Length);
+ if (result == 0)
+ {
+ return Array.Empty();
+ }
+
+ byte[] encoded = ReadAndFreeBytes(result);
+ int[] handles = new int[encoded.Length / sizeof(int)];
+ for (int index = 0; index < handles.Length; index++)
+ {
+ handles[index] = BinaryPrimitives.ReadInt32LittleEndian(encoded.AsSpan(index * sizeof(int)));
+ }
+
+ return handles;
+ });
+
+ ///
+ /// The hex hash of the payer's last block in the staple, or null when the
+ /// payer has no block in the round.
+ ///
+ internal string? StapleTipFor(int staple, int payer) =>
+ Run(() =>
+ {
+ int result = Invoke("keeta_staple_tip_for", staple, payer);
+ if (result == 0)
+ {
+ return null;
+ }
+
+ return Text(result);
+ });
+
+ internal int NetworkBaseToken(long network) =>
+ Run(() =>
+ {
+ int result = Invoke("keeta_base_token", network);
+ return TakeHandle(result);
+ });
+}
diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs
index 0baa4b6..c1f284b 100644
--- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs
+++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs
@@ -24,6 +24,9 @@ public sealed partial class WasmRuntime
/// Creates and opens selectively disclosed attribute bundles.
public SharableCertificateAttributesFactory Sharables { get; }
+ /// Creates block builders, ledger operations, and parsed blocks.
+ public BlockFactory Blocks { get; }
+
///
/// Create a KYC anchor client signed by , resolving
/// providers from 's on-chain service metadata read via
@@ -41,11 +44,13 @@ public AssetMovementClient CreateAssetMovementClient(string nodeUrl, string root
AssetMovementClient.WithAccount(this, nodeUrl, root, account);
///
- /// Create a lite, read-only client for the node API at
- /// . An injected
- /// (for example from IHttpClientFactory) is borrowed, not disposed.
- /// Absent one the client owns its own.
+ /// Create a lite 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 (
+ /// and fee blocks). A client without one stays read-only.
///
- public NodeClient CreateNodeClient(string nodeUrl, HttpClient? httpClient = null) =>
- new(this, nodeUrl, httpClient);
+ public NodeClient CreateNodeClient(string nodeUrl, HttpClient? httpClient = null, long? network = null) =>
+ new(this, nodeUrl, httpClient, network);
}
diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.cs
index df54872..f26d731 100644
--- a/src/KeetaNet.Anchor/Interop/WasmRuntime.cs
+++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.cs
@@ -59,6 +59,7 @@ private WasmRuntime(Func loadModule)
KycCertificates = new Crypto.KycCertificateFactory(this);
Containers = new Crypto.EncryptedContainerFactory(this);
Sharables = new Crypto.SharableCertificateAttributesFactory(this);
+ Blocks = new Crypto.BlockFactory(this);
_dispatcher = new WasmDispatcher();
try
diff --git a/src/KeetaNet.Anchor/Services/Node/NodeApiSettings.cs b/src/KeetaNet.Anchor/Services/Node/NodeApiSettings.cs
new file mode 100644
index 0000000..b679acf
--- /dev/null
+++ b/src/KeetaNet.Anchor/Services/Node/NodeApiSettings.cs
@@ -0,0 +1,16 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace KeetaNet.Anchor.Generated.Node;
+
+///
+/// Serializer settings for the generated node transport. Null optional members
+/// must vanish from request bodies: the vote endpoint reads an explicit
+/// votes: null differently from an absent field, exactly as the
+/// reference clients (which serialize with null suppression) rely on.
+///
+public partial class NodeApi
+{
+ static partial void UpdateJsonSerializerSettings(JsonSerializerOptions settings) =>
+ settings.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+}
diff --git a/src/KeetaNet.Anchor/Services/Node/NodeClient.cs b/src/KeetaNet.Anchor/Services/Node/NodeClient.cs
index f465e7b..c690525 100644
--- a/src/KeetaNet.Anchor/Services/Node/NodeClient.cs
+++ b/src/KeetaNet.Anchor/Services/Node/NodeClient.cs
@@ -16,6 +16,9 @@ namespace KeetaNet.Anchor;
///
public sealed class NodeClient : IDisposable
{
+ /// The block version the reference clients build.
+ private const int BlockVersion = 2;
+
private readonly WasmRuntime _runtime;
/// The client-owned transport. Null when an injected one is borrowed.
@@ -23,14 +26,26 @@ public sealed class NodeClient : IDisposable
private readonly NodeApi _api;
+ private readonly long? _network;
+
+ /// The network's base token; derived only when a network is bound.
+ private readonly Crypto.Account? _baseToken;
+
///
/// A client for the node API at . An injected
/// (for example from IHttpClientFactory) is
- /// borrowed, not disposed.
+ /// 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)
+ internal NodeClient(WasmRuntime runtime, string nodeUrl, HttpClient? http = null, long? network = null)
{
_runtime = runtime;
+ _network = network;
+ if (network is { } bound)
+ {
+ _baseToken = runtime.Blocks.NetworkBaseToken(bound);
+ }
+
if (http is null)
{
_ownedHttp = new HttpClient();
@@ -40,6 +55,15 @@ internal NodeClient(WasmRuntime runtime, string nodeUrl, HttpClient? http = null
_api = new NodeApi(http) { BaseUrl = nodeUrl };
}
+ /// 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.
+ ///
+ public Crypto.Account? BaseToken => _baseToken;
+
/// The node software version string.
public async Task GetNodeVersion(CancellationToken cancellationToken = default)
{
@@ -162,6 +186,125 @@ public async Task GetAccountBalance(
return OptionalHexAmount(response.Balance) ?? BigInteger.Zero;
}
+ /// Publish one signed block as its own staple. See the list overload.
+ public Task Transmit(
+ Crypto.Block block,
+ TransmitOptions? options = null,
+ CancellationToken cancellationToken = default) =>
+ 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.
+ ///
+ 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, cancellationToken).ConfigureAwait(false);
+
+ Crypto.Block? feeBlock = null;
+ try
+ {
+ if (VoteRequiresFee(temporary))
+ {
+ feeBlock = await FeeBlockFor(blocks, temporary, resolved, cancellationToken).ConfigureAwait(false);
+ }
+
+ IReadOnlyList all = blocks;
+ if (feeBlock is not null)
+ {
+ // The fee block joins the permanent round last. The node
+ // recognizes it by its FEE purpose and escalates the temporary
+ // votes over the original blocks.
+ all = blocks.Append(feeBlock).ToArray();
+ encoded.Add(EncodeBlock(feeBlock));
+ }
+
+ string permanent = await RequestVote(encoded, temporary, cancellationToken).ConfigureAwait(false);
+ return await PublishStaple(all, permanent, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ feeBlock?.Dispose();
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ public async Task BuildFeeBlock(
+ Crypto.VoteStaple staple,
+ Crypto.Account account,
+ Crypto.Account signer,
+ IReadOnlyList? feeTokenPriority = null,
+ CancellationToken cancellationToken = default)
+ {
+ (long network, Crypto.Account baseToken) = RequireNetwork();
+
+ int[] feeOps = _runtime.StapleFeeSends(staple.Handle, baseToken.Handle, Crypto.Handles.Of(feeTokenPriority));
+ if (feeOps.Length == 0)
+ {
+ return null;
+ }
+
+ // Adopt every operation handle up front so a failure anywhere below
+ // releases them all.
+ var feeOperations = new List(feeOps.Length);
+ foreach (int handle in feeOps)
+ {
+ feeOperations.Add(new Crypto.BlockOperation(_runtime, handle));
+ }
+
+ try
+ {
+ string? previous = _runtime.StapleTipFor(staple.Handle, account.Handle);
+ if (previous is null)
+ {
+ AccountState state = await GetAccountState(account, cancellationToken).ConfigureAwait(false);
+ previous = state.HeadBlock?.ToString();
+ }
+
+ using var builder = _runtime.Blocks.NewBuilder();
+ builder
+ .WithVersion(BlockVersion)
+ .WithNetwork(network)
+ .WithAccount(account)
+ .WithSigner(signer)
+ .WithPurpose(Crypto.BlockPurpose.Fee)
+ .WithDate(DateTimeOffset.UtcNow);
+ PositionAfter(builder, previous);
+
+ foreach (Crypto.BlockOperation feeOp in feeOperations)
+ {
+ builder.AddOperation(feeOp);
+ }
+
+ return builder.Build();
+ }
+ finally
+ {
+ foreach (Crypto.BlockOperation feeOp in feeOperations)
+ {
+ feeOp.Dispose();
+ }
+ }
+ }
+
///
/// Every certificate has published on-chain, each
/// with the intermediates recorded alongside it. An account with no published
@@ -244,8 +387,144 @@ public CertificateChainStatus EvaluateCertificateChain(
return CertificateChainStatus.Untrusted;
}
- /// Release the HTTP resources the client owns. An injected is left alone.
- public void Dispose() => _ownedHttp?.Dispose();
+ ///
+ /// Release the resources the client owns: its base token account and, when
+ /// not injected, its .
+ ///
+ public void Dispose()
+ {
+ _baseToken?.Dispose();
+ _ownedHttp?.Dispose();
+ }
+
+ /// The bound network and its base token, required by the write path.
+ private (long Network, Crypto.Account BaseToken) RequireNetwork()
+ {
+ if (_network is not { } network || _baseToken is null)
+ {
+ throw new KeetaException("NETWORK_REQUIRED", "bind a network id when creating the node client to build or transmit blocks");
+ }
+
+ return (network, _baseToken);
+ }
+
+ /// A block's transport bytes in the base64 form the vote endpoint carries.
+ private static string EncodeBlock(Crypto.Block block) => Convert.ToBase64String(block.ToBytes());
+
+ ///
+ /// Request one vote over . Round one leaves
+ /// null so the body omits votes entirely.
+ /// Round two attaches the temporary vote so the representative escalates it.
+ ///
+ private async Task RequestVote(
+ IReadOnlyList blocksBase64,
+ string? priorVote,
+ CancellationToken cancellationToken)
+ {
+ var body = new Body { Blocks = blocksBase64.ToList() };
+ if (priorVote is not null)
+ {
+ body.Votes = new List { priorVote };
+ }
+
+ CreateVoteResponse response = await Attempt(() => _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;
+ }
+
+ /// Materialize 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)
+ {
+ using Crypto.Vote vote = DecodeVote(voteBase64);
+ return vote.RequiresFee;
+ }
+
+ ///
+ /// Produce the fee block the temporary round requires through the
+ /// caller's factory, handing it the validated staple over
+ /// and .
+ ///
+ private async Task FeeBlockFor(
+ IReadOnlyList blocks,
+ string temporaryVote,
+ TransmitOptions options,
+ CancellationToken cancellationToken)
+ {
+ if (options.FeeBlockFactory is not { } factory)
+ {
+ throw new KeetaException("FEE_REQUIRED", "the votes require a fee but no fee-block factory is set");
+ }
+
+ IReadOnlyList priority = options.FeeTokenPriority.ToArray();
+ using Crypto.VoteStaple staple = StapleFor(blocks, temporaryVote);
+ Crypto.Block? feeBlock = await factory(this, staple, priority, cancellationToken).ConfigureAwait(false);
+ if (feeBlock is null)
+ {
+ throw new KeetaException("FEE_REQUIRED", "the votes require a fee but the fee-block factory produced none");
+ }
+
+ return feeBlock;
+ }
+
+ ///
+ /// A validated staple over and the base64 vote
+ /// endorsing them, enforcing the staple invariants.
+ ///
+ private Crypto.VoteStaple StapleFor(IReadOnlyList blocks, string voteBase64)
+ {
+ 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));
+ }
+
+ /// Assemble the staple over plus the permanent vote, and post it.
+ private async Task PublishStaple(
+ IReadOnlyList blocks,
+ string permanentVoteBase64,
+ CancellationToken cancellationToken)
+ {
+ byte[] stapleBytes;
+ using (Crypto.Vote vote = DecodeVote(permanentVoteBase64))
+ {
+ int[] blockHandles = blocks.Select(block => block.Handle).ToArray();
+ long moment = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ stapleBytes = _runtime.VoteStapleBuild(blockHandles, new[] { vote.Handle }, moment);
+ }
+
+ var body = new Body3 { VotesAndBlocks = Convert.ToBase64String(stapleBytes) };
+ 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.
+ return true;
+ }
+
+ ///
+ /// Position atop , or
+ /// as an opening block when the account has no chain yet.
+ ///
+ private static void PositionAfter(Crypto.BlockBuilder builder, string? previous)
+ {
+ if (string.IsNullOrEmpty(previous))
+ {
+ builder.AsOpening();
+ return;
+ }
+
+ builder.WithPrevious(Crypto.BlockHash.Parse(previous));
+ }
///
/// Whether one published record chains to a trusted issuer at the moment.
diff --git a/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs b/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs
new file mode 100644
index 0000000..75c7969
--- /dev/null
+++ b/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs
@@ -0,0 +1,47 @@
+namespace KeetaNet.Anchor;
+
+///
+/// Produces the fee block a vote round requires: the transmit flow calls the
+/// factory with the temporary round's staple so it can read the fee owed and
+/// the payer's chaining tip. Return null to decline, which fails the transmit
+/// with FEE_REQUIRED before anything is published.
+///
+public delegate Task GenerateFeeBlock(
+ NodeClient client,
+ Crypto.VoteStaple staple,
+ IReadOnlyList feeTokenPriority,
+ CancellationToken cancellationToken);
+
+///
+/// How a transmit pays the fee its vote round may require. The default pays
+/// none: a vote requiring one fails with FEE_REQUIRED.
+///
+public sealed class TransmitOptions
+{
+ ///
+ /// Token accounts to prefer, in order, when the votes offer a fee choice.
+ /// The base token is always an implicit last resort.
+ ///
+ public IList FeeTokenPriority { get; } = new List();
+
+ /// The fee-block factory, or null to pay no fee.
+ public GenerateFeeBlock? FeeBlockFactory { get; set; }
+
+ ///
+ /// Pay any required fee from 's own balance,
+ /// signed by itself - the common case.
+ ///
+ public static TransmitOptions WithFeeSigner(Crypto.Account signer) => WithFeeBlockFrom(signer, signer);
+
+ ///
+ /// Pay any required fee from 's balance with
+ /// signing (delegated signing, e.g. a storage
+ /// account whose owner signs).
+ ///
+ public static TransmitOptions WithFeeBlockFrom(Crypto.Account account, Crypto.Account signer) =>
+ new()
+ {
+ FeeBlockFactory = (client, staple, priority, cancellationToken) =>
+ client.BuildFeeBlock(staple, account, signer, priority, cancellationToken),
+ };
+}
diff --git a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs
index 0628b1a..c1c03f3 100644
--- a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs
+++ b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs
@@ -63,13 +63,15 @@ internal sealed class LedgerNode
public string Api { get; }
public string BaseToken { get; }
public string Representative { get; }
+ public long Network { get; }
- private LedgerNode(NodeHarness harness, string api, string baseToken, string representative)
+ private LedgerNode(NodeHarness harness, string api, string baseToken, string representative, long network)
{
_harness = harness;
Api = api;
BaseToken = baseToken;
Representative = representative;
+ Network = network;
}
/// Boot the reference node with an initialized chain.
@@ -81,7 +83,8 @@ public static LedgerNode Start(NodeHarness harness)
harness,
started.GetProperty("api").GetString()!,
started.GetProperty("baseToken").GetString()!,
- started.GetProperty("representative").GetString()!);
+ started.GetProperty("representative").GetString()!,
+ long.Parse(started.GetProperty("network").GetString()!, System.Globalization.CultureInfo.InvariantCulture));
}
/// Fund the seed-derived account, returning its address.
diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs
index 3d45508..9f145bb 100644
--- a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs
+++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs
@@ -74,6 +74,7 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode()
// The state's head must be the exact head hash the reference client
// reports, and the height must reflect the two published blocks.
Assert.NotNull(state.HeadBlock);
+
string? head = node.Head(holder.PublicKeyString);
Assert.NotNull(head);
Assert.Equal(BlockHash.Parse(head!), state.HeadBlock!.Value);
@@ -124,6 +125,142 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode()
harness.Shutdown();
}
+ /// The flat base-token fee the harness chain charges per vote round.
+ private static readonly BigInteger RoundFee = BigInteger.One;
+
+ [Fact]
+ public async Task FeeBearingSendTransmitsAgainstTheLiveNode()
+ {
+ CancellationToken cancellationToken = TestContext.Current.CancellationToken;
+ using var harness = NodeHarness.Spawn("node");
+ LedgerNode node = LedgerNode.Start(harness);
+
+ using var runtime = WasmRuntime.Load();
+ using NodeClient client = runtime.CreateNodeClient(node.Api, network: node.Network);
+
+ // Both sides must derive the same base token from the network id.
+ Assert.NotNull(client.BaseToken);
+ Assert.Equal(node.BaseToken, client.BaseToken!.PublicKeyString);
+
+ 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.
+ 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);
+ }
+
+ // The recipient gains exactly the amount; the holder also paid the
+ // round's flat fee.
+ BigInteger credited = await client.GetAccountBalance(recipient, client.BaseToken, cancellationToken);
+ Assert.Equal(new BigInteger(Amount), credited);
+
+ BigInteger remaining = await client.GetAccountBalance(holder, client.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);
+ 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));
+ }
+
+ state = await client.GetAccountState(holder, cancellationToken);
+ Assert.Equal(recipient.PublicKeyString, state.Representative!.PublicKeyString);
+ remaining -= RoundFee;
+ Assert.Equal(remaining, await client.GetAccountBalance(holder, client.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))
+ {
+ KeetaException refused = await Assert.ThrowsAsync(
+ () => client.Transmit(feeless, cancellationToken: cancellationToken));
+ Assert.Equal("FEE_REQUIRED", refused.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 (Block opening = BuildSend(runtime, client, recipient, holder, 1, previous: null))
+ {
+ KeetaException unbound = await Assert.ThrowsAsync(
+ () => readOnly.Transmit(opening, TransmitOptions.WithFeeSigner(recipient), cancellationToken));
+ Assert.Equal("NETWORK_REQUIRED", unbound.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));
+
+ harness.Shutdown();
+ }
+
+ /// A signed base-token send from to .
+ private static Block BuildSend(
+ WasmRuntime runtime,
+ NodeClient client,
+ Account from,
+ 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);
+
+ if (previous is { } hash)
+ {
+ builder.WithPrevious(hash);
+ }
+ else
+ {
+ builder.AsOpening();
+ }
+
+ return builder.Build();
+ }
+
///
/// The three representative reads agree on the chain's one representative:
/// the node's own, the singular lookup, and the advertised set.
diff --git a/tests/KeetaNet.Anchor.Tests/BlockTests.cs b/tests/KeetaNet.Anchor.Tests/BlockTests.cs
new file mode 100644
index 0000000..5726846
--- /dev/null
+++ b/tests/KeetaNet.Anchor.Tests/BlockTests.cs
@@ -0,0 +1,92 @@
+using KeetaNet.Anchor.Crypto;
+using Xunit;
+
+namespace KeetaNet.Anchor.Tests;
+
+///
+/// The offline block surface: building and signing a block, its transport
+/// round-trip, and the network's derived base token. The networked transmit
+/// flow lives in the E2E suite.
+///
+public sealed class BlockTests
+{
+ /// The reference TEST network id; signing rejects unknown networks.
+ private const long Network = 0x5445_5354;
+
+ /// A neighboring known network (DEV), for the derivation contrast.
+ private const long OtherNetwork = 0x44_4556;
+
+ [Fact]
+ public void ASignedOpeningBlockRoundTripsAndConsumesItsBuilder()
+ {
+ 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();
+
+ byte[] bytes = block.ToBytes();
+ Assert.NotEmpty(bytes);
+
+ // Decoding the transport bytes yields the identical block.
+ using Block decoded = runtime.Blocks.ParseHex(Convert.ToHexString(bytes));
+ Assert.Equal(block.Hash, decoded.Hash);
+
+ // Building consumed the builder, so a second build refuses.
+ KeetaException refused = Assert.Throws(builder.Build);
+ Assert.Equal("BUILDER_CONSUMED", refused.Code);
+ }
+
+ [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 NodeClient client = runtime.CreateNodeClient(TestSeeds.NonRoutableAnchor);
+ KeetaException refused = await Assert.ThrowsAsync(
+ () => client.Transmit(block, cancellationToken: TestContext.Current.CancellationToken));
+ Assert.Equal("NETWORK_REQUIRED", refused.Code);
+ }
+
+ [Fact]
+ public void TheBaseTokenDerivesDeterministicallyFromTheNetwork()
+ {
+ using var runtime = WasmRuntime.Load();
+ using Account first = runtime.Blocks.NetworkBaseToken(Network);
+ using Account again = runtime.Blocks.NetworkBaseToken(Network);
+ using Account other = runtime.Blocks.NetworkBaseToken(OtherNetwork);
+
+ Assert.StartsWith("keeta_", first.PublicKeyString, StringComparison.Ordinal);
+ Assert.Equal(first.PublicKeyString, again.PublicKeyString);
+ Assert.NotEqual(first.PublicKeyString, other.PublicKeyString);
+ }
+}
diff --git a/tests/node-harness/src/node.ts b/tests/node-harness/src/node.ts
index 70d56d9..df7494c 100644
--- a/tests/node-harness/src/node.ts
+++ b/tests/node-harness/src/node.ts
@@ -98,7 +98,8 @@ async function handleStartNode(): Promise {
event: 'node-started',
api: chain.api,
baseToken: chain.repClient.baseToken.publicKeyString.get(),
- representative: chain.repClient.account.publicKeyString.get()
+ representative: chain.repClient.account.publicKeyString.get(),
+ network: chain.node.config.network.toString()
});
}