Implement SmartTransaction line serialization.
MODIFY
WalletWasabi.Tests/UnitTests/SmartTransactionTests.cs
WalletWasabi.Tests/UnitTests/SmartTransactionTests.cs
@@ -123,5 +123,204 @@ namespace WalletWasabi.Tests.UnitTests
deserialized = JsonConvert.DeserializeObject<SmartTransaction>(serialized);
Assert.Equal(twoThousandEight.UtcDateTime, deserialized.FirstSeen.UtcDateTime, TimeSpan.FromSeconds(1));
}
+
+ [Theory]
+ [MemberData(nameof(GetSmartTransactionCombinations))]
+ public void SmartTransactionLineSerialization(SmartTransaction stx, Network network)
+ {
+ var line = stx.ToLine();
+ var sameStx = SmartTransaction.FromLine(line, network);
+ Assert.Equal(stx, sameStx);
+ Assert.Equal(stx.BlockHash, sameStx.BlockHash);
+ Assert.Equal(stx.BlockIndex, sameStx.BlockIndex);
+ Assert.Equal(stx.Confirmed, sameStx.Confirmed);
+ Assert.Equal(stx.FirstSeen.UtcDateTime, sameStx.FirstSeen.UtcDateTime, TimeSpan.FromSeconds(1));
+ Assert.Equal(stx.Height, sameStx.Height);
+ Assert.Equal(stx.IsRBF, sameStx.IsRBF);
+ Assert.Equal(stx.IsReplacement, sameStx.IsReplacement);
+ Assert.Equal(stx.Label, sameStx.Label);
+ Assert.Equal(stx.Transaction.GetHash(), sameStx.Transaction.GetHash());
+ }
+
+ [Fact]
+ public void SmartTransactionLineDeserialization()
+ {
+ // Basic deserialization test.
+ var txHash = "dea20cf140bc40d4a6940ac85246989138541e530ed58cbaa010c6b730efd2f6";
+ var txHex = "0100000001a67535553fea8a41550e79571359df9e5458b3c2264e37523b0b5d550feecefe0000000000ffffffff017584010000000000160014e1fd78b34c52864ee4a667862f9f9995d850c73100000000";
+ var height = "Unknown";
+ var blockHash = "";
+ var blockIndex = "0";
+ var label = "foo";
+ var unixSeconds = "1567084917";
+ var isReplacement = "False";
+ SmartTransaction stx;
+ foreach (var net in new[] { Network.Main, Network.TestNet, Network.RegTest })
+ {
+ foreach (var inp in new[]
+ {
+ // Normal input.
+ $"{txHash}:{txHex}:{height}:{blockHash}:{blockIndex}:{label}:{unixSeconds}:{isReplacement}",
+ // Whitespaces.
+ $" {txHash} : {txHex} : {height} : {blockHash} : {blockIndex} : {label} : {unixSeconds} : {isReplacement} ",
+ // Don't fail on more inputs.
+ $"{txHash}:{txHex}:{height}:{blockHash}:{blockIndex}:{label}:{unixSeconds}:{isReplacement}::",
+ $"{txHash}:{txHex}:{height}:{blockHash}:{blockIndex}:{label}:{unixSeconds}:{isReplacement}:bar:buz",
+ // Can leave out some inputs.
+ $":{txHex}:{height}:{blockHash}:{blockIndex}:{label}:{unixSeconds}:{isReplacement}",
+ $"{txHex}:{height}:{blockHash}:{blockIndex}:{label}:{unixSeconds}:{isReplacement}",
+ $"{txHash}:{txHex}::{blockHash}:{blockIndex}:{label}:{unixSeconds}:{isReplacement}",
+ $"{txHash}:{txHex}:{height}::{blockIndex}:{label}:{unixSeconds}:{isReplacement}",
+ $"{txHash}:{txHex}:{height}:{blockHash}::{label}:{unixSeconds}:{isReplacement}",
+ $"{txHex}",
+ })
+ {
+ stx = SmartTransaction.FromLine(inp, net);
+
+ if (inp == $"{txHex}")
+ {
+ Assert.Equal(txHash, stx.GetHash().ToString());
+ Assert.Equal(txHex, stx.Transaction.ToHex());
+ Assert.Equal(Height.Unknown, stx.Height);
+ Assert.Null(stx.BlockHash);
+ Assert.Equal(0, stx.BlockIndex);
+ Assert.True(stx.Label.IsEmpty);
+ Assert.Equal(stx.FirstSeen.UtcDateTime, DateTime.UtcNow, TimeSpan.FromSeconds(1));
+ Assert.False(stx.IsReplacement);
+ }
+ else
+ {
+ Assert.Equal(txHash, stx.GetHash().ToString());
+ Assert.Equal(txHex, stx.Transaction.ToHex());
+ Assert.Equal(height, stx.Height.ToString());
+ Assert.Equal(blockHash, Guard.Correct(stx.BlockHash?.ToString()));
+ Assert.Equal(blockIndex, stx.BlockIndex.ToString());
+ Assert.Equal(label, stx.Label.ToString());
+ Assert.Equal(unixSeconds, stx.FirstSeen.ToUnixTimeSeconds().ToString());
+ Assert.Equal(isReplacement, stx.IsReplacement.ToString());
+ }
+ }
+ }
+
+ // Null and empty arguments.
+ string input = $"{txHash}:{txHex}:{height}:{blockHash}:{blockIndex}:{label}:{unixSeconds}:{isReplacement}";
+ Network network = null;
+ Assert.Throws<ArgumentNullException>(() => SmartTransaction.FromLine(input, network));
+ network = Network.Main;
+ input = "";
+ Assert.Throws<ArgumentException>(() => SmartTransaction.FromLine(input, network));
+ input = " ";
+ Assert.Throws<ArgumentException>(() => SmartTransaction.FromLine(input, network));
+ input = null;
+ Assert.Throws<ArgumentNullException>(() => SmartTransaction.FromLine(input, network));
+ input = "";
+ Assert.Throws<ArgumentException>(() => SmartTransaction.FromLine(input, network));
+ input = " ";
+ Assert.Throws<ArgumentException>(() => SmartTransaction.FromLine(input, network));
+ }
+
+ public static IEnumerable<object[]> GetSmartTransactionCombinations()
+ {
+ var networks = new List<Network>
+ {
+ Network.Main,
+ Network.TestNet,
+ Network.RegTest
+ };
+ var defaultNetwork = Network.Main;
+
+ var txHexes = new List<string>
+ {
+ "02000000040aa8d0af84518df6e3a60c5bb19d9c3fcc3dc6e26b2f2449e8d7bf8d3fe84b87010000006a473044022018dfe9216c1209dd6c2b6c1607dbac4e499c1fce4878bc7d5d83fccbf3e24c9402202cac351c9c6a2b5eef338cbf0ec000d8de1c05e96a904cbba2b9e6ffc2d4e19501210364cc39da1091b1a9c12ec905a14a9e8478f951f7a1accdabeb40180533f2eaa5feffffff112c07d0f5e0617d720534f0b2b84dc0d5b7314b358c3ab338823b9e5bfbddf5010000006b483045022100ec155e7141e74661ee511ae980150a6c89261f31070999858738369afc28f2b6022006230d2aa24fac110b74ef15b84371486cf76c539b335a253c14462447912a300121020c2f41390f031d471b22abdb856e6cdbe0f4d74e72c197469bfd54e5a08f7e67feffffff38e799b8f6cf04fd021a9b135cdcd347da7aac4fd8bb8d0da9316a9fb228bb6e000000006b483045022100fc1944544a3f96edd8c8a9795c691e2725612b5ab2e1c999be11a2a4e3f841f1022077b2e088877829edeada0c707a9bb577aa79f26dafacba3d1d2d047f52524296012102e6015963dff9826836400cf8f45597c0705757d5dcdc6bf734f661c7dab89e69feffffff64c3f0377e86625123f2f1ee229319ed238e8ca8b7dda5bc080a2c5ecb984629000000006a47304402204233a90d6296182914424fd2901e16e6f5b13b451b67b0eec25a5eaacc5033c902203d8a13ef0b494c12009663475458e51da6bd55cc67688264230ece81d3eeca24012102f806d7152da2b52c1d9ad928e4a6253ccba080a5b9ab9efdd80e37274ac67f9bfeffffff0290406900000000001976a91491ac4e49b66f845180d98d8f8be6121588be6e3b88ac52371600000000001976a9142f44ed6749e8c84fd476e4440741f7e6f55542fa88acadd30700",
+ "0200000001268171371edff285e937adeea4b37b78000c0566cbb3ad64641713ca42171bf6000000006a473044022070b2245123e6bf474d60c5b50c043d4c691a5d2435f09a34a7662a9dc251790a022001329ca9dacf280bdf30740ec0390422422c81cb45839457aeb76fc12edd95b3012102657d118d3357b8e0f4c2cd46db7b39f6d9c38d9a70abcb9b2de5dc8dbfe4ce31feffffff02d3dff505000000001976a914d0c59903c5bac2868760e90fd521a4665aa7652088ac00e1f5050000000017a9143545e6e33b832c47050f24d3eeb93c9c03948bc787b32e1300",
+ "0100000002d8c8df6a6fdd2addaf589a83d860f18b44872d13ee6ec3526b2b470d42a96d4d000000008b483045022100b31557e47191936cb14e013fb421b1860b5e4fd5d2bc5ec1938f4ffb1651dc8902202661c2920771fd29dd91cd4100cefb971269836da4914d970d333861819265ba014104c54f8ea9507f31a05ae325616e3024bd9878cb0a5dff780444002d731577be4e2e69c663ff2da922902a4454841aa1754c1b6292ad7d317150308d8cce0ad7abffffffff2ab3fa4f68a512266134085d3260b94d3b6cfd351450cff021c045a69ba120b2000000008b4830450220230110bc99ef311f1f8bda9d0d968bfe5dfa4af171adbef9ef71678d658823bf022100f956d4fcfa0995a578d84e7e913f9bb1cf5b5be1440bcede07bce9cd5b38115d014104c6ec27cffce0823c3fecb162dbd576c88dd7cda0b7b32b0961188a392b488c94ca174d833ee6a9b71c0996620ae71e799fc7c77901db147fa7d97732e49c8226ffffffff02c0175302000000001976a914a3d89c53bb956f08917b44d113c6b2bcbe0c29b788acc01c3d09000000001976a91408338e1d5e26db3fce21b011795b1c3c8a5a5d0788ac00000000"
+ };
+ var defaultTx = Transaction.Parse(txHexes.First(), defaultNetwork);
+
+ var heights = new List<Height>
+ {
+ Height.Unknown,
+ Height.Mempool,
+ new Height(0),
+ new Height(100),
+ new Height(int.MaxValue)
+ };
+ var defaultHeight = new Height(0);
+
+ var blockHashes = new List<uint256>
+ {
+ null,
+ uint256.Parse("000000000000000000093e2e41b170cd9e10ed8a0469c9719abd227d5226672f")
+ };
+
+ var blockIndexes = new List<int>
+ {
+ 0,
+ 1,
+ 100,
+ int.MaxValue
+ };
+
+ var labels = new List<string>
+ {
+ "",
+ " ",
+ "foo",
+ "foo, bar",
+ " :foo:bar:buz: ",
+ "~!@#$%^&*()"
+ };
+
+ var firstSeens = new List<DateTimeOffset>
+ {
+ DateTimeOffset.UtcNow,
+ DateTimeOffset.Now,
+ DateTimeOffset.MaxValue,
+ DateTimeOffset.MinValue,
+ DateTimeOffset.UnixEpoch
+ };
+
+ var isReplacements = new List<bool>
+ {
+ false,
+ true
+ };
+
+ foreach (var network in networks)
+ {
+ foreach (var txHex in txHexes)
+ {
+ var tx = Transaction.Parse(txHex, network);
+ foreach (var height in heights)
+ {
+ yield return new object[] { new SmartTransaction(tx, height), network };
+ }
+ }
+ }
+
+ foreach (var blockHash in blockHashes)
+ {
+ yield return new object[] { new SmartTransaction(defaultTx, defaultHeight, blockHash), defaultNetwork };
+ }
+
+ foreach (var blockIndex in blockIndexes)
+ {
+ yield return new object[] { new SmartTransaction(defaultTx, defaultHeight, blockIndex: blockIndex), defaultNetwork };
+ }
+ foreach (var label in labels)
+ {
+ yield return new object[] { new SmartTransaction(defaultTx, defaultHeight, label: new SmartLabel(label)), defaultNetwork };
+ }
+
+ foreach (var firstSeen in firstSeens)
+ {
+ yield return new object[] { new SmartTransaction(defaultTx, defaultHeight, firstSeen: firstSeen), defaultNetwork };
+ }
+
+ foreach (var isReplacement in isReplacements)
+ {
+ yield return new object[] { new SmartTransaction(defaultTx, defaultHeight, isReplacement: isReplacement), defaultNetwork };
+ }
+ }
}
}

