diff --git a/src/KeetaNet.Anchor/Crypto/Account.cs b/src/KeetaNet.Anchor/Crypto/Account.cs index 588de32..cc93f29 100644 --- a/src/KeetaNet.Anchor/Crypto/Account.cs +++ b/src/KeetaNet.Anchor/Crypto/Account.cs @@ -36,5 +36,18 @@ internal Account(WasmRuntime runtime, int handle) /// Decrypt with the account's private key. public byte[] Decrypt(byte[] ciphertext) => Runtime.AccountDecrypt(Handle, ciphertext); + /// + /// Derive the identifier account this account claims + /// at block (its opening block when omitted) and + /// operation . The caller owns the returned handle. + /// + public Account GenerateIdentifier(IdentifierKind kind, BlockHash? previous = null, int index = 0) + { + byte[] hash = previous?.ToBytes() ?? Array.Empty(); + int handle = Runtime.GenerateIdentifier(Handle, CoreNames.Of(kind), hash, index); + + return new Account(Runtime, handle); + } + private protected override void Release(WasmRuntime runtime, int handle) => runtime.AccountFree(handle); } diff --git a/src/KeetaNet.Anchor/Crypto/BlockFactory.cs b/src/KeetaNet.Anchor/Crypto/BlockFactory.cs index b86b717..8a29ccc 100644 --- a/src/KeetaNet.Anchor/Crypto/BlockFactory.cs +++ b/src/KeetaNet.Anchor/Crypto/BlockFactory.cs @@ -36,6 +36,91 @@ public BlockOperation SetRep(Account representative) return new BlockOperation(_runtime, handle); } + /// + /// A RECEIVE operation crediting base units + /// of from . With + /// set the send must match the amount exactly; + /// redirects the funds onward. + /// + public BlockOperation Receive(Account from, BigInteger amount, Account token, bool exact = false, Account? forward = null) + { + string value = amount.ToString(System.Globalization.CultureInfo.InvariantCulture); + int handle = _runtime.OpReceive(from.Handle, value, token.Handle, exact, forward?.Handle ?? 0); + + return new BlockOperation(_runtime, handle); + } + + /// + /// A SET_INFO operation publishing the account's name, description, + /// and metadata. is required for + /// identifier accounts. + /// + public BlockOperation SetInfo(string name, string description, string metadata, Permissions? defaultPermission = null) + { + int handle = _runtime.OpSetInfo(name, description, metadata, defaultPermission?.Handle ?? 0); + return new BlockOperation(_runtime, handle); + } + + /// + /// A MODIFY_PERMISSIONS operation applying + /// to with , optionally + /// scoped to (the block account when omitted). + /// + public BlockOperation ModifyPermissions(Account principal, Permissions permissions, AdjustMethod method, Account? target = null) + { + int handle = _runtime.OpModifyPermissions( + principal.Handle, permissions.Handle, CoreNames.Of(method), target?.Handle ?? 0); + + return new BlockOperation(_runtime, handle); + } + + /// + /// A TOKEN_ADMIN_SUPPLY operation adjusting the block token's supply + /// by using + /// ( is not a valid supply adjustment). + /// + public BlockOperation TokenAdminSupply(BigInteger amount, AdjustMethod method) + { + string value = amount.ToString(System.Globalization.CultureInfo.InvariantCulture); + int handle = _runtime.OpTokenAdminSupply(value, CoreNames.Of(method)); + + return new BlockOperation(_runtime, handle); + } + + /// A CREATE_IDENTIFIER operation claiming . + public BlockOperation CreateIdentifier(Account identifier) + { + int handle = _runtime.OpCreateIdentifier(identifier.Handle); + return new BlockOperation(_runtime, handle); + } + + /// + /// A CREATE_IDENTIFIER operation claiming + /// as a multisig account governed by with the + /// given signing . + /// + public BlockOperation CreateMultisig(Account multisig, IReadOnlyList signers, int quorum) + { + int handle = _runtime.OpCreateMultisig(multisig.Handle, Handles.Of(signers), quorum); + return new BlockOperation(_runtime, handle); + } + + /// A permission set from base and optional external bit . + public Permissions PermissionsFromFlags(IReadOnlyList flags, byte[]? externalOffsets = null) + { + string names = string.Join('\n', flags.Select(CoreNames.Of)); + int handle = _runtime.PermissionsFromFlags(names, externalOffsets ?? Array.Empty()); + + return new Permissions(_runtime, handle); + } + + /// A permission set decoded from its [base, external] hex bitmaps, the ACL transport form. + public Permissions PermissionsFromBitmaps(string baseBitmap, string externalBitmap) + { + int handle = _runtime.PermissionsFromBitmaps(baseBitmap, externalBitmap); + return new Permissions(_runtime, handle); + } + /// Decode a signed block from its transport hex. public Block ParseHex(string hex) { diff --git a/src/KeetaNet.Anchor/Crypto/Blocks.cs b/src/KeetaNet.Anchor/Crypto/Blocks.cs index 355a7a0..958e138 100644 --- a/src/KeetaNet.Anchor/Crypto/Blocks.cs +++ b/src/KeetaNet.Anchor/Crypto/Blocks.cs @@ -18,6 +18,12 @@ internal Block(WasmRuntime runtime, int handle) /// The block's raw transport bytes, as a vote request carries them. public byte[] ToBytes() => Runtime.BlockToBytes(Handle); + /// The block's transport hex encoding. + public string ToHex() => Runtime.BlockToHex(Handle); + + /// The block's originating account. The caller owns the returned handle. + public Account GetAccount() => new(Runtime, Runtime.BlockAccount(Handle)); + private protected override void Release(WasmRuntime runtime, int handle) => runtime.BlockFree(handle); } @@ -37,10 +43,10 @@ internal BlockOperation(WasmRuntime runtime, int handle) } /// -/// A representative vote decoded from its transport bytes. Internal: votes -/// only ever pass through the transmit flow. +/// A representative vote decoded from its transport bytes. Produced by the +/// transmit flow and the vote reads on . /// -internal sealed class Vote : WasmObject +public sealed class Vote : WasmObject { internal Vote(WasmRuntime runtime, int handle) : base(runtime, handle) diff --git a/src/KeetaNet.Anchor/Crypto/Permissions.cs b/src/KeetaNet.Anchor/Crypto/Permissions.cs new file mode 100644 index 0000000..e9c0c94 --- /dev/null +++ b/src/KeetaNet.Anchor/Crypto/Permissions.cs @@ -0,0 +1,146 @@ +namespace KeetaNet.Anchor.Crypto; + +/// How a permission or supply adjustment is applied. +public enum AdjustMethod +{ + /// Grant on top of the existing set, or mint supply. + Add, + /// Revoke from the existing set, or burn supply. + Subtract, + /// Replace the existing set outright. + Set, +} + +/// The kind of identifier account an account can derive. +public enum IdentifierKind +{ + /// A network identifier. + Network, + /// A token identifier. + Token, + /// A storage identifier. + Storage, +} + +/// +/// A named base permission bit, matching the reference ledger's offsets. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Naming", + "CA1711:Identifiers should not have incorrect suffix", + Justification = "BaseFlag is the reference implementations' name for this type")] +public enum BaseFlag +{ + /// Account has access. + Access = 0, + /// Account is an owner. + Owner = 1, + /// Account is an administrator. + Admin = 2, + /// Account can update info. + UpdateInfo = 3, + /// Account can send on behalf of the entity. + SendOnBehalf = 4, + /// Account can create tokens. + TokenAdminCreate = 5, + /// Account can modify token supply. + TokenAdminSupply = 6, + /// Account can modify token balances. + TokenAdminModifyBalance = 7, + /// Account can create storage accounts. + StorageCreate = 8, + /// Storage account can hold the principal token. + StorageCanHold = 9, + /// Account can deposit into the storage account. + StorageDeposit = 10, + /// Account can delegate permission additions. + PermissionDelegateAdd = 11, + /// Account can delegate permission removals. + PermissionDelegateRemove = 12, + /// Account can manage certificates. + ManageCertificate = 13, + /// Account is a multisig signer. + MultisigSigner = 14, +} + +/// +/// A ledger permission set: named base flags plus external bit offsets. +/// Created through or decoded +/// from its [base, external] bitmaps via +/// . +/// +public sealed class Permissions : WasmObject +{ + internal Permissions(WasmRuntime runtime, int handle) + : base(runtime, handle) + { + } + + /// The granted base flags, in ledger offset order. + public IReadOnlyList Flags => + Lines(Runtime.PermissionsFlags(Handle)).Select(CoreNames.FlagOf).ToArray(); + + /// The external permission bit offsets. + public byte[] ExternalOffsets => Runtime.PermissionsOffsets(Handle); + + /// The [base, external] bitmaps as 0x-prefixed hex, the ACL transport form. + public IReadOnlyList Bitmaps => Lines(Runtime.PermissionsBitmaps(Handle)); + + private protected override void Release(WasmRuntime runtime, int handle) => runtime.PermissionsFree(handle); + + private static string[] Lines(string joined) => + joined.Length == 0 ? Array.Empty() : joined.Split('\n'); +} + +/// Transport names for the adjust, identifier, and flag enums. +internal static class CoreNames +{ + /// The base flag wire names, indexed by ledger offset. + private static readonly string[] FlagNames = + { + "access", + "owner", + "admin", + "update_info", + "send_on_behalf", + "token_admin_create", + "token_admin_supply", + "token_admin_modify_balance", + "storage_create", + "storage_can_hold", + "storage_deposit", + "permission_delegate_add", + "permission_delegate_remove", + "manage_certificate", + "multisig_signer", + }; + + public static string Of(AdjustMethod method) => + method switch + { + AdjustMethod.Add => "add", + AdjustMethod.Subtract => "subtract", + _ => "set", + }; + + public static string Of(IdentifierKind kind) => + kind switch + { + IdentifierKind.Network => "network", + IdentifierKind.Token => "token", + _ => "storage", + }; + + public static string Of(BaseFlag flag) => FlagNames[(int)flag]; + + public static BaseFlag FlagOf(string name) + { + int offset = Array.IndexOf(FlagNames, name); + if (offset < 0) + { + throw new KeetaException("UNKNOWN", $"unknown base permission flag: {name}"); + } + + return (BaseFlag)offset; + } +} diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs index eb38a15..23496bc 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs @@ -14,10 +14,60 @@ public sealed partial class WasmRuntime internal string BlockHashHex(int handle) => TextOf("keeta_block_hash", handle); + internal string BlockToHex(int handle) => TextOf("keeta_block_to_hex", handle); + internal byte[] BlockToBytes(int handle) => BytesOf("keeta_block_to_bytes", handle); + internal int BlockAccount(int handle) => + Run(() => TakeHandle(Invoke("keeta_block_account", handle))); + internal void BlockFree(int handle) => RunFree("keeta_block_free", handle); + internal int PermissionsFromFlags(string flagsJoined, byte[] offsets) => + Run(() => + { + using var arguments = new ArgumentScope(this); + Argument flags = arguments.Write(flagsJoined); + Argument external = arguments.WriteBytes(offsets); + + int result = Invoke( + "keeta_permissions_from_flags", flags.Pointer, flags.Length, external.Pointer, external.Length); + return TakeHandle(result); + }); + + internal int PermissionsFromBitmaps(string baseHex, string externalHex) => + Run(() => + { + using var arguments = new ArgumentScope(this); + Argument baseMap = arguments.Write(baseHex); + Argument externalMap = arguments.Write(externalHex); + + int result = Invoke( + "keeta_permissions_from_bitmaps", baseMap.Pointer, baseMap.Length, externalMap.Pointer, externalMap.Length); + return TakeHandle(result); + }); + + internal string PermissionsFlags(int handle) => TextOf("keeta_permissions_flags", handle); + + internal byte[] PermissionsOffsets(int handle) => BytesOf("keeta_permissions_offsets", handle); + + internal string PermissionsBitmaps(int handle) => TextOf("keeta_permissions_bitmaps", handle); + + internal void PermissionsFree(int handle) => RunFree("keeta_permissions_free", handle); + + internal int GenerateIdentifier(int account, string kind, byte[] previous, int index) => + Run(() => + { + using var arguments = new ArgumentScope(this); + Argument name = arguments.Write(kind); + Argument previousHash = arguments.WriteBytes(previous); + + int result = Invoke( + "keeta_generate_identifier", + account, name.Pointer, name.Length, previousHash.Pointer, previousHash.Length, index); + return TakeHandle(result); + }); + internal int OpSetRep(int to) => Run(() => { @@ -37,6 +87,71 @@ internal int OpSend(int to, string amount, int token, string external) => return TakeHandle(result); }); + internal int OpReceive(int from, string amount, int token, bool exact, int forward) => + Run(() => + { + using var arguments = new ArgumentScope(this); + Argument value = arguments.Write(amount); + + int result = Invoke( + "keeta_op_receive", from, value.Pointer, value.Length, token, exact ? 1 : 0, forward); + return TakeHandle(result); + }); + + internal int OpSetInfo(string name, string description, string metadata, int permissions) => + Run(() => + { + using var arguments = new ArgumentScope(this); + Argument accountName = arguments.Write(name); + Argument accountDescription = arguments.Write(description); + Argument accountMetadata = arguments.Write(metadata); + + int result = Invoke( + "keeta_op_set_info", + accountName.Pointer, accountName.Length, + accountDescription.Pointer, accountDescription.Length, + accountMetadata.Pointer, accountMetadata.Length, + permissions); + return TakeHandle(result); + }); + + internal int OpModifyPermissions(int principal, int permissions, string method, int target) => + Run(() => + { + using var arguments = new ArgumentScope(this); + Argument adjust = arguments.Write(method); + + int result = Invoke( + "keeta_op_modify_permissions", principal, permissions, adjust.Pointer, adjust.Length, target); + return TakeHandle(result); + }); + + internal int OpTokenAdminSupply(string amount, string method) => + Run(() => + { + using var arguments = new ArgumentScope(this); + Argument value = arguments.Write(amount); + Argument adjust = arguments.Write(method); + + int result = Invoke( + "keeta_op_token_admin_supply", value.Pointer, value.Length, adjust.Pointer, adjust.Length); + return TakeHandle(result); + }); + + internal int OpCreateIdentifier(int identifier) => + Run(() => TakeHandle(Invoke("keeta_op_create_identifier", identifier))); + + internal int OpCreateMultisig(int multisig, int[] signers, int quorum) => + Run(() => + { + using var arguments = new ArgumentScope(this); + Argument signerList = arguments.WriteHandles(signers); + + int result = Invoke( + "keeta_op_create_multisig", multisig, signerList.Pointer, signerList.Length, quorum); + return TakeHandle(result); + }); + internal void OpFree(int handle) => RunFree("keeta_op_free", handle); internal int BuilderNew() => diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs index fa38d6e..9bbb8ce 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs @@ -54,12 +54,20 @@ public AssetMovementClient CreateAssetMovementClient(string nodeUrl, string root public KeetaClient CreateKeetaClient(string nodeUrl, HttpClient? httpClient = null, long? network = null) => new(this, nodeUrl, httpClient, network); + /// + /// Create the base client for a well-known , + /// the reference fromNetwork: its first representative's endpoint + /// and its network id, so the write path is enabled. + /// + public KeetaClient CreateKeetaClient(KeetaNetwork network, HttpClient? httpClient = null) => + new(this, network.RepresentativeApiUrl(), httpClient, network.Id()); + /// /// 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. + /// disposed. See + /// for the remaining parameters. /// public UserClient CreateUserClient( string nodeUrl, @@ -68,4 +76,16 @@ public UserClient CreateUserClient( long? network = null, Account? account = null) => new(this, nodeUrl, httpClient, network, signer, account); + + /// + /// Create a signer-bound client for a well-known + /// , the reference UserClient.fromNetwork. + /// See the URL overload for the remaining parameters. + /// + public UserClient CreateUserClient( + KeetaNetwork network, + Account? signer, + HttpClient? httpClient = null, + Account? account = null) => + new(this, network.RepresentativeApiUrl(), httpClient, network.Id(), signer, account); } diff --git a/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs b/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs index 696aefc..bb2dc6d 100644 --- a/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs @@ -1,11 +1,15 @@ +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Numerics; using System.Text.Json; using KeetaNet.Anchor.Generated.Node; +using GeneratedBlock = KeetaNet.Anchor.Generated.Node.Block; using GeneratedCertificate = KeetaNet.Anchor.Generated.Node.Certificate; +using GeneratedHistoryEntry = KeetaNet.Anchor.Generated.Node.HistoryEntry; using GeneratedRepresentative = KeetaNet.Anchor.Generated.Node.Representative; +using GeneratedVote = KeetaNet.Anchor.Generated.Node.Vote; namespace KeetaNet.Anchor; @@ -186,6 +190,213 @@ public async Task GetAccountBalance( return OptionalHexAmount(response.Balance) ?? BigInteger.Zero; } + /// The head block of 's chain, or null for a never-used account. + public async Task GetHeadBlock( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + GetAccountHeadResponse response = await Attempt(() => _api.GetAccountHeadAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + return DecodeBlock(response.Block); + } + + /// The next pending (unreceived) block for , if any. + public async Task GetPendingBlock( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + GetPendingBlockResponse response = await Attempt(() => _api.GetPendingBlockAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + return DecodeBlock(response.Block); + } + + /// The block identified by on the given , if present. + public async Task GetBlock( + Crypto.BlockHash blockHash, + LedgerSide? side = null, + CancellationToken cancellationToken = default) + { + Side2? generated = side switch + { + LedgerSide.Main => Side2.Main, + LedgerSide.Side => Side2.Side, + _ => null, + }; + + GetBlockResponse response = await Attempt(() => _api.GetBlockAsync(blockHash.ToString(), generated, cancellationToken)).ConfigureAwait(false); + return DecodeBlock(response.Block); + } + + /// The block following , if one exists. + public async Task GetSuccessorBlock( + Crypto.BlockHash blockHash, + CancellationToken cancellationToken = default) + { + GetSuccessorBlockResponse response = await Attempt(() => _api.GetSuccessorBlockAsync(blockHash.ToString(), cancellationToken)).ConfigureAwait(false); + return DecodeBlock(response.SuccessorBlock); + } + + /// + /// The block produced by for the idempotent + /// , if any, searching the given + /// (the main ledger when omitted). + /// + public async Task GetBlockFromIdempotent( + Crypto.Account account, + string key, + LedgerSide? side = null, + CancellationToken cancellationToken = default) + { + Side3? generated = side switch + { + LedgerSide.Main => Side3.Main, + LedgerSide.Side => Side3.Side, + _ => null, + }; + + GetBlockFromIdempotentResponse response = await Attempt(() => _api.GetBlockFromIdempotentAsync(account.PublicKeyString, key, generated, cancellationToken)).ConfigureAwait(false); + return DecodeBlock(response.Block); + } + + /// + /// The verified votes the node holds for on + /// , or null when it holds none. The caller owns + /// the votes and must dispose them. + /// + public async Task?> GetBlockVotes( + Crypto.BlockHash blockHash, + LedgerSide side = LedgerSide.Main, + CancellationToken cancellationToken = default) + { + Side generated = side == LedgerSide.Side ? Side.Side : Side.Main; + GetBlockVotesResponse response = await Attempt(() => _api.GetBlockVotesAsync(blockHash.ToString(), generated, cancellationToken)).ConfigureAwait(false); + if (response.Votes is null) + { + return null; + } + + var votes = new List(response.Votes.Count); + try + { + foreach (GeneratedVote vote in response.Votes) + { + votes.Add(DecodeVote(vote.Binary)); + } + } + catch + { + foreach (Crypto.Vote vote in votes) + { + vote.Dispose(); + } + + throw; + } + + return votes; + } + + /// + /// A single page of 's block chain (most recent + /// first), bounded by , with the cursor for the + /// next page. The caller owns the blocks and must dispose them. + /// + public async Task GetAccountChain( + Crypto.Account account, + ChainQuery? query = null, + CancellationToken cancellationToken = default) + { + ChainQuery bounds = query ?? new ChainQuery(); + GetAccountChainResponse response = await Attempt(() => _api.GetAccountChainAsync( + account.PublicKeyString, + bounds.Start?.ToString(), + bounds.End?.ToString(), + bounds.Limit, + cancellationToken)).ConfigureAwait(false); + + ICollection items = response.Blocks ?? Array.Empty(); + var blocks = new List(items.Count); + try + { + foreach (GetAccountChainResponseBlocksItem item in items) + { + if (DecodeBlock(item.Block) is { } block) + { + blocks.Add(block); + } + } + } + catch + { + foreach (Crypto.Block block in blocks) + { + block.Dispose(); + } + + throw; + } + + return new ChainPage(blocks, OptionalBlockHash(response.NextKey)); + } + + /// + /// A single page of 's committed staple history, + /// bounded by , with the cursor for the next page. + /// + public async Task GetAccountHistory( + Crypto.Account account, + HistoryQuery? query = null, + CancellationToken cancellationToken = default) + { + HistoryQuery bounds = query ?? new HistoryQuery(); + GetAccountHistoryResponse response = await Attempt(() => _api.GetAccountHistoryAsync( + account.PublicKeyString, + bounds.Start?.ToString(), + bounds.Limit, + cancellationToken)).ConfigureAwait(false); + + return DecodeHistoryPage(response.History, response.NextKey); + } + + /// + /// A single page of the node's global staple history, bounded by + /// , with the cursor for the next page. + /// + public async Task GetGlobalHistory( + HistoryQuery? query = null, + CancellationToken cancellationToken = default) + { + HistoryQuery bounds = query ?? new HistoryQuery(); + GetGlobalHistoryResponse response = await Attempt(() => _api.GetGlobalHistoryAsync( + bounds.Start?.ToString(), + bounds.Limit, + cancellationToken)).ConfigureAwait(false); + + return DecodeHistoryPage(response.History, response.NextKey); + } + + /// + /// ACL entries where is the principal. The + /// caller owns the returned accounts and permission sets. + /// + public async Task> GetAclsByPrincipal( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + ListAclsByPrincipalResponse response = await Attempt(() => _api.ListAclsByPrincipalAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + return DecodeAcls(response.Permissions); + } + + /// + /// ACL entries granted to as an entity. The + /// caller owns the returned accounts and permission sets. + /// + public async Task> GetAclsByEntity( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + ListAclsByEntityResponse response = await Attempt(() => _api.ListAclsByEntityAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + return DecodeAcls(response.Permissions); + } + /// /// A builder pre-set with the reference block version, the bound network, /// as originator, @@ -230,7 +441,7 @@ public async Task Transmit( TransmitOptions resolved = options ?? new TransmitOptions(); List encoded = blocks.Select(EncodeBlock).ToList(); - string temporary = await RequestVote(encoded, priorVote: null, cancellationToken).ConfigureAwait(false); + string temporary = await RequestVote(encoded, priorVote: null, resolved.Quote, cancellationToken).ConfigureAwait(false); Crypto.Block? feeBlock = null; try @@ -250,7 +461,7 @@ public async Task Transmit( encoded.Add(EncodeBlock(feeBlock)); } - string permanent = await RequestVote(encoded, temporary, cancellationToken).ConfigureAwait(false); + string permanent = await RequestVote(encoded, temporary, quote: null, cancellationToken).ConfigureAwait(false); return await PublishStaple(all, permanent, cancellationToken).ConfigureAwait(false); } finally @@ -430,14 +641,37 @@ public void Dispose() /// 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 a non-binding vote quote for , locking + /// in the fee the node would charge. Attach it to a transmit through + /// . + /// + public async Task GetVoteQuote( + IReadOnlyList blocks, + CancellationToken cancellationToken = default) + { + var body = new Body2 { Blocks = blocks.Select(EncodeBlock).ToList() }; + CreateVoteQuoteResponse response = await Attempt(() => _api.CreateVoteQuoteAsync(body, cancellationToken)).ConfigureAwait(false); + + string? quote = response.Quote?.Binary; + if (string.IsNullOrEmpty(quote)) + { + throw new KeetaException("VOTE_DECLINED", "the node returned no vote quote"); + } + + return Convert.FromBase64String(quote); + } + /// /// 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. + /// null so the body omits votes entirely, + /// and may attach a pre-fetched . Round two attaches + /// the temporary vote so the representative escalates it. /// private async Task RequestVote( IReadOnlyList blocksBase64, string? priorVote, + byte[]? quote, CancellationToken cancellationToken) { var body = new Body { Blocks = blocksBase64.ToList() }; @@ -446,6 +680,11 @@ private async Task RequestVote( body.Votes = new List { priorVote }; } + if (quote is not null) + { + body.Quote = Convert.ToBase64String(quote); + } + CreateVoteResponse response = await Attempt(() => _api.CreateVoteAsync(body, cancellationToken)).ConfigureAwait(false); string? vote = response.Vote?.Binary; if (string.IsNullOrEmpty(vote)) @@ -584,7 +823,9 @@ private bool RecordChainsToRoot( /// /// Run one generated transport call, projecting its failure to a - /// with the stable NODE_STATUS code. + /// . A node error envelope surfaces its own + /// code (for example LEDGER_SUCCESSOR_VOTE_EXISTS); anything else + /// collapses to the stable NODE_STATUS code. /// private static async Task Attempt(Func> operation) { @@ -592,6 +833,10 @@ private static async Task Attempt(Func> operation) { return await operation().ConfigureAwait(false); } + catch (NodeApiException error) when (!string.IsNullOrEmpty(error.Result?.Code)) + { + throw new KeetaException(error.Result.Code, error.Result.Message ?? "the node rejected the request", error); + } catch (NodeApiException error) { throw new KeetaException("NODE_STATUS", $"node request failed with status {error.StatusCode}", error); @@ -602,6 +847,112 @@ private static async Task Attempt(Func> operation) private static Certificate DecodeCertificate(GeneratedCertificate record) => new(record.Certificate1, record.Intermediates?.ToArray() ?? Array.Empty()); + /// + /// Materialize a transport block (base64 $binary) inside the core. + /// An absent block field is the node's "none" shape. + /// + private Crypto.Block? DecodeBlock(GeneratedBlock? block) + { + if (string.IsNullOrEmpty(block?.Binary)) + { + return null; + } + + string hex = Convert.ToHexString(Convert.FromBase64String(block.Binary)); + return _runtime.Blocks.ParseHex(hex); + } + + /// Map generated history entries and the paging cursor to the typed page. + private static HistoryPage DecodeHistoryPage(ICollection? history, string? nextKey) + { + ICollection items = history ?? Array.Empty(); + var entries = new List(items.Count); + foreach (GeneratedHistoryEntry item in items) + { + string? binary = item.VoteStaple?.Binary; + if (string.IsNullOrEmpty(binary)) + { + continue; + } + + DateTimeOffset? timestamp = null; + if (!string.IsNullOrEmpty(item.Timestamp)) + { + timestamp = DateTimeOffset.Parse(item.Timestamp, CultureInfo.InvariantCulture); + } + + entries.Add(new NodeHistoryEntry(Convert.FromBase64String(binary), OptionalBlockHash(item.Id), timestamp)); + } + + return new HistoryPage(entries, OptionalBlockHash(nextKey)); + } + + /// + /// Map generated ACL rows to typed entries: each principal by its declared + /// type, the entity/target accounts, and the [base, external] + /// permission bitmaps decoded through the core. + /// + [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP001:Dispose created", + Justification = "Ownership of the permission set transfers to the returned Acl entry; the caller disposes it with the entry's accounts, as with every model carrying live handles.")] + private Acl[] DecodeAcls(ICollection? rows) => + (rows ?? Array.Empty()) + .Select(row => + { + Crypto.Permissions granted = _runtime.Blocks.PermissionsFromBitmaps( + row.Permissions?.FirstOrDefault() ?? "0x0", + row.Permissions?.Skip(1).FirstOrDefault() ?? "0x0"); + + return new Acl( + DecodeAclPrincipal(row.PrincipalType, row.Principal), + OptionalAccount(row.Entity), + OptionalAccount(row.Target), + granted); + }) + .ToArray(); + + /// + /// Decode an ACL principal from its wire shape: an account address string + /// when the type is ACCOUNT, or an object carrying the issuing + /// certificate hash and its anchor account when CERTIFICATE. + /// + private AclPrincipal? DecodeAclPrincipal(ACLRowPrincipalType kind, object? principal) + { + if (principal is not JsonElement value) + { + return null; + } + + if (kind == ACLRowPrincipalType.CERTIFICATE) + { + string? hash = value.GetProperty("certificate").GetString(); + string? anchor = value.GetProperty("certificateAccount").GetString(); + if (hash is null || anchor is null) + { + throw new KeetaException("ACL_PRINCIPAL", "a certificate principal requires 'certificate' and 'certificateAccount'"); + } + + return new AclCertificatePrincipal( + Crypto.CertificateHash.Parse(hash), + _runtime.Accounts.FromPublicKeyString(anchor)); + } + + string? address = value.GetString(); + if (address is null) + { + throw new KeetaException("ACL_PRINCIPAL", "an account principal must be an address string"); + } + + return new AclAccountPrincipal(_runtime.Accounts.FromPublicKeyString(address)); + } + + /// Parse an optional account address field, null when absent. + private Crypto.Account? OptionalAccount(string? address) => + string.IsNullOrEmpty(address) ? null : _runtime.Accounts.FromPublicKeyString(address); + + /// Parse an optional hex hash field, null when absent. + private static Crypto.BlockHash? OptionalBlockHash(string? hex) => + string.IsNullOrEmpty(hex) ? null : Crypto.BlockHash.Parse(hex); + /// /// Map one account's generated state fields to the typed /// , shared by the single and batch reads. diff --git a/src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs b/src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs new file mode 100644 index 0000000..5d8c491 --- /dev/null +++ b/src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs @@ -0,0 +1,58 @@ +namespace KeetaNet.Anchor; + +/// +/// A well-known KeetaNet network, the port of the reference network registry. +/// Feeds the FromNetwork-style client factories with the network id and +/// its first representative's API endpoint. +/// +public enum KeetaNetwork +{ + /// The production network. + Main, + /// The staging network. + Staging, + /// The public test network. + Test, + /// The development network (deterministic, seed-derived accounts). + Dev, +} + +/// The reference registry values for each . +public static class KeetaNetworkExtensions +{ + /// The network identifier stamped onto blocks for this network. + public static long Id(this KeetaNetwork network) => + network switch + { + KeetaNetwork.Main => 0x5382, + KeetaNetwork.Staging => 0x0053_8201, + KeetaNetwork.Test => 0x5445_5354, + _ => 0x0044_4556, + }; + + /// The lowercase alias used in URLs and string parsing. + public static string Alias(this KeetaNetwork network) => + network switch + { + KeetaNetwork.Main => "main", + KeetaNetwork.Staging => "staging", + KeetaNetwork.Test => "test", + _ => "dev", + }; + + /// + /// The API endpoint of representative + /// (numbered from one). Production networks carry a network infix; + /// dev does not. + /// + public static string RepresentativeApiUrl(this KeetaNetwork network, int representative = 1) + { + string alias = network.Alias(); + if (network == KeetaNetwork.Dev) + { + return $"https://rep{representative}.{alias}.api.keeta.com/api"; + } + + return $"https://rep{representative}.{alias}.network.api.keeta.com/api"; + } +} diff --git a/src/KeetaNet.Anchor/Services/Node/NodeModels.cs b/src/KeetaNet.Anchor/Services/Node/NodeModels.cs index cc7c23f..ae86f6e 100644 --- a/src/KeetaNet.Anchor/Services/Node/NodeModels.cs +++ b/src/KeetaNet.Anchor/Services/Node/NodeModels.cs @@ -51,3 +51,74 @@ public sealed record NodeRepresentative(Account Account, BigInteger Weight, stri /// (, milliseconds). /// public sealed record LedgerChecksum(BigInteger Checksum, DateTimeOffset? Moment, double MomentRangeMs); + +/// Which ledger a block lookup searches. +public enum LedgerSide +{ + /// The settled main ledger. + Main, + /// The unsettled side ledger. + Side, +} + +/// +/// Pagination/range bounds for . +/// / are block-hash cursors; +/// caps the page size (the node applies its own default +/// and maximum). +/// +public sealed record ChainQuery(BlockHash? Start = null, BlockHash? End = null, int? Limit = null); + +/// +/// A single page of an account's chain (most recent first) together with the +/// cursor for the next page: pass as the next query's +/// ; null once the chain is exhausted. The +/// caller owns the blocks and must dispose them. +/// +public sealed record ChainPage(IReadOnlyList Blocks, BlockHash? NextKey); + +/// +/// Pagination bounds for . +/// is the previous page's last staple id; +/// caps the page size. +/// +public sealed record HistoryQuery(BlockHash? Start = null, int? Limit = null); + +/// +/// One committed vote staple in an account's history: its transport bytes, +/// its id (the hash over the block hashes it covers), and the moment it was +/// committed. +/// +public sealed record NodeHistoryEntry(byte[] StapleBytes, BlockHash? Id, DateTimeOffset? Timestamp); + +/// +/// A single page of history together with the cursor for the next page: pass +/// as the next query's ; +/// null once the history is exhausted. +/// +public sealed record HistoryPage(IReadOnlyList Entries, BlockHash? NextKey); + +/// The principal an ACL entry grants permissions to. +public abstract record AclPrincipal +{ + private protected AclPrincipal() + { + } +} + +/// A concrete account principal. +public sealed record AclAccountPrincipal(Account Account) : AclPrincipal; + +/// +/// A certificate principal: any account presenting a certificate issued by +/// the certificate with , anchored to . +/// +public sealed record AclCertificatePrincipal(CertificateHash Hash, Account Account) : AclPrincipal; + +/// +/// An access-control entry granting the +/// permissions over , keyed under +/// . Carries live accounts and a permission set the +/// caller must dispose, like every other model carrying handles. +/// +public sealed record Acl(AclPrincipal? Principal, Account? Entity, Account? Target, Permissions Granted); diff --git a/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs b/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs index 6e49eb1..48d5ddd 100644 --- a/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs +++ b/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs @@ -24,6 +24,12 @@ public sealed class TransmitOptions /// public IList FeeTokenPriority { get; } = new List(); + /// + /// A pre-fetched vote quote (from ) + /// to attach to the temporary round, locking in the quoted fee. + /// + public byte[]? Quote { get; set; } + /// The fee-block factory, or null to pay no fee. public GenerateFeeBlock? FeeBlockFactory { get; set; } diff --git a/src/KeetaNet.Anchor/Services/Node/UserClient.cs b/src/KeetaNet.Anchor/Services/Node/UserClient.cs index 564afeb..774f35b 100644 --- a/src/KeetaNet.Anchor/Services/Node/UserClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/UserClient.cs @@ -82,6 +82,40 @@ public Task> GetAllCertificates(CancellationToken can CancellationToken cancellationToken = default) => _client.GetCertificateByHash(Account, certificateHash, cancellationToken); + /// The head block of the operating account's chain, or null for a fresh account. + public Task GetHeadBlock(CancellationToken cancellationToken = default) => + _client.GetHeadBlock(Account, cancellationToken); + + /// The next pending (unreceived) block for the operating account, if any. + public Task GetPendingBlock(CancellationToken cancellationToken = default) => + _client.GetPendingBlock(Account, cancellationToken); + + /// + /// The block the operating account produced for the idempotent + /// , if any. + /// + public Task GetBlockFromIdempotent( + string key, + LedgerSide? side = null, + CancellationToken cancellationToken = default) => + _client.GetBlockFromIdempotent(Account, key, side, cancellationToken); + + /// A page of the operating account's block chain, most recent first. + public Task GetChain(ChainQuery? query = null, CancellationToken cancellationToken = default) => + _client.GetAccountChain(Account, query, cancellationToken); + + /// A page of the operating account's committed staple history. + public Task GetHistory(HistoryQuery? query = null, CancellationToken cancellationToken = default) => + _client.GetAccountHistory(Account, query, cancellationToken); + + /// ACL entries where the operating account is the principal. + public Task> GetAcls(CancellationToken cancellationToken = default) => + _client.GetAclsByPrincipal(Account, cancellationToken); + + /// ACL entries granted to the operating account as an entity. + public Task> GetAclsByEntity(CancellationToken cancellationToken = default) => + _client.GetAclsByEntity(Account, 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 @@ -114,6 +148,60 @@ public Task Transmit( return _client.Transmit(blocks, OrDefaultFeePayer(options), cancellationToken); } + /// + /// Position atop the operating account's + /// ledger head (opening a fresh chain when it has none), build its block, + /// and transmit it, the reference publishBuilder. The builder must + /// not carry a position of its own. + /// + public async Task Publish( + Crypto.BlockBuilder builder, + TransmitOptions? options = null, + CancellationToken cancellationToken = default) + { + // Require a signer to publish a block + _ = RequireSigner(); + + TransmitOptions resolved = OrDefaultFeePayer(options); + AccountState state = await GetState(cancellationToken).ConfigureAwait(false); + + KeetaClient.PositionAfter(builder, state.HeadBlock?.ToString()); + using Crypto.Block block = builder.Build(); + + return await _client.Transmit(block, resolved, cancellationToken).ConfigureAwait(false); + } + + /// + /// Create a identifier under the operating account + /// and publish the creating block, returning the derived account. The + /// caller owns the returned account. + /// + public async Task GenerateIdentifier( + Crypto.IdentifierKind kind, + TransmitOptions? options = null, + CancellationToken cancellationToken = default) + { + TransmitOptions resolved = OrDefaultFeePayer(options); + AccountState state = await GetState(cancellationToken).ConfigureAwait(false); + + Crypto.Account identifier = Account.GenerateIdentifier(kind, state.HeadBlock); + try + { + using Crypto.BlockOperation claim = _runtime.Blocks.CreateIdentifier(identifier); + using Crypto.BlockBuilder builder = InitBuilder(); + KeetaClient.PositionAfter(builder, state.HeadBlock?.ToString()); + using Crypto.Block block = builder.AddOperation(claim).Build(); + + await _client.Transmit(block, resolved, cancellationToken).ConfigureAwait(false); + return identifier; + } + catch + { + identifier.Dispose(); + throw; + } + } + /// /// Send of to /// , carrying an optional @@ -141,26 +229,52 @@ public async Task SetRep( return await BuildAndTransmit(setRep, options, cancellationToken).ConfigureAwait(false); } - /// Release the owned ; the bound accounts stay with the caller. - public void Dispose() => _client.Dispose(); + /// + /// Publish the operating account's on-chain info. + /// is required for identifier accounts. + /// + public async Task SetInfo( + string name, + string description, + string metadata, + Crypto.Permissions? defaultPermission = null, + TransmitOptions? options = null, + CancellationToken cancellationToken = default) + { + using Crypto.BlockOperation setInfo = _runtime.Blocks.SetInfo(name, description, metadata, defaultPermission); + return await BuildAndTransmit(setInfo, options, cancellationToken).ConfigureAwait(false); + } /// - /// Build the operating account's one-operation block against its ledger - /// head (opening a fresh chain when it has none) and transmit it. + /// Apply to + /// with , optionally scoped to + /// (the operating account when omitted). /// + public async Task UpdatePermissions( + Crypto.Account principal, + Crypto.Permissions permissions, + Crypto.Account? target = null, + Crypto.AdjustMethod method = Crypto.AdjustMethod.Set, + TransmitOptions? options = null, + CancellationToken cancellationToken = default) + { + using Crypto.BlockOperation modify = _runtime.Blocks.ModifyPermissions(principal, permissions, method, target); + return await BuildAndTransmit(modify, options, cancellationToken).ConfigureAwait(false); + } + + /// Release the owned ; the bound accounts stay with the caller. + public void Dispose() => _client.Dispose(); + + /// Publish the operating account's one-operation block. 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(); + builder.AddOperation(operation); - return await _client.Transmit(block, resolved, cancellationToken).ConfigureAwait(false); + return await Publish(builder, options, cancellationToken).ConfigureAwait(false); } /// Absent a fee-block factory, the bound signer pays any required fee itself. @@ -174,6 +288,7 @@ private TransmitOptions OrDefaultFeePayer(TransmitOptions? options) TransmitOptions resolved = TransmitOptions.WithFeeSigner(RequireSigner()); if (options is not null) { + resolved.Quote = options.Quote; foreach (Crypto.Account token in options.FeeTokenPriority) { resolved.FeeTokenPriority.Add(token); diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs index bc79179..d05a8ed 100644 --- a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs @@ -217,6 +217,142 @@ public async Task FeeBearingSendTransmitsAgainstTheLiveNode() harness.Shutdown(); } + /// The one base flag the ACL grant carries. + private static readonly BaseFlag[] AccessFlag = { BaseFlag.Access }; + + [Fact] + public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("node"); + LedgerNode node = LedgerNode.Start(harness); + + using var runtime = WasmRuntime.Load(); + using Account holder = runtime.Accounts.FromSeed(E2eSeeds.Subject, 0, E2eSeeds.Secp256k1); + using Account recipient = runtime.Accounts.FromSeed(E2eSeeds.Recipient, 0, E2eSeeds.Secp256k1); + using UserClient user = runtime.CreateUserClient(node.Api, holder, network: node.Network); + KeetaClient client = user.Client; + Account baseToken = client.BaseToken!; + + node.Fund(E2eSeeds.Subject, Funding); + + // Drive the ledger through the client's own writes: a send opens the + // chain, SET_INFO publishes metadata, and MODIFY_PERMISSIONS grants + // the recipient access on the holder's account. + const long Amount = 500; + Assert.True(await user.Send(recipient, Amount, baseToken, cancellationToken: cancellationToken)); + Assert.True(await user.SetInfo("HOLDER", "ledger reads fixture", "meta", cancellationToken: cancellationToken)); + + using Permissions access = runtime.Blocks.PermissionsFromFlags(AccessFlag); + Assert.True(await user.UpdatePermissions(recipient, access, cancellationToken: cancellationToken)); + + AccountState state = await user.GetState(cancellationToken); + Assert.Equal("HOLDER", state.Info!.Name); + Assert.NotNull(state.HeadBlock); + + // The head reads back as a live block originated by the holder, and + // fetching it by hash yields the identical block. An unknown hash is + // the node's "none" shape, not a failure. + using Block? head = await user.GetHeadBlock(cancellationToken); + Assert.NotNull(head); + Assert.Equal(state.HeadBlock!.Value, head!.Hash); + + using (Account originator = head.GetAccount()) + { + Assert.Equal(holder.PublicKeyString, originator.PublicKeyString); + } + + using Block? byHash = await client.GetBlock(head.Hash, cancellationToken: cancellationToken); + Assert.Equal(head.Hash, byHash!.Hash); + Assert.Null(await client.GetBlock(BlockHash.Parse(new string('0', 64)), cancellationToken: cancellationToken)); + + // The chain lists most recent first; a limit of one pages with a + // cursor, and the block behind the head names the head as successor. + ChainPage newest = await user.GetChain(new ChainQuery(Limit: 1), cancellationToken); + Assert.Equal(head.Hash, Assert.Single(newest.Blocks).Hash); + Assert.NotNull(newest.NextKey); + + ChainPage chain = await user.GetChain(cancellationToken: cancellationToken); + Assert.True(chain.Blocks.Count >= 2); + Assert.Equal(head.Hash, chain.Blocks[0].Hash); + + using Block? successor = await client.GetSuccessorBlock(chain.Blocks[1].Hash, cancellationToken); + Assert.Equal(head.Hash, successor!.Hash); + + // Account and global history both carry the committed staples. + HistoryPage history = await user.GetHistory(cancellationToken: cancellationToken); + Assert.NotEmpty(history.Entries); + Assert.All(history.Entries, entry => Assert.NotEmpty(entry.StapleBytes)); + Assert.All(history.Entries, entry => Assert.NotNull(entry.Timestamp)); + + HistoryPage global = await client.GetGlobalHistory(cancellationToken: cancellationToken); + Assert.NotEmpty(global.Entries); + + // The settled head retains its votes; nothing is pending and an + // unknown idempotent key resolves to no block. + IReadOnlyList? votes = await client.GetBlockVotes(head.Hash, cancellationToken: cancellationToken); + Assert.NotNull(votes); + Assert.NotEmpty(votes!); + foreach (Vote vote in votes!) + { + vote.Dispose(); + } + + Assert.Null(await user.GetPendingBlock(cancellationToken)); + Assert.Null(await user.GetBlockFromIdempotent(Guid.NewGuid().ToString("N"), cancellationToken: cancellationToken)); + + // The grant reads back typed from both directions: the recipient as + // principal, the holder as entity, carrying the access flag. + IReadOnlyList granted = await client.GetAclsByPrincipal(recipient, cancellationToken); + Acl grant = Assert.Single(granted); + AclAccountPrincipal principal = Assert.IsType(grant.Principal); + Assert.Equal(recipient.PublicKeyString, principal.Account.PublicKeyString); + Assert.Equal(holder.PublicKeyString, grant.Entity!.PublicKeyString); + Assert.Contains(BaseFlag.Access, grant.Granted.Flags); + + IReadOnlyList byEntity = await client.GetAclsByEntity(holder, cancellationToken); + Assert.Contains(byEntity, entry => entry.Principal is AclAccountPrincipal account + && account.Account.PublicKeyString == recipient.PublicKeyString); + + // A pre-fetched vote quote rides the transmit's temporary round. + using (Block quoted = BuildSend(runtime, user, recipient, Amount, state.HeadBlock)) + { + byte[] quote = await client.GetVoteQuote(new[] { quoted }, cancellationToken); + Assert.NotEmpty(quote); + + TransmitOptions options = TransmitOptions.WithFeeSigner(holder); + options.Quote = quote; + Assert.True(await client.Transmit(quoted, options, cancellationToken)); + } + + BigInteger credited = await client.GetAccountBalance(recipient, baseToken, cancellationToken); + Assert.Equal(new BigInteger(Amount * 2), credited); + + // A builder without a position publishes through the one-call path: + // the user client positions it on the live head and pays the fee. + using (BlockOperation send = runtime.Blocks.Send(recipient, Amount, baseToken)) + using (BlockBuilder builder = user.InitBuilder()) + { + builder.AddOperation(send); + Assert.True(await user.Publish(builder, cancellationToken: cancellationToken)); + } + + credited = await client.GetAccountBalance(recipient, baseToken, cancellationToken); + Assert.Equal(new BigInteger(Amount * 3), credited); + + // The one-call identifier claim derives against the pre-claim head, + // publishes the CREATE_IDENTIFIER block, and returns the account. + AccountState beforeClaim = await user.GetState(cancellationToken); + using Account tokenId = await user.GenerateIdentifier(IdentifierKind.Token, cancellationToken: cancellationToken); + using Account expectedId = holder.GenerateIdentifier(IdentifierKind.Token, beforeClaim.HeadBlock); + Assert.Equal(expectedId.PublicKeyString, tokenId.PublicKeyString); + + AccountState afterClaim = await user.GetState(cancellationToken); + Assert.NotEqual(beforeClaim.HeadBlock, afterClaim.HeadBlock); + + harness.Shutdown(); + } + /// /// A signed base-token send from 's operating /// account to , opening the chain when diff --git a/tests/KeetaNet.Anchor.Tests/BlockTests.cs b/tests/KeetaNet.Anchor.Tests/BlockTests.cs index a4f777e..6aa043a 100644 --- a/tests/KeetaNet.Anchor.Tests/BlockTests.cs +++ b/tests/KeetaNet.Anchor.Tests/BlockTests.cs @@ -40,9 +40,14 @@ public void ASignedOpeningBlockRoundTripsAndConsumesItsBuilder() byte[] bytes = block.ToBytes(); Assert.NotEmpty(bytes); - // Decoding the transport bytes yields the identical block. + // Decoding the transport bytes yields the identical block, and the + // accessors expose its originator and hex form. using Block decoded = runtime.Blocks.ParseHex(Convert.ToHexString(bytes)); Assert.Equal(block.Hash, decoded.Hash); + Assert.Equal(Convert.ToHexString(bytes), block.ToHex(), ignoreCase: true); + + using Account originator = block.GetAccount(); + Assert.Equal(sender.PublicKeyString, originator.PublicKeyString); // Building consumed the builder, so a second build refuses. KeetaException refused = Assert.Throws(builder.Build); @@ -80,6 +85,96 @@ public void TheUserBuilderPreSetsTheSigningDefaults() Assert.Equal("SIGNER_REQUIRED", unsigned.Code); } + [Theory] + [InlineData(BaseFlag.Access)] + [InlineData(BaseFlag.Access, BaseFlag.SendOnBehalf)] + [InlineData(BaseFlag.Access, BaseFlag.UpdateInfo, BaseFlag.ManageCertificate)] + public void PermissionsRoundTripTheirFlagsThroughTheBitmapTransport(params BaseFlag[] flags) + { + using var runtime = WasmRuntime.Load(); + using Permissions permissions = runtime.Blocks.PermissionsFromFlags(flags); + + Assert.Equal(flags.OrderBy(flag => flag), permissions.Flags.OrderBy(flag => flag)); + Assert.Empty(permissions.ExternalOffsets); + + IReadOnlyList bitmaps = permissions.Bitmaps; + Assert.Equal(2, bitmaps.Count); + + using Permissions decoded = runtime.Blocks.PermissionsFromBitmaps(bitmaps[0], bitmaps[1]); + Assert.Equal(permissions.Flags, decoded.Flags); + Assert.Equal(bitmaps, decoded.Bitmaps); + } + + [Fact] + public void AnyGrantImpliesAccessExactlyAsTheReferenceRules() + { + using var runtime = WasmRuntime.Load(); + + // The reference injects ACCESS into every non-empty flag set, so the + // set reads back with both flags. + using Permissions owner = runtime.Blocks.PermissionsFromFlags(OwnerFlag); + Assert.Contains(BaseFlag.Owner, owner.Flags); + Assert.Contains(BaseFlag.Access, owner.Flags); + } + + /// The one base flag the permission-bearing tests grant. + private static readonly BaseFlag[] AccessFlag = { BaseFlag.Access }; + + /// The composite flag whose reference expansion the tests assert. + private static readonly BaseFlag[] OwnerFlag = { BaseFlag.Owner }; + + [Fact] + public void TheWiderOperationSurfaceBuildsIntoASignedBlock() + { + using var runtime = WasmRuntime.Load(); + using Account sender = runtime.Accounts.FromSeed(TestSeeds.Subject, 0, TestSeeds.DefaultAlgorithm); + using Account counterparty = runtime.Accounts.FromSeed(TestSeeds.Recipient, 0, TestSeeds.DefaultAlgorithm); + using Account token = runtime.Blocks.NetworkBaseToken(Network); + using Permissions access = runtime.Blocks.PermissionsFromFlags(AccessFlag); + + using BlockOperation receive = runtime.Blocks.Receive(counterparty, 7, token); + using BlockOperation setInfo = runtime.Blocks.SetInfo("NAME", "description", "metadata"); + using BlockOperation modify = runtime.Blocks.ModifyPermissions(counterparty, access, AdjustMethod.Add); + + 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(receive) + .AddOperation(setInfo) + .AddOperation(modify); + + using Block block = builder.Build(); + Assert.NotEmpty(block.ToBytes()); + } + + [Fact] + public void IdentifierOperationsDeriveDeterministicIdentifierAccounts() + { + using var runtime = WasmRuntime.Load(); + using Account owner = runtime.Accounts.FromSeed(TestSeeds.Subject, 0, TestSeeds.DefaultAlgorithm); + using Account signerA = runtime.Accounts.FromSeed(TestSeeds.Recipient, 0, TestSeeds.DefaultAlgorithm); + + // Identifier derivation is a pure function of account, kind, chain + // position, and operation index. + using Account tokenId = owner.GenerateIdentifier(IdentifierKind.Token); + using Account tokenIdAgain = owner.GenerateIdentifier(IdentifierKind.Token); + using Account laterTokenId = owner.GenerateIdentifier(IdentifierKind.Token, index: 1); + using Account storageId = owner.GenerateIdentifier(IdentifierKind.Storage); + + Assert.Equal(tokenId.PublicKeyString, tokenIdAgain.PublicKeyString); + Assert.NotEqual(tokenId.PublicKeyString, laterTokenId.PublicKeyString); + Assert.NotEqual(tokenId.PublicKeyString, storageId.PublicKeyString); + + using BlockOperation claim = runtime.Blocks.CreateIdentifier(tokenId); + using BlockOperation multisig = runtime.Blocks.CreateMultisig(storageId, new[] { owner, signerA }, quorum: 2); + using BlockOperation supply = runtime.Blocks.TokenAdminSupply(1_000, AdjustMethod.Add); + } + [Fact] public async Task TransmitRefusesAClientWithoutABoundNetwork() { @@ -140,6 +235,19 @@ public async Task TransmitRefusesAReadOnlyUserClientEvenWithAFeeFactory() KeetaException refused = await Assert.ThrowsAsync( () => readOnly.Transmit(block, options, TestContext.Current.CancellationToken)); Assert.Equal("SIGNER_REQUIRED", refused.Code); + + // Publish takes a caller-built builder, so its gate must refuse + using var external = runtime.Blocks.NewBuilder(); + external + .WithVersion(2) + .WithNetwork(Network) + .WithAccount(sender) + .WithSigner(sender) + .WithDate(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000)); + + KeetaException refusedPublish = await Assert.ThrowsAsync( + () => readOnly.Publish(external, options, TestContext.Current.CancellationToken)); + Assert.Equal("SIGNER_REQUIRED", refusedPublish.Code); } [Fact] diff --git a/tests/KeetaNet.Anchor.Tests/NetworkTests.cs b/tests/KeetaNet.Anchor.Tests/NetworkTests.cs new file mode 100644 index 0000000..e8627ad --- /dev/null +++ b/tests/KeetaNet.Anchor.Tests/NetworkTests.cs @@ -0,0 +1,41 @@ +using Xunit; + +namespace KeetaNet.Anchor.Tests; + +/// +/// The well-known network registry: ids, aliases, and representative +/// endpoints must match the reference registry verbatim, and the +/// fromNetwork-style factories must bind them. +/// +public sealed class NetworkTests +{ + [Theory] + [InlineData(KeetaNetwork.Main, 0x5382, "main", "https://rep1.main.network.api.keeta.com/api")] + [InlineData(KeetaNetwork.Staging, 0x0053_8201, "staging", "https://rep1.staging.network.api.keeta.com/api")] + [InlineData(KeetaNetwork.Test, 0x5445_5354, "test", "https://rep1.test.network.api.keeta.com/api")] + [InlineData(KeetaNetwork.Dev, 0x0044_4556, "dev", "https://rep1.dev.api.keeta.com/api")] + public void TheRegistryMatchesTheReferenceValues(KeetaNetwork network, long id, string alias, string apiUrl) + { + Assert.Equal(id, network.Id()); + Assert.Equal(alias, network.Alias()); + Assert.Equal(apiUrl, network.RepresentativeApiUrl()); + } + + [Fact] + public void TheNetworkFactoriesBindTheNetworkAndDeriveItsBaseToken() + { + using var runtime = WasmRuntime.Load(); + + using KeetaClient client = runtime.CreateKeetaClient(KeetaNetwork.Test); + Assert.Equal(KeetaNetwork.Test.Id(), client.Network); + Assert.NotNull(client.BaseToken); + + // The bound network's base token is the deterministic derivation. + using Crypto.Account derived = runtime.Blocks.NetworkBaseToken(KeetaNetwork.Test.Id()); + Assert.Equal(derived.PublicKeyString, client.BaseToken!.PublicKeyString); + + using UserClient user = runtime.CreateUserClient(KeetaNetwork.Dev, signer: null); + Assert.True(user.IsReadOnly); + Assert.Equal(KeetaNetwork.Dev.Id(), user.Client.Network); + } +}