diff --git a/NArk.Abstractions/ArkCash.cs b/NArk.Abstractions/ArkCash.cs new file mode 100644 index 00000000..2c6c7a92 --- /dev/null +++ b/NArk.Abstractions/ArkCash.cs @@ -0,0 +1,145 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using NArk.Abstractions.Contracts; +using NBitcoin; +using NBitcoin.DataEncoders; +using NBitcoin.Secp256k1; + +namespace NArk.Abstractions; + +public class ArkCash: IDisposable +{ + private const byte Version = 0x00; + private const int PayloadLength = 1 + 32 + 32 + 4; + + private const string HrpMainnet = "arkcash"; + private const string HrpTestnet = "tarkcash"; + + private static readonly Bech32Encoder MainnetEncoder; + private static readonly Bech32Encoder TestnetEncoder; + + public string Hrp { get; } + public ECPrivKey PrivKey { get; } + public ECXOnlyPubKey Pubkey { get; } + public ECXOnlyPubKey ServerPubkey { get; } + public Sequence LockTime { get; } + + + static ArkCash() + { + MainnetEncoder = Encoders.Bech32(HrpMainnet); + MainnetEncoder.StrictLength = false; + MainnetEncoder.SquashBytes = true; + + TestnetEncoder = Encoders.Bech32(HrpTestnet); + TestnetEncoder.StrictLength = false; + TestnetEncoder.SquashBytes = true; + } + + public ArkCash(ECPrivKey privKey, ECXOnlyPubKey serverPubkey, Sequence lockTime, string hrp = "arkcash") + { + + if (hrp is not ("tarkcash" or "arkcash")) + { + throw new ArgumentException($"Invalid hrp: {hrp}. Supported arguments: {HrpMainnet}, {HrpTestnet}"); + } + + this.PrivKey = privKey; + this.Pubkey = privKey.CreateXOnlyPubKey(); + this.ServerPubkey = serverPubkey; + this.LockTime = lockTime; + this.Hrp = hrp; + } + + public static ArkCash Generate(ECXOnlyPubKey serverPubkey, Sequence locktime, string? hrp = null) + { + var pk = RandomUtils.GetBytes(32); + try + { + if (!ECPrivKey.TryCreate(pk, out var key)) + { + throw new InvalidOperationException("Could not generate ArkCash address!"); + } + + if (hrp is null) + { + return new ArkCash(key, serverPubkey, locktime); + } + + return new ArkCash(key, serverPubkey, locktime, hrp); + } + finally + { + CryptographicOperations.ZeroMemory(pk); + } + } + + public override string ToString() + { + Span payload = stackalloc byte[PayloadLength]; + payload[0] = Version; + PrivKey.WriteToSpan(payload[1..33]); + ServerPubkey.WriteToSpan(payload[33..65]); + BinaryPrimitives.WriteUInt32BigEndian(payload[65..69], LockTime.Value); + + var encoder = Hrp switch + { + HrpMainnet => MainnetEncoder, + HrpTestnet => TestnetEncoder, + _ => throw new InvalidOperationException() + }; + + return encoder.EncodeData(payload, Bech32EncodingType.BECH32M); + } + + public static ArkCash Parse(string encoded) + { + encoded = encoded.Trim().ToLowerInvariant(); + var encoder = + encoded.StartsWith(HrpMainnet) ? MainnetEncoder : + encoded.StartsWith(HrpTestnet) ? TestnetEncoder : + throw new FormatException($"Invalid ArkCash HRP: {encoded}"); + + var decodedRaw = encoder.DecodeDataRaw(encoded, out _); + if (decodedRaw == null) + { + throw new FormatException("Could not decode encoded data"); + } + + var payload = decodedRaw.AsSpan(); + if (payload.Length != PayloadLength) + { + throw new FormatException($"Invalid payload length! (Expected: {PayloadLength} bytes, got {payload.Length})"); + } + if (payload[0] != Version) + { + throw new FormatException($"Invalid version! {payload[0]}"); + } + var privKey = ECPrivKey.Create(payload[1..33]); + var serverPubkey = ECXOnlyPubKey.Create(payload[33..65]); + var lockTimeVal = BinaryPrimitives.ReadUInt32BigEndian(payload[65..69]); + var locktime = new Sequence(lockTimeVal); + + + return new ArkCash(privKey, serverPubkey, locktime, encoded.StartsWith(HrpMainnet) ? HrpMainnet : HrpTestnet); + } + + public static bool TryParse(string encoded, out ArkCash? arkCash) + { + arkCash = null; + try + { + arkCash = Parse(encoded); + return true; + } + catch + { + return false; + } + } + + public void Dispose() + { + PrivKey.Dispose(); + } +} diff --git a/NArk.Core/Extensions/ArkCashExtensions.cs b/NArk.Core/Extensions/ArkCashExtensions.cs new file mode 100644 index 00000000..53f409a7 --- /dev/null +++ b/NArk.Core/Extensions/ArkCashExtensions.cs @@ -0,0 +1,23 @@ +using NArk.Abstractions; +using NArk.Core.Contracts; +using NArk.Transport.GrpcClient.Extensions; +using NBitcoin; +using NBitcoin.Scripting; + +namespace NArk.Core.Extensions; + +public static class ArkCashExtensions +{ + public static ArkPaymentContract ToContract(this ArkCash cash, Network network) + { + var serverDesc = KeyExtensions + .ParseOutputDescriptor(cash.ServerPubkey.ToBytes().ToHexStringLower(), network); + var userDesc = KeyExtensions.ParseOutputDescriptor(cash.Pubkey.ToBytes().ToHexStringLower(), network); + return new ArkPaymentContract(serverDesc, cash.LockTime, userDesc); + } + + public static ArkAddress GetAddress(this ArkCash cash, Network network) + { + return cash.ToContract(network).GetArkAddress(); + } +} \ No newline at end of file diff --git a/NArk.Tests.End2End/ArkCashTests.cs b/NArk.Tests.End2End/ArkCashTests.cs new file mode 100644 index 00000000..6bac90ff --- /dev/null +++ b/NArk.Tests.End2End/ArkCashTests.cs @@ -0,0 +1,75 @@ +using NArk.Abstractions; +using NArk.Abstractions.Extensions; +using NArk.Core; +using NArk.Core.Extensions; +using NArk.Core.Services; +using NArk.Safety.AsyncKeyedLock; +using NArk.Tests.End2End.Common; +using NArk.Tests.End2End.Core; +using NArk.Tests.End2End.TestPersistance; +using NArk.Transport.GrpcClient; + +namespace NArk.Tests.End2End; + +public class ArkCashTests +{ + [Test] + public async Task RoundripsCorrectly() + { + var safetyService = new AsyncSafetyService(); + var storage = new TestStorage(safetyService); + var clientTransport = new GrpcClientTransport(SharedArkInfrastructure.ArkdEndpoint.ToString()); + var walletProvider = new InMemoryWalletProvider(clientTransport); + var contractService = new ContractService(walletProvider, storage.ContractStorage, clientTransport); + + await using var vtxoSync = new VtxoSynchronizationService( + storage.VtxoStorage, + clientTransport, + [storage.VtxoStorage, storage.ContractStorage]); + await vtxoSync.StartAsync(CancellationToken.None); + + var serverInfo = await clientTransport.GetServerInfoAsync(); + var cash = await CreateFundedArkCash(serverInfo, 100000); + + var receiverWalletId = await walletProvider.CreateTestWallet(); + await contractService.ImportContract(receiverWalletId, cash.ToContract(serverInfo.Network)); + + var cashScript = cash.GetAddress(serverInfo.Network).ScriptPubKey.ToHex(); + await ForcePollScript(vtxoSync, cashScript, TimeSpan.FromSeconds(15)); + + var receiverUnspent = await storage.VtxoStorage.GetVtxos( + walletIds: [receiverWalletId], + includeSpent: false); + var receiverAmount = receiverUnspent.Select(v => v.Amount).Aggregate(0UL, (acc, x) => acc + x); + + Assert.That(receiverUnspent.Count, Is.EqualTo(1), + "Receiver should have at least one unspent ArkCash VTXO"); + Assert.That(receiverAmount, Is.EqualTo(100000UL), + "Receiver unspent amount should be greater than zero"); + } + + private static async Task CreateFundedArkCash(ArkServerInfo serverInfo, long amount) + { + var cash = ArkCash.Generate( + serverInfo.SignerKey.ToXOnlyPubKey(), + serverInfo.UnilateralExit, + "tarkcash"); + + var cashAddress = cash.GetAddress(serverInfo.Network); + await DockerHelper.SendArkdNoteTo(cashAddress.ToString(false), amount); + return cash; + } + + private static async Task ForcePollScript( + VtxoSynchronizationService vtxoSync, + string scriptHex, + TimeSpan timeout) + { + var timeoutAt = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < timeoutAt) + { + await vtxoSync.PollScriptsForVtxos(new HashSet { scriptHex }); + await Task.Delay(TimeSpan.FromMilliseconds(500)); + } + } +} diff --git a/NArk.Tests/ArkCashTests.cs b/NArk.Tests/ArkCashTests.cs new file mode 100644 index 00000000..f63f75da --- /dev/null +++ b/NArk.Tests/ArkCashTests.cs @@ -0,0 +1,119 @@ +using NArk.Abstractions; +using NBitcoin; +using NBitcoin.Secp256k1; +using NArk.Core.Extensions; +namespace NArk.Tests; + +public class ArkCashTests +{ + private static readonly ECPrivKey TestPrivKey = + ECPrivKey.Create( + Convert.FromHexString("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20") + ); + private static readonly ECXOnlyPubKey TestPubKey = TestPrivKey.CreateXOnlyPubKey(); + + private static readonly ECPrivKey ServerPrivkey = + ECPrivKey.Create( + Convert.FromHexString("a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0") + ); + + private static readonly ECXOnlyPubKey ServerPubkey = ServerPrivkey.CreateXOnlyPubKey(); + + [Test] + [TestCase("arkcash")] + [TestCase("tarkcash")] + [TestCase(null)] + public void RoundtripsEncodeMainnet(string? hrp) + { + var locktime = new Sequence(144); + var cash = hrp is not null ? new ArkCash(TestPrivKey, ServerPubkey, locktime, hrp) : new ArkCash(TestPrivKey, ServerPubkey, locktime); + var encoded = cash.ToString(); + + Assert.That(encoded.StartsWith(hrp ?? "arkcash"), Is.True); + Assert.That(ArkCash.TryParse(encoded, out var parsed)); + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed.LockTime, Is.EqualTo(locktime)); + Assert.That(parsed.Pubkey.ToBytes(), Is.EqualTo(TestPrivKey.CreateXOnlyPubKey().ToBytes())); + Assert.That(parsed.ServerPubkey.ToBytes(), Is.EqualTo(ServerPubkey.ToBytes())); + } + + [Test] + public void IdentityTests() + { + var cash = new ArkCash(TestPrivKey, ServerPubkey, new Sequence(144)); + Assert.That(cash.Pubkey.ToBytes(), Is.EqualTo(TestPubKey.ToBytes())); + Assert.That(cash.PrivKey.Equals(TestPrivKey), Is.True); + } + + [Test] + public void CreatesValidVtxoScript() + { + var cash = new ArkCash(TestPrivKey, ServerPubkey, new Sequence(144)); + + var contract = cash.ToContract(Network.Main); + Script pkScript = contract.GetScriptPubKey(); + var address = cash.GetAddress(Network.Main); + + Assert.That(pkScript, Is.EqualTo(address.ScriptPubKey)); + Assert.That(pkScript, Is.Not.Null); + Assert.That(pkScript, Has.Length); + } + + [Test] + public void CreatesValidArkAddress() + { + var cash = new ArkCash(TestPrivKey, ServerPubkey, new Sequence(144)); + var address = cash.GetAddress(Network.Main); + + // encode as mainnet + var encoded = address.ToString(true); + Assert.That(encoded.StartsWith("ark1"), Is.True); + // encode as testntet + var encoded2 = address.ToString(false); + Assert.That(encoded2.StartsWith("tark1"), Is.True); + } + + [Test] + public void GeneratesRandomArkCash() + { + var cash1 = ArkCash.Generate(ServerPubkey, new Sequence(144)); + var cash2 = ArkCash.Generate(ServerPubkey, new Sequence(144)); + + Assert.That(cash1, Is.Not.Null); + Assert.That(cash1.Pubkey, Is.Not.Null); + Assert.That(cash1.ServerPubkey, Is.Not.Null); + Assert.That(cash1.PrivKey, Is.Not.Null); + + Assert.That(cash2.Pubkey, Is.Not.Null); + Assert.That(cash2.ServerPubkey, Is.Not.Null); + Assert.That(cash2.PrivKey, Is.Not.Null); + + Assert.That(cash1.Pubkey.ToBytes(), Is.Not.EqualTo(cash2.Pubkey.ToBytes())); + Assert.That(cash1.PrivKey, Is.Not.EqualTo(cash2.PrivKey)); + + Assert.That(cash1.ServerPubkey, Is.EqualTo(cash2.ServerPubkey)); + Assert.That(cash2.Hrp, Is.EqualTo(cash1.Hrp)); + } + + [Test] + public void ShouldRejectInvalidData() + { + // invalid privkey + Assert.Throws(() => ArkCash.Parse("invalidarkcash")); + Assert.That(ArkCash.TryParse("invalidarkcash", out var probablyNull), Is.False); + Assert.That(probablyNull, Is.Null); + + // wrong data length + Assert.Throws(() => ArkCash.Parse("arkcash1qqqqqqqqq0saqvp")); + Assert.That(ArkCash.TryParse("arkcash1qqqqqqqqq0saqvp", out var probablyNullToo), Is.False); + Assert.That(probablyNullToo, Is.Null); + } + + + [OneTimeTearDown] + public void Cleanup() + { + TestPrivKey.Dispose(); + ServerPrivkey.Dispose(); + } +}