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/tests/KeetaNet.Anchor.Tests/BlockTests.cs b/tests/KeetaNet.Anchor.Tests/BlockTests.cs
index a4f777e..07845f8 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()
{