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() {