MODIFY
WalletWasabi/Models/SmartTransaction.cs
WalletWasabi/Models/SmartTransaction.cs
@@ -2,6 +2,7 @@ using NBitcoin;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
+using System.Linq;
using WalletWasabi.Helpers;
using WalletWasabi.JsonConverters;
@@ -124,6 +125,111 @@ namespace WalletWasabi.Models
});
}
+ #region LineSerialization
+
+ public string ToLine()
+ {
+ // GetHash is also serialized, so file can be interpreted with our eyes better.
+
+ return string.Join(':',
+ GetHash(),
+ Transaction.ToHex(),
+ Height,
+ BlockHash,
+ BlockIndex,
+ Label,
+ FirstSeen.ToUnixTimeSeconds(),
+ IsReplacement);
+ }
+
+ public static SmartTransaction FromLine(string line, Network expectedNetwork)
+ {
+ line = Guard.NotNullOrEmptyOrWhitespace(nameof(line), line, trim: true);
+ expectedNetwork = Guard.NotNull(nameof(expectedNetwork), expectedNetwork);
+
+ var parts = line.Split(':', StringSplitOptions.None).Select(x => x.Trim()).ToArray();
+
+ // Find the Transaction hex, it must be always present.
+ ParseTransactionHex(expectedNetwork, parts, out Transaction tx, out int txHexIndex);
+
+ try
+ {
+ // First is redundand txhash serialization.
+ var heightString = parts[txHexIndex + 1];
+ var blockHashString = parts[txHexIndex + 2];
+ var blockIndexString = parts[txHexIndex + 3];
+ var label = parts[txHexIndex + 4];
+ var firstSeen = parts[txHexIndex + 5];
+ var isReplacementString = parts[txHexIndex + 6];
+
+ if (!Height.TryParse(heightString, out Height h))
+ {
+ h = Height.Unknown;
+ }
+ if (!uint256.TryParse(blockHashString, out uint256 bh))
+ {
+ bh = null;
+ }
+ if (!int.TryParse(blockIndexString, out int bi))
+ {
+ bi = 0;
+ }
+ var sl = new SmartLabel(label);
+ DateTimeOffset fs = default;
+ if (long.TryParse(firstSeen, out long unixSeconds))
+ {
+ fs = DateTimeOffset.FromUnixTimeSeconds(unixSeconds);
+ }
+ if (!bool.TryParse(isReplacementString, out bool ir))
+ {
+ ir = false;
+ }
+
+ return new SmartTransaction(tx, h, bh, bi, sl, ir, fs);
+ }
+ catch
+ {
+ return new SmartTransaction(tx, Height.Unknown);
+ }
+ }
+
+ private static void ParseTransactionHex(Network expectedNetwork, string[] parts, out Transaction tx, out int txHexIndex)
+ {
+ tx = null;
+ txHexIndex = 1;
+ try
+ {
+ tx = Transaction.Parse(parts[txHexIndex], expectedNetwork);
+ }
+ catch
+ {
+ for (txHexIndex = 0; txHexIndex < parts.Length; txHexIndex++)
+ {
+ if (txHexIndex == 1)
+ {
+ continue; // We already checked this.
+ }
+
+ string part = parts[txHexIndex];
+ try
+ {
+ tx = Transaction.Parse(part, expectedNetwork);
+ break;
+ }
+ catch
+ {
+ continue;
+ }
+ }
+ }
+ if (tx is null)
+ {
+ throw new FormatException($"Transaction hex is not present.");
+ }
+ }
+
+ #endregion LineSerialization
+
#region Equality
public bool Equals(SmartTransaction other) => GetHash().Equals(other?.GetHash());

Implement SmartTransaction line serialization.