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() }); }