diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a7f63a..d89cf710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## Unreleased changes +- Added + - New transaction `InitContract` ## 4.3.1 - Added diff --git a/ConcordiumNetSdk.sln b/ConcordiumNetSdk.sln index 96db10c8..ed3e8bea 100644 --- a/ConcordiumNetSdk.sln +++ b/ConcordiumNetSdk.sln @@ -81,6 +81,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Transations.UpdateContract" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeployModule", "examples\DeployModule\DeployModule.csproj", "{D35681A3-04AE-41BA-86F3-3CF5369D6D97}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InitContract", "examples\InitContract\InitContract.csproj", "{E68CBBAC-7BC9-46D3-AA04-2B7A62BD6921}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -239,6 +241,10 @@ Global {D35681A3-04AE-41BA-86F3-3CF5369D6D97}.Debug|Any CPU.Build.0 = Debug|Any CPU {D35681A3-04AE-41BA-86F3-3CF5369D6D97}.Release|Any CPU.ActiveCfg = Release|Any CPU {D35681A3-04AE-41BA-86F3-3CF5369D6D97}.Release|Any CPU.Build.0 = Release|Any CPU + {E68CBBAC-7BC9-46D3-AA04-2B7A62BD6921}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E68CBBAC-7BC9-46D3-AA04-2B7A62BD6921}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E68CBBAC-7BC9-46D3-AA04-2B7A62BD6921}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E68CBBAC-7BC9-46D3-AA04-2B7A62BD6921}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -283,5 +289,6 @@ Global {E2CC6AD7-98CE-41F5-8C66-AE8781F29C77} = {FD2CDD9F-4650-4705-9CA2-98CC81F8891D} {DBFBB7D1-E82D-4380-8263-B4B0AC3A6266} = {FD2CDD9F-4650-4705-9CA2-98CC81F8891D} {D35681A3-04AE-41BA-86F3-3CF5369D6D97} = {FD2CDD9F-4650-4705-9CA2-98CC81F8891D} + {E68CBBAC-7BC9-46D3-AA04-2B7A62BD6921} = {FD2CDD9F-4650-4705-9CA2-98CC81F8891D} EndGlobalSection EndGlobal diff --git a/examples/InitContract/InitContract.csproj b/examples/InitContract/InitContract.csproj new file mode 100644 index 00000000..5e61ee63 --- /dev/null +++ b/examples/InitContract/InitContract.csproj @@ -0,0 +1,22 @@ + + + + Exe + net6.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/examples/InitContract/Program.cs b/examples/InitContract/Program.cs new file mode 100644 index 00000000..1a30f8aa --- /dev/null +++ b/examples/InitContract/Program.cs @@ -0,0 +1,95 @@ +using System.Globalization; +using CommandLine; +using Concordium.Sdk.Client; +using Concordium.Sdk.Types; +using Concordium.Sdk.Wallets; + +// We disable these warnings since CommandLine needs to set properties in options +// but we don't want to give default values. +#pragma warning disable CS8618 + +namespace InitContract; + +internal sealed class InitContractOptions +{ + [Option( + 'k', + "keys", + HelpText = "Path to a file with contents that is in the Concordium browser wallet key export format.", + Required = true + )] + public string WalletKeysFile { get; set; } + [Option(HelpText = "URL representing the endpoint where the gRPC V2 API is served.", + Default = "http://grpc.testnet.concordium.com:20000/")] + public string Endpoint { get; set; } + [Option('a', "amount", HelpText = "Amount of CCD to deposit.", Default = 0)] + public ulong Amount { get; set; } + + [Option('m', "module-ref", HelpText = "The module reference of the smart contract.", Required = true)] + public string ModuleRef { get; set; } + + [Option('i', "init-name", HelpText = "The init_name of the module.", Required = true)] + public string InitName { get; set; } + + [Option('e', "max-energy", HelpText = "The maximum energy to spend on the module.", Required = true)] + public string MaxEnergy { get; set; } +} + +public static class Program +{ + /// + /// Example demonstrating how to submit a smart contract initialization + /// transaction. + /// + /// The example assumes you have your account key information stored + /// in the Concordium browser wallet key export format, and that a path + /// pointing to it is supplied to it from the command line. + /// + public static async Task Main(string[] args) => + await Parser.Default + .ParseArguments(args) + .WithParsedAsync(Run); + + private static async Task Run(InitContractOptions o) + { + // Read the account keys from a file. + var walletData = File.ReadAllText(o.WalletKeysFile); + var account = WalletAccount.FromWalletKeyExportFormat(walletData); + + // Construct the client. + var clientOptions = new ConcordiumClientOptions + { + Timeout = TimeSpan.FromSeconds(10) + }; + using var client = new ConcordiumClient(new Uri(o.Endpoint), clientOptions); + + // Create the init transaction. + var successfulParse = ContractName.TryParse(o.InitName, out var parsed); + if (!successfulParse) + { + throw new ArgumentException("Error parsing (" + o.InitName + "): " + parsed.Error.ToString()); + }; + + var amount = CcdAmount.FromCcd(o.Amount); + var moduleRef = new ModuleReference(o.ModuleRef); + var param = new Parameter(Array.Empty()); + var maxEnergy = new EnergyAmount(uint.Parse(o.MaxEnergy, CultureInfo.InvariantCulture)); + var payload = new Concordium.Sdk.Transactions.InitContract(amount, moduleRef, parsed.ContractName!, param); + + // Prepare the transaction for signing. + var sender = account.AccountAddress; + var sequenceNumber = client.GetNextAccountSequenceNumber(sender).Item1; + var expiry = Expiry.AtMinutesFromNow(30); + var preparedPayload = payload.Prepare(sender, sequenceNumber, expiry, maxEnergy); + + // Sign the transaction using the account keys. + var signedTrx = preparedPayload.Sign(account); + + // Submit the transaction. + var txHash = client.SendAccountTransaction(signedTrx); + + // Print the transaction hash. + Console.WriteLine($"Successfully submitted init-contract transaction with hash {txHash}"); + } +} + diff --git a/src/Transactions/AccountTransactionPayload.cs b/src/Transactions/AccountTransactionPayload.cs index 988e37e4..8694fff6 100644 --- a/src/Transactions/AccountTransactionPayload.cs +++ b/src/Transactions/AccountTransactionPayload.cs @@ -88,13 +88,18 @@ private static AccountTransactionPayload ParseRawPayload(Google.Protobuf.ByteStr parsedPayload = output; break; } + case TransactionType.InitContract: + { + InitContract.TryDeserial(payload.ToArray(), out var output); + parsedPayload = output; + break; + } case TransactionType.Update: { UpdateContract.TryDeserial(payload.ToArray(), out var output); parsedPayload = output; break; } - case TransactionType.InitContract: case TransactionType.AddBaker: case TransactionType.RemoveBaker: case TransactionType.UpdateBakerStake: diff --git a/src/Transactions/InitContract.cs b/src/Transactions/InitContract.cs new file mode 100644 index 00000000..af9fcadf --- /dev/null +++ b/src/Transactions/InitContract.cs @@ -0,0 +1,130 @@ +using Concordium.Sdk.Types; + +namespace Concordium.Sdk.Transactions; + +/// +/// Represents an "init_contract" transaction. +/// +/// Used for initializing deployed smart contracts. +/// +/// Deposit this amount of CCD. +/// The smart contract module reference. +/// The init name of the smart contract. +/// The parameters for the smart contract. +public sealed record InitContract(CcdAmount Amount, ModuleReference ModuleRef, ContractName ContractName, Parameter Parameter) : AccountTransactionPayload +{ + /// + /// The init contract transaction type to be used in the serialized payload. + /// + private const byte TransactionType = (byte)Types.TransactionType.InitContract; + + /// + /// The minimum serialized length in the serialized payload. + /// + internal const uint MinSerializedLength = + CcdAmount.BytesLength + + Hash.BytesLength + // ModuleRef + ContractName.MinSerializedLength + + Parameter.MinSerializedLength; + + /// + /// Prepares the account transaction payload for signing. + /// + /// Address of the sender of the transaction. + /// Account sequence number to use for the transaction. + /// Expiration time of the transaction. + /// + /// The amount of energy that can be used for contract execution. + /// The base energy amount for transaction verification will be added to this cost. + /// + public PreparedAccountTransaction Prepare( + AccountAddress sender, + AccountSequenceNumber sequenceNumber, + Expiry expiry, + EnergyAmount energy + ) => new(sender, sequenceNumber, expiry, energy, this); + + /// + /// Gets the size (number of bytes) of the payload. + /// + internal override PayloadSize Size() => new( + sizeof(TransactionType) + + CcdAmount.BytesLength + + Hash.BytesLength + // ModuleRef + this.ContractName.SerializedLength() + + this.Parameter.SerializedLength()); + + /// + /// Deserialize a "InitContract" payload from a serialized byte array. + /// + /// The serialized InitContract payload. + /// Where to write the result of the operation. + public static bool TryDeserial(ReadOnlySpan bytes, out (InitContract? InitContract, string? Error) output) + { + if (bytes.Length < MinSerializedLength) + { + var msg = $"Invalid length in `InitContract.TryDeserial`. Expected at least {MinSerializedLength}, found {bytes.Length}"; + output = (null, msg); + return false; + }; + if (bytes[0] != TransactionType) + { + var msg = $"Invalid transaction type in `InitContract.TryDeserial`. expected {TransactionType}, found {bytes[0]}"; + output = (null, msg); + return false; + }; + + var remainingBytes = bytes[sizeof(TransactionType)..]; + + if (!CcdAmount.TryDeserial(remainingBytes, out var amount)) + { + output = (null, amount.Error); + return false; + }; + remainingBytes = remainingBytes[(int)CcdAmount.BytesLength..]; + + if (!ModuleReference.TryDeserial(remainingBytes, out var moduleRef)) + { + output = (null, moduleRef.Error); + return false; + }; + remainingBytes = remainingBytes[Hash.BytesLength..]; // ModuleRef + + if (!ContractName.TryDeserial(remainingBytes, out var name)) + { + output = (null, name.Error); + return false; + }; + remainingBytes = remainingBytes[(int)name.ContractName!.SerializedLength()..]; + + if (!Parameter.TryDeserial(remainingBytes, out var param)) + { + output = (null, param.Error); + return false; + }; + + if (amount.Amount == null || moduleRef.Ref == null || name.ContractName == null || param.Parameter == null) + { + var msg = $"Amount, ModuleRef, ContractName or Parameter were null, but did not produce an error"; + output = (null, msg); + return false; + } + + output = (new InitContract(amount.Amount.Value, moduleRef.Ref, name.ContractName, param.Parameter), null); + return true; + } + + /// + /// Copies the "init_contract" transaction in the binary format expected by the node to a byte array. + /// + public override byte[] ToBytes() + { + using var memoryStream = new MemoryStream((int)this.Size().Size); + memoryStream.WriteByte(TransactionType); + memoryStream.Write(this.Amount.ToBytes()); + memoryStream.Write(this.ModuleRef.ToBytes()); + memoryStream.Write(this.ContractName.ToBytes()); + memoryStream.Write(this.Parameter.ToBytes()); + return memoryStream.ToArray(); + } +} diff --git a/src/Types/ContractName.cs b/src/Types/ContractName.cs index f72e148b..585c89a6 100644 --- a/src/Types/ContractName.cs +++ b/src/Types/ContractName.cs @@ -1,3 +1,5 @@ +using System.Buffers.Binary; +using System.Text; using Concordium.Sdk.Helpers; namespace Concordium.Sdk.Types; @@ -17,6 +19,16 @@ public sealed record ContractName /// public string Name { get; init; } + /// + /// Gets the minimum serialized length (number of bytes) of the init name. + /// + internal const uint MinSerializedLength = sizeof(ushort); + + /// + /// Gets the serialized length (number of bytes) of the init name. + /// + internal uint SerializedLength() => sizeof(ushort) + (uint)this.Name.Length; + private ContractName(string name) => this.Name = name; internal static ContractName From(Grpc.V2.InitName initName) => new(initName.Value); @@ -36,6 +48,59 @@ public static bool TryParse(string name, out (ContractName? ContractName, Valida return validate; } + /// + /// Copies the init name to a byte array which has the length preprended. + /// + public byte[] ToBytes() + { + var bytes = Encoding.ASCII.GetBytes(this.Name); + + using var memoryStream = new MemoryStream((int)this.SerializedLength()); + memoryStream.Write(Serialization.ToBytes((ushort)bytes.Length)); + memoryStream.Write(bytes); + return memoryStream.ToArray(); + } + + /// + /// Deserialize an init name from a serialized byte array. + /// + /// The serialized init name. + /// Where to write the result of the operation. + public static bool TryDeserial(ReadOnlySpan bytes, out (ContractName? ContractName, string? Error) output) + { + if (bytes.Length < MinSerializedLength) + { + var msg = $"Invalid length of input in `InitName.TryDeserial`. Expected at least {MinSerializedLength}, found {bytes.Length}"; + output = (null, msg); + return false; + }; + + var sizeRead = BinaryPrimitives.ReadUInt16BigEndian(bytes); + var size = sizeRead + sizeof(ushort); + if (size > bytes.Length) + { + var msg = $"Invalid length of input in `InitName.TryDeserial`. Expected array of size at least {size}, found {bytes.Length}"; + output = (null, msg); + return false; + }; + + try + { + var initNameBytes = bytes.Slice(sizeof(ushort), sizeRead).ToArray(); + var ascii = Encoding.ASCII.GetString(initNameBytes); + + var correctlyParsed = TryParse(ascii, out var parseOut); + output = correctlyParsed ? (parseOut.ContractName, null) : (null, "Error parsing contract name (" + ascii + "): " + parseOut.Error.ToString()); + return correctlyParsed; + } + catch (ArgumentException e) + { + var msg = $"Invalid InitName in `InitName.TryDeserial`: {e.Message}"; + output = (null, msg); + return false; + }; + } + /// /// Get the contract name part of . /// @@ -79,4 +144,10 @@ private static bool IsValid(string name, out ValidationError? error) error = null; return true; } + + /// Check for equality. + public bool Equals(ContractName? other) => other != null && this.Name == other.Name; + + /// Gets hash code. + public override int GetHashCode() => this.Name.GetHashCode(); } diff --git a/src/Types/EnergyAmount.cs b/src/Types/EnergyAmount.cs index 9def3443..7b0d1a12 100644 --- a/src/Types/EnergyAmount.cs +++ b/src/Types/EnergyAmount.cs @@ -8,6 +8,7 @@ namespace Concordium.Sdk.Types; /// Value of the energy amount. public readonly record struct EnergyAmount(ulong Value) { + ///Byte length of Energy. Used for serialization. public const uint BytesLength = sizeof(ulong); /// @@ -16,4 +17,41 @@ public readonly record struct EnergyAmount(ulong Value) public byte[] ToBytes() => Serialization.ToBytes(this.Value); internal static EnergyAmount From(Grpc.V2.Energy energy) => new(energy.Value); + + /// + /// Add Energy amounts. + /// + /// The result odoes not fit in + public static EnergyAmount operator +(EnergyAmount a, EnergyAmount b) + { + try + { + return new EnergyAmount(checked(a.Value + b.Value)); + } + catch (OverflowException e) + { + throw new ArgumentException( + $"The result of {a.Value} + {b.Value} does not fit in UInt64.", e + ); + } + } + + /// + /// Subtract Energy amounts. + /// + /// The result does not fit in + public static EnergyAmount operator -(EnergyAmount a, EnergyAmount b) + { + try + { + return new EnergyAmount(checked(a.Value - b.Value)); + } + catch (OverflowException e) + { + throw new ArgumentException( + $"The result of {a.Value} - {b.Value} does not fit in UInt64.", e + ); + } + } + } diff --git a/src/Types/ModuleReference.cs b/src/Types/ModuleReference.cs index e91d55e6..19065a7b 100644 --- a/src/Types/ModuleReference.cs +++ b/src/Types/ModuleReference.cs @@ -14,10 +14,30 @@ internal ModuleReference(ByteString byteString) : base(byteString.ToArray()) internal ModuleRef Into() => new() { Value = ByteString.CopyFrom(this.AsSpan()) }; + /// + /// Create a module reference from a byte array. + /// + /// The serialized module reference. + /// Where to write the result of the operation. + public static bool TryDeserial(ReadOnlySpan bytes, out (ModuleReference? Ref, string? Error) output) + { + if (bytes.Length < BytesLength) + { + var msg = $"Invalid length of input in `ModuleReference.TryDeserial`. Expected at least {BytesLength}, found {bytes.Length}"; + output = (null, msg); + return false; + }; + + output = (new ModuleReference(bytes[..BytesLength].ToArray()), null); + return true; + } + /// /// Initializes a new instance. /// /// A hash represented as a length-64 hex encoded string. /// The supplied string is not a 64-character hex encoded string. public ModuleReference(string hexString) : base(hexString) { } + + private ModuleReference(byte[] bytes) : base(bytes) { } } diff --git a/src/Types/OnChainData.cs b/src/Types/OnChainData.cs index 9191d73c..112b774a 100644 --- a/src/Types/OnChainData.cs +++ b/src/Types/OnChainData.cs @@ -147,7 +147,7 @@ public byte[] ToBytes() public override string ToString() => Convert.ToHexString(this._value).ToLowerInvariant(); /// - /// Create an "OnChainData" from a byte array. + /// Deserialize an "OnChainData" from a byte array. /// /// The serialized "OnChainData". /// Where to write the result of the operation. diff --git a/src/Types/Parameter.cs b/src/Types/Parameter.cs index 56d389f4..15f193be 100644 --- a/src/Types/Parameter.cs +++ b/src/Types/Parameter.cs @@ -6,14 +6,13 @@ namespace Concordium.Sdk.Types; /// /// Parameter to the init function or entrypoint. /// -public sealed record Parameter(byte[] Param) +public sealed record Parameter(byte[] Param) : IEquatable { /// /// Construct an empty smart contract parameter. /// public static Parameter Empty() => new(Array.Empty()); - private const uint MaxByteLength = 65535; /// /// Gets the serialized length (number of bytes) of the parameter. /// @@ -24,6 +23,11 @@ public sealed record Parameter(byte[] Param) /// internal const uint MinSerializedLength = sizeof(ushort); + /// + /// Gets the maximum serialized length (number of bytes) of the parameter. + /// + internal const uint MaxSerializedLength = 65535; + internal static Parameter From(Grpc.V2.Parameter parameter) => new(parameter.Value.ToArray()); /// @@ -52,9 +56,9 @@ public static bool TryDeserial(ReadOnlySpan bytes, out (Parameter? Paramet }; var sizeRead = BinaryPrimitives.ReadUInt16BigEndian(bytes); - if (sizeRead > MaxByteLength) + if (sizeRead > MaxSerializedLength) { - var msg = $"Invalid length of input in `Parameter.TryDeserial`. The parameter size can be at most {MaxByteLength} bytes, found {bytes.Length}"; + var msg = $"Invalid length of input in `Parameter.TryDeserial`. The parameter size can be at most {MaxSerializedLength} bytes, found {bytes.Length}"; output = (null, msg); return false; } @@ -66,11 +70,21 @@ public static bool TryDeserial(ReadOnlySpan bytes, out (Parameter? Paramet output = (null, msg); return false; }; - var parameter = new Parameter(bytes.Slice(sizeof(ushort), sizeRead).ToArray()); - output = (parameter, null); + + output = (new Parameter(bytes.Slice(sizeof(ushort), sizeRead).ToArray()), null); return true; } + /// Check for equality. + public bool Equals(Parameter? other) => other != null && this.Param.SequenceEqual(other.Param); + + /// Gets hash code. + public override int GetHashCode() + { + var paramHash = Helpers.HashCode.GetHashCodeByteArray(this.Param); + return paramHash; + } + /// /// Convert parameters to hex string. /// diff --git a/tests/UnitTests/Transactions/InitContract.cs b/tests/UnitTests/Transactions/InitContract.cs new file mode 100644 index 00000000..b6f6aa1d --- /dev/null +++ b/tests/UnitTests/Transactions/InitContract.cs @@ -0,0 +1,100 @@ +using Concordium.Sdk.Transactions; +using Concordium.Sdk.Types; +using FluentAssertions; +using Xunit; + +namespace Concordium.Sdk.Tests.UnitTests.Transactions; + +public sealed class InitContractTests +{ + /// + /// Creates a new instance of the + /// transaction. + /// + public static InitContract NewInitContract() + { + var amount = CcdAmount.FromCcd(100); + var moduleRef = new ModuleReference("0000000000000000000000000000000000000000000000000000000000000000"); + var contractName = ContractName.TryParse("init_name", out var parsed); + var parameter = new Parameter(System.Array.Empty()); + + return new InitContract(amount, moduleRef, parsed.ContractName!, parameter); + } + + [Fact] + public void ToBytes_ReturnsCorrectValue() + { + // The expected payload was generated using the Concordium Rust SDK. + var expectedBytes = new byte[] + { + 1, + 0, + 0, + 0, + 0, + 5, + 245, + 225, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 105, + 110, + 105, + 116, + 95, + 110, + 97, + 109, + 101, + 0, + 0 + }; + NewInitContract().ToBytes().Should().BeEquivalentTo(expectedBytes); + } + + [Fact] + public void ToBytes_InverseOfFromBytes() + { + if (InitContract.TryDeserial(NewInitContract().ToBytes(), out var deserial)) + { + NewInitContract().Should().Be(deserial.InitContract); + } + else + { + Assert.Fail(deserial.Error); + } + } +} diff --git a/tests/UnitTests/Types/ContractNameTests.cs b/tests/UnitTests/Types/ContractNameTests.cs index 1794d90c..8393feca 100644 --- a/tests/UnitTests/Types/ContractNameTests.cs +++ b/tests/UnitTests/Types/ContractNameTests.cs @@ -54,4 +54,47 @@ public void WhenGetContractNamePart_ThenReturnContractName() // Assert actual.ContractName.Should().Be(expected); } + + [Fact] + public void DeserializesCorrectly() + { + var success = ContractName.TryParse("init_name", out var parsed); + if (!success) + { + Assert.Fail(parsed.Error.ToString()); + } + + var bytes = new byte[] { + 0, + 9, + 105, + 110, + 105, + 116, + 95, + 110, + 97, + 109, + 101 + }; + Assert.Equal(parsed.ContractName!.ToBytes(), bytes); + Assert.Equal(parsed.ContractName!.SerializedLength(), (uint)bytes.Length); + } + + [Fact] + public void SerializeDeserialize() + { + var parseSuccess = ContractName.TryParse("init_some_name", out var parsed); + if (!parseSuccess) + { + Assert.Fail(parsed.Error.ToString()); + } + var deserialSuccess = ContractName.TryDeserial(parsed.ContractName!.ToBytes(), out var deserial); + if (!deserialSuccess) + { + Assert.Fail(deserial.Error); + } + deserial.ContractName.Should().Be(parsed.ContractName); + Assert.Equal(parsed.ContractName!.SerializedLength(), (uint)parsed.ContractName!.ToBytes().Length); + } }