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