Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions NArk.Abstractions/ArkCash.cs
Original file line number Diff line number Diff line change
@@ -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<byte> 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();
}
}
23 changes: 23 additions & 0 deletions NArk.Core/Extensions/ArkCashExtensions.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
75 changes: 75 additions & 0 deletions NArk.Tests.End2End/ArkCashTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArkCash> 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<string> { scriptHex });
await Task.Delay(TimeSpan.FromMilliseconds(500));
}
}
}
119 changes: 119 additions & 0 deletions NArk.Tests/ArkCashTests.cs
Original file line number Diff line number Diff line change
@@ -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<FormatException>(() => ArkCash.Parse("invalidarkcash"));
Assert.That(ArkCash.TryParse("invalidarkcash", out var probablyNull), Is.False);
Assert.That(probablyNull, Is.Null);

// wrong data length
Assert.Throws<FormatException>(() => 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();
}
}
Loading