From 3a9a2b2dff7fc1bfb92c411f32451409ada25cd0 Mon Sep 17 00:00:00 2001 From: safestak-keith Date: Thu, 9 Jun 2022 00:58:19 +1000 Subject: [PATCH 1/9] Big overhaul of Mintsafe code to start working with CardanoSharp --- .github/workflows/build.yaml | 45 ++ CONTRIBUTING.md | Bin 0 -> 3016 bytes Mintsafe.sln | 8 + Src/Abstractions/CardanoTypes.cs | 19 +- Src/Abstractions/NiftyTypes.cs | 22 +- Src/Abstractions/YoloPayment.cs | 10 - .../Composers/CollectionAggregateComposer.cs | 6 +- .../Mappers/NiftyCollectionMapper.cs | 16 +- Src/DataAccess/Mappers/NiftyFileMapper.cs | 8 +- Src/DataAccess/Mappers/NiftyMapper.cs | 14 +- Src/DataAccess/Mappers/SaleMapper.cs | 6 + Src/DataAccess/Models/Nifty.cs | 20 +- Src/DataAccess/Models/NiftyCollection.cs | 10 +- Src/DataAccess/Models/NiftyFile.cs | 10 +- Src/DataAccess/Models/Sale.cs | 10 +- Src/DataImporter/Program.cs | 13 +- Src/Lib/BlockfrostTxInfoRetriever.cs | 4 +- Src/Lib/BlockfrostTxSubmitter.cs | 4 +- Src/Lib/BlockfrostUtxoRetriever.cs | 12 +- Src/Lib/CardanoCliUtxoRetriever.cs | 8 +- Src/Lib/LocalNiftyDataService.cs | 24 +- Src/Lib/MetadataJsonBuilder.cs | 154 +++++- Src/Lib/Mintsafe.Lib.csproj | 5 +- Src/Lib/MintsafeAppSettings.cs | 1 - Src/Lib/NiftyDistributor.cs | 3 +- Src/Lib/SimpleWalletService.cs | 438 +++++++++++++++ Src/Lib/TxUtils.cs | 95 ++++ Src/Lib/YoloWalletService.cs | 210 -------- Src/SaleWorker/Program.cs | 49 +- Src/SaleWorker/appsettings.Local.json | 2 +- Src/WasmApp/Pages/YoloWallet.razor | 20 +- Src/WasmApp/Program.cs | 2 +- Src/WasmApp/Services/SimplePaymentService.cs | 35 ++ Src/WasmApp/Services/YoloPaymentService.cs | 27 - .../Controllers/DataAccessTestController.cs | 6 +- .../Controllers/SimplePaymentController.cs | 50 ++ .../Controllers/YoloPaymentController.cs | 50 -- Src/WebApi/Mintsafe.WebApi.csproj | 10 +- Src/WebApi/Program.cs | 2 +- .../Mappers/NiftyCollectionMapperShould.cs | 17 +- .../Mappers/NiftyMapperShould.cs | 9 +- .../Mappers/SaleMapperShould.cs | 3 +- .../Mintsafe.DataAccess.UnitTests.csproj | 8 +- .../Repositories/NiftyRepositoryShould.cs | 2 +- .../BlockfrostUtxoRetrieverShould.cs | 66 +++ Tests/Lib.UnitTests/FakeGenerator.cs | 29 +- .../MetadataJsonBuilderShould.cs | 151 ++++++ .../Mintsafe.Lib.UnitTests.csproj | 8 +- Tests/Lib.UnitTests/NiftyDistributorShould.cs | 5 +- .../SimpleWalletServiceShould.cs | 140 +++++ Tests/Lib.UnitTests/TimeUtilShould.cs | 7 +- Tests/Lib.UnitTests/TxUtilsShould.cs | 506 +++++++++++------- 52 files changed, 1727 insertions(+), 652 deletions(-) create mode 100644 .github/workflows/build.yaml create mode 100644 CONTRIBUTING.md delete mode 100644 Src/Abstractions/YoloPayment.cs create mode 100644 Src/Lib/SimpleWalletService.cs delete mode 100644 Src/Lib/YoloWalletService.cs create mode 100644 Src/WasmApp/Services/SimplePaymentService.cs delete mode 100644 Src/WasmApp/Services/YoloPaymentService.cs create mode 100644 Src/WebApi/Controllers/SimplePaymentController.cs delete mode 100644 Src/WebApi/Controllers/YoloPaymentController.cs create mode 100644 Tests/Lib.UnitTests/BlockfrostUtxoRetrieverShould.cs create mode 100644 Tests/Lib.UnitTests/SimpleWalletServiceShould.cs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..c174ae8 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,45 @@ +name: Build, Test and Publish Package + +on: + push: + tags: + - v* + branches: + - main + - feature/* + +jobs: + build: + runs-on: ubuntu-latest + env: + DOTNET_VERSION: '6.0.x' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + shell: bash + run: dotnet restore + + - name: Build + shell: bash + run: dotnet build --no-restore -c Release + + - name: Test + shell: bash + run: dotnet test --no-build --verbosity normal -c Release + + - name: Publish package + shell: bash + run: dotnet publish --no-build Src/Mintsafe.SaleWorker/Mintsafe.SaleWorker.csproj -c Release -o release --nologo \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..ba2ab0a1b727a84f0cd4653f2883b64459f8fa48 GIT binary patch literal 3016 zcmZ{mOK;Oq5QXm=iT_}!3m$6Ao)rQC0)$$rMXd91TDN&AcKYDQ1LvEQshcKMB_1!Wmwj_YxMu3yeu!u?^4TRpf!G5eb?oBvtE_aj^{#_ ztt>mORI+(T6nG13qh~Xn$Y4Ijv+wZ$p z;bo{8_+9DWP}jWJsz*Ro;WhK)KXrhiZVT;!Y)*ombV zzAJ@}&=0!7(>3KTm2fNHY2;xoU|Y-fMxLv{Ig9^YIn)Z)__l$D?6=jLn#<_VRdN~H zac-$lYGJpoee>8#7Z4j1{9jzdLYfD`V>pwG~~?#{PH*bTHqrF-4!iO`nY`5p$! zxlkqlgDq^H5p6_e1^$=%Fd5)>uBs$A^h@5Ox<-ujc+4u(XYDwU0<6luVnbm;BlLpNQ3v+gH*e{|)bdC>zyPSS|4=G@ zNqw4N{<%&A6MiFcw2r#If57@yd;E$hj=Qgav-|oO>`JYvI;$7KFbZNXVt&+pVzpMN zAssc5Ep_#5MxzsbtP*2@pIWnl{D{GMl6|?(E3eXNs|i)6J1N7nxdk zGGRECIHRmJ_;XCUbvG)f9uYvoX~!;H&uNocMnAzEEHO3cXy>x6+F{_?ov2xUS+u=Z?Xwxc$tWv>i!c&uOIKaPxLr%%o`*N&JlGf-#T z=-U&fSJb)I7sA4Pp)$-m?%L3CcI3P0D=eAU)XLbhGTyTd@9d}DS?B!Fz2`NjRZc;W zQ=#ikHBtMiFi~3 zS)jY@mHuss$YgHRREz0^`aE&-B!XqMMnC1WW*48Ab$GywPb)GqYgq->;3G5iTLyEl NN9#(XSZeKR{0Ao)7^VOK literal 0 HcmV?d00001 diff --git a/Mintsafe.sln b/Mintsafe.sln index 8d53940..a1e54c0 100644 --- a/Mintsafe.sln +++ b/Mintsafe.sln @@ -20,6 +20,8 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C51E32B4-D9A9-480B-8C70-5AE9812A7BF0}" ProjectSection(SolutionItems) = preProject .gitignore = .gitignore + .github\workflows\build.yaml = .github\workflows\build.yaml + CONTRIBUTING.md = CONTRIBUTING.md README.md = README.md EndProjectSection EndProject @@ -37,6 +39,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{610719E3 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mintsafe.DataImporter", "Src\DataImporter\Mintsafe.DataImporter.csproj", "{BF3C0082-4D79-4307-9988-FD4D513D028A}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CardanoSharp.Wallet", "..\cardanosharp-wallet\CardanoSharp.Wallet\CardanoSharp.Wallet.csproj", "{C46A2637-A636-41F8-8CF4-F71562CDE785}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -83,6 +87,10 @@ Global {BF3C0082-4D79-4307-9988-FD4D513D028A}.Debug|Any CPU.Build.0 = Debug|Any CPU {BF3C0082-4D79-4307-9988-FD4D513D028A}.Release|Any CPU.ActiveCfg = Release|Any CPU {BF3C0082-4D79-4307-9988-FD4D513D028A}.Release|Any CPU.Build.0 = Release|Any CPU + {C46A2637-A636-41F8-8CF4-F71562CDE785}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C46A2637-A636-41F8-8CF4-F71562CDE785}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C46A2637-A636-41F8-8CF4-F71562CDE785}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C46A2637-A636-41F8-8CF4-F71562CDE785}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Src/Abstractions/CardanoTypes.cs b/Src/Abstractions/CardanoTypes.cs index 5e83b40..39a5cbb 100644 --- a/Src/Abstractions/CardanoTypes.cs +++ b/Src/Abstractions/CardanoTypes.cs @@ -13,11 +13,28 @@ public record struct Value(string Unit, long Quantity); public record Utxo(string TxHash, int OutputIndex, Value[] Values) { public override int GetHashCode() => ToString().GetHashCode(); - public override string ToString() => $"{TxHash}__{OutputIndex}"; + public override string ToString() => $"{TxHash}_{OutputIndex}"; bool IEquatable.Equals(Utxo? other) => other != null && TxHash == other.TxHash && OutputIndex == other.OutputIndex; public long Lovelaces => Values.First(v => v.Unit == Assets.LovelaceUnit).Quantity; } +// TODO: Ideal types +public record struct NativeAssetValue(string PolicyId, string AssetName, ulong Quantity); + +public record struct AggregateValue(ulong Lovelaces, NativeAssetValue[] NativeAssets); + +public record struct PendingTransactionOutput(string Address, AggregateValue Value); + +public record UnspentTransactionOutput(string TxHash, uint OutputIndex, AggregateValue Value) +{ + public override int GetHashCode() => ToString().GetHashCode(); + public override string ToString() => $"{TxHash}_{OutputIndex}"; + bool IEquatable.Equals(UnspentTransactionOutput? other) + => other != null && TxHash == other.TxHash && OutputIndex == other.OutputIndex; + public ulong Lovelaces => Value.Lovelaces; +} +// End TODO + public record TxIo(string Address, int OutputIndex, Value[] Values); public record TxInfo(string TxHash, TxIo[] Inputs, TxIo[] Outputs); diff --git a/Src/Abstractions/NiftyTypes.cs b/Src/Abstractions/NiftyTypes.cs index 7c85d8c..b1dbf96 100644 --- a/Src/Abstractions/NiftyTypes.cs +++ b/Src/Abstractions/NiftyTypes.cs @@ -18,7 +18,8 @@ public record NiftyCollection( string[] Publishers, DateTime CreatedAt, DateTime LockedAt, - long SlotExpiry); + long SlotExpiry, + Royalty Royalty); public record Nifty( Guid Id, @@ -26,14 +27,13 @@ public record Nifty( bool IsMintable, string AssetName, string Name, - string Description, + string? Description, string[] Creators, - string Image, - string MediaType, + string? Image, + string? MediaType, NiftyFile[] Files, DateTime CreatedAt, - Royalty Royalty, - string Version, + string? Version, KeyValuePair[] Attributes); public record NiftyFile( @@ -118,3 +118,13 @@ public record Mint( string PolicyId, string AssetName ); + +public record MintMessage( + Guid MessageId, + string FromAddress, + string[] ToAddresses, + string[] CcAddresses, + string MessageTitle, + string MessageBody, + DateTime MessageSentAt, + string PolicyId); diff --git a/Src/Abstractions/YoloPayment.cs b/Src/Abstractions/YoloPayment.cs deleted file mode 100644 index 5b1b70b..0000000 --- a/Src/Abstractions/YoloPayment.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Mintsafe.Abstractions; - -public class YoloPayment -{ - public string? SourceAddress { get; init; } - public string? DestinationAddress { get; init; } - public Value[]? Values { get; init; } - public string[]? Message { get; init; } - public string? SigningKeyCborHex { get; init; } -} diff --git a/Src/DataAccess/Composers/CollectionAggregateComposer.cs b/Src/DataAccess/Composers/CollectionAggregateComposer.cs index 23b6f98..5a0d90a 100644 --- a/Src/DataAccess/Composers/CollectionAggregateComposer.cs +++ b/Src/DataAccess/Composers/CollectionAggregateComposer.cs @@ -5,12 +5,12 @@ namespace Mintsafe.DataAccess.Composers { public interface ICollectionAggregateComposer { - CollectionAggregate Build(Models.NiftyCollection? collection, IEnumerable nifties, IEnumerable sales, IEnumerable niftyFiles); + CollectionAggregate Build(Models.NiftyCollection collection, IEnumerable nifties, IEnumerable sales, IEnumerable niftyFiles); } public class CollectionAggregateComposer : ICollectionAggregateComposer { - public CollectionAggregate Build(Models.NiftyCollection? collection, IEnumerable nifties, IEnumerable sales, IEnumerable niftyFiles) + public CollectionAggregate Build(Models.NiftyCollection collection, IEnumerable nifties, IEnumerable sales, IEnumerable niftyFiles) { var activeSales = sales.Where(IsSaleOpen).ToArray(); var hydratedNifties = HydrateNifties(nifties, niftyFiles); @@ -28,7 +28,7 @@ private static bool IsSaleOpen(Models.Sale sale) && (!sale.End.HasValue || (sale.End.HasValue && sale.End > DateTime.UtcNow)); } - private Nifty[] HydrateNifties(IEnumerable nifties, IEnumerable allFiles) + private static Nifty[] HydrateNifties(IEnumerable nifties, IEnumerable allFiles) { var returnNifties = new List(); foreach (var nifty in nifties) diff --git a/Src/DataAccess/Mappers/NiftyCollectionMapper.cs b/Src/DataAccess/Mappers/NiftyCollectionMapper.cs index f8650d6..f3e94ea 100644 --- a/Src/DataAccess/Mappers/NiftyCollectionMapper.cs +++ b/Src/DataAccess/Mappers/NiftyCollectionMapper.cs @@ -17,12 +17,19 @@ public static Models.NiftyCollection Map(NiftyCollection niftyCollection) Publishers = niftyCollection.Publishers, CreatedAt = niftyCollection.CreatedAt.ToUniversalTime(), LockedAt = niftyCollection.LockedAt.ToUniversalTime(), - SlotExpiry = niftyCollection.SlotExpiry + SlotExpiry = niftyCollection.SlotExpiry, + RoyaltyAddress = niftyCollection.Royalty.Address, + RoyaltyPortion = niftyCollection.Royalty.PortionOfSale }; } public static NiftyCollection Map(Models.NiftyCollection dtoNiftyCollection) { + if (dtoNiftyCollection == null) throw new ArgumentNullException(nameof(dtoNiftyCollection)); + if (dtoNiftyCollection.PolicyId == null) throw new ArgumentNullException(nameof(dtoNiftyCollection.PolicyId)); + if (dtoNiftyCollection.Name == null) throw new ArgumentNullException(nameof(dtoNiftyCollection.Name)); + if (dtoNiftyCollection.BrandImage == null) throw new ArgumentNullException(nameof(dtoNiftyCollection.BrandImage)); + return new NiftyCollection( Guid.Parse(dtoNiftyCollection.RowKey), dtoNiftyCollection.PolicyId, @@ -30,10 +37,13 @@ public static NiftyCollection Map(Models.NiftyCollection dtoNiftyCollection) dtoNiftyCollection.Description, dtoNiftyCollection.IsActive, dtoNiftyCollection.BrandImage, - dtoNiftyCollection.Publishers, + dtoNiftyCollection.Publishers ?? Array.Empty(), dtoNiftyCollection.CreatedAt, dtoNiftyCollection.LockedAt, - dtoNiftyCollection.SlotExpiry); + dtoNiftyCollection.SlotExpiry, + new Royalty( + dtoNiftyCollection.RoyaltyPortion, + dtoNiftyCollection.RoyaltyAddress ?? string.Empty)); } } } diff --git a/Src/DataAccess/Mappers/NiftyFileMapper.cs b/Src/DataAccess/Mappers/NiftyFileMapper.cs index c32079b..5941ae0 100644 --- a/Src/DataAccess/Mappers/NiftyFileMapper.cs +++ b/Src/DataAccess/Mappers/NiftyFileMapper.cs @@ -6,13 +6,19 @@ public static class NiftyFileMapper { public static NiftyFile Map(Models.NiftyFile niftyFileDto) { + if (niftyFileDto == null) throw new ArgumentNullException(nameof(niftyFileDto)); + if (niftyFileDto.NiftyId == null) throw new ArgumentNullException(nameof(niftyFileDto.NiftyId)); + if (niftyFileDto.Name == null) throw new ArgumentNullException(nameof(niftyFileDto.Name)); + if (niftyFileDto.MediaType == null) throw new ArgumentNullException(nameof(niftyFileDto.MediaType)); + if (niftyFileDto.Url == null) throw new ArgumentNullException(nameof(niftyFileDto.Url)); + return new NiftyFile( Guid.Parse(niftyFileDto.RowKey), Guid.Parse(niftyFileDto.NiftyId), niftyFileDto.Name, niftyFileDto.MediaType, niftyFileDto.Url, - niftyFileDto.FileHash + niftyFileDto.FileHash ?? string.Empty ); } diff --git a/Src/DataAccess/Mappers/NiftyMapper.cs b/Src/DataAccess/Mappers/NiftyMapper.cs index c0d1973..b5c18b1 100644 --- a/Src/DataAccess/Mappers/NiftyMapper.cs +++ b/Src/DataAccess/Mappers/NiftyMapper.cs @@ -19,14 +19,18 @@ public static Models.Nifty Map(Nifty nifty) MediaType = nifty.MediaType, CreatedAt = nifty.CreatedAt.ToUniversalTime(), Version = nifty.Version, - RoyaltyAddress = nifty.Royalty.Address, - RoyaltyPortion = nifty.Royalty.PortionOfSale, Attributes = nifty.Attributes }; } public static Nifty Map(Models.Nifty dtoNifty, IEnumerable niftyFiles) { + if (dtoNifty == null) throw new ArgumentNullException(nameof(dtoNifty)); + if (dtoNifty.AssetName == null) throw new ArgumentNullException(nameof(dtoNifty.AssetName)); + if (dtoNifty.Name == null) throw new ArgumentNullException(nameof(dtoNifty.Name)); + if (dtoNifty.RowKey == null) throw new ArgumentNullException(nameof(dtoNifty.RowKey)); + if (dtoNifty.PartitionKey == null) throw new ArgumentNullException(nameof(dtoNifty.PartitionKey)); + return new Nifty( Guid.Parse(dtoNifty.RowKey), Guid.Parse(dtoNifty.PartitionKey), @@ -34,14 +38,14 @@ public static Nifty Map(Models.Nifty dtoNifty, IEnumerable nif dtoNifty.AssetName, dtoNifty.Name, dtoNifty.Description, - dtoNifty.Creators, + dtoNifty.Creators ?? Array.Empty(), dtoNifty.Image, dtoNifty.MediaType, niftyFiles.Select(NiftyFileMapper.Map).ToArray(), dtoNifty.CreatedAt, - new Royalty(dtoNifty.RoyaltyPortion, dtoNifty.RoyaltyAddress), dtoNifty.Version, - dtoNifty.Attributes.ToArray() + dtoNifty.Attributes == null + ? Array.Empty>() : dtoNifty.Attributes.ToArray() ); } } diff --git a/Src/DataAccess/Mappers/SaleMapper.cs b/Src/DataAccess/Mappers/SaleMapper.cs index 44a220b..bac585f 100644 --- a/Src/DataAccess/Mappers/SaleMapper.cs +++ b/Src/DataAccess/Mappers/SaleMapper.cs @@ -27,6 +27,12 @@ public static Models.Sale Map(Sale sale) public static Sale Map(Models.Sale saleDto) { + if (saleDto == null) throw new ArgumentNullException(nameof(saleDto)); + if (saleDto.Name == null) throw new ArgumentNullException(nameof(saleDto.Name)); + if (saleDto.SaleAddress == null) throw new ArgumentNullException(nameof(saleDto.SaleAddress)); + if (saleDto.ProceedsAddress == null) throw new ArgumentNullException(nameof(saleDto.ProceedsAddress)); + if (saleDto.CreatorAddress == null) throw new ArgumentNullException(nameof(saleDto.CreatorAddress)); + return new Sale( Guid.Parse(saleDto.RowKey), Guid.Parse(saleDto.PartitionKey), diff --git a/Src/DataAccess/Models/Nifty.cs b/Src/DataAccess/Models/Nifty.cs index afbc14d..9ac0c47 100644 --- a/Src/DataAccess/Models/Nifty.cs +++ b/Src/DataAccess/Models/Nifty.cs @@ -7,13 +7,13 @@ namespace Mintsafe.DataAccess.Models { public class Nifty : ITableEntity { - public string PartitionKey { get; set; } - public string RowKey { get; set; } + public string? PartitionKey { get; set; } + public string? RowKey { get; set; } public bool IsMintable { get; set; } - public string AssetName { get; set; } - public string Name { get; set; } - public string Description { get; set; } + public string? AssetName { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } [IgnoreDataMember] public string[]? Creators { get; set; } @@ -24,15 +24,13 @@ public string? CreatorsAsString set => Creators = value?.Split(','); } - public string Image { get; set; } - public string MediaType { get; set; } + public string? Image { get; set; } + public string? MediaType { get; set; } public DateTime CreatedAt { get; set; } - public string Version { get; set; } - public double RoyaltyPortion { get; set; } - public string RoyaltyAddress { get; set; } + public string? Version { get; set; } [IgnoreDataMember] - public IEnumerable> Attributes { get; set; } + public IEnumerable>? Attributes { get; set; } public string AttributesAsString { diff --git a/Src/DataAccess/Models/NiftyCollection.cs b/Src/DataAccess/Models/NiftyCollection.cs index ead32a0..2218a04 100644 --- a/Src/DataAccess/Models/NiftyCollection.cs +++ b/Src/DataAccess/Models/NiftyCollection.cs @@ -9,11 +9,11 @@ public class NiftyCollection : ITableEntity public string PartitionKey { get; set; } public string RowKey { get; set; } - public string PolicyId { get; set; } - public string Name { get; set; } - public string Description { get; set; } + public string? PolicyId { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } public bool IsActive { get; set; } - public string BrandImage { get; set; } + public string? BrandImage { get; set; } [IgnoreDataMember] public string[]? Publishers { get; set; } @@ -27,6 +27,8 @@ public string? PublishersAsString public DateTime CreatedAt { get; set; } public DateTime LockedAt { get; set; } public long SlotExpiry { get; set; } + public double RoyaltyPortion { get; set; } + public string? RoyaltyAddress { get; set; } public DateTimeOffset? Timestamp { get; set; } public ETag ETag { get; set; } diff --git a/Src/DataAccess/Models/NiftyFile.cs b/Src/DataAccess/Models/NiftyFile.cs index 07bc231..6019dd8 100644 --- a/Src/DataAccess/Models/NiftyFile.cs +++ b/Src/DataAccess/Models/NiftyFile.cs @@ -8,11 +8,11 @@ public class NiftyFile : ITableEntity public string PartitionKey { get; set; } public string RowKey { get; set; } - public string NiftyId { get; set; } - public string Name { get; set; } - public string MediaType { get; set; } - public string Url { get; set; } - public string FileHash { get; set; } + public string? NiftyId { get; set; } + public string? Name { get; set; } + public string? MediaType { get; set; } + public string? Url { get; set; } + public string? FileHash { get; set; } public DateTimeOffset? Timestamp { get; set; } public ETag ETag { get; set; } diff --git a/Src/DataAccess/Models/Sale.cs b/Src/DataAccess/Models/Sale.cs index e94d12b..6ce61b4 100644 --- a/Src/DataAccess/Models/Sale.cs +++ b/Src/DataAccess/Models/Sale.cs @@ -9,12 +9,12 @@ public class Sale : ITableEntity public string RowKey { get; set; } public bool IsActive { get; set; } - public string Name { get; set; } - public string Description { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } public long LovelacesPerToken { get; set; } - public string SaleAddress { get; set; } - public string CreatorAddress { get; set; } - public string ProceedsAddress { get; set; } + public string? SaleAddress { get; set; } + public string? CreatorAddress { get; set; } + public string? ProceedsAddress { get; set; } public double PostPurchaseMargin { get; set; } public int TotalReleaseQuantity { get; set; } public int MaxAllowedPurchaseQuantity { get; set; } diff --git a/Src/DataImporter/Program.cs b/Src/DataImporter/Program.cs index cebc9b8..baeb708 100644 --- a/Src/DataImporter/Program.cs +++ b/Src/DataImporter/Program.cs @@ -49,7 +49,7 @@ async void BuildModelsAndInsertAsync( IServiceProvider services, NiftyCollection niftyCollection, Sale sale, - IList niftyJson) + IList niftyJson) { using IServiceScope serviceScope = services.CreateScope(); IServiceProvider provider = serviceScope.ServiceProvider; @@ -68,7 +68,7 @@ async void BuildModelsAndInsertAsync( attributesJsonObjectsArray .Select(x => new KeyValuePair((string) x["key"], (string) x["value"])).ToArray(); var creators = ((JsonArray)jsonObject["creators"]).Cast() - .Select(jv => (string)jv) + .Select(jv => (string)jv ?? string.Empty) .ToArray(); var nifty = new Nifty( @@ -83,7 +83,6 @@ async void BuildModelsAndInsertAsync( MediaType: "image/png", Files: Array.Empty(), CreatedAt: DateTimeOffset.FromUnixTimeMilliseconds((long)jsonObject["date"]).UtcDateTime, - Royalty: new Royalty(0, ""), Version: (string)jsonObject["version"], Attributes: attributes); @@ -95,16 +94,16 @@ async void BuildModelsAndInsertAsync( await dataService.InsertCollectionAggregateAsync(aggregate, CancellationToken.None); } -async Task LoadJsonFromFileAsync(string path) +async Task LoadJsonFromFileAsync(string path) { - var raw = await File.ReadAllTextAsync(path); + var raw = await File.ReadAllTextAsync(path).ConfigureAwait(false); return JsonSerializer.Deserialize(raw); } -async Task> LoadDynamicJsonFromDirAsync(string path) +async Task> LoadDynamicJsonFromDirAsync(string path) { var files = Directory.GetFiles(path); - var list = new List(); + var list = new List(); foreach (var filePath in files) { var raw = await File.ReadAllTextAsync(filePath); diff --git a/Src/Lib/BlockfrostTxInfoRetriever.cs b/Src/Lib/BlockfrostTxInfoRetriever.cs index e9d5d99..044c0e3 100644 --- a/Src/Lib/BlockfrostTxInfoRetriever.cs +++ b/Src/Lib/BlockfrostTxInfoRetriever.cs @@ -8,9 +8,9 @@ namespace Mintsafe.Lib; public class BlockfrostTxInfoRetriever : ITxInfoRetriever { - private readonly BlockfrostClient _blockFrostClient; + private readonly IBlockfrostClient _blockFrostClient; - public BlockfrostTxInfoRetriever(BlockfrostClient blockFrostClient) + public BlockfrostTxInfoRetriever(IBlockfrostClient blockFrostClient) { _blockFrostClient = blockFrostClient; } diff --git a/Src/Lib/BlockfrostTxSubmitter.cs b/Src/Lib/BlockfrostTxSubmitter.cs index 11b7b3b..582de0e 100644 --- a/Src/Lib/BlockfrostTxSubmitter.cs +++ b/Src/Lib/BlockfrostTxSubmitter.cs @@ -6,9 +6,9 @@ namespace Mintsafe.Lib; public class BlockfrostTxSubmitter : ITxSubmitter { - private readonly BlockfrostClient _blockFrostClient; + private readonly IBlockfrostClient _blockFrostClient; - public BlockfrostTxSubmitter(BlockfrostClient blockFrostClient) + public BlockfrostTxSubmitter(IBlockfrostClient blockFrostClient) { _blockFrostClient = blockFrostClient; } diff --git a/Src/Lib/BlockfrostUtxoRetriever.cs b/Src/Lib/BlockfrostUtxoRetriever.cs index 263ff7d..66b2599 100644 --- a/Src/Lib/BlockfrostUtxoRetriever.cs +++ b/Src/Lib/BlockfrostUtxoRetriever.cs @@ -10,12 +10,11 @@ namespace Mintsafe.Lib; public class BlockfrostUtxoRetriever : IUtxoRetriever { private readonly ILogger _logger; - private readonly BlockfrostClient _blockFrostClient; + private readonly IBlockfrostClient _blockFrostClient; - public BlockfrostUtxoRetriever( ILogger logger, - BlockfrostClient blockFrostClient) + IBlockfrostClient blockFrostClient) { _logger = logger; _blockFrostClient = blockFrostClient; @@ -45,9 +44,14 @@ static Value MapValueFromAmount(BlockFrostValue bfVal) { if (bfVal.Quantity == null) throw new BlockfrostResponseException("Blockfrost response has null amount.quantity", 0); + if (bfVal.Unit == null) + throw new BlockfrostResponseException("Blockfrost response has null amount.unit", 0); + // Add a dividing '.' for cardano-cli compatibility + var unit = bfVal.Unit == Assets.LovelaceUnit + ? Assets.LovelaceUnit : bfVal.Unit.Insert(56, "."); return new Value( - bfVal.Unit ?? throw new BlockfrostResponseException("Blockfrost response has null amount.unit", 0), + unit, long.Parse(bfVal.Quantity)); } diff --git a/Src/Lib/CardanoCliUtxoRetriever.cs b/Src/Lib/CardanoCliUtxoRetriever.cs index f936918..41b2606 100644 --- a/Src/Lib/CardanoCliUtxoRetriever.cs +++ b/Src/Lib/CardanoCliUtxoRetriever.cs @@ -110,11 +110,11 @@ public class FakeUtxoRetriever : IUtxoRetriever { public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) { - await Task.Delay(1000, ct); + await Task.Delay(1000, ct).ConfigureAwait(false); return GenerateUtxos(3, - 15000000, - 10000000, - 30000000); + 45_000000, + 10_000000, + 60_000000); } private static Utxo[] GenerateUtxos(int count, params long[] values) diff --git a/Src/Lib/LocalNiftyDataService.cs b/Src/Lib/LocalNiftyDataService.cs index 1fa11e9..5a3eabb 100644 --- a/Src/Lib/LocalNiftyDataService.cs +++ b/Src/Lib/LocalNiftyDataService.cs @@ -26,12 +26,13 @@ public class LocalNiftyDataService : INiftyDataService IsActive: true, Publishers: new[] { "cryptoquokkas.com", "mintsafe.io" }, BrandImage: "", - CreatedAt: new DateTime(2021, 9, 4, 0, 0, 0, DateTimeKind.Utc), - LockedAt: new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), - SlotExpiry: 49027186); // testnet christmas + CreatedAt: new DateTime(2022, 1, 28, 0, 0, 0, DateTimeKind.Utc), + LockedAt: new DateTime(2022, 12, 1, 0, 0, 0, DateTimeKind.Utc), + SlotExpiry: 74686216, + Royalty: new Royalty(0, string.Empty)); // testnet christmas var tokens = GenerateTokens( - 3000, + 100, FakeCollectionId); var sale = new Sale( @@ -39,16 +40,16 @@ public class LocalNiftyDataService : INiftyDataService CollectionId: fakeCollectionId, IsActive: true, Name: "Launch #1", - Description: "Limited 300 item launch", - LovelacesPerToken: 6000000, - Start: new DateTime(2021, 9, 4, 0, 0, 0, DateTimeKind.Utc), - End: new DateTime(2021, 12, 14, 0, 0, 0, DateTimeKind.Utc), + Description: "Limited 40 item launch", + LovelacesPerToken: 45000000, + Start: new DateTime(2022, 4, 1, 0, 0, 0, DateTimeKind.Utc), + End: new DateTime(2022, 12, 14, 0, 0, 0, DateTimeKind.Utc), SaleAddress: "addr_test1vqgh0dutf08aynjcvhwa8jeaclpxs29fpjtsunlw2056pycjut5w7", CreatorAddress: "addr_test1vp92pf7y6mk9qgqs2474mxvjh9u3e5h885v6hy8c8qp3wdcddsldj", ProceedsAddress: "addr_test1vp92pf7y6mk9qgqs2474mxvjh9u3e5h885v6hy8c8qp3wdcddsldj", PostPurchaseMargin: 0.1m, - TotalReleaseQuantity: 300, - MaxAllowedPurchaseQuantity: 3); + TotalReleaseQuantity: 45, + MaxAllowedPurchaseQuantity: 1); var activeSales = collection.IsActive && IsSaleOpen(sale) ? new[] { sale } : Array.Empty(); @@ -79,8 +80,6 @@ private static Nifty[] GenerateTokens( string urlBase = "ipfs://QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6", string mediaType = "image/png", string createdAtIso8601 = "2021-01-01T19:30:00Z", - double royaltyPortion = 0, - string royaltyAddress = "", string version = "1", string attributeKey = "size", string attributeValue = "full") @@ -122,7 +121,6 @@ Dictionary GetAttributesForIndex(int i) new(Guid.NewGuid(), niftyId, "specs_pdf", "application/pdf", $"{urlBase}{i + 1}") }, dateTimeParsed.AddDays(i), - new Royalty(royaltyPortion, royaltyAddress), version, GetAttributesForIndex(i).ToArray()); }) diff --git a/Src/Lib/MetadataJsonBuilder.cs b/Src/Lib/MetadataJsonBuilder.cs index 62f22c9..6200c38 100644 --- a/Src/Lib/MetadataJsonBuilder.cs +++ b/Src/Lib/MetadataJsonBuilder.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -17,6 +19,14 @@ public class CnftStandardFile public string? Hash { get; set; } } + public class CnftOnChainStandardFile + { + public string? Name { get; set; } + public string? MediaType { get; set; } + public string[]? Src { get; set; } + public string? Hash { get; set; } + } + public class CnftStandardAsset { public string? Name { get; set; } @@ -29,9 +39,21 @@ public class CnftStandardAsset public IEnumerable>? Attributes { get; set; } } + public class CnftOnChainStandardAsset + { + public string? Name { get; set; } + public string? Description { get; set; } + public string? MediaType { get; set; } + public string[]? Image { get; set; } + public string[]? Creators { get; set; } + public string[]? Publishers { get; set; } + public CnftOnChainStandardFile[]? Files { get; set; } + public IEnumerable>? Attributes { get; set; } + } + public class CnftStandardRoyalty { - public double Pct { get; set; } + public double Rate { get; set; } public string[]? Addr { get; set; } } #endregion @@ -47,10 +69,12 @@ public class MetadataJsonBuilder : IMetadataJsonBuilder private const string MessageStandardKey = "674"; private const string NftStandardKey = "721"; private const string NftRoyaltyStandardKey = "777"; + private const int MaxMetadataStringLength = 64; private static readonly JsonSerializerOptions SerialiserOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; @@ -71,6 +95,17 @@ public MetadataJsonBuilder( public string GenerateNftStandardJson( Nifty[] nfts, NiftyCollection collection) + { + var hasOnChainNifties = nfts.Any( + n => (n.Image != null && n.Image.Length > MaxMetadataStringLength) + || n.Files.Any(nf => nf.Url.Length > MaxMetadataStringLength)); + + return hasOnChainNifties + ? GetOnChainNftStandardJson(nfts, collection) + : GetOffChainNftStandardJson(nfts, collection); + } + + private string GetOffChainNftStandardJson(Nifty[] nfts, NiftyCollection collection) { var nftStandard = new Dictionary< string, // 721 @@ -108,7 +143,52 @@ public string GenerateNftStandardJson( nftStandard.Add(NftStandardKey, policyCnfts); var json = JsonSerializer.Serialize(nftStandard, SerialiserOptions); - _logger.LogDebug($"NFT Metadata JSON built after {sw.ElapsedMilliseconds}ms"); + _logger.LogDebug($"NFT Metadata JSON (off-chain) built after {sw.ElapsedMilliseconds}ms"); + + return json; + } + + private string GetOnChainNftStandardJson( + Nifty[] nfts, + NiftyCollection collection) + { + var nftStandard = new Dictionary< + string, // 721 + Dictionary< + string, // PolicyID + Dictionary< + string, // AssetName + CnftOnChainStandardAsset>>>(); + var policyCnfts = new Dictionary< + string, // PolicyID + Dictionary< + string, // AssetName + CnftOnChainStandardAsset>>(); + + var sw = Stopwatch.StartNew(); + var nftDictionary = new Dictionary(); + foreach (var nft in nfts) + { + var nftAsset = new CnftOnChainStandardAsset + { + Name = nft.Name, + Description = nft.Description, + Image = SplitStringToChunks(nft.Image), + MediaType = nft.MediaType, + Creators = nft.Creators, + Publishers = collection.Publishers, + Files = nft.Files.Length == 0 ? null // don't serialise empty arrays + : nft.Files.Select( + f => new CnftOnChainStandardFile { Name = f.Name, MediaType = f.MediaType, Src = SplitStringToChunks(f.Url), Hash = f.FileHash }).ToArray(), + Attributes = nft.Attributes.Length == 0 ? null : nft.Attributes + }; + nftDictionary.Add(nft.AssetName, nftAsset); + } + policyCnfts.Add(collection.PolicyId, nftDictionary); + nftStandard.Add(NftStandardKey, policyCnfts); + + var json = JsonSerializer.Serialize(nftStandard, SerialiserOptions); + _logger.LogDebug($"NFT Metadata JSON (on-chain) built after {sw.ElapsedMilliseconds}ms"); return json; } @@ -136,5 +216,75 @@ public string GenerateMessageJson(string[] message) return json; } + + public string GenerateRoyaltyJson(Royalty royalty) + { + var sw = Stopwatch.StartNew(); + var metadataBody = new Dictionary< + string, // 777 + CnftStandardRoyalty> + { + { + NftRoyaltyStandardKey, + new CnftStandardRoyalty { Rate = royalty.PortionOfSale, Addr = SplitStringToChunks(royalty.Address) } + } + }; + var json = JsonSerializer.Serialize(metadataBody, SerialiserOptions); + _logger.LogDebug($"Royalty Metadata JSON built after {sw.ElapsedMilliseconds}ms"); + + return json; + } + + public static string[] SplitStringToChunks(string? value, int maxLength = MaxMetadataStringLength) + { + if (value == null) + return Array.Empty(); + if (value.Length <= maxLength) + return new[] { value }; + + var offsetLength = maxLength - 1; + var itemsLength = (value.Length + offsetLength) / maxLength; + var items = new string[itemsLength]; + for (var i = 0; i < itemsLength; i++) + { + var substringStartIndex = i * maxLength; + var substringLength = (substringStartIndex + maxLength) <= value.Length + ? maxLength + : value.Length % maxLength; // take remainder mod to prevent index-out-of-bounds + var segment = value.Substring(substringStartIndex, substringLength); + items[i] = segment; + } + return items; + } + + public static string GetBase64SvgForMessage(string message, string title = "") + { + const int MaxMessageBodyLength = 256; + const int MaxMessageLineCharLength = 32; + if (title.Length > MaxMessageLineCharLength) + throw new ArgumentException($"{nameof(title)} cannot be greater than {MaxMessageLineCharLength} characters", nameof(title)); + if (message.Length > MaxMessageBodyLength) + throw new ArgumentException($"{nameof(message)} cannot be greater than {MaxMessageBodyLength} characters", nameof(message)); + + var svgBuilder = new StringBuilder($""); + if (!string.IsNullOrWhiteSpace(title)) + { + svgBuilder.Append($"{title}"); + } + + var chunks = SplitStringToChunks(message, MaxMessageLineCharLength); + var yOffset = 35; + foreach (var chunk in chunks) + { + svgBuilder.Append($"{chunk}"); + yOffset += 15; + } + svgBuilder.Append($"reply @ mintsafe.io"); + svgBuilder.Append(""); + + var base64Svg = Convert.ToBase64String(Encoding.UTF8.GetBytes(svgBuilder.ToString())); + + return $"data:image/svg+xml;base64,{base64Svg}"; + } } } diff --git a/Src/Lib/Mintsafe.Lib.csproj b/Src/Lib/Mintsafe.Lib.csproj index c69b400..3fa223f 100644 --- a/Src/Lib/Mintsafe.Lib.csproj +++ b/Src/Lib/Mintsafe.Lib.csproj @@ -7,12 +7,15 @@ - + + + + diff --git a/Src/Lib/MintsafeAppSettings.cs b/Src/Lib/MintsafeAppSettings.cs index 13493ba..ef5fb02 100644 --- a/Src/Lib/MintsafeAppSettings.cs +++ b/Src/Lib/MintsafeAppSettings.cs @@ -13,4 +13,3 @@ public record MintsafeAppSettings public string? AppInsightsInstrumentationKey { get; init; } public Guid CollectionId { get; init; } } - diff --git a/Src/Lib/NiftyDistributor.cs b/Src/Lib/NiftyDistributor.cs index b91c9ee..b3a8d96 100644 --- a/Src/Lib/NiftyDistributor.cs +++ b/Src/Lib/NiftyDistributor.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -61,7 +62,7 @@ public async Task DistributeNiftiesForSalePurchase( Exception: buyerAddressException); } - var tokenMintValues = nfts.Select(n => new Value($"{saleContext.Collection.PolicyId}.{n.AssetName}", 1)).ToArray(); + var tokenMintValues = nfts.Select(n => new Value($"{saleContext.Collection.PolicyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName))}", 1)).ToArray(); var txBuildOutputs = GetTxBuildOutputs(saleContext.Sale, purchaseAttempt, address, tokenMintValues); var policyScriptPath = Path.Combine(_settings.BasePath, $"{saleContext.Collection.PolicyId}.policy.script"); var metadataJsonPath = Path.Combine(_settings.BasePath, $"metadata-mint-{purchaseAttempt.Utxo}.json"); diff --git a/Src/Lib/SimpleWalletService.cs b/Src/Lib/SimpleWalletService.cs new file mode 100644 index 0000000..94a8b3c --- /dev/null +++ b/Src/Lib/SimpleWalletService.cs @@ -0,0 +1,438 @@ +using CardanoSharp.Koios.Sdk; +using CardanoSharp.Koios.Sdk.Contracts; +using CardanoSharp.Wallet.Encoding; +using CardanoSharp.Wallet.Extensions; +using CardanoSharp.Wallet.Extensions.Models; +using CardanoSharp.Wallet.Extensions.Models.Transactions; +using CardanoSharp.Wallet.Models.Addresses; +using CardanoSharp.Wallet.Models.Keys; +using CardanoSharp.Wallet.Models.Transactions; +using CardanoSharp.Wallet.Models.Transactions.Scripts; +using CardanoSharp.Wallet.Models.Transactions.TransactionWitness.Scripts; +using CardanoSharp.Wallet.TransactionBuilding; +using CardanoSharp.Wallet.Utilities; +using Microsoft.Extensions.Logging; +using Mintsafe.Abstractions; +using PeterO.Cbor2; +using Refit; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Mintsafe.Lib; + +public interface ISimpleWalletService +{ + Task SubmitTransactionAsync( + string sourcePaymentAddress, + string sourcePaymentSkey, + Network network, + NativeAssetValue[]? nativeAssetsToMint = null, + PendingTransactionOutput[]? outputs = null, + Dictionary>? metadata = null, + string? withdrawalStakeSkey = null, + string[]? policySkey = null, + uint? policyExpirySlot = null, + CancellationToken ct = default); +} + +public class SimpleWalletService : ISimpleWalletService +{ + private readonly ILogger _logger; + private readonly IInstrumentor _instrumentor; + + public SimpleWalletService( + ILogger logger, + IInstrumentor instrumentor) + { + _logger = logger; + _instrumentor = instrumentor; + } + + public async Task SubmitTransactionAsync( + string sourcePaymentAddress, + string sourcePaymentSkey, + Network network, + NativeAssetValue[]? nativeAssetsToMint = null, + PendingTransactionOutput[]? outputs = null, + Dictionary>? metadata = null, + string? withdrawalStakeSkey = null, + string[]? policySkeys = null, + uint? policyExpirySlot = null, + CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + (var epochClient, var networkClient, var addressClient, var txClient) = GetKoiosClients(network); + var tipTask = networkClient.GetChainTip().ConfigureAwait(false); + var sourceAddressInfoTask = addressClient.GetAddressInformation(sourcePaymentAddress).ConfigureAwait(false); + var tipResponse = await tipTask; + var addrInfoResponse = await sourceAddressInfoTask; + if (tipResponse == null || !tipResponse.IsSuccessStatusCode || tipResponse.Content == null + || addrInfoResponse == null || !addrInfoResponse.IsSuccessStatusCode + || addrInfoResponse.Content == null || addrInfoResponse.Content.Length == 0) + { + _logger.LogWarning("Koios responses from tip and addressinfo are null, empty or have unsuccessful status codes"); + return null; + } + var tip = tipResponse.Content.First(); + var protocolParamsResponse = await epochClient.GetProtocolParameters(tip.Epoch.ToString()); + if (protocolParamsResponse == null || !protocolParamsResponse.IsSuccessStatusCode || protocolParamsResponse.Content == null) + { + _logger.LogWarning("Koios responses from protocol params is null or have unsuccessful status codes"); + return null; + } + _logger.LogInformation( + "Queried Koios {elapsedMs}ms - Epoch: {Epoch}, AbsSlot: {AbsSlot}, SourceAddressUtxoCount: {SourceAddressUtxoCount}", + sw.ElapsedMilliseconds, tip.Epoch, tip.AbsSlot, addrInfoResponse.Content.Length); + + var sourceAddressUtxos = BuildSourceAddressUtxos(addrInfoResponse.Content); + // Inputs TODO: Coin selection? + var txInputs = sourceAddressUtxos; + var consolidatedInputValue = BuildConsolidatedTxInputValue(sourceAddressUtxos, nativeAssetsToMint); + // Outputs + var txOutputs = outputs ?? Array.Empty(); + var consolidatedOutputValue = txOutputs.Select(txOut => txOut.Value).Sum(); + var txChangeOutput = consolidatedInputValue.Subtract(consolidatedOutputValue); + + // Start building transaction body using CardanoSharp + var txBodyBuilder = TransactionBodyBuilder.Create + .SetFee(0) + .SetTtl((uint)tip.AbsSlot + 7200); + // TxInputs + foreach (var txInput in txInputs) + { + txBodyBuilder.AddInput(txInput.TxHash, txInput.OutputIndex); + } + // TxOutputs + foreach (var txOutput in txOutputs) + { + var tokenBundleBuilder = (txOutput.Value.NativeAssets.Length > 0) + ? GetTokenBundleBuilderFromNativeAssets(txOutput.Value.NativeAssets) + : null; + txBodyBuilder.AddOutput(new Address(txOutput.Address), txOutput.Value.Lovelaces, tokenBundleBuilder); + } + // Build Output Change back to source address + var minUtxoLovelace = TxUtils.CalculateMinUtxoLovelace(txChangeOutput, (int)protocolParamsResponse.Content.Single().CoinsPerUtxoWord); + if (txChangeOutput.Lovelaces < minUtxoLovelace) + { + throw new ApplicationException($"Change output does not meet minimum UTxO lovelace requirement of {minUtxoLovelace}"); + } + txBodyBuilder.AddOutput(new Address(sourcePaymentAddress), txChangeOutput.Lovelaces, GetTokenBundleBuilderFromNativeAssets(txChangeOutput.NativeAssets)); + // TxMint + if (nativeAssetsToMint != null && nativeAssetsToMint.Length > 0) + { + // Build Cardano Native Assets from TestResults + var freshMintTokenBundleBuilder = TokenBundleBuilder.Create; + foreach (var newAssetMint in nativeAssetsToMint) + { + freshMintTokenBundleBuilder = freshMintTokenBundleBuilder + .AddToken(newAssetMint.PolicyId.HexToByteArray(), newAssetMint.AssetName.HexToByteArray(), 1); + } + txBodyBuilder.SetMint(freshMintTokenBundleBuilder); + } + // TxWitnesses + var paymentSkey = GetPrivateKeyFromBech32SigningKey(sourcePaymentSkey); + var witnesses = TransactionWitnessSetBuilder.Create + .AddVKeyWitness(paymentSkey.GetPublicKey(false), paymentSkey); + if (policySkeys != null && policySkeys.Any()) + { + foreach (var policySKey in policySkeys) + { + var policyKey = GetPrivateKeyFromBech32SigningKey(policySKey); + witnesses.AddVKeyWitness(policyKey.GetPublicKey(false), policyKey); + } + var policyScriptAllBuilder = GetScriptAllBuilder(policySkeys.Select(GetPrivateKeyFromBech32SigningKey), policyExpirySlot); + witnesses.SetNativeScript(policyScriptAllBuilder); + } + // Build Tx for fee calculation + var txBuilder = TransactionBuilder.Create + .SetBody(txBodyBuilder) + .SetWitnesses(witnesses); + // Metadata + var auxDataBuilder = AuxiliaryDataBuilder.Create; + if (metadata != null && metadata.Any()) + { + var tag = metadata.Keys.First(); + auxDataBuilder = auxDataBuilder.AddMetadata(tag, metadata[tag]); + txBuilder = txBuilder.SetAuxData(auxDataBuilder); + _logger.LogInformation("Build Metadata {txMetadata}", auxDataBuilder); + } + // Calculate and update change Utxo + var protocolParams = protocolParamsResponse.Content.Single(); + var tx = txBuilder.Build(); + var fee = tx.CalculateFee(protocolParams.MinFeeA, protocolParams.MinFeeB); + txBodyBuilder.SetFee(fee); + tx.TransactionBody.TransactionOutputs.Last().Value.Coin -= fee; + var txBytes = tx.Serialize(); + var txHash = HashUtility.Blake2b256(tx.TransactionBody.Serialize(auxDataBuilder.Build())).ToStringHex(); + _logger.LogInformation("Built mint tx {elapnsed}ms", sw.ElapsedMilliseconds); + + // Submit Tx + try + { + sw.Restart(); + using var stream = new MemoryStream(txBytes); + var response = await txClient.Submit(stream).ConfigureAwait(false); + if (!response.IsSuccessStatusCode || response.Content == null) + { + _logger.LogWarning("Failed tx submission status={statusCode}, error={errorContent}", response.Error.StatusCode, response.Error.Content); + return null; + } + var txId = response.Content.TrimStart('"').TrimEnd('"'); + if (txId != txHash) + { + _logger.LogWarning("TxId {txId} from txClient.Submit is different to calculated TxHash {txHash}", txId, txHash); + } + _logger.LogInformation("Submitted mint tx {elapnsed}ms TxId: {txId} ({txBytesLength}bytes)", sw.ElapsedMilliseconds, txId, txBytes.Length); + return txId; + } + catch (ApiException ex) + { + _logger.LogError(ex, "Failed tx submission {error}", ex.Content); + return null; + } + } + + private static UnspentTransactionOutput[] BuildSourceAddressUtxos(AddressInformation[] addrInfoResponse) + { + return addrInfoResponse.Single().UtxoSets + .Select(utxo => new UnspentTransactionOutput( + utxo.TxHash, + utxo.TxIndex, + new AggregateValue( + ulong.Parse(utxo.Value), + utxo.AssetList.Select( + a => new NativeAssetValue( + a.PolicyId, + a.AssetName, + ulong.Parse(a.Quantity))) + .ToArray()))) + .ToArray(); + } + + private static AggregateValue BuildConsolidatedTxInputValue( + UnspentTransactionOutput[] sourceAddressUtxos, + NativeAssetValue[]? nativeAssetsToMint) + { + if (nativeAssetsToMint != null && nativeAssetsToMint.Length > 0) + { + return sourceAddressUtxos + .Select(utxo => utxo.Value) + .Concat(new[] { new AggregateValue(0, nativeAssetsToMint) }) + .Sum(); + } + return sourceAddressUtxos.Select(utxo => utxo.Value).Sum(); + } + + private static ITokenBundleBuilder? GetTokenBundleBuilderFromNativeAssets(NativeAssetValue[] nativeAssets) + { + if (nativeAssets.Length == 0) + return null; + + var tokenBundleBuilder = TokenBundleBuilder.Create; + foreach (var nativeAsset in nativeAssets) + { + tokenBundleBuilder = tokenBundleBuilder.AddToken( + nativeAsset.PolicyId.HexToByteArray(), + nativeAsset.AssetName.HexToByteArray(), + nativeAsset.Quantity); + } + return tokenBundleBuilder; + } + + private static ( + IEpochClient epochClient, + INetworkClient networkClient, + IAddressClient addressClient, + ITransactionClient transactionClient + ) GetKoiosClients(Network network) => + (GetBackendClient(network), + GetBackendClient(network), + GetBackendClient(network), + GetBackendClient(network)); + + public static T GetBackendClient(Network networkType) => + RestService.For(GetBaseUrlForNetwork(networkType)); + + private static string GetBaseUrlForNetwork(Network networkType) => networkType switch + { + Network.Mainnet => "https://api.koios.rest/api/v0", + Network.Testnet => "https://testnet.koios.rest/api/v0", + _ => throw new ArgumentException($"{nameof(networkType)} {networkType} is invalid", nameof(networkType)) + }; + + private static PrivateKey GetPrivateKeyFromBech32SigningKey(string bech32EncodedSigningKey) + { + var keyBytes = Bech32.Decode(bech32EncodedSigningKey, out _, out _); + return new PrivateKey(keyBytes[..64], keyBytes[64..]); + } + + private static IScriptAllBuilder GetScriptAllBuilder( + IEnumerable policySKeys, ulong? policyExpiry = null) + { + var scriptAllBuilder = ScriptAllBuilder.Create; + if (policyExpiry.HasValue) + { + scriptAllBuilder.SetScript( + NativeScriptBuilder.Create.SetInvalidAfter((uint)policyExpiry.Value)); + } + foreach (var policySKey in policySKeys) + { + var policyVKey = policySKey.GetPublicKey(false); + var policyVKeyHash = HashUtility.Blake2b224(policyVKey.Key); + scriptAllBuilder = scriptAllBuilder.SetScript( + NativeScriptBuilder.Create.SetKeyHash(policyVKeyHash)); + } + return scriptAllBuilder; + } + + //public async Task SendAllAsync( + // string sourceAddress, + // string destinationAddress, + // string[] message, + // string sourceAddressSigningkeyCborHex, + // CancellationToken ct = default) + //{ + // var paymentId = Guid.NewGuid(); + + // var sw = Stopwatch.StartNew(); + // var utxosAtSourceAddress = await _utxoRetriever.GetUtxosAtAddressAsync(sourceAddress, ct).ConfigureAwait(false); + // _logger.LogDebug($"{nameof(_utxoRetriever.GetUtxosAtAddressAsync)} completed with {utxosAtSourceAddress.Length} after {sw.ElapsedMilliseconds}ms"); + + // var combinedUtxoValues = utxosAtSourceAddress.SelectMany(u => u.Values) + // .GroupBy(uv => uv.Unit) + // .Select(uvg => new Value(Unit: uvg.Key, Quantity: uvg.Sum(u => u.Quantity))) + // .ToArray(); + + // // Generate payment message metadata + // sw.Restart(); + // var metadataJsonFileName = $"metadata-payment-{paymentId}.json"; + // var metadataJsonPath = Path.Combine(_settings.BasePath, metadataJsonFileName); + // await _metadataGenerator.GenerateMessageMetadataJsonFile(message, metadataJsonPath, ct).ConfigureAwait(false); + // _logger.LogDebug($"{nameof(_metadataGenerator.GenerateMessageMetadataJsonFile)} generated at {metadataJsonPath} after {sw.ElapsedMilliseconds}ms"); + + // // Generate signing key + // var skeyFileName = $"{paymentId}.skey"; + // var skeyPath = Path.Combine(_settings.BasePath, skeyFileName); + // var cliKeyObject = new + // { + // type = "PaymentSigningKeyShelley_ed25519", + // description = "Payment Signing Key", + // cborHex = sourceAddressSigningkeyCborHex + // }; + // await File.WriteAllTextAsync(skeyPath, JsonSerializer.Serialize(cliKeyObject)).ConfigureAwait(false); + // _logger.LogDebug($"Generated yolo signing key at {skeyPath} for {sourceAddress} after {sw.ElapsedMilliseconds}ms"); + + // var txBuildCommand = new TxBuildCommand( + // utxosAtSourceAddress, + // new[] { new TxBuildOutput(destinationAddress, combinedUtxoValues, IsFeeDeducted: true) }, + // Mint: Array.Empty(), + // MintingScriptPath: string.Empty, + // MetadataJsonPath: metadataJsonPath, + // TtlSlot: 0, + // new[] { skeyPath }); + + // sw.Restart(); + // var submissionPayload = await _txBuilder.BuildTxAsync(txBuildCommand, ct).ConfigureAwait(false); + // _logger.LogDebug($"{_txBuilder.GetType()}{nameof(_txBuilder.BuildTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); + + // sw.Restart(); + // var txHash = await _txSubmitter.SubmitTxAsync(submissionPayload, ct).ConfigureAwait(false); + // _logger.LogDebug($"{_txSubmitter.GetType()}{nameof(_txSubmitter.SubmitTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); + // _instrumentor.TrackDependency( + // EventIds.PaymentElapsed, + // sw.ElapsedMilliseconds, + // DateTime.UtcNow, + // nameof(SimpleWalletService), + // string.Empty, + // nameof(SendAllAsync), + // data: JsonSerializer.Serialize(txBuildCommand), + // isSuccessful: true); + // return txHash; + //} + + //public async Task SendValuesAsync( + // string sourceAddress, + // string destinationAddress, + // Value[] values, + // string[] message, + // string sourceAddressSigningkeyCborHex, + // CancellationToken ct = default) + //{ + // var paymentId = Guid.NewGuid(); + + // var sw = Stopwatch.StartNew(); + // var utxosAtSourceAddress = await _utxoRetriever.GetUtxosAtAddressAsync(sourceAddress, ct).ConfigureAwait(false); + // _logger.LogDebug($"{nameof(_utxoRetriever.GetUtxosAtAddressAsync)} completed with {utxosAtSourceAddress.Length} after {sw.ElapsedMilliseconds}ms"); + + // // Validate source address has the values + // var combinedAssetValues = utxosAtSourceAddress.SelectMany(u => u.Values) + // .GroupBy(uv => uv.Unit) + // .Select(uvg => new Value(Unit: uvg.Key, Quantity: uvg.Sum(u => u.Quantity))) + // .ToArray(); + // foreach (var valueToSend in values) + // { + // var combinedUnitValue = combinedAssetValues.FirstOrDefault(u => u.Unit == valueToSend.Unit); + // if (combinedUnitValue == default) + // throw new ArgumentException($"{nameof(values)} utxo does not exist at source address"); + // if (combinedUnitValue.Quantity < valueToSend.Quantity) + // throw new ArgumentException($"{nameof(values)} quantity in source address insufficient for payment"); + // } + + // // Generate payment message metadata + // sw.Restart(); + // var metadataJsonFileName = $"metadata-payment-{paymentId}.json"; + // var metadataJsonPath = Path.Combine(_settings.BasePath, metadataJsonFileName); + // await _metadataGenerator.GenerateMessageMetadataJsonFile(message, metadataJsonPath, ct).ConfigureAwait(false); + // _logger.LogDebug($"{nameof(_metadataGenerator.GenerateMessageMetadataJsonFile)} generated at {metadataJsonPath} after {sw.ElapsedMilliseconds}ms"); + + // sw.Restart(); + // var skeyFileName = $"{paymentId}.skey"; + // var skeyPath = Path.Combine(_settings.BasePath, skeyFileName); + // var cliKeyObject = new + // { + // type = "PaymentSigningKeyShelley_ed25519", + // description = "Payment Signing Key", + // cborHex = sourceAddressSigningkeyCborHex + // }; + // await File.WriteAllTextAsync(skeyPath, JsonSerializer.Serialize(cliKeyObject)).ConfigureAwait(false); + // _logger.LogDebug($"Generated yolo signing key at {skeyPath} for {sourceAddress} after {sw.ElapsedMilliseconds}ms"); + + // // Determine change and build Tx + // var changeValues = combinedAssetValues.SubtractValues(values); + // var txBuildCommand = new TxBuildCommand( + // utxosAtSourceAddress, + // new[] { + // new TxBuildOutput(destinationAddress, values), + // new TxBuildOutput(sourceAddress, changeValues, IsFeeDeducted: true), + // }, + // Mint: Array.Empty(), + // MintingScriptPath: string.Empty, + // MetadataJsonPath: metadataJsonPath, + // TtlSlot: 0, + // new[] { skeyPath }); + + // sw.Restart(); + // var submissionPayload = await _txBuilder.BuildTxAsync(txBuildCommand, ct).ConfigureAwait(false); + // _logger.LogDebug($"{_txBuilder.GetType()}{nameof(_txBuilder.BuildTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); + + // sw.Restart(); + // var txHash = await _txSubmitter.SubmitTxAsync(submissionPayload, ct).ConfigureAwait(false); + // _logger.LogDebug($"{_txSubmitter.GetType()}{nameof(_txSubmitter.SubmitTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); + // _instrumentor.TrackDependency( + // EventIds.PaymentElapsed, + // sw.ElapsedMilliseconds, + // DateTime.UtcNow, + // nameof(SimpleWalletService), + // string.Empty, + // nameof(SendValuesAsync), + // data: JsonSerializer.Serialize(txBuildCommand), + // isSuccessful: true); + // return txHash; + //} +} diff --git a/Src/Lib/TxUtils.cs b/Src/Lib/TxUtils.cs index d4b7805..5e370af 100644 --- a/Src/Lib/TxUtils.cs +++ b/Src/Lib/TxUtils.cs @@ -1,4 +1,5 @@ using Mintsafe.Abstractions; +using System; using System.Collections.Generic; using System.Linq; @@ -26,6 +27,57 @@ static Value SubtractSingleValue(Value lhsValue, Value rhsValue) return diff; } + public static AggregateValue Sum(this IEnumerable values) + { + var lovelaces = 0UL; + var nativeAssets = new Dictionary<(string PolicyId, string AssetNameHex), ulong>(); + foreach (var value in values) + { + lovelaces += value.Lovelaces; + foreach (var nativeAsset in value.NativeAssets) + { + if (nativeAssets.ContainsKey((nativeAsset.PolicyId, nativeAsset.AssetName))) + { + nativeAssets[(nativeAsset.PolicyId, nativeAsset.AssetName)] += nativeAsset.Quantity; + continue; + } + nativeAssets.Add((nativeAsset.PolicyId, nativeAsset.AssetName), nativeAsset.Quantity); + } + } + return new AggregateValue( + lovelaces, + nativeAssets.Select(nav => new NativeAssetValue(nav.Key.PolicyId, nav.Key.AssetNameHex, nav.Value)).ToArray()); + } + + public static AggregateValue Subtract(this AggregateValue lhsValue, AggregateValue rhsValue) + { + static NativeAssetValue SubtractSingleValue(NativeAssetValue lhsValue, NativeAssetValue rhsValue) + { + return rhsValue == default + ? lhsValue + : new NativeAssetValue(lhsValue.PolicyId, lhsValue.AssetName, lhsValue.Quantity - rhsValue.Quantity); + }; + + if (rhsValue.NativeAssets.Length == 0) + return new AggregateValue(lhsValue.Lovelaces - rhsValue.Lovelaces, lhsValue.NativeAssets); + + var missingLhsValues = rhsValue.NativeAssets + .Where(rna => !lhsValue.NativeAssets + .Any(lna => lna.PolicyId == rna.PolicyId && lna.AssetName == rna.AssetName)) + .ToArray(); + if (missingLhsValues.Any()) + throw new ArgumentException("lhsValue is missing Native Assets found on rhsValue", nameof(rhsValue)); + + var nativeAssets = lhsValue.NativeAssets + .Select(lv => SubtractSingleValue( + lv, + rhsValue.NativeAssets.FirstOrDefault( + rv => rv.PolicyId == lv.PolicyId && rv.AssetName == lv.AssetName))) + .Where(na => na.Quantity != 0) + .ToArray(); + return new AggregateValue(lhsValue.Lovelaces - rhsValue.Lovelaces, nativeAssets); + } + public static long CalculateMinUtxoLovelace( Value[] outputValues, int lovelacePerUtxoWord = 34482, @@ -69,4 +121,47 @@ public static long CalculateMinUtxoLovelace( return minUtxoLovelace; } + + public static ulong CalculateMinUtxoLovelace( + AggregateValue txOutBundle, + int lovelacePerUtxoWord = 34482, // utxoCostPerWord in protocol params (could change in the future) + int policyIdSizeBytes = 28, // 224 bit policyID (won't in forseeable future) + bool hasDataHash = false) // for UTxOs with smart contract datum + { + // https://docs.cardano.org/native-tokens/minimum-ada-value-requirement#min-ada-valuecalculation + const int fixedUtxoPrefixWords = 6; + const int fixedUtxoEntryWithoutValueSizeWords = 27; // The static parts of a UTxO: 6 + 7 + 14 words + const int coinSizeWords = 2; // since updated from 0 in docs.cardano.org/native-tokens/minimum-ada-value-requirement + const int adaOnlyUtxoSizeWords = fixedUtxoEntryWithoutValueSizeWords + coinSizeWords; + const int fixedPerTokenCost = 12; + const int byteRoundUpAddition = 7; + const int bytesPerWord = 8; // One "word" is 8 bytes (64-bit) + const int fixedDataHashSizeWords = 10; + + var isAdaOnly = txOutBundle.NativeAssets.Length == 0; + if (isAdaOnly) + return (ulong)lovelacePerUtxoWord * adaOnlyUtxoSizeWords; // 999978 lovelaces or 0.999978 ADA + + // Get distinct policyIDs and assetNames + var policyIds = new HashSet(); + var assetNameHexadecimals = new HashSet(); + foreach (var customToken in txOutBundle.NativeAssets) + { + policyIds.Add(customToken.PolicyId); + assetNameHexadecimals.Add(customToken.AssetName); + } + + // Calculate (prefix + (numDistinctPids * 28(policyIdSizeBytes) + numTokens * 12(fixedPerTokenCost) + tokensNameLen + 7) ~/8) + var tokensNameLen = assetNameHexadecimals.Sum(an => an.Length) / 2; // 2 hexadecimal chars = 1 Byte + var valueSizeWords = fixedUtxoPrefixWords + ( + (policyIds.Count * policyIdSizeBytes) + + (txOutBundle.NativeAssets.Length * fixedPerTokenCost) + + tokensNameLen + byteRoundUpAddition) / bytesPerWord; + var dataHashSizeWords = hasDataHash ? fixedDataHashSizeWords : 0; + + var minUtxoLovelace = lovelacePerUtxoWord + * (fixedUtxoEntryWithoutValueSizeWords + valueSizeWords + dataHashSizeWords); + + return (ulong)minUtxoLovelace; + } } diff --git a/Src/Lib/YoloWalletService.cs b/Src/Lib/YoloWalletService.cs deleted file mode 100644 index f63dc0a..0000000 --- a/Src/Lib/YoloWalletService.cs +++ /dev/null @@ -1,210 +0,0 @@ -using Microsoft.Extensions.Logging; -using Mintsafe.Abstractions; -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace Mintsafe.Lib; - -/// -/// A TOTALLY UNSAFE wallet API used for quicker testnet validation. -/// The signing keys behind the source address are passed in raw hex format -/// Take the cboxHex field value after running "cat payment.skey" -/// -public interface IYoloWalletService -{ - Task SendAllAsync( - string sourceAddress, - string destinationAddress, - string[] message, - string sourceAddressSigningkeyCborHex, - CancellationToken ct = default); - - Task SendValuesAsync( - string sourceAddress, - string destinationAddress, - Value[] values, - string[] message, - string sourceAddressSigningkeyCborHex, - CancellationToken ct = default); -} - -public class YoloWalletService : IYoloWalletService -{ - private readonly ILogger _logger; - private readonly IInstrumentor _instrumentor; - private readonly MintsafeAppSettings _settings; - private readonly IUtxoRetriever _utxoRetriever; - private readonly IMetadataFileGenerator _metadataGenerator; - private readonly ITxSubmitter _txSubmitter; - private readonly ITxBuilder _txBuilder; - - public YoloWalletService( - ILogger logger, - IInstrumentor instrumentor, - MintsafeAppSettings settings, - IUtxoRetriever utxoRetriever, - IMetadataFileGenerator metadataGenerator, - ITxBuilder txBuilder, - ITxSubmitter txSubmitter) - { - _logger = logger; - _instrumentor = instrumentor; - _settings = settings; - _utxoRetriever = utxoRetriever; - _metadataGenerator = metadataGenerator; - _txBuilder = txBuilder; - _txSubmitter = txSubmitter; - } - - public async Task SendAllAsync( - string sourceAddress, - string destinationAddress, - string[] message, - string sourceAddressSigningkeyCborHex, - CancellationToken ct = default) - { - var paymentId = Guid.NewGuid(); - - var sw = Stopwatch.StartNew(); - var utxosAtSourceAddress = await _utxoRetriever.GetUtxosAtAddressAsync(sourceAddress, ct).ConfigureAwait(false); - _logger.LogDebug($"{nameof(_utxoRetriever.GetUtxosAtAddressAsync)} completed with {utxosAtSourceAddress.Length} after {sw.ElapsedMilliseconds}ms"); - - var combinedUtxoValues = utxosAtSourceAddress.SelectMany(u => u.Values) - .GroupBy(uv => uv.Unit) - .Select(uvg => new Value(Unit: uvg.Key, Quantity: uvg.Sum(u => u.Quantity))) - .ToArray(); - - // Generate payment message metadata - sw.Restart(); - var metadataJsonFileName = $"metadata-payment-{paymentId}.json"; - var metadataJsonPath = Path.Combine(_settings.BasePath, metadataJsonFileName); - await _metadataGenerator.GenerateMessageMetadataJsonFile(message, metadataJsonPath, ct).ConfigureAwait(false); - _logger.LogDebug($"{nameof(_metadataGenerator.GenerateMessageMetadataJsonFile)} generated at {metadataJsonPath} after {sw.ElapsedMilliseconds}ms"); - - // Generate signing key - var skeyFileName = $"{paymentId}.skey"; - var skeyPath = Path.Combine(_settings.BasePath, skeyFileName); - var cliKeyObject = new - { - type = "PaymentSigningKeyShelley_ed25519", - description = "Payment Signing Key", - cborHex = sourceAddressSigningkeyCborHex - }; - await File.WriteAllTextAsync(skeyPath, JsonSerializer.Serialize(cliKeyObject)).ConfigureAwait(false); - _logger.LogDebug($"Generated yolo signing key at {skeyPath} for {sourceAddress} after {sw.ElapsedMilliseconds}ms"); - - var txBuildCommand = new TxBuildCommand( - utxosAtSourceAddress, - new[] { new TxBuildOutput(destinationAddress, combinedUtxoValues, IsFeeDeducted: true) }, - Mint: Array.Empty(), - MintingScriptPath: string.Empty, - MetadataJsonPath: metadataJsonPath, - TtlSlot: 0, - new[] { skeyPath }); - - sw.Restart(); - var submissionPayload = await _txBuilder.BuildTxAsync(txBuildCommand, ct).ConfigureAwait(false); - _logger.LogDebug($"{_txBuilder.GetType()}{nameof(_txBuilder.BuildTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); - - sw.Restart(); - var txHash = await _txSubmitter.SubmitTxAsync(submissionPayload, ct).ConfigureAwait(false); - _logger.LogDebug($"{_txSubmitter.GetType()}{nameof(_txSubmitter.SubmitTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); - _instrumentor.TrackDependency( - EventIds.PaymentElapsed, - sw.ElapsedMilliseconds, - DateTime.UtcNow, - nameof(YoloWalletService), - string.Empty, - nameof(SendAllAsync), - data: JsonSerializer.Serialize(txBuildCommand), - isSuccessful: true); - return txHash; - } - - public async Task SendValuesAsync( - string sourceAddress, - string destinationAddress, - Value[] values, - string[] message, - string sourceAddressSigningkeyCborHex, - CancellationToken ct = default) - { - var paymentId = Guid.NewGuid(); - - var sw = Stopwatch.StartNew(); - var utxosAtSourceAddress = await _utxoRetriever.GetUtxosAtAddressAsync(sourceAddress, ct).ConfigureAwait(false); - _logger.LogDebug($"{nameof(_utxoRetriever.GetUtxosAtAddressAsync)} completed with {utxosAtSourceAddress.Length} after {sw.ElapsedMilliseconds}ms"); - - // Validate source address has the values - var combinedAssetValues = utxosAtSourceAddress.SelectMany(u => u.Values) - .GroupBy(uv => uv.Unit) - .Select(uvg => new Value(Unit: uvg.Key, Quantity: uvg.Sum(u => u.Quantity))) - .ToArray(); - foreach (var valueToSend in values) - { - var combinedUnitValue = combinedAssetValues.FirstOrDefault(u => u.Unit == valueToSend.Unit); - if (combinedUnitValue == default) - throw new ArgumentException($"{nameof(values)} utxo does not exist at source address"); - if (combinedUnitValue.Quantity < valueToSend.Quantity) - throw new ArgumentException($"{nameof(values)} quantity in source address insufficient for payment"); - } - - // Generate payment message metadata - sw.Restart(); - var metadataJsonFileName = $"metadata-payment-{paymentId}.json"; - var metadataJsonPath = Path.Combine(_settings.BasePath, metadataJsonFileName); - await _metadataGenerator.GenerateMessageMetadataJsonFile(message, metadataJsonPath, ct).ConfigureAwait(false); - _logger.LogDebug($"{nameof(_metadataGenerator.GenerateMessageMetadataJsonFile)} generated at {metadataJsonPath} after {sw.ElapsedMilliseconds}ms"); - - sw.Restart(); - var skeyFileName = $"{paymentId}.skey"; - var skeyPath = Path.Combine(_settings.BasePath, skeyFileName); - var cliKeyObject = new - { - type = "PaymentSigningKeyShelley_ed25519", - description = "Payment Signing Key", - cborHex = sourceAddressSigningkeyCborHex - }; - await File.WriteAllTextAsync(skeyPath, JsonSerializer.Serialize(cliKeyObject)).ConfigureAwait(false); - _logger.LogDebug($"Generated yolo signing key at {skeyPath} for {sourceAddress} after {sw.ElapsedMilliseconds}ms"); - - // Determine change and build Tx - var changeValues = combinedAssetValues.SubtractValues(values); - var txBuildCommand = new TxBuildCommand( - utxosAtSourceAddress, - new[] { - new TxBuildOutput(destinationAddress, values), - new TxBuildOutput(sourceAddress, changeValues, IsFeeDeducted: true), - }, - Mint: Array.Empty(), - MintingScriptPath: string.Empty, - MetadataJsonPath: metadataJsonPath, - TtlSlot: 0, - new[] { skeyPath }); - - sw.Restart(); - var submissionPayload = await _txBuilder.BuildTxAsync(txBuildCommand, ct).ConfigureAwait(false); - _logger.LogDebug($"{_txBuilder.GetType()}{nameof(_txBuilder.BuildTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); - - sw.Restart(); - var txHash = await _txSubmitter.SubmitTxAsync(submissionPayload, ct).ConfigureAwait(false); - _logger.LogDebug($"{_txSubmitter.GetType()}{nameof(_txSubmitter.SubmitTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); - _instrumentor.TrackDependency( - EventIds.PaymentElapsed, - sw.ElapsedMilliseconds, - DateTime.UtcNow, - nameof(YoloWalletService), - string.Empty, - nameof(SendValuesAsync), - data: JsonSerializer.Serialize(txBuildCommand), - isSuccessful: true); - return txHash; - } - - -} diff --git a/Src/SaleWorker/Program.cs b/Src/SaleWorker/Program.cs index feeb024..d44cfac 100644 --- a/Src/SaleWorker/Program.cs +++ b/Src/SaleWorker/Program.cs @@ -106,33 +106,34 @@ services.AddSingleton(); // Fakes - //services.AddSingleton(); - //services.AddSingleton(); - //services.AddSingleton(); - //services.AddSingleton(); - //services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); //// Reals + //services.AddSingleton(); //services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddAzureClients(clientBuilder => - { - var connectionString = hostContext.Configuration.GetSection("Storage:ConnectionString").Value; - clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyCollection); - clientBuilder.AddTableClient(connectionString, Constants.TableNames.Nifty); - clientBuilder.AddTableClient(connectionString, Constants.TableNames.Sale); - clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyFile); - }); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); + //services.AddAzureClients(clientBuilder => + //{ + // var connectionString = hostContext.Configuration.GetSection("Storage:ConnectionString").Value; + // clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyCollection); + // clientBuilder.AddTableClient(connectionString, Constants.TableNames.Nifty); + // clientBuilder.AddTableClient(connectionString, Constants.TableNames.Sale); + // clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyFile); + //}); + //services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); }) .Build(); diff --git a/Src/SaleWorker/appsettings.Local.json b/Src/SaleWorker/appsettings.Local.json index e5e2d9a..0575a1f 100644 --- a/Src/SaleWorker/appsettings.Local.json +++ b/Src/SaleWorker/appsettings.Local.json @@ -18,7 +18,7 @@ }, "ApplicationInsights": { "Enabled": false, - "InstrumentationKey": "9ca55025-e271-4eb1-860c-ea9e3de84978" + "InstrumentationKey": "" }, "Storage": { "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=safetneunmscd;AccountKey=FGOQi0QnA5P8J1O+vrj3fBV7q0g3L22QvmK8nna6tmCjKq4X/VCk6UHtPak3xPHrgHDBeszQ9mjFLKCV4FQdZA==;EndpointSuffix=core.windows.net" diff --git a/Src/WasmApp/Pages/YoloWallet.razor b/Src/WasmApp/Pages/YoloWallet.razor index 521a9fa..ef09875 100644 --- a/Src/WasmApp/Pages/YoloWallet.razor +++ b/Src/WasmApp/Pages/YoloWallet.razor @@ -53,7 +53,7 @@ [Inject] private IAddressUtxoService AddressUtxoService { get; set; } [Inject] - private IYoloPaymentService YoloPaymentService { get; set; } + private ISimplePaymentService SimplePaymentService { get; set; } private Utxo[]? Utxos { get; set; } @@ -101,16 +101,16 @@ throw new ArgumentNullException(nameof(Quantity)); } - var payment = new YoloPayment - { - DestinationAddress = DestinationAddress, - SourceAddress = SourceAddress, - Message = new[] { Message }, - SigningKeyCborHex = SigningKeyCborHex, - Values = new[] { new Value(Unit, Quantity.Value) } - }; + //var payment = new YoloPayment + // { + // DestinationAddress = DestinationAddress, + // SourceAddress = SourceAddress, + // Message = new[] { Message }, + // SigningKeyCborHex = SigningKeyCborHex, + // Values = new[] { new Value(Unit, Quantity.Value) } + // }; - var txId = await YoloPaymentService.MakePaymentAsync(payment); + //var txId = await SimplePaymentService.MakePaymentAsync(payment); } } \ No newline at end of file diff --git a/Src/WasmApp/Program.cs b/Src/WasmApp/Program.cs index b1b225c..648d1ce 100644 --- a/Src/WasmApp/Program.cs +++ b/Src/WasmApp/Program.cs @@ -26,6 +26,6 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(address) }); builder.Services.AddScoped(); -builder.Services.AddScoped(); +//builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/Src/WasmApp/Services/SimplePaymentService.cs b/Src/WasmApp/Services/SimplePaymentService.cs new file mode 100644 index 0000000..09a5bf4 --- /dev/null +++ b/Src/WasmApp/Services/SimplePaymentService.cs @@ -0,0 +1,35 @@ +using Mintsafe.Abstractions; +using System.Net.Http.Json; + +namespace Mintsafe.WasmApp.Services; + +public record SimplePayment +{ + public string SourcePaymentAddress { get; init; } + public string SourcePaymentAddressSigningKey { get; init; } + public string DestinationPaymentAddress { get; init; } + public ulong PaymentLovelaces { get; init; } + public string Comment { get; init; } +} + +public interface ISimplePaymentService +{ + Task MakePaymentAsync(SimplePayment yoloPayment); +} + +public class SimplePaymentService : ISimplePaymentService +{ + private readonly HttpClient _httpClient; + + public SimplePaymentService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task MakePaymentAsync(SimplePayment simplePayment) + { + var response = await _httpClient.PostAsJsonAsync($"SimplePayment", simplePayment); + var txId = await response.Content.ReadAsStringAsync(); + return txId; + } +} diff --git a/Src/WasmApp/Services/YoloPaymentService.cs b/Src/WasmApp/Services/YoloPaymentService.cs deleted file mode 100644 index 69aa325..0000000 --- a/Src/WasmApp/Services/YoloPaymentService.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Mintsafe.Abstractions; -using System.Net.Http.Json; - -namespace Mintsafe.WasmApp.Services -{ - public interface IYoloPaymentService - { - Task MakePaymentAsync(YoloPayment yoloPayment); - } - - public class YoloPaymentService : IYoloPaymentService - { - private readonly HttpClient _httpClient; - - public YoloPaymentService(HttpClient httpClient) - { - _httpClient = httpClient; - } - - public async Task MakePaymentAsync(YoloPayment yoloPayment) - { - var response = await _httpClient.PostAsJsonAsync($"YoloPayment", yoloPayment); - var txId = await response.Content.ReadAsStringAsync(); - return txId; - } - } -} diff --git a/Src/WebApi/Controllers/DataAccessTestController.cs b/Src/WebApi/Controllers/DataAccessTestController.cs index f3d3822..6e406a3 100644 --- a/Src/WebApi/Controllers/DataAccessTestController.cs +++ b/Src/WebApi/Controllers/DataAccessTestController.cs @@ -19,7 +19,7 @@ public DataAccessTestController(INiftyDataService dataService) } [HttpGet("{collectionId}")] - public async Task Get(Guid collectionId, CancellationToken ct) + public async Task Get(Guid collectionId, CancellationToken ct) { var collectionAggregate = await _dataService.GetCollectionAggregateAsync(collectionId, ct); @@ -32,14 +32,14 @@ public async Task Post(CancellationToken ct) var collectionId = Guid.NewGuid(); var niftyCollection = new NiftyCollection(collectionId, "a", "name", "desc", true, "", new[] { "a", "b" }, - DateTime.UtcNow, DateTime.UtcNow, 5); + DateTime.UtcNow, DateTime.UtcNow, 5, new Royalty(5, "lol")); var niftyId = Guid.NewGuid(); var niftyFile = new NiftyFile(Guid.NewGuid(), niftyId, "file1.file", "image/jpeg", "http://url.com", "hash"); var nifty = new Nifty(niftyId, collectionId, true, "file.jpg", "file", "desc", new[] { "a", "b" }, - "http://", "img", new []{ niftyFile }, DateTime.UtcNow, new Royalty(5, "lol"), "v1", + "http://", "img", new []{ niftyFile }, DateTime.UtcNow, "v1", new KeyValuePair[] { new("a", "b"), diff --git a/Src/WebApi/Controllers/SimplePaymentController.cs b/Src/WebApi/Controllers/SimplePaymentController.cs new file mode 100644 index 0000000..a8a1086 --- /dev/null +++ b/Src/WebApi/Controllers/SimplePaymentController.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Mintsafe.Abstractions; +using Mintsafe.Lib; +using System.Threading; +using System.Threading.Tasks; + +namespace Mintsafe.WebApi.Controllers +{ + [ApiController] + [Route("[controller]")] + public class SimplePaymentController : ControllerBase + { + private readonly ILogger _logger; + private readonly ISimpleWalletService _walletService; + + public SimplePaymentController( + ILogger logger, + ISimpleWalletService walletService) + { + _logger = logger; + _walletService = walletService; + } + + //[HttpPost] + //public async Task Post(YoloPayment yoloPayment, CancellationToken ct) + //{ + // if (yoloPayment.SourceAddress == null) + // return BadRequest(nameof(yoloPayment.SourceAddress)); + // if (yoloPayment.DestinationAddress == null) + // return BadRequest(nameof(yoloPayment.DestinationAddress)); + // if (yoloPayment.Values == null) + // return BadRequest(nameof(yoloPayment.Values)); + // if (yoloPayment.Message == null) + // return BadRequest(nameof(yoloPayment.Message)); + // if (yoloPayment.SigningKeyCborHex == null) + // return BadRequest(nameof(yoloPayment.SigningKeyCborHex)); + + // var txHash = await _walletService.SendValuesAsync( + // yoloPayment.SourceAddress, + // yoloPayment.DestinationAddress, + // yoloPayment.Values, + // yoloPayment.Message, + // yoloPayment.SigningKeyCborHex, + // ct); + + // return Accepted(txHash); + //} + } +} diff --git a/Src/WebApi/Controllers/YoloPaymentController.cs b/Src/WebApi/Controllers/YoloPaymentController.cs deleted file mode 100644 index 1118a56..0000000 --- a/Src/WebApi/Controllers/YoloPaymentController.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Mintsafe.Abstractions; -using Mintsafe.Lib; -using System.Threading; -using System.Threading.Tasks; - -namespace Mintsafe.WebApi.Controllers -{ - [ApiController] - [Route("[controller]")] - public class YoloPaymentController : ControllerBase - { - private readonly ILogger _logger; - private readonly IYoloWalletService _walletService; - - public YoloPaymentController( - ILogger logger, - IYoloWalletService walletService) - { - _logger = logger; - _walletService = walletService; - } - - [HttpPost] - public async Task Post(YoloPayment yoloPayment, CancellationToken ct) - { - if (yoloPayment.SourceAddress == null) - return BadRequest(nameof(yoloPayment.SourceAddress)); - if (yoloPayment.DestinationAddress == null) - return BadRequest(nameof(yoloPayment.DestinationAddress)); - if (yoloPayment.Values == null) - return BadRequest(nameof(yoloPayment.Values)); - if (yoloPayment.Message == null) - return BadRequest(nameof(yoloPayment.Message)); - if (yoloPayment.SigningKeyCborHex == null) - return BadRequest(nameof(yoloPayment.SigningKeyCborHex)); - - var txHash = await _walletService.SendValuesAsync( - yoloPayment.SourceAddress, - yoloPayment.DestinationAddress, - yoloPayment.Values, - yoloPayment.Message, - yoloPayment.SigningKeyCborHex, - ct); - - return Accepted(txHash); - } - } -} diff --git a/Src/WebApi/Mintsafe.WebApi.csproj b/Src/WebApi/Mintsafe.WebApi.csproj index 9a8697c..75e3580 100644 --- a/Src/WebApi/Mintsafe.WebApi.csproj +++ b/Src/WebApi/Mintsafe.WebApi.csproj @@ -6,11 +6,11 @@ - - - - - + + + + + diff --git a/Src/WebApi/Program.cs b/Src/WebApi/Program.cs index f6d1d9c..8618504 100644 --- a/Src/WebApi/Program.cs +++ b/Src/WebApi/Program.cs @@ -55,7 +55,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Data Access builder.Services.AddSingleton(); diff --git a/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs b/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs index 4fa694b..6693e3e 100644 --- a/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs +++ b/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs @@ -1,5 +1,6 @@ using System; using FluentAssertions; +using Mintsafe.Abstractions; using Mintsafe.DataAccess.Mappers; using Mintsafe.DataAccess.Models; using Xunit; @@ -14,7 +15,7 @@ public void Map_Dto_Correctly() var now = DateTime.UtcNow; var rowKey = Guid.NewGuid(); - var niftyCollection = new NiftyCollection() + var niftyCollection = new DataAccess.Models.NiftyCollection() { RowKey = rowKey.ToString(), PartitionKey = "1", @@ -26,7 +27,9 @@ public void Map_Dto_Correctly() Publishers = new[] { "Me", "You" }, CreatedAt = now, LockedAt = now.AddDays(-1), - SlotExpiry = 5 + SlotExpiry = 5, + RoyaltyPortion = 0.1, + RoyaltyAddress = "addr1x" }; var model = NiftyCollectionMapper.Map(niftyCollection); @@ -42,6 +45,8 @@ public void Map_Dto_Correctly() model.CreatedAt.Should().Be(now); model.LockedAt.Should().Be(now.AddDays(-1)); model.SlotExpiry.Should().Be(5); + model.Royalty.PortionOfSale.Should().Be(0.1); + model.Royalty.Address.Should().Be("addr1x"); } [Fact] @@ -51,7 +56,7 @@ public void Map_Model_Correctly() var rowKey = Guid.NewGuid(); var niftyCollection = new Abstractions.NiftyCollection( - rowKey, + rowKey, "3", "Name", "Description", @@ -60,8 +65,8 @@ public void Map_Model_Correctly() new[] { "Me", "You" }, now, now.AddDays(-1), - 5 - ); + 5, + new Royalty(0, string.Empty)); var model = NiftyCollectionMapper.Map(niftyCollection); @@ -77,6 +82,8 @@ public void Map_Model_Correctly() model.CreatedAt.Should().Be(now); model.LockedAt.Should().Be(now.AddDays(-1)); model.SlotExpiry.Should().Be(5); + model.RoyaltyAddress.Should().BeEmpty(); + model.RoyaltyPortion.Should().Be(0); } } } diff --git a/Tests/DataAccess.UnitTests/Mappers/NiftyMapperShould.cs b/Tests/DataAccess.UnitTests/Mappers/NiftyMapperShould.cs index f4fa2cf..3872852 100644 --- a/Tests/DataAccess.UnitTests/Mappers/NiftyMapperShould.cs +++ b/Tests/DataAccess.UnitTests/Mappers/NiftyMapperShould.cs @@ -33,8 +33,6 @@ public void Map_Dto_Correctly() MediaType = "jpeg", CreatedAt = now, Version = "Version", - RoyaltyPortion = 1.0, - RoyaltyAddress = "RoyaltyAddress", Attributes = new List>() { new("key", "value") } }; @@ -66,8 +64,6 @@ public void Map_Dto_Correctly() model.Image.Should().Be("Image"); model.MediaType.Should().Be("jpeg"); model.CreatedAt.Should().Be(now); - model.Royalty.Address.Should().Be("RoyaltyAddress"); - model.Royalty.PortionOfSale.Should().Be(1.0); model.Version.Should().Be("Version"); model.Attributes.Should().BeEquivalentTo(new List>() { new("key", "value") }); @@ -86,7 +82,7 @@ public void Map_Model_Correctly() var rowKey = Guid.NewGuid(); var niftyId = Guid.NewGuid(); var collectionId = Guid.NewGuid(); - var now = DateTime.Now; + var now = DateTime.UtcNow; var nifty = new Abstractions.Nifty( rowKey, @@ -109,7 +105,6 @@ public void Map_Model_Correctly() ) }, now, - new Royalty(1.0, "RoyaltyAddress"), "Version", new KeyValuePair[] { new("key", "value") } ); @@ -128,8 +123,6 @@ public void Map_Model_Correctly() model.MediaType.Should().Be("jpeg"); model.CreatedAt.Should().Be(now); model.Version.Should().Be("Version"); - model.RoyaltyAddress.Should().Be("RoyaltyAddress"); - model.RoyaltyPortion.Should().Be(1.0); model.Attributes.Should().BeEquivalentTo(new List>() { new("key", "value") }); } } diff --git a/Tests/DataAccess.UnitTests/Mappers/SaleMapperShould.cs b/Tests/DataAccess.UnitTests/Mappers/SaleMapperShould.cs index 4605060..d6629a4 100644 --- a/Tests/DataAccess.UnitTests/Mappers/SaleMapperShould.cs +++ b/Tests/DataAccess.UnitTests/Mappers/SaleMapperShould.cs @@ -24,6 +24,7 @@ public void Map_Dto_Correctly() Description = "Description", LovelacesPerToken = 1, SaleAddress = "SaleAddress", + CreatorAddress = "CreatorAddress", ProceedsAddress = "ProceedsAddress", TotalReleaseQuantity = 5, MaxAllowedPurchaseQuantity = 2, @@ -52,7 +53,7 @@ public void Map_Model_Correctly() { var rowKey = Guid.NewGuid(); var partitionKey = Guid.NewGuid(); - var now = DateTime.Now; + var now = DateTime.UtcNow; var sale = new Abstractions.Sale( rowKey, diff --git a/Tests/DataAccess.UnitTests/Mintsafe.DataAccess.UnitTests.csproj b/Tests/DataAccess.UnitTests/Mintsafe.DataAccess.UnitTests.csproj index 79474c1..76550e1 100644 --- a/Tests/DataAccess.UnitTests/Mintsafe.DataAccess.UnitTests.csproj +++ b/Tests/DataAccess.UnitTests/Mintsafe.DataAccess.UnitTests.csproj @@ -10,15 +10,15 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Tests/DataAccess.UnitTests/Repositories/NiftyRepositoryShould.cs b/Tests/DataAccess.UnitTests/Repositories/NiftyRepositoryShould.cs index 1d6d200..dd82bdd 100644 --- a/Tests/DataAccess.UnitTests/Repositories/NiftyRepositoryShould.cs +++ b/Tests/DataAccess.UnitTests/Repositories/NiftyRepositoryShould.cs @@ -66,7 +66,7 @@ public async Task Call_UpdateEntityAsync_When_UpdateOneAsync_Is_Called() [Fact] - public async Task Call_AddEntityAsync__When_InsertOneAsync_Is_Called() + public async Task Call_AddEntityAsync_When_InsertOneAsync_Is_Called() { var fixture = new Fixture().Build().Without(x => x.AttributesAsString).Without(x => x.CreatorsAsString); diff --git a/Tests/Lib.UnitTests/BlockfrostUtxoRetrieverShould.cs b/Tests/Lib.UnitTests/BlockfrostUtxoRetrieverShould.cs new file mode 100644 index 0000000..e911946 --- /dev/null +++ b/Tests/Lib.UnitTests/BlockfrostUtxoRetrieverShould.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Text; +using Mintsafe.Abstractions; +using Moq; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using System.Threading; +using FluentAssertions; + +namespace Mintsafe.Lib.UnitTests +{ + public class BlockfrostUtxoRetrieverShould + { + private readonly BlockfrostUtxoRetriever _bfUtxoRetriever; + private readonly Mock _mockBlockfrostClient; + + public BlockfrostUtxoRetrieverShould() + { + _mockBlockfrostClient = new Mock(); + _bfUtxoRetriever = new BlockfrostUtxoRetriever(NullLogger.Instance, _mockBlockfrostClient.Object); + } + + [Theory] + [InlineData("d29bbb14dbe448eda8156f5439335ce6c800f39f4812dfc6f8293274871d6e52", 0, "540f107c7a3df20d2111a41c3bc407cce3e63c10c8dd673d51a02c22434f4e4431", "1", "2172289", + "540f107c7a3df20d2111a41c3bc407cce3e63c10c8dd673d51a02c22.434f4e4431", 1, 2172289)] + public async Task Should_Map_Utxo_Values_Correctly( + string bfTxHash, int bfOutputIndex, string bfNativeAssetUnit, string bfNativeAssetQuantity, string bfLovelaceQuantity, + string expectedNativeAssetUnit, long expectedNativeAssetQuantity, long expectedLovelaceQuantity) + { + _mockBlockfrostClient.Setup(m => m.GetUtxosAtAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { + new BlockFrostAddressUtxo + { + Tx_hash = bfTxHash, + Output_index = bfOutputIndex, + Amount = new[] + { + new BlockFrostValue + { + Unit = "lovelace", + Quantity = bfLovelaceQuantity + }, + new BlockFrostValue + { + Unit = bfNativeAssetUnit, + Quantity = bfNativeAssetQuantity + }, + } + } + }); + + var utxos = await _bfUtxoRetriever.GetUtxosAtAddressAsync("addr1qy3y89nnzdqs4fmqv49fmpqw24hjheen3ce7tch082hh6x7nwwgg06dngunf9ea4rd7mu9084sd3km6z56rqd7e04ylslhzn9h", CancellationToken.None); + + Assert.NotNull(utxos); + utxos.Length.Should().Be(1); + var utxo = utxos[0]; + utxo.Values[0].Unit.Should().Be("lovelace"); + utxo.Values[0].Quantity.Should().Be(expectedLovelaceQuantity); + utxo.Values[1].Unit.Should().Be(expectedNativeAssetUnit); + utxo.Values[1].Quantity.Should().Be(expectedNativeAssetQuantity); + } + } +} diff --git a/Tests/Lib.UnitTests/FakeGenerator.cs b/Tests/Lib.UnitTests/FakeGenerator.cs index d221f34..82ce4b6 100644 --- a/Tests/Lib.UnitTests/FakeGenerator.cs +++ b/Tests/Lib.UnitTests/FakeGenerator.cs @@ -29,11 +29,12 @@ public static NiftyCollection GenerateCollection( Name: "GREATARTIST", Description: "Top secret artist", IsActive: true, - Publishers: new[] { "topsecret", "mintsafe.io" }, BrandImage: "ipfs://cid", + Publishers: new[] { "mintsafe.io" }, CreatedAt: new DateTime(2021, 9, 4, 0, 0, 0, DateTimeKind.Utc), LockedAt: new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), - SlotExpiry: 44674366); + SlotExpiry: 44674366, + Royalty: new Royalty(0, String.Empty)); } public static List GenerateTokens(int mintableTokenCount) @@ -51,7 +52,29 @@ public static List GenerateTokens(int mintableTokenCount) "image/png", Array.Empty(), DateTime.UtcNow, - new Royalty(0, string.Empty), + "1.0", + Array.Empty>())) + .ToList(); + } + + public static List GenerateOnChainTokens( + int mintableTokenCount, string onChainImageSrc, string onChainFileSrc) + { + return Enumerable.Range(0, mintableTokenCount) + .Select(i => new Nifty( + Guid.NewGuid(), + Guid.NewGuid(), + true, + $"Token{i}", + $"Token {i}", + $"Token {i} Description", + new[] { "mintsafe.io" }, + onChainImageSrc, + "image/png", + string.IsNullOrEmpty(onChainFileSrc) + ? Array.Empty() + : new[] { new NiftyFile(Guid.NewGuid(), Guid.NewGuid(), "File", "image/jpg", onChainFileSrc) }, + DateTime.UtcNow, "1.0", Array.Empty>())) .ToList(); diff --git a/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs b/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs index 741b9ce..3942e81 100644 --- a/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs +++ b/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs @@ -1,7 +1,9 @@ using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; +using Mintsafe.Abstractions; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text.Json; using Xunit; @@ -74,4 +76,153 @@ public void Generate_The_Right_Json_With_Correct_Token_Metadata(int nftCount) } } } + + [Theory] + [InlineData(1, "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG", null)] + [InlineData(2, null, "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG")] + [InlineData(4, "", "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG")] + [InlineData(15, "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG", "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG")] + public void Generate_The_Right_Json_With_Correct_Token_OnChain_Metadata(int nftCount, string onChainImage, string onChainFile) + { + var collection = GenerateCollection(); + var tokens = GenerateOnChainTokens(nftCount, onChainImage, onChainFile).ToArray(); + + var json = _metadataJsonBuilder.GenerateNftStandardJson( + tokens, collection); + + var deserialised = JsonSerializer.Deserialize>>>(json, SerialiserOptions); + + deserialised.Should().NotBeNull(); +#pragma warning disable CS8602 // Dereference of a possibly null reference. + var policyAssets = deserialised["721"][collection.PolicyId]; +#pragma warning restore CS8602 // Dereference of a possibly null reference. + + policyAssets.Keys.Count.Should().Be(nftCount); + foreach (var token in tokens) + { + var asset = policyAssets[token.AssetName]; + asset.Should().NotBeNull(); + asset.Name.Should().Be(token.Name); + asset.Description.Should().Be(token.Description); + asset.Creators.Should().BeEquivalentTo(token.Creators); + asset.Publishers.Should().BeEquivalentTo(collection.Publishers); + if (!string.IsNullOrEmpty(token.Image)) + { + asset.Image.Should().BeEquivalentTo(MetadataJsonBuilder.SplitStringToChunks(token.Image)); + } + asset.MediaType.Should().Be(token.MediaType); + foreach (var file in token.Files) + { + if (asset.Files == null) + throw new Exception("Files cannot be null"); + var assetFile = asset.Files.First(f => f.Name == file.Name); + assetFile.Name.Should().Be(file.Name); + assetFile.Src.Should().BeEquivalentTo(MetadataJsonBuilder.SplitStringToChunks(file.Url)); + assetFile.MediaType.Should().Be(file.MediaType); + assetFile.Hash.Should().Be(file.FileHash); + } + } + } + +// [Fact] +// public void Generate_The_Right_Json_With_Correct_Token_OnChain_Metadata() +// { +// var collection = GenerateCollection("9dadb20a-4996-446e-a655-bc6668cfa635", policyId: "d92b380b5413b76202056eea98b6bf579d52a54a44688c1f7f97b823"); +// var collectionId = Guid.Parse("9dadb20a-4996-446e-a655-bc6668cfa635"); +// var createdAt = DateTime.UtcNow; + +// var nft1 = new Nifty( +// Id: Guid.Parse("519a0911-e4c4-455e-aea6-48fd24fd766a"), CollectionId: collectionId, IsMintable: true, +// AssetName: "ticket_chicago_01", Name: "Chicago Concert Ticket #01", Description: "Ticket for Chicago Concert #01", Creators: new[] { "Taki Alsop Conducting Fellowship" }, +// Image: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMjAwMTA5MDQvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvVFIvMjAwMS9SRUMtU1ZHLTIwMDEwOTA0L0RURC9zdmcxMC5kdGQiPjxzdmcgdmVyc2lvbj0iMS4wIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MjVwdCIgaGVpZ2h0PSIzMDNwdCIgdmlld0JveD0iMCAwIDQyNSAzMDMiICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBtZWV0IiBzdHlsZT0nYmFja2dyb3VuZDojZmZmJz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLDMwMykgc2NhbGUoMC4wNSwtMC4wNSkiIGZpbGw9IiNkMTIwM2QiPjxwYXRoIGQ9Ik0xNTExIDU5ODEgYy0zOCAtNzIgLTQwIC0yNTYgLTUgLTM5MSAyMSAtNzcgMjggLTQ4MiAzMiAtMTc5MCA1Ci0xNTAxIDEyIC0xNzU4IDUwIC0xNzE5IDQgNSAxNCA3NzkgMjIgMTcxOSAxMSAxMjQ5IDIyIDE3NDIgNDAgMTgzMCA1OSAyNzkKLTQ0IDUzNiAtMTM5IDM1MXoiLz48cGF0aCBkPSJNMTI1IDUxMTEgYy0xMzMgLTE1MSAtMTc0IC00OTEgLTgzIC02OTEgMjAwIC00NDUgODQ4IC00NTQgMTA4MiAtMTYKMTA5IDIwNSA0OSA2MDQgLTExNCA3NTYgbC01MyA1MCAtMzkgLTQ3IC0zOCAtNDggNjkgLTc3IGMyOTggLTMyOSAxOSAtODgzCi00MTcgLTgyOCAtNDA4IDUxIC01ODYgNTUzIC0yOTggODQxIDU5IDU5IDYwIDYyIDI1IDEwNSAtNDYgNTcgLTQzIDU4IC0xMzQKLTQ1eiIvPjxwYXRoIGQ9Ik0yMDU4IDQ4OTUgYy0xMzAgLTg3IC0xNDcgLTE1NCAtMTU0IC02MTAgLTkgLTU1MCAzOSAtNjk4IDI0MCAtNzQ0CjIyMSAtNDkgMzc2IDEwOSAzNzYgMzg0IGwwIDk1IC04OCAwIC04OSAwIDEgLTExMSBjMiAtMTc0IC0xNDcgLTI2NyAtMjMzCi0xNDUgLTM5IDU3IC00NiA4NTAgLTggOTIyIDgwIDE1MyAyMzMgNzkgMjUxIC0xMjEgbDEyIC0xMjUgNzkgMCA4MCAwIC05IDE0OApjLTE5IDI5NiAtMjQ2IDQ0OCAtNDU4IDMwN3oiLz48cGF0aCBkPSJNMjc4MCA0OTA0IGMtMTQzIC03NSAtMTY1IC0xNjIgLTE2NSAtNjYxIDAgLTM5NyA0IC00NDUgNDEgLTUyOSAxMDUKLTIzNyA0MzggLTIzNyA1MzcgLTEgNTIgMTI1IDM4IDk1OCAtMTggMTA2NiAtNzYgMTQ5IC0yNDYgMjAzIC0zOTUgMTI1eiBtMjMyCi0xOTQgbDUwIC01MCAtNiAtNDQyIGMtNyAtNDg2IC0xMCAtNDk4IC0xMzcgLTQ5OCAtMTE5IDAgLTEyOSAzOSAtMTI5IDUyNCAwCjQzOCAwIDQ0MCA0NyA0NzggNjQgNTEgMTE1IDQ4IDE3NSAtMTJ6Ii8+PHBhdGggZD0iTTMzNDAgNDI0MCBsMCAtNzAwIDkzIDAgOTIgMCAtOSA0MjEgYy01IDIzMSAtMyA0MTMgNCA0MDUgNyAtOSA2MQotMTUxIDExOSAtMzE2IDE5MyAtNTQ5IDE3MyAtNTEwIDI1NyAtNTEwIGw3NCAwIDAgNzAwIDAgNzAwIC04NSAwIC04NSAwIC0xCi00MjUgMCAtNDI1IC0xMjcgMzUwIGMtNjkgMTkzIC0xMzggMzg0IC0xNTMgNDI1IC0yNSA2OSAtMzMgNzUgLTEwMyA3NSBsLTc2CjAgMCAtNzAweiIvPjxwYXRoIGQ9Ik00MDk1IDQyMzcgbDQgLTcwNyAxODQgNSBjMzYwIDEwIDM4NyA1OSAzODcgNzA1IDAgNjM1IC0yNyA2ODQgLTM4Ngo2OTcgbC0xOTQgNiA1IC03MDZ6IG0zNTAgNDk1IGM1NyAtNDMgODAgLTIzMyA3MiAtNTg0IC0xMCAtMzg1IC0yMyAtNDIwIC0xNjYKLTQzMSBsLTkxIC03IDAgNTI1IDAgNTI1IDc1IC0xIGM0MSAwIDkxIC0xMiAxMTAgLTI3eiIvPjxwYXRoIGQ9Ik00NzgwIDQzNzUgYzAgLTc0MyAzMiAtODM1IDI5MCAtODM1IDI1NyAwIDI5MCA5OCAyOTAgODQ5IGwwIDU1MQotODUgMCAtODUgMCAwIC01NjUgYzAgLTYzNiAtNCAtNjU1IC0xMjIgLTY1NSAtMTIzIDAgLTEzNCA1NyAtMTI1IDY4NSBsOCA1MzUKLTg2IDAgLTg1IDAgMCAtNTY1eiIvPjxwYXRoIGQ9Ik01NjUwIDQ5MjEgYy0xNjUgLTY3IC0xOTUgLTE4NCAtMTg3IC03MzMgOCAtNDg3IDE5IC01MzMgMTQzIC02MDkKMjI4IC0xMzkgNDc0IDUxIDQ3NCAzNjUgbDAgNzYgLTkwIDAgLTkwIDAgMCAtOTUgYy0xIC0xOTkgLTE2NCAtMjg2IC0yNDMKLTEyOSAtNDYgOTIgLTUzIDc2NiAtOSA4NzIgNzcgMTg1IDI1MiA5NSAyNTIgLTEyOSBsMCAtOTkgOTMgMCA5MiAwIC0xMCAxMjgKYy0yMyAyODMgLTIxMiA0NDAgLTQyNSAzNTN6Ii8+PHBhdGggZD0iTTYwODAgNDg1MCBsMCAtOTAgMTUxIDAgMTUxIDAgLTYgLTYyMCAtNiAtNjIwIDg1IDAgODUgMCAwIDYyMCAwCjYyMCAxNDAgMCAxNDAgMCAwIDkwIDAgOTAgLTM3MCAwIC0zNzAgMCAwIC05MHoiLz48cGF0aCBkPSJNNjkwMCA0MjMwIGwwIC03MTAgOTAgMCA5MCAwIDAgNzEwIDAgNzEwIC05MCAwIC05MCAwIDAgLTcxMHoiLz48cGF0aCBkPSJNNzE2MCA0MjQwIGwwIC03MDAgOTMgMCA5MiAwIC05IDQxOSBjLTkgMzkxIDAgNDgwIDMzIDM0NyAxMiAtNDUKMjE1IC02MTQgMjYxIC03MzEgMTAgLTI0IDM2IC0zNSA4NyAtMzUgbDczIDAgMCA3MDAgMCA3MDAgLTg1IDAgLTg1IDAgLTEKLTQyNSAtMSAtNDI1IC04NyAyNDAgYy00OCAxMzIgLTExNyAzMjMgLTE1NCA0MjUgbC02NiAxODUgLTc2IDAgLTc1IDAgMCAtNzAweiIvPjxwYXRoIGQ9Ik04MDQxIDQ5MDUgYy0xNTYgLTgwIC0xODggLTIxNCAtMTc4IC03NDUgMTAgLTUwMiA3MCAtNjIzIDMwOCAtNjIzCjIxNiAwIDI5NCAxMTQgMzA2IDQ0NyBsOCAyMzYgLTE4OCAwIC0xODcgMCAtMSAtOTAgMCAtOTAgMTA4IDAgMTA5IDAgLTEzCi0xMjMgYy0yMCAtMTg2IC0xMzAgLTI2MCAtMjMzIC0xNTcgLTQwIDQwIC02MyA3ODMgLTI4IDkxMyA1MSAxODggMjY4IDY1IDI2OAotMTUyIGwwIC04MSA4MyAwIDgyIDAgLTkgMTQ4IGMtMTkgMjg4IC0yMTMgNDMwIC00MzUgMzE3eiIvPjxnIGZpbGw9IiM4ODgiPjxwYXRoIGQ9Ik0zNjMxIDE3NjEgYy03IC0xMSAtNCAtMjEgOCAtMjEgMjcgMCAyNyAtNzEgMCAtMTIyIC00MSAtNzYgLTExOQotNTA0IC0xMTkgLTY1MCAwIC0xNTEgLTggLTE2NCAtOTAgLTE0OSAtMTEgMiAtMTAgLTcgMyAtMjAgNjMgLTYzIDEwMSAtNCAxMTMKMTc2IDEyIDE4NiA3NCA0OTkgMTI2IDY0MCAzMSA4NSAtMyAyMDggLTQxIDE0NnoiLz48cGF0aCBkPSJNMTMzMSAxNjQ5IGMzOCAtMzAgMzggLTMxIC02IC0xOSAtMjI2IDYyIC01NDAgLTYxOSAtMzQyIC03NDIgMTMgLTgKMTIgNSAtMyAzMyAtMTA4IDIwMSAxMjUgNjc5IDMzMSA2NzkgMzggMCA2OSA3IDY5IDE2IDAgMjIgLTQ0IDY0IC02OCA2NCAtMTEKMCAtMiAtMTQgMTkgLTMxeiIvPjxwYXRoIGQ9Ik04MTIgMTU5MiBjNjMgLTE1NyAtODIgLTM5MCAtMzYxIC01ODIgLTYxIC00MiAtMTExIC04MCAtMTExIC04MyAwCi0zMiA4MSAxNiAyMzEgMTQwIDI2MCAyMTMgMzU2IDQyOSAyNDkgNTU2IGwtNDAgNDcgMzIgLTc4eiIvPjxwYXRoIGQ9Ik01NjcgMTYwNCBjLTEwNCAtNTYgLTE4OCAtMTQyIC0yMDAgLTIwNiAtOCAtNDAgLTIyIC01NyAtNDAgLTUwIC0xNQo2IC0yNyAyIC0yNyAtOSAwIC0xMCAxOCAtMTkgNDAgLTE5IDI1IDAgNDAgMTMgNDAgMzYgMCA4NiAxODQgMjQ0IDI4NCAyNDQgMzQKMCA1MyA4IDQ2IDIwIC0xOCAyOSAtNzEgMjMgLTE0MyAtMTZ6Ii8+PHBhdGggZD0iTTIwNjQgMTU4MSBjLTMgLTExMCAtMTM5IC0zMDEgLTIxNCAtMzAxIC0yMSAwIDg4IDE4MiAxNDYgMjQ1IDM3IDM5CjQzIDU1IDIyIDU1IC01MiAwIC0yMTMgLTI1OSAtMTg4IC0zMDEgNyAtMTAgNCAtMTkgLTcgLTE5IC0yMCAwIC0xMDkgLTI2MAotOTQgLTI3NiAxMSAtMTEgNzAgMTI4IDg1IDIwMCA2IDMwIDI4IDYwIDQ4IDY3IDEwNiAzMyAyMzggMjEwIDIzOCAzMTcgMCA3MAotMzQgODMgLTM2IDEzeiIvPjxwYXRoIGQ9Ik0xMjY4IDE1NTggYy00NCAtMTggLTM4IC0yMiAzNyAtMjcgMzAgLTEgNTUgNSA1NSAxNCAwIDM5IDM2IC04IDQ2Ci02MCA1NCAtMjc5IC0yNTcgLTcyMSAtNDU2IC02NDUgLTc2IDI5IC04NiAxMSAtMTEgLTIwIDE0MyAtNTkgMzA3IDU4IDQyMAozMDEgMTMzIDI4NiA4NyA1MDcgLTkxIDQzN3oiLz48cGF0aCBkPSJNMzQwMCAxNDI3IGMtNTUgLTgzIC0xMDAgLTE2MSAtMTAwIC0xNzEgMCAtMjIgMTE3IC0xOTYgMTMyIC0xOTYgMTMKMCAtNiAzNSAtNjcgMTE2IGwtNDkgNjcgMTEyIDE2OCBjNjIgOTMgMTAzIDE2OSA5MiAxNjkgLTEyIDAgLTY2IC02OSAtMTIwCi0xNTN6Ii8+PHBhdGggZD0iTTUyMDAgMTUwNCBjMCAtMjEgLTE5IC02MiAtNDEgLTkxIC0zMSAtMzkgLTM1IC01MyAtMTQgLTUzIDQ0IDAgMTA4CjE0OSA3NCAxNzEgLTEwIDYgLTE5IC02IC0xOSAtMjd6Ii8+PHBhdGggZD0iTTE2NDYgMTQyNCBjLTU1IC0xMTggLTc0IC0xODQgLTUyIC0xODQgOSAxIDM3IDUyIDYyIDExNSAyNSA2MyA1MQoxMjYgNTcgMTQwIDYgMTQgNCAyNSAtNiAyNCAtOSAwIC0zNyAtNDMgLTYxIC05NXoiLz48cGF0aCBkPSJNMjg2OSAxMzExIGMtNTAgLTUwIC02NyAtMTU3IC0yOSAtMTgxIDEyIC03IDE1IDExIDggNDcgLTI2IDEzMCAxMzgKMTg2IDMwMiAxMDIgNzkgLTQwIDgwIC00MCAzMyAwIC0xMDAgODYgLTI0NSAxMDEgLTMxNCAzMnoiLz48cGF0aCBkPSJNNDQ1MyAxMTYzIGMtNyAtNDQgLTE5IC05NiAtMjYgLTExNiAtOSAtMjYgLTYgLTMxIDggLTE3IDMxIDMwIDY3CjE3NiA0NyAxOTYgLTkgOSAtMjIgLTE5IC0yOSAtNjN6Ii8+PHBhdGggZD0iTTY3NTggMTIzNSBjLTMgLTExIC0xNSAtOTEgLTI5IC0xOTUgLTE4IC0xMzQgMCAtMTI4IDI4IDEwIDExIDU1IDI2CjEyMCAzNCAxNDUgMTAgMzQgLTIyIDczIC0zMyA0MHoiLz48cGF0aCBkPSJNMTU4MyAxMjAyIGMtNzIgLTQ1IC0xMjkgLTQyMCAtNzQgLTQ5MyAyNiAtMzUgMjYgLTMwIDcgNDYgLTE3IDcwCi0xMyAxMTMgMjIgMjQ1IDIzIDg5IDQyIDE2NSA0MiAxNzEgMCA1IDIyIDkgNDkgOSAyNyAwIDU0IDkgNjEgMjAgMTUgMjQgLTY5CjI2IC0xMDcgMnoiLz48cGF0aCBkPSJNMzgyMyAxMTQzIGMtMjUgLTgzIC0zMCAtMTM3IC0xMiAtMTE4IDIyIDIxIDY3IDE5NSA1MSAxOTUgLTkgMCAtMjYKLTM1IC0zOSAtNzd6Ii8+PHBhdGggZD0iTTgyNDAgMTE3NiBjMCAtMjUgLTIzIC0xMTggLTUxIC0yMDYgLTE1OCAtNTAwIC0zNDQgLTc1NyAtNTUxIC03NTkKLTQzIC0xIC03OCAtOCAtNzggLTE2IDAgLTgwIDI1NSAyMCAzNTggMTQwIDE2MiAxOTAgNDEyIDgzMiAzNDAgODc2IC0xMCA2Ci0xOCAtOSAtMTggLTM1eiIvPjxwYXRoIGQ9Ik0zMTUwIDEwNjYgYy0xODMgLTI0NCAtMzkxIC0zMzYgLTU0MCAtMjM4IC0yNyAxOCAtNTAgMjQgLTUwIDEyIDAKLTExIDEwIC0yMCAyMSAtMjAgMTIgMCAxNyAtNyAxMiAtMTYgLTI3IC00MyAyNTcgLTQxIDM0MCAyIDgyIDQzIDM0NyAzMzYgMzQ3CjM4NCAwIDM0IC00MiAtNyAtMTMwIC0xMjR6Ii8+PHBhdGggZD0iTTUwMzUgMTE3OSBjMTcgLTIzIDE0IC00OSAtMTQgLTExMiAtMzYgLTgwIC03NSAtMjI3IC01NiAtMjA3IDU2IDU2CjExNyAyODcgODMgMzE3IC0yNyAyNSAtMzAgMjUgLTEzIDJ6Ii8+PHBhdGggZD0iTTUzODcgMTE4NCBjNiAtOCAtMyAtNjkgLTE4IC0xMzUgLTMyIC0xMzYgLTM3IC0yMDYgLTEyIC0xNzggMTUgMTcKNjMgMjUwIDYzIDMwNiAwIDEzIC0xMCAyMyAtMjEgMjMgLTEyIDAgLTE3IC03IC0xMiAtMTZ6Ii8+PHBhdGggZD0iTTU1NzcgMTE0MSBjLTIgLTMzIC00IC03NiAtNSAtOTUgLTYgLTEwOCAtMjY1IC0yNjggLTMxMCAtMTkxIC0xOQozMyAtMjEgMzMgLTIxIDEgLTIgLTEwMCAxNDYgLTcxIDI1MSA0OCA1NSA2MiA2NSA2NiAxMDEgNDIgMjMgLTE1IDU2IC0yMSA3NAotMTQgNTMgMjAgMzggNDMgLTIwIDMyIGwtNTMgLTEwIDExIDExMyBjNyA2MiA0IDExOCAtNiAxMjQgLTExIDYgLTIwIC0xNiAtMjIKLTUweiIvPjxwYXRoIGQ9Ik0zMDIwIDExNTEgYzAgLTY5IC0xNjAgLTk1IC0yMjQgLTM3IC0zNCAzMSAtMzcgMzEgLTI1IDEgMTggLTQ4IDEzMQotODEgMTk1IC01NyA2NSAyNSAxMjQgMTIyIDc0IDEyMiAtMTEgMCAtMjAgLTEzIC0yMCAtMjl6Ii8+PHBhdGggZD0iTTU5NjUgMTE2MCBjMTcgLTUyIC00MyAtMTM5IC0xMTUgLTE2OSAtNjkgLTI5IC05NSAtOTMgLTUwIC0xMjEgMTEKLTcgMjAgNSAyMCAyNSAwIDIyIDMyIDU5IDc1IDg3IDg2IDU1IDEyOCAxNDQgODYgMTgzIC0yMSAyMCAtMjQgMTkgLTE2IC01eiIvPjxwYXRoIGQ9Ik03OTgwIDExNTYgYzAgLTE3IC0xOCAtNzEgLTQwIC0xMTkgLTIxIC00OCAtMzkgLTEwMCAtMzggLTExNyAwIC0xNgoyOCAyOCA2MCA5OCA0MSA4OCA1MyAxMzUgMzkgMTQ5IC0xNSAxNSAtMjEgMTEgLTIxIC0xMXoiLz48cGF0aCBkPSJNMjAyMyAxMTMwIGMwIC0yMiAtNSAtOTQgLTEyIC0xNjAgbC0xMSAtMTIwIDI5IDEyOCBjMTkgODEgMjMgMTQwCjExIDE2MCAtMTUgMjcgLTE4IDI2IC0xNyAtOHoiLz48cGF0aCBkPSJNNDA0NCAxMDMyIGMtMyAtNzEgMSAtMTMzIDEwIC0xMzggOSAtNiAxOSA1MiAyMiAxMjggMyA3NiAtMiAxMzgKLTEwIDEzOCAtOSAwIC0xOSAtNTggLTIyIC0xMjh6Ii8+PHBhdGggZD0iTTQ2NzMgMTA0MCBjLTcgLTcwIC01IC0xMzYgNSAtMTQ2IDExIC0xMSAyMSAyNyAyNSA5NSAxMCAxNjIgLTE0CjIwMyAtMzAgNTF6Ii8+PHBhdGggZD0iTTcwNTggMTEzMCBjLTg3IC00NyAtMjE4IC0yNTAgLTE2MiAtMjUwIDExIDAgMjUgMjIgMzIgNTAgMjAgNzkgMTQ0CjE5MCAyMTMgMTkwIDMyIDAgNTkgOSA1OSAyMCAwIDI5IC04MSAyMyAtMTQyIC0xMHoiLz48cGF0aCBkPSJNNzIyOSAxMTIzIGMxMiAtMzkgMCAtNTAgLTQ5IC00NyAtNSAwIC0xMSAtMjMgLTExIC01MyAtMSAtMjkgLTkKLTc4IC0xOCAtMTA4IC05IC0zNCAtOCAtNTUgNSAtNTUgMTkgMCA1NCAxMjMgNTAgMTc1IC0xIDE0IDExIDI1IDI2IDI1IDMzIDAKMzggNzIgNiA5MSAtMTUgMTAgLTE4IDAgLTkgLTI4eiIvPjxwYXRoIGQ9Ik01ODQ5IDEwODcgYy0yNyAtMjkgLTQ5IC02MyAtNDggLTc1IDAgLTEyIDE5IDAgNDEgMjcgMjIgMjYgNTYgNjAKNzQgNzQgMjMgMTcgMjUgMjYgOCAyNiAtMTQgMSAtNDggLTIzIC03NSAtNTJ6Ii8+PHBhdGggZD0iTTE4NjUgMTA2NSBjLTM0IC0zMCAtOTUgLTEwMCAtMTM2IC0xNTUgLTQwIC01NSAtODggLTEwNiAtMTA2IC0xMTQKLTI2IC0xMCAtMjQgLTEzIDEwIC0xNSAyNyAwIDYyIDI2IDk4IDc0IDMwIDQxIDkxIDExMyAxMzYgMTYwIDkyIDk2IDkxIDEzMQotMiA1MHoiLz48cGF0aCBkPSJNMjY2MCAxMTAwIGMwIC0xMSAtMTMgLTIwIC0zMCAtMjAgLTQ0IDAgLTU4IC04OCAtMjMgLTE1MyBsMzAgLTU3Ci0xMSA4MyBjLTkgNzAgLTQgODcgMzIgMTA2IDQzIDIzIDU3IDYxIDIyIDYxIC0xMSAwIC0yMCAtOSAtMjAgLTIweiIvPjxwYXRoIGQ9Ik0zODMwIDk5OCBjLTUwIC02NyAtOTAgLTEzNSAtOTAgLTE1MCAwIC0xNyAtMjIgLTI4IC01NSAtMjkgLTQ1IDAKLTQ5IC00IC0yMiAtMjAgNDkgLTI4IDg2IC02IDEyNSA3NSAxOSAzOSA2NiAxMTEgMTA1IDE1OSA0MCA0OCA2MiA4NyA0OSA4NwotMTIgMCAtNjMgLTU1IC0xMTIgLTEyMnoiLz48cGF0aCBkPSJNNDQ3MyAxMDI3IGMtMzkgLTUxIC04MyAtMTE4IC05NyAtMTUwIC0yMCAtNDQgLTQwIC01NyAtODYgLTU4IC01NgotMSAtNTcgLTMgLTE1IC0yMCA2NSAtMjYgOTggLTYgMTQ0IDg4IDIzIDQ2IDY4IDExMyAxMDEgMTUxIDY1IDczIDY5IDgyIDQzCjgyIC0xMCAwIC01MCAtNDIgLTkwIC05M3oiLz48cGF0aCBkPSJNNTIwMiAxMDI1IGMtMTA3IC0xMjcgLTE5NiAtMTkyIC0yNjUgLTE5MiAtMzAgMCAtNTEgLTcgLTQ1IC0xNiAzNgotNTggMTg5IDE2IDI4OSAxNDEgMzUgNDQgNzUgNzUgODcgNjggMTIgLTcgMTUgLTUgNyA0IC04IDkgLTQgMjkgOCA0NCAxMiAxNQoxOCAzMiAxMiAzOCAtNiA2IC00OCAtMzMgLTkzIC04N3oiLz48cGF0aCBkPSJNNjMxMyAxMDYzIGMtNyAtMzUgLTE4IC04OSAtMjUgLTEyMSAtNyAtMzYgLTMgLTYzIDEzIC03MyAxNiAtOSAyMAotNyAxMiA3IC04IDEyIC0xIDYwIDE2IDEwNiAxNiA0OSAyMiA5OCAxMyAxMTQgLTEyIDIyIC0yMCAxMyAtMjkgLTMzeiIvPjxwYXRoIGQ9Ik02NTcwIDEwNDAgYy00MyAtNDQgLTcxIC04NSAtNjQgLTkzIDggLTggMTYgLTEwIDE5IC01IDMgNSAzNiA0MiA3NAo4MyA5MyAxMDEgNjggMTE0IC0yOSAxNXoiLz48cGF0aCBkPSJNNzU4MyAxMDA3IGMtMjEgLTg4IC0yMSAtMTE3IC0yIC0xMzQgMTkgLTE3IDIyIC0xNyAxMiAxIC04IDEzIC0xCjYyIDE2IDEwOCAxNiA0NyAyMiA5NyAxNCAxMTEgLTggMTYgLTI1IC0xOSAtNDAgLTg2eiIvPjxwYXRoIGQ9Ik0zNjU5IDEwMjggYy01NCAtNTcgLTYxIC03MCAtMzAgLTY1IDIxIDQgNTYgMzYgNzcgNzIgNDkgODQgMzggODIKLTQ3IC03eiIvPjxwYXRoIGQ9Ik00MjYyIDEwMDUgYy0xMTcgLTEyNiAtMjA0IC0xODkgLTI0NyAtMTc4IC0xOSA1IC0zNSAxIC0zNSAtOSAwIC0yNwo3NSAtMjIgMTM3IDEwIDg4IDQ2IDI4NiAyNzIgMjM4IDI3MiAtMyAwIC00NSAtNDMgLTkzIC05NXoiLz48cGF0aCBkPSJNNjA2MiA5NzkgYy0xMjUgLTExNyAtMjQ5IC0xNzYgLTMxNSAtMTUxIC0xNiA3IC0yNCAzIC0xNyAtOCAxOCAtMzAKMTMyIC0yNCAxOTcgMTAgNzIgMzggMjkzIDIyOCAyOTMgMjUyIDAgMzUgLTMyIDE1IC0xNTggLTEwM3oiLz48cGF0aCBkPSJNNzQzNCAxMDYwIGMtNzkgLTExMyAtMjYwIC0yNDQgLTMyMiAtMjMzIC01NSAxMCAtNTcgOCAtMTggLTExIDI0Ci0xMSA2NCAtMTYgOTAgLTkgNDkgMTIgMzE2IDI0NSAzMTYgMjc1IDAgMzIgLTM3IDE5IC02NiAtMjJ6Ii8+PHBhdGggZD0iTTc3MzggOTQ4IGMtOTUgLTk0IC0xMjQgLTExMiAtMTg4IC0xMTQgLTYzIC0yIC02OSAtNSAtMzQgLTIxIDc4Ci0zNCAzNDQgMTQ0IDM0NCAyMzAgMCAyNyAtNCAyMyAtMTIyIC05NXoiLz48cGF0aCBkPSJNNDgzMSA5NDQgYy03MSAtODMgLTE5MyAtMTQ3IC0yMDYgLTEwOSAtNSAxNCAtMTcgMjAgLTI3IDE0IC0zMyAtMjEKMyAtNDkgNjEgLTQ5IDYyIDAgMjU0IDE1NSAyMzkgMTkyIC00IDExIC0zNCAtMTAgLTY3IC00OHoiLz48cGF0aCBkPSJNMjExMSA5MDggYy02MSAtNTQgLTEyMCAtODggLTE1MCAtODkgLTQ1IDAgLTQ3IC0zIC0xNSAtMjEgNDEgLTI0CjEzMCAyMyAyMjMgMTIwIDkwIDk1IDUzIDg4IC01OCAtMTB6Ii8+PHBhdGggZD0iTTcwMzAgOTM5IGMtNTcgLTYyIC0xNjcgLTEyMyAtMTkzIC0xMDcgLTE0IDkgLTE4IDYgLTkgLTkgOCAtMTMgMzEKLTIzIDUxIC0yMyA0MCAwIDIxOCAxNDkgMTk5IDE2OCAtNiA3IC0yOCAtNiAtNDggLTI5eiIvPjxwYXRoIGQ9Ik04MDEwIDkwOSBjLTQ0IC0zOCAtMTA1IC03NSAtMTM1IC04MiAtMzAgLTcgLTUwIC0xOSAtNDQgLTI5IDIzIC0zNwoyMTEgNzEgMjUwIDE0MyAyOCA1MyAyMSA1MCAtNzEgLTMyeiIvPjxwYXRoIGQ9Ik02NjkxIDg3NiBjLTg1IC01NiAtMTAyIC02MCAtMTU1IC00MSAtNTEgMTkgLTU2IDE5IC0zNSAtNiAzOCAtNDYKMTUwIC0zNiAyMTAgMjAgMjkgMjcgNjMgNTMgNzYgNTcgMTMgNSAxOCAxNCAxMSAyMSAtNiA3IC01NCAtMTYgLTEwNyAtNTF6Ii8+PHBhdGggZD0iTTcyMSA4NTYgYy00OCAtNjAgLTQ5IC02MSAtMTgwIC00NiAtNzIgOSAtMTkxIDE3IC0yNjUgMTkgLTE0MCAzCi0xODUgMjAgLTEzNSA1MiAxOCAxMSAyMCAxOSA1IDE5IC00NCAwIC01MyAtNDYgLTE0IC03NSAyNSAtMTkgNjUgLTI1IDEyMQotMTcgNDYgNiAxMzMgLTIgMTk2IC0xOSAxOTAgLTQ5IDM1MSAxIDM1MSAxMDkgMCAzNyAtMjggMjIgLTc5IC00MnoiLz48cGF0aCBkPSJNNjM3NSA4NzQgYy0zNSAtMjYgLTgzIC00MSAtMTMwIC00MCAtNTcgMSAtNjUgLTMgLTM1IC0xNSA3MSAtMjkKMTI3IC0yMCAxODQgMjkgNzAgNjEgNTUgODIgLTE5IDI2eiIvPjxwYXRoIGQ9Ik0xNDc4IDY3NCBjMjcgLTMwIDY1IC01NCA4NSAtNTQgNDEgMCA0OSAzMCAxNCA1MiAtMTQgOSAtMTggNSAtOSAtOQoyMyAtMzcgLTE4IC0yNyAtODAgMjEgbC01OCA0NCA0OCAtNTR6Ii8+PC9nPjwvZz48L3N2Zz4=", +// MediaType: "image/svg+xml", Files: Array.Empty(), CreatedAt: createdAt, +// Version: "1.0", +// Attributes: new[] +// { +// new KeyValuePair("Location", "Chicago"), +// new KeyValuePair("Seat", "A39"), +// }); + +// var nft2 = new Nifty( +// Id: Guid.Parse("10cf1b4e-d457-4707-8ad9-7e8ce97e22da"), CollectionId: collectionId, IsMintable: true, +// AssetName: "ticket_madrid_01", Name: "Madrid Concert Ticket #01", Description: "Ticket for Madrid Concert #01", Creators: new[] { "Taki Alsop Conducting Fellowship" }, +// Image: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMjAwMTA5MDQvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvVFIvMjAwMS9SRUMtU1ZHLTIwMDEwOTA0L0RURC9zdmcxMC5kdGQiPjxzdmcgdmVyc2lvbj0iMS4wIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MjVwdCIgaGVpZ2h0PSIzMDNwdCIgdmlld0JveD0iMCAwIDQyNSAzMDMiICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBtZWV0IiBzdHlsZT0nYmFja2dyb3VuZDojZmZmJz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLDMwMykgc2NhbGUoMC4wNSwtMC4wNSkiIGZpbGw9IiMzODc5RDMiPjxwYXRoIGQ9Ik0xNTExIDU5ODEgYy0zOCAtNzIgLTQwIC0yNTYgLTUgLTM5MSAyMSAtNzcgMjggLTQ4MiAzMiAtMTc5MCA1Ci0xNTAxIDEyIC0xNzU4IDUwIC0xNzE5IDQgNSAxNCA3NzkgMjIgMTcxOSAxMSAxMjQ5IDIyIDE3NDIgNDAgMTgzMCA1OSAyNzkKLTQ0IDUzNiAtMTM5IDM1MXoiLz48cGF0aCBkPSJNMTI1IDUxMTEgYy0xMzMgLTE1MSAtMTc0IC00OTEgLTgzIC02OTEgMjAwIC00NDUgODQ4IC00NTQgMTA4MiAtMTYKMTA5IDIwNSA0OSA2MDQgLTExNCA3NTYgbC01MyA1MCAtMzkgLTQ3IC0zOCAtNDggNjkgLTc3IGMyOTggLTMyOSAxOSAtODgzCi00MTcgLTgyOCAtNDA4IDUxIC01ODYgNTUzIC0yOTggODQxIDU5IDU5IDYwIDYyIDI1IDEwNSAtNDYgNTcgLTQzIDU4IC0xMzQKLTQ1eiIvPjxwYXRoIGQ9Ik0yMDU4IDQ4OTUgYy0xMzAgLTg3IC0xNDcgLTE1NCAtMTU0IC02MTAgLTkgLTU1MCAzOSAtNjk4IDI0MCAtNzQ0CjIyMSAtNDkgMzc2IDEwOSAzNzYgMzg0IGwwIDk1IC04OCAwIC04OSAwIDEgLTExMSBjMiAtMTc0IC0xNDcgLTI2NyAtMjMzCi0xNDUgLTM5IDU3IC00NiA4NTAgLTggOTIyIDgwIDE1MyAyMzMgNzkgMjUxIC0xMjEgbDEyIC0xMjUgNzkgMCA4MCAwIC05IDE0OApjLTE5IDI5NiAtMjQ2IDQ0OCAtNDU4IDMwN3oiLz48cGF0aCBkPSJNMjc4MCA0OTA0IGMtMTQzIC03NSAtMTY1IC0xNjIgLTE2NSAtNjYxIDAgLTM5NyA0IC00NDUgNDEgLTUyOSAxMDUKLTIzNyA0MzggLTIzNyA1MzcgLTEgNTIgMTI1IDM4IDk1OCAtMTggMTA2NiAtNzYgMTQ5IC0yNDYgMjAzIC0zOTUgMTI1eiBtMjMyCi0xOTQgbDUwIC01MCAtNiAtNDQyIGMtNyAtNDg2IC0xMCAtNDk4IC0xMzcgLTQ5OCAtMTE5IDAgLTEyOSAzOSAtMTI5IDUyNCAwCjQzOCAwIDQ0MCA0NyA0NzggNjQgNTEgMTE1IDQ4IDE3NSAtMTJ6Ii8+PHBhdGggZD0iTTMzNDAgNDI0MCBsMCAtNzAwIDkzIDAgOTIgMCAtOSA0MjEgYy01IDIzMSAtMyA0MTMgNCA0MDUgNyAtOSA2MQotMTUxIDExOSAtMzE2IDE5MyAtNTQ5IDE3MyAtNTEwIDI1NyAtNTEwIGw3NCAwIDAgNzAwIDAgNzAwIC04NSAwIC04NSAwIC0xCi00MjUgMCAtNDI1IC0xMjcgMzUwIGMtNjkgMTkzIC0xMzggMzg0IC0xNTMgNDI1IC0yNSA2OSAtMzMgNzUgLTEwMyA3NSBsLTc2CjAgMCAtNzAweiIvPjxwYXRoIGQ9Ik00MDk1IDQyMzcgbDQgLTcwNyAxODQgNSBjMzYwIDEwIDM4NyA1OSAzODcgNzA1IDAgNjM1IC0yNyA2ODQgLTM4Ngo2OTcgbC0xOTQgNiA1IC03MDZ6IG0zNTAgNDk1IGM1NyAtNDMgODAgLTIzMyA3MiAtNTg0IC0xMCAtMzg1IC0yMyAtNDIwIC0xNjYKLTQzMSBsLTkxIC03IDAgNTI1IDAgNTI1IDc1IC0xIGM0MSAwIDkxIC0xMiAxMTAgLTI3eiIvPjxwYXRoIGQ9Ik00NzgwIDQzNzUgYzAgLTc0MyAzMiAtODM1IDI5MCAtODM1IDI1NyAwIDI5MCA5OCAyOTAgODQ5IGwwIDU1MQotODUgMCAtODUgMCAwIC01NjUgYzAgLTYzNiAtNCAtNjU1IC0xMjIgLTY1NSAtMTIzIDAgLTEzNCA1NyAtMTI1IDY4NSBsOCA1MzUKLTg2IDAgLTg1IDAgMCAtNTY1eiIvPjxwYXRoIGQ9Ik01NjUwIDQ5MjEgYy0xNjUgLTY3IC0xOTUgLTE4NCAtMTg3IC03MzMgOCAtNDg3IDE5IC01MzMgMTQzIC02MDkKMjI4IC0xMzkgNDc0IDUxIDQ3NCAzNjUgbDAgNzYgLTkwIDAgLTkwIDAgMCAtOTUgYy0xIC0xOTkgLTE2NCAtMjg2IC0yNDMKLTEyOSAtNDYgOTIgLTUzIDc2NiAtOSA4NzIgNzcgMTg1IDI1MiA5NSAyNTIgLTEyOSBsMCAtOTkgOTMgMCA5MiAwIC0xMCAxMjgKYy0yMyAyODMgLTIxMiA0NDAgLTQyNSAzNTN6Ii8+PHBhdGggZD0iTTYwODAgNDg1MCBsMCAtOTAgMTUxIDAgMTUxIDAgLTYgLTYyMCAtNiAtNjIwIDg1IDAgODUgMCAwIDYyMCAwCjYyMCAxNDAgMCAxNDAgMCAwIDkwIDAgOTAgLTM3MCAwIC0zNzAgMCAwIC05MHoiLz48cGF0aCBkPSJNNjkwMCA0MjMwIGwwIC03MTAgOTAgMCA5MCAwIDAgNzEwIDAgNzEwIC05MCAwIC05MCAwIDAgLTcxMHoiLz48cGF0aCBkPSJNNzE2MCA0MjQwIGwwIC03MDAgOTMgMCA5MiAwIC05IDQxOSBjLTkgMzkxIDAgNDgwIDMzIDM0NyAxMiAtNDUKMjE1IC02MTQgMjYxIC03MzEgMTAgLTI0IDM2IC0zNSA4NyAtMzUgbDczIDAgMCA3MDAgMCA3MDAgLTg1IDAgLTg1IDAgLTEKLTQyNSAtMSAtNDI1IC04NyAyNDAgYy00OCAxMzIgLTExNyAzMjMgLTE1NCA0MjUgbC02NiAxODUgLTc2IDAgLTc1IDAgMCAtNzAweiIvPjxwYXRoIGQ9Ik04MDQxIDQ5MDUgYy0xNTYgLTgwIC0xODggLTIxNCAtMTc4IC03NDUgMTAgLTUwMiA3MCAtNjIzIDMwOCAtNjIzCjIxNiAwIDI5NCAxMTQgMzA2IDQ0NyBsOCAyMzYgLTE4OCAwIC0xODcgMCAtMSAtOTAgMCAtOTAgMTA4IDAgMTA5IDAgLTEzCi0xMjMgYy0yMCAtMTg2IC0xMzAgLTI2MCAtMjMzIC0xNTcgLTQwIDQwIC02MyA3ODMgLTI4IDkxMyA1MSAxODggMjY4IDY1IDI2OAotMTUyIGwwIC04MSA4MyAwIDgyIDAgLTkgMTQ4IGMtMTkgMjg4IC0yMTMgNDMwIC00MzUgMzE3eiIvPjxnIGZpbGw9IiM4ODgiPjxwYXRoIGQ9Ik0zNjMxIDE3NjEgYy03IC0xMSAtNCAtMjEgOCAtMjEgMjcgMCAyNyAtNzEgMCAtMTIyIC00MSAtNzYgLTExOQotNTA0IC0xMTkgLTY1MCAwIC0xNTEgLTggLTE2NCAtOTAgLTE0OSAtMTEgMiAtMTAgLTcgMyAtMjAgNjMgLTYzIDEwMSAtNCAxMTMKMTc2IDEyIDE4NiA3NCA0OTkgMTI2IDY0MCAzMSA4NSAtMyAyMDggLTQxIDE0NnoiLz48cGF0aCBkPSJNMTMzMSAxNjQ5IGMzOCAtMzAgMzggLTMxIC02IC0xOSAtMjI2IDYyIC01NDAgLTYxOSAtMzQyIC03NDIgMTMgLTgKMTIgNSAtMyAzMyAtMTA4IDIwMSAxMjUgNjc5IDMzMSA2NzkgMzggMCA2OSA3IDY5IDE2IDAgMjIgLTQ0IDY0IC02OCA2NCAtMTEKMCAtMiAtMTQgMTkgLTMxeiIvPjxwYXRoIGQ9Ik04MTIgMTU5MiBjNjMgLTE1NyAtODIgLTM5MCAtMzYxIC01ODIgLTYxIC00MiAtMTExIC04MCAtMTExIC04MyAwCi0zMiA4MSAxNiAyMzEgMTQwIDI2MCAyMTMgMzU2IDQyOSAyNDkgNTU2IGwtNDAgNDcgMzIgLTc4eiIvPjxwYXRoIGQ9Ik01NjcgMTYwNCBjLTEwNCAtNTYgLTE4OCAtMTQyIC0yMDAgLTIwNiAtOCAtNDAgLTIyIC01NyAtNDAgLTUwIC0xNQo2IC0yNyAyIC0yNyAtOSAwIC0xMCAxOCAtMTkgNDAgLTE5IDI1IDAgNDAgMTMgNDAgMzYgMCA4NiAxODQgMjQ0IDI4NCAyNDQgMzQKMCA1MyA4IDQ2IDIwIC0xOCAyOSAtNzEgMjMgLTE0MyAtMTZ6Ii8+PHBhdGggZD0iTTIwNjQgMTU4MSBjLTMgLTExMCAtMTM5IC0zMDEgLTIxNCAtMzAxIC0yMSAwIDg4IDE4MiAxNDYgMjQ1IDM3IDM5CjQzIDU1IDIyIDU1IC01MiAwIC0yMTMgLTI1OSAtMTg4IC0zMDEgNyAtMTAgNCAtMTkgLTcgLTE5IC0yMCAwIC0xMDkgLTI2MAotOTQgLTI3NiAxMSAtMTEgNzAgMTI4IDg1IDIwMCA2IDMwIDI4IDYwIDQ4IDY3IDEwNiAzMyAyMzggMjEwIDIzOCAzMTcgMCA3MAotMzQgODMgLTM2IDEzeiIvPjxwYXRoIGQ9Ik0xMjY4IDE1NTggYy00NCAtMTggLTM4IC0yMiAzNyAtMjcgMzAgLTEgNTUgNSA1NSAxNCAwIDM5IDM2IC04IDQ2Ci02MCA1NCAtMjc5IC0yNTcgLTcyMSAtNDU2IC02NDUgLTc2IDI5IC04NiAxMSAtMTEgLTIwIDE0MyAtNTkgMzA3IDU4IDQyMAozMDEgMTMzIDI4NiA4NyA1MDcgLTkxIDQzN3oiLz48cGF0aCBkPSJNMzQwMCAxNDI3IGMtNTUgLTgzIC0xMDAgLTE2MSAtMTAwIC0xNzEgMCAtMjIgMTE3IC0xOTYgMTMyIC0xOTYgMTMKMCAtNiAzNSAtNjcgMTE2IGwtNDkgNjcgMTEyIDE2OCBjNjIgOTMgMTAzIDE2OSA5MiAxNjkgLTEyIDAgLTY2IC02OSAtMTIwCi0xNTN6Ii8+PHBhdGggZD0iTTUyMDAgMTUwNCBjMCAtMjEgLTE5IC02MiAtNDEgLTkxIC0zMSAtMzkgLTM1IC01MyAtMTQgLTUzIDQ0IDAgMTA4CjE0OSA3NCAxNzEgLTEwIDYgLTE5IC02IC0xOSAtMjd6Ii8+PHBhdGggZD0iTTE2NDYgMTQyNCBjLTU1IC0xMTggLTc0IC0xODQgLTUyIC0xODQgOSAxIDM3IDUyIDYyIDExNSAyNSA2MyA1MQoxMjYgNTcgMTQwIDYgMTQgNCAyNSAtNiAyNCAtOSAwIC0zNyAtNDMgLTYxIC05NXoiLz48cGF0aCBkPSJNMjg2OSAxMzExIGMtNTAgLTUwIC02NyAtMTU3IC0yOSAtMTgxIDEyIC03IDE1IDExIDggNDcgLTI2IDEzMCAxMzgKMTg2IDMwMiAxMDIgNzkgLTQwIDgwIC00MCAzMyAwIC0xMDAgODYgLTI0NSAxMDEgLTMxNCAzMnoiLz48cGF0aCBkPSJNNDQ1MyAxMTYzIGMtNyAtNDQgLTE5IC05NiAtMjYgLTExNiAtOSAtMjYgLTYgLTMxIDggLTE3IDMxIDMwIDY3CjE3NiA0NyAxOTYgLTkgOSAtMjIgLTE5IC0yOSAtNjN6Ii8+PHBhdGggZD0iTTY3NTggMTIzNSBjLTMgLTExIC0xNSAtOTEgLTI5IC0xOTUgLTE4IC0xMzQgMCAtMTI4IDI4IDEwIDExIDU1IDI2CjEyMCAzNCAxNDUgMTAgMzQgLTIyIDczIC0zMyA0MHoiLz48cGF0aCBkPSJNMTU4MyAxMjAyIGMtNzIgLTQ1IC0xMjkgLTQyMCAtNzQgLTQ5MyAyNiAtMzUgMjYgLTMwIDcgNDYgLTE3IDcwCi0xMyAxMTMgMjIgMjQ1IDIzIDg5IDQyIDE2NSA0MiAxNzEgMCA1IDIyIDkgNDkgOSAyNyAwIDU0IDkgNjEgMjAgMTUgMjQgLTY5CjI2IC0xMDcgMnoiLz48cGF0aCBkPSJNMzgyMyAxMTQzIGMtMjUgLTgzIC0zMCAtMTM3IC0xMiAtMTE4IDIyIDIxIDY3IDE5NSA1MSAxOTUgLTkgMCAtMjYKLTM1IC0zOSAtNzd6Ii8+PHBhdGggZD0iTTgyNDAgMTE3NiBjMCAtMjUgLTIzIC0xMTggLTUxIC0yMDYgLTE1OCAtNTAwIC0zNDQgLTc1NyAtNTUxIC03NTkKLTQzIC0xIC03OCAtOCAtNzggLTE2IDAgLTgwIDI1NSAyMCAzNTggMTQwIDE2MiAxOTAgNDEyIDgzMiAzNDAgODc2IC0xMCA2Ci0xOCAtOSAtMTggLTM1eiIvPjxwYXRoIGQ9Ik0zMTUwIDEwNjYgYy0xODMgLTI0NCAtMzkxIC0zMzYgLTU0MCAtMjM4IC0yNyAxOCAtNTAgMjQgLTUwIDEyIDAKLTExIDEwIC0yMCAyMSAtMjAgMTIgMCAxNyAtNyAxMiAtMTYgLTI3IC00MyAyNTcgLTQxIDM0MCAyIDgyIDQzIDM0NyAzMzYgMzQ3CjM4NCAwIDM0IC00MiAtNyAtMTMwIC0xMjR6Ii8+PHBhdGggZD0iTTUwMzUgMTE3OSBjMTcgLTIzIDE0IC00OSAtMTQgLTExMiAtMzYgLTgwIC03NSAtMjI3IC01NiAtMjA3IDU2IDU2CjExNyAyODcgODMgMzE3IC0yNyAyNSAtMzAgMjUgLTEzIDJ6Ii8+PHBhdGggZD0iTTUzODcgMTE4NCBjNiAtOCAtMyAtNjkgLTE4IC0xMzUgLTMyIC0xMzYgLTM3IC0yMDYgLTEyIC0xNzggMTUgMTcKNjMgMjUwIDYzIDMwNiAwIDEzIC0xMCAyMyAtMjEgMjMgLTEyIDAgLTE3IC03IC0xMiAtMTZ6Ii8+PHBhdGggZD0iTTU1NzcgMTE0MSBjLTIgLTMzIC00IC03NiAtNSAtOTUgLTYgLTEwOCAtMjY1IC0yNjggLTMxMCAtMTkxIC0xOQozMyAtMjEgMzMgLTIxIDEgLTIgLTEwMCAxNDYgLTcxIDI1MSA0OCA1NSA2MiA2NSA2NiAxMDEgNDIgMjMgLTE1IDU2IC0yMSA3NAotMTQgNTMgMjAgMzggNDMgLTIwIDMyIGwtNTMgLTEwIDExIDExMyBjNyA2MiA0IDExOCAtNiAxMjQgLTExIDYgLTIwIC0xNiAtMjIKLTUweiIvPjxwYXRoIGQ9Ik0zMDIwIDExNTEgYzAgLTY5IC0xNjAgLTk1IC0yMjQgLTM3IC0zNCAzMSAtMzcgMzEgLTI1IDEgMTggLTQ4IDEzMQotODEgMTk1IC01NyA2NSAyNSAxMjQgMTIyIDc0IDEyMiAtMTEgMCAtMjAgLTEzIC0yMCAtMjl6Ii8+PHBhdGggZD0iTTU5NjUgMTE2MCBjMTcgLTUyIC00MyAtMTM5IC0xMTUgLTE2OSAtNjkgLTI5IC05NSAtOTMgLTUwIC0xMjEgMTEKLTcgMjAgNSAyMCAyNSAwIDIyIDMyIDU5IDc1IDg3IDg2IDU1IDEyOCAxNDQgODYgMTgzIC0yMSAyMCAtMjQgMTkgLTE2IC01eiIvPjxwYXRoIGQ9Ik03OTgwIDExNTYgYzAgLTE3IC0xOCAtNzEgLTQwIC0xMTkgLTIxIC00OCAtMzkgLTEwMCAtMzggLTExNyAwIC0xNgoyOCAyOCA2MCA5OCA0MSA4OCA1MyAxMzUgMzkgMTQ5IC0xNSAxNSAtMjEgMTEgLTIxIC0xMXoiLz48cGF0aCBkPSJNMjAyMyAxMTMwIGMwIC0yMiAtNSAtOTQgLTEyIC0xNjAgbC0xMSAtMTIwIDI5IDEyOCBjMTkgODEgMjMgMTQwCjExIDE2MCAtMTUgMjcgLTE4IDI2IC0xNyAtOHoiLz48cGF0aCBkPSJNNDA0NCAxMDMyIGMtMyAtNzEgMSAtMTMzIDEwIC0xMzggOSAtNiAxOSA1MiAyMiAxMjggMyA3NiAtMiAxMzgKLTEwIDEzOCAtOSAwIC0xOSAtNTggLTIyIC0xMjh6Ii8+PHBhdGggZD0iTTQ2NzMgMTA0MCBjLTcgLTcwIC01IC0xMzYgNSAtMTQ2IDExIC0xMSAyMSAyNyAyNSA5NSAxMCAxNjIgLTE0CjIwMyAtMzAgNTF6Ii8+PHBhdGggZD0iTTcwNTggMTEzMCBjLTg3IC00NyAtMjE4IC0yNTAgLTE2MiAtMjUwIDExIDAgMjUgMjIgMzIgNTAgMjAgNzkgMTQ0CjE5MCAyMTMgMTkwIDMyIDAgNTkgOSA1OSAyMCAwIDI5IC04MSAyMyAtMTQyIC0xMHoiLz48cGF0aCBkPSJNNzIyOSAxMTIzIGMxMiAtMzkgMCAtNTAgLTQ5IC00NyAtNSAwIC0xMSAtMjMgLTExIC01MyAtMSAtMjkgLTkKLTc4IC0xOCAtMTA4IC05IC0zNCAtOCAtNTUgNSAtNTUgMTkgMCA1NCAxMjMgNTAgMTc1IC0xIDE0IDExIDI1IDI2IDI1IDMzIDAKMzggNzIgNiA5MSAtMTUgMTAgLTE4IDAgLTkgLTI4eiIvPjxwYXRoIGQ9Ik01ODQ5IDEwODcgYy0yNyAtMjkgLTQ5IC02MyAtNDggLTc1IDAgLTEyIDE5IDAgNDEgMjcgMjIgMjYgNTYgNjAKNzQgNzQgMjMgMTcgMjUgMjYgOCAyNiAtMTQgMSAtNDggLTIzIC03NSAtNTJ6Ii8+PHBhdGggZD0iTTE4NjUgMTA2NSBjLTM0IC0zMCAtOTUgLTEwMCAtMTM2IC0xNTUgLTQwIC01NSAtODggLTEwNiAtMTA2IC0xMTQKLTI2IC0xMCAtMjQgLTEzIDEwIC0xNSAyNyAwIDYyIDI2IDk4IDc0IDMwIDQxIDkxIDExMyAxMzYgMTYwIDkyIDk2IDkxIDEzMQotMiA1MHoiLz48cGF0aCBkPSJNMjY2MCAxMTAwIGMwIC0xMSAtMTMgLTIwIC0zMCAtMjAgLTQ0IDAgLTU4IC04OCAtMjMgLTE1MyBsMzAgLTU3Ci0xMSA4MyBjLTkgNzAgLTQgODcgMzIgMTA2IDQzIDIzIDU3IDYxIDIyIDYxIC0xMSAwIC0yMCAtOSAtMjAgLTIweiIvPjxwYXRoIGQ9Ik0zODMwIDk5OCBjLTUwIC02NyAtOTAgLTEzNSAtOTAgLTE1MCAwIC0xNyAtMjIgLTI4IC01NSAtMjkgLTQ1IDAKLTQ5IC00IC0yMiAtMjAgNDkgLTI4IDg2IC02IDEyNSA3NSAxOSAzOSA2NiAxMTEgMTA1IDE1OSA0MCA0OCA2MiA4NyA0OSA4NwotMTIgMCAtNjMgLTU1IC0xMTIgLTEyMnoiLz48cGF0aCBkPSJNNDQ3MyAxMDI3IGMtMzkgLTUxIC04MyAtMTE4IC05NyAtMTUwIC0yMCAtNDQgLTQwIC01NyAtODYgLTU4IC01NgotMSAtNTcgLTMgLTE1IC0yMCA2NSAtMjYgOTggLTYgMTQ0IDg4IDIzIDQ2IDY4IDExMyAxMDEgMTUxIDY1IDczIDY5IDgyIDQzCjgyIC0xMCAwIC01MCAtNDIgLTkwIC05M3oiLz48cGF0aCBkPSJNNTIwMiAxMDI1IGMtMTA3IC0xMjcgLTE5NiAtMTkyIC0yNjUgLTE5MiAtMzAgMCAtNTEgLTcgLTQ1IC0xNiAzNgotNTggMTg5IDE2IDI4OSAxNDEgMzUgNDQgNzUgNzUgODcgNjggMTIgLTcgMTUgLTUgNyA0IC04IDkgLTQgMjkgOCA0NCAxMiAxNQoxOCAzMiAxMiAzOCAtNiA2IC00OCAtMzMgLTkzIC04N3oiLz48cGF0aCBkPSJNNjMxMyAxMDYzIGMtNyAtMzUgLTE4IC04OSAtMjUgLTEyMSAtNyAtMzYgLTMgLTYzIDEzIC03MyAxNiAtOSAyMAotNyAxMiA3IC04IDEyIC0xIDYwIDE2IDEwNiAxNiA0OSAyMiA5OCAxMyAxMTQgLTEyIDIyIC0yMCAxMyAtMjkgLTMzeiIvPjxwYXRoIGQ9Ik02NTcwIDEwNDAgYy00MyAtNDQgLTcxIC04NSAtNjQgLTkzIDggLTggMTYgLTEwIDE5IC01IDMgNSAzNiA0MiA3NAo4MyA5MyAxMDEgNjggMTE0IC0yOSAxNXoiLz48cGF0aCBkPSJNNzU4MyAxMDA3IGMtMjEgLTg4IC0yMSAtMTE3IC0yIC0xMzQgMTkgLTE3IDIyIC0xNyAxMiAxIC04IDEzIC0xCjYyIDE2IDEwOCAxNiA0NyAyMiA5NyAxNCAxMTEgLTggMTYgLTI1IC0xOSAtNDAgLTg2eiIvPjxwYXRoIGQ9Ik0zNjU5IDEwMjggYy01NCAtNTcgLTYxIC03MCAtMzAgLTY1IDIxIDQgNTYgMzYgNzcgNzIgNDkgODQgMzggODIKLTQ3IC03eiIvPjxwYXRoIGQ9Ik00MjYyIDEwMDUgYy0xMTcgLTEyNiAtMjA0IC0xODkgLTI0NyAtMTc4IC0xOSA1IC0zNSAxIC0zNSAtOSAwIC0yNwo3NSAtMjIgMTM3IDEwIDg4IDQ2IDI4NiAyNzIgMjM4IDI3MiAtMyAwIC00NSAtNDMgLTkzIC05NXoiLz48cGF0aCBkPSJNNjA2MiA5NzkgYy0xMjUgLTExNyAtMjQ5IC0xNzYgLTMxNSAtMTUxIC0xNiA3IC0yNCAzIC0xNyAtOCAxOCAtMzAKMTMyIC0yNCAxOTcgMTAgNzIgMzggMjkzIDIyOCAyOTMgMjUyIDAgMzUgLTMyIDE1IC0xNTggLTEwM3oiLz48cGF0aCBkPSJNNzQzNCAxMDYwIGMtNzkgLTExMyAtMjYwIC0yNDQgLTMyMiAtMjMzIC01NSAxMCAtNTcgOCAtMTggLTExIDI0Ci0xMSA2NCAtMTYgOTAgLTkgNDkgMTIgMzE2IDI0NSAzMTYgMjc1IDAgMzIgLTM3IDE5IC02NiAtMjJ6Ii8+PHBhdGggZD0iTTc3MzggOTQ4IGMtOTUgLTk0IC0xMjQgLTExMiAtMTg4IC0xMTQgLTYzIC0yIC02OSAtNSAtMzQgLTIxIDc4Ci0zNCAzNDQgMTQ0IDM0NCAyMzAgMCAyNyAtNCAyMyAtMTIyIC05NXoiLz48cGF0aCBkPSJNNDgzMSA5NDQgYy03MSAtODMgLTE5MyAtMTQ3IC0yMDYgLTEwOSAtNSAxNCAtMTcgMjAgLTI3IDE0IC0zMyAtMjEKMyAtNDkgNjEgLTQ5IDYyIDAgMjU0IDE1NSAyMzkgMTkyIC00IDExIC0zNCAtMTAgLTY3IC00OHoiLz48cGF0aCBkPSJNMjExMSA5MDggYy02MSAtNTQgLTEyMCAtODggLTE1MCAtODkgLTQ1IDAgLTQ3IC0zIC0xNSAtMjEgNDEgLTI0CjEzMCAyMyAyMjMgMTIwIDkwIDk1IDUzIDg4IC01OCAtMTB6Ii8+PHBhdGggZD0iTTcwMzAgOTM5IGMtNTcgLTYyIC0xNjcgLTEyMyAtMTkzIC0xMDcgLTE0IDkgLTE4IDYgLTkgLTkgOCAtMTMgMzEKLTIzIDUxIC0yMyA0MCAwIDIxOCAxNDkgMTk5IDE2OCAtNiA3IC0yOCAtNiAtNDggLTI5eiIvPjxwYXRoIGQ9Ik04MDEwIDkwOSBjLTQ0IC0zOCAtMTA1IC03NSAtMTM1IC04MiAtMzAgLTcgLTUwIC0xOSAtNDQgLTI5IDIzIC0zNwoyMTEgNzEgMjUwIDE0MyAyOCA1MyAyMSA1MCAtNzEgLTMyeiIvPjxwYXRoIGQ9Ik02NjkxIDg3NiBjLTg1IC01NiAtMTAyIC02MCAtMTU1IC00MSAtNTEgMTkgLTU2IDE5IC0zNSAtNiAzOCAtNDYKMTUwIC0zNiAyMTAgMjAgMjkgMjcgNjMgNTMgNzYgNTcgMTMgNSAxOCAxNCAxMSAyMSAtNiA3IC01NCAtMTYgLTEwNyAtNTF6Ii8+PHBhdGggZD0iTTcyMSA4NTYgYy00OCAtNjAgLTQ5IC02MSAtMTgwIC00NiAtNzIgOSAtMTkxIDE3IC0yNjUgMTkgLTE0MCAzCi0xODUgMjAgLTEzNSA1MiAxOCAxMSAyMCAxOSA1IDE5IC00NCAwIC01MyAtNDYgLTE0IC03NSAyNSAtMTkgNjUgLTI1IDEyMQotMTcgNDYgNiAxMzMgLTIgMTk2IC0xOSAxOTAgLTQ5IDM1MSAxIDM1MSAxMDkgMCAzNyAtMjggMjIgLTc5IC00MnoiLz48cGF0aCBkPSJNNjM3NSA4NzQgYy0zNSAtMjYgLTgzIC00MSAtMTMwIC00MCAtNTcgMSAtNjUgLTMgLTM1IC0xNSA3MSAtMjkKMTI3IC0yMCAxODQgMjkgNzAgNjEgNTUgODIgLTE5IDI2eiIvPjxwYXRoIGQ9Ik0xNDc4IDY3NCBjMjcgLTMwIDY1IC01NCA4NSAtNTQgNDEgMCA0OSAzMCAxNCA1MiAtMTQgOSAtMTggNSAtOSAtOQoyMyAtMzcgLTE4IC0yNyAtODAgMjEgbC01OCA0NCA0OCAtNTR6Ii8+PC9nPjwvZz48L3N2Zz4=", +// MediaType: "image/svg+xml", Files: Array.Empty(), CreatedAt: createdAt, +// Version: "1.0", +// Attributes: new[] +// { +// new KeyValuePair("Location", "Madrid"), +// new KeyValuePair("Seat", "B10"), +// }); + +// var tokens = new[] { nft1, nft2 }; +// var json = _metadataJsonBuilder.GenerateNftStandardJson(tokens, collection); + +// //File.WriteAllText(@"C:\ws\temp\nft-metadata.json", json); +// var deserialised = JsonSerializer.Deserialize>>>(json, SerialiserOptions); + +// deserialised.Should().NotBeNull(); +//#pragma warning disable CS8602 // Dereference of a possibly null reference. +// var policyAssets = deserialised["721"][collection.PolicyId]; +//#pragma warning restore CS8602 // Dereference of a possibly null reference. + +// policyAssets.Keys.Count.Should().Be(2); +// foreach (var token in tokens) +// { +// var asset = policyAssets[token.AssetName]; +// asset.Should().NotBeNull(); +// asset.Name.Should().Be(token.Name); +// asset.Description.Should().Be(token.Description); +// asset.Creators.Should().BeEquivalentTo(token.Creators); +// asset.Publishers.Should().BeEquivalentTo(collection.Publishers); +// asset.Image.Should().BeEquivalentTo(new[] { token.Image }); +// asset.MediaType.Should().Be(token.MediaType); +// foreach (var file in token.Files) +// { +// if (asset.Files == null) +// throw new Exception("Files cannot be null"); +// var assetFile = asset.Files.First(f => f.Name == file.Name); +// assetFile.Name.Should().Be(file.Name); +// assetFile.Src.Should().BeEquivalentTo(new[] { file.Url }); +// assetFile.MediaType.Should().Be(file.MediaType); +// assetFile.Hash.Should().Be(file.FileHash); +// } +// } +// } + + [Theory] + [InlineData("https://mintsafe.io/project1/1.png", 1)] + [InlineData("ipfs://QmZxFT9cswMB2MWCnjKMkLGQMoUy3A6WvKjNh16ht5S55m", 1)] + [InlineData("data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodH", 1)] + [InlineData("data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG", 2)] + [InlineData("data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaGVpZ2h0PSI0MDQuMDAwMDAwcHQiIHZpZXdCb3g9IjAgMCA0MjYuMDAwMDAwIDQwNC4wMDAwMDAiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiNmZmYiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIG1lZXQiPg0KDQogICAgPGcgdHJhbnNmb3JtPSJ", 6)] + public void Split_Large_Strings(string value, int expectedItems) + { + var items = MetadataJsonBuilder.SplitStringToChunks(value); + var itemsTotalLength = items.Sum(s => s.Length); + items.Should().HaveCount(expectedItems); + itemsTotalLength.Should().Be(value.Length); + } + + //[Fact] + //public void Create_Svg() + //{ + // var dataUriSvgBase64 = MetadataJsonBuilder.GetBase64SvgForMessage("Hey there! I would like to buy your ClayNation NFT 🙏 Please send me a message at $keefie", "Sick ClayNation 🤘"); + + // var output = $""; + + // output.Should().NotBeNull(); + //} } diff --git a/Tests/Lib.UnitTests/Mintsafe.Lib.UnitTests.csproj b/Tests/Lib.UnitTests/Mintsafe.Lib.UnitTests.csproj index 5061be5..cfd09d4 100644 --- a/Tests/Lib.UnitTests/Mintsafe.Lib.UnitTests.csproj +++ b/Tests/Lib.UnitTests/Mintsafe.Lib.UnitTests.csproj @@ -8,15 +8,15 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Tests/Lib.UnitTests/NiftyDistributorShould.cs b/Tests/Lib.UnitTests/NiftyDistributorShould.cs index eb4f6c2..a4bc008 100644 --- a/Tests/Lib.UnitTests/NiftyDistributorShould.cs +++ b/Tests/Lib.UnitTests/NiftyDistributorShould.cs @@ -4,6 +4,7 @@ using Moq; using System; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -258,7 +259,7 @@ private static bool IsBuyerOutputCorrect( var buyerOutputLovelace = buyerOutput.Values.First(v => v.Unit == Assets.LovelaceUnit).Quantity; var minUtxoLovelaceQuantity = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); - var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{n.AssetName}").ToArray(); + var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName))}").ToArray(); var allSingleNiftyOutputs = buyerOutput.Values .Where(v => v.Unit != Assets.LovelaceUnit) .All(v => expectedNiftyAssetNames.Contains(v.Unit) && v.Quantity == 1); @@ -309,7 +310,7 @@ private static bool IsMintCorrect( Nifty[] nifties, string policyId) { - var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{n.AssetName}").ToArray(); + var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName))}").ToArray(); var allSingleNiftyMints = buildCommand.Mint .All(v => expectedNiftyAssetNames.Contains(v.Unit) && v.Quantity == 1); diff --git a/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs new file mode 100644 index 0000000..21a7c37 --- /dev/null +++ b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs @@ -0,0 +1,140 @@ +using CardanoSharp.Wallet.Encoding; +using CardanoSharp.Wallet.Extensions; +using CardanoSharp.Wallet.Extensions.Models; +using CardanoSharp.Wallet.Models.Keys; +using CardanoSharp.Wallet.Models.Transactions; +using CardanoSharp.Wallet.Models.Transactions.Scripts; +using CardanoSharp.Wallet.Models.Transactions.TransactionWitness.Scripts; +using CardanoSharp.Wallet.Utilities; +using Microsoft.Extensions.Logging.Abstractions; +using Mintsafe.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; +using static Mintsafe.Lib.UnitTests.FakeGenerator; + +namespace Mintsafe.Lib.UnitTests; + +public class SimpleWalletServiceShould +{ + private const int MessageMetadataStandardKey = 674; + private const int NftRoyaltyMetadataStandardKey = 777; + + [Fact] + public async Task Submit_Transaction_Successfully_When_Consolidating_Own_Address_Utxos() + { + var simpleWalletService = new SimpleWalletService(NullLogger.Instance, NullInstrumentor.Instance); + var sourcePaymentAddress = "addr_test1qrlsqwhg4vay0x4yy6lw08s4qx4kcpnujcc7dz8e5lqjdnkdfj93s4scmnax7lgemz2q2ftms4zna9y9xle0c3c88f5qhvdm5m"; + var sourcePaymentXsk = "addr_xsk1gr99v0ynu0wvuxwe4vu2sap3d3vxqzpupu93gvhtu869wmk8wezu2zlkssy9xj4j5u5ymf356np04f2tt77pfees3uckytdlpdhv8pwss378smdhsecy7un46q8738rhgwd0hytz4r77k6v95dmt47g0ms4ec63v"; + var network = Network.Testnet; + var messageBodyMetadata = new Dictionary + { { "msg", new[] { "mintsafe.io test", DateTime.UtcNow.ToString("o") } } }; + var messageMetadata = new Dictionary> + { { MessageMetadataStandardKey, messageBodyMetadata } }; + + var txId = await simpleWalletService.SubmitTransactionAsync( + sourcePaymentAddress, + sourcePaymentXsk, + network, + metadata: messageMetadata); + + Assert.NotNull(txId); + } + + [Fact] + public async Task Submit_Transaction_Successfully_When_Making_Simple_Ada_Payment_To_One_Address() + { + var simpleWalletService = new SimpleWalletService(NullLogger.Instance, NullInstrumentor.Instance); + var sourcePaymentAddress = "addr_test1qrlsqwhg4vay0x4yy6lw08s4qx4kcpnujcc7dz8e5lqjdnkdfj93s4scmnax7lgemz2q2ftms4zna9y9xle0c3c88f5qhvdm5m"; + var sourcePaymentXsk = "addr_xsk1gr99v0ynu0wvuxwe4vu2sap3d3vxqzpupu93gvhtu869wmk8wezu2zlkssy9xj4j5u5ymf356np04f2tt77pfees3uckytdlpdhv8pwss378smdhsecy7un46q8738rhgwd0hytz4r77k6v95dmt47g0ms4ec63v"; + var network = Network.Testnet; + // Not used now + var destinationPaymentAddress = "addr_test1qplxcfvad2uzq2w4k99unzj6d5hmpprgrujn3l0nwsl8vh3e2mgaxpeslac7hghtxxzcwerr3wt6ly2t9hr7unkua9rskg2855"; + var destinationOutputValue = new AggregateValue(8888888, Array.Empty()); + var messageBodyMetadata = new Dictionary + { { "msg", new[] { "mintsafe.io test", DateTime.UtcNow.ToString("o") } } }; + var messageMetadata = new Dictionary> + { { MessageMetadataStandardKey, messageBodyMetadata } }; + + var txId = await simpleWalletService.SubmitTransactionAsync( + sourcePaymentAddress, + sourcePaymentXsk, + network, + outputs: new[] { new PendingTransactionOutput(destinationPaymentAddress, destinationOutputValue) }, + metadata: messageMetadata); + + Assert.NotNull(txId); + } + + [Fact] + public async Task Submit_Transaction_Successfully_When_Minting_Nft_Royalty_Asset() + { + var simpleWalletService = new SimpleWalletService(NullLogger.Instance, NullInstrumentor.Instance); + var sourcePaymentAddress = "addr_test1qrlsqwhg4vay0x4yy6lw08s4qx4kcpnujcc7dz8e5lqjdnkdfj93s4scmnax7lgemz2q2ftms4zna9y9xle0c3c88f5qhvdm5m"; + var sourcePaymentXsk = "addr_xsk1gr99v0ynu0wvuxwe4vu2sap3d3vxqzpupu93gvhtu869wmk8wezu2zlkssy9xj4j5u5ymf356np04f2tt77pfees3uckytdlpdhv8pwss378smdhsecy7un46q8738rhgwd0hytz4r77k6v95dmt47g0ms4ec63v"; + var royaltyRate = "0.08"; + var royaltyAddress = "addr_test1qplxcfvad2uzq2w4k99unzj6d5hmpprgrujn3l0nwsl8vh3e2mgaxpeslac7hghtxxzcwerr3wt6ly2t9hr7unkua9rskg2855"; + var policySkey = "policy_sk16zy4n87qj996t77yfzqp3hmsv3l689gm5yq939gnlxresmx8wezhuj2qzf7sz7eck62wct5vv72rf4gfa48ehgrn3j5tffqfe6zm67qsk0p44"; + var policyExpirySlot = 96997186U; + var policyId = BuildScriptAllPolicy(policySkey, policyExpirySlot).GetPolicyId().ToStringHex(); + var network = Network.Testnet; + var nativeAssetsToMint = new[] { new NativeAssetValue(policyId, "", 1) }; // empty assetname is required for CIP27 + var minUtxoLovelace = TxUtils.CalculateMinUtxoLovelace(new AggregateValue(1000000, nativeAssetsToMint)); + var royaltyBodyMetadata = new Dictionary + { + { "rate", royaltyRate }, + { "addr", royaltyAddress.Length > 64 ? MetadataJsonBuilder.SplitStringToChunks(royaltyAddress) : royaltyAddress } + }; + var royaltyMetadata = new Dictionary> + { { NftRoyaltyMetadataStandardKey, royaltyBodyMetadata } }; + + var txId = await simpleWalletService.SubmitTransactionAsync( + sourcePaymentAddress, + sourcePaymentXsk, + network, + outputs: new[] { new PendingTransactionOutput(sourcePaymentAddress, new AggregateValue(minUtxoLovelace, nativeAssetsToMint)) }, + nativeAssetsToMint: nativeAssetsToMint, + metadata: royaltyMetadata, + policySkeys: new[] { policySkey }, + policyExpirySlot: policyExpirySlot); + + Assert.NotNull(txId); + } + + private static NativeScript BuildScriptAllPolicy( + string policySKey, ulong? policyExpiry = null) + { + var scriptAll = new ScriptAll(); + if (policyExpiry.HasValue) + { + scriptAll.NativeScripts.Add( + new NativeScript + { + InvalidAfter = new ScriptInvalidAfter + { + After = (uint)policyExpiry.Value + } + }); + } + var policyVKey = GetPrivateKeyFromBech32SigningKey(policySKey).GetPublicKey(false); + var policyVKeyHash = HashUtility.Blake2b224(policyVKey.Key); + scriptAll.NativeScripts.Add( + new NativeScript + { + ScriptPubKey = new ScriptPubKey + { + KeyHash = policyVKeyHash + } + }); + return new NativeScript { ScriptAll = scriptAll }; + } + + private static PrivateKey GetPrivateKeyFromBech32SigningKey(string bech32EncodedSigningKey) + { + var keyBytes = Bech32.Decode(bech32EncodedSigningKey, out _, out _); + return new PrivateKey(keyBytes[..64], keyBytes[64..]); + } +} diff --git a/Tests/Lib.UnitTests/TimeUtilShould.cs b/Tests/Lib.UnitTests/TimeUtilShould.cs index 7787ff9..c322aab 100644 --- a/Tests/Lib.UnitTests/TimeUtilShould.cs +++ b/Tests/Lib.UnitTests/TimeUtilShould.cs @@ -8,8 +8,10 @@ namespace Mintsafe.Lib.UnitTests; public class TimeUtilShould { [Theory] - [InlineData(2021, 10, 28, 14, 0, 4, 41060390)] - [InlineData(2022, 1, 28, 19, 0, 0, 49027186)] + //[InlineData(2021, 10, 28, 14, 0, 4, 41060390)] + //[InlineData(2022, 1, 28, 19, 0, 0, 49027186)] + //[InlineData(2022, 12, 25, 0, 0, 0, 77557186)] + [InlineData(2022, 6, 6, 7, 53, 0, 77557186)] public void Return_Correct_Testnet_Slot( int year, int month, int day, int hour, int minute, int second, int expectedSlot) @@ -24,6 +26,7 @@ public void Return_Correct_Testnet_Slot( [Theory] [InlineData(41060390, 2021, 10, 28, 14, 0, 4)] [InlineData(49027186, 2022, 1, 28, 19, 0, 0)] + [InlineData(74686216, 2022, 11, 21, 18, 30, 30)] public void Return_Correct_Utc_Time_From_Testnet_Slot( int slot, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute, int expectedSecond) { diff --git a/Tests/Lib.UnitTests/TxUtilsShould.cs b/Tests/Lib.UnitTests/TxUtilsShould.cs index e98e686..517ed4c 100644 --- a/Tests/Lib.UnitTests/TxUtilsShould.cs +++ b/Tests/Lib.UnitTests/TxUtilsShould.cs @@ -3,229 +3,337 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using Xunit; -namespace Mintsafe.Lib.UnitTests +namespace Mintsafe.Lib.UnitTests; + +public class TxUtilsShould { - public class TxUtilsShould + const long AdaOnlyUtxoLovelaces = 999978; + + [Theory] + [InlineData( + 8822996633231, "e9b6f907ea790ca51957eb513430eb0ec155f8df654d48e961d7ea3e.cryptodingos00002")] + [InlineData( + 1620654, + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.cryptoroos123", + "91acca0a2614212d68a5ae7313c85962849994aab54e340d3a68aabb.cryptoquokka99999")] + [InlineData( + 422810293, + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.cryptokookaburras0123", + "91acca0a2614212d68a5ae7313c85962849994aab54e340d3a68aabb.cryptokoalas0087", + "e8209a96a456202276f66224241a703676122d606d208fe464f2e09f.cryptowombats27699")] + public void SubtractValues_With_No_Effect_When_Rhs_Values_Are_Empty( + long lovelaceQuantity, params string[] customTokenUnits) { - const long AdaOnlyUtxoLovelaces = 999978; - - [Theory] - [InlineData( - 8822996633231, "e9b6f907ea790ca51957eb513430eb0ec155f8df654d48e961d7ea3e.cryptodingos00002")] - [InlineData( - 1620654, - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.cryptoroos123", - "91acca0a2614212d68a5ae7313c85962849994aab54e340d3a68aabb.cryptoquokka99999")] - [InlineData( - 422810293, - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.cryptokookaburras0123", - "91acca0a2614212d68a5ae7313c85962849994aab54e340d3a68aabb.cryptokoalas0087", - "e8209a96a456202276f66224241a703676122d606d208fe464f2e09f.cryptowombats27699")] - public void SubtractValues_With_No_Effect_When_Rhs_Values_Are_Empty( - long lovelaceQuantity, params string[] customTokenUnits) + var valuesLhs = new List { new Value(Assets.LovelaceUnit, lovelaceQuantity) }; + valuesLhs.AddRange(customTokenUnits.Select(u => new Value(u, 1))); + var valuesRhs = Array.Empty(); + + var result = TxUtils.SubtractValues(valuesLhs.ToArray(), valuesRhs); + + result.Length.Should().Be(customTokenUnits.Length + 1); // + 1 for Default Lovelace Unit + result.First().Unit.Should().Be(Assets.LovelaceUnit); + result.First().Quantity.Should().Be(lovelaceQuantity); + var valueLookup = result.ToDictionary(v => v.Unit, v => v.Quantity); + var customTokens = result.Where(v => v.Unit != Assets.LovelaceUnit).ToArray(); + customTokens.Length.Should().Be(customTokenUnits.Length); + foreach (var value in customTokens) { - var valuesLhs = new List { new Value(Assets.LovelaceUnit, lovelaceQuantity) }; - valuesLhs.AddRange(customTokenUnits.Select(u => new Value(u, 1))); - var valuesRhs = Array.Empty(); - - var result = TxUtils.SubtractValues(valuesLhs.ToArray(), valuesRhs); - - result.Length.Should().Be(customTokenUnits.Length + 1); // + 1 for Default Lovelace Unit - result.First().Unit.Should().Be(Assets.LovelaceUnit); - result.First().Quantity.Should().Be(lovelaceQuantity); - var valueLookup = result.ToDictionary(v => v.Unit, v => v.Quantity); - var customTokens = result.Where(v => v.Unit != Assets.LovelaceUnit).ToArray(); - customTokens.Length.Should().Be(customTokenUnits.Length); - foreach (var value in customTokens) - { - valueLookup[value.Unit].Should().Be(1); - } + valueLookup[value.Unit].Should().Be(1); } + } - [Theory] - [InlineData(2993600, 179097, 2814503)] - [InlineData(15000000, 1413762, 13586238)] - [InlineData(14946_734549, 2299_663323, 12647071226)] - public void SubtractValues_Correctly_When_Only_Ada_Values( - long lovelaceQuantityLhs, long lovelaceQuantityRhs, long expectedLovelaceValues) - { - var valuesLhs = new[] { new Value(Assets.LovelaceUnit, lovelaceQuantityLhs) }; - var valuesRhs = new[] { new Value(Assets.LovelaceUnit, lovelaceQuantityRhs) }; + [Theory] + [InlineData(2993600, 179097, 2814503)] + [InlineData(15000000, 1413762, 13586238)] + [InlineData(14946_734549, 2299_663323, 12647071226)] + public void SubtractValues_Correctly_When_Only_Ada_Values( + long lovelaceQuantityLhs, long lovelaceQuantityRhs, long expectedLovelaceValues) + { + var valuesLhs = new[] { new Value(Assets.LovelaceUnit, lovelaceQuantityLhs) }; + var valuesRhs = new[] { new Value(Assets.LovelaceUnit, lovelaceQuantityRhs) }; - var result = TxUtils.SubtractValues(valuesLhs, valuesRhs); - result.Length.Should().Be(1); - result.First().Unit.Should().Be(Assets.LovelaceUnit); - result.First().Quantity.Should().Be(expectedLovelaceValues); - } + var result = TxUtils.SubtractValues(valuesLhs, valuesRhs); + result.Length.Should().Be(1); + result.First().Unit.Should().Be(Assets.LovelaceUnit); + result.First().Quantity.Should().Be(expectedLovelaceValues); + } - [Theory] - [InlineData(761792, 43284, 718508)] - [InlineData(9014946734549, 8822996633231, 191950101318)] - public void SubtractValues_Correctly_When_Ada_And_Custom_Fungible_Token_Values_Are_Given( - long ftQuantityLhs, long ftQuantityRhs, long expectedFtQuantity) - { - var valuesLhs = new[] { - new Value(Assets.LovelaceUnit, 15000000), - new Value("f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add.ft", ftQuantityLhs), - new Value("7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.nft0001", 1), - new Value("7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.nft9999", 1), - }; - var valuesRhs = new[] { - new Value(Assets.LovelaceUnit, 1413762), - new Value("f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add.ft", ftQuantityRhs), - }; - - var result = TxUtils.SubtractValues(valuesLhs, valuesRhs); - - result.Length.Should().Be(4); - var valueLookup = result.ToDictionary(v => v.Unit, v => v.Quantity); - valueLookup[Assets.LovelaceUnit].Should().Be(13586238); - valueLookup["f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add.ft"].Should().Be(expectedFtQuantity); - valueLookup["7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.nft0001"].Should().Be(1); - valueLookup["7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.nft9999"].Should().Be(1); - } + [Theory] + [InlineData(761792, 43284, 718508)] + [InlineData(9014946734549, 8822996633231, 191950101318)] + public void SubtractValues_Correctly_When_Ada_And_Custom_Fungible_Token_Values_Are_Given( + long ftQuantityLhs, long ftQuantityRhs, long expectedFtQuantity) + { + var valuesLhs = new[] { + new Value(Assets.LovelaceUnit, 15000000), + new Value("f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add.ft", ftQuantityLhs), + new Value("7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.nft0001", 1), + new Value("7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.nft9999", 1), + }; + var valuesRhs = new[] { + new Value(Assets.LovelaceUnit, 1413762), + new Value("f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add.ft", ftQuantityRhs), + }; - [Theory] - [InlineData(0, AdaOnlyUtxoLovelaces)] - [InlineData(1000000, AdaOnlyUtxoLovelaces)] - [InlineData(821_945678, AdaOnlyUtxoLovelaces)] - [InlineData(5197820531_945678, AdaOnlyUtxoLovelaces)] - public void DeriveMinUtxoLovelace_Correctly_Given_All_Default_Params_When_Values_Have_Ada_Only( - long lovelaceValue, long expectedMinUtxo) - { - var values = new[] { new Value(Assets.LovelaceUnit, lovelaceValue) }; + var result = TxUtils.SubtractValues(valuesLhs, valuesRhs); - var minUtxo = TxUtils.CalculateMinUtxoLovelace(values); + result.Length.Should().Be(4); + var valueLookup = result.ToDictionary(v => v.Unit, v => v.Quantity); + valueLookup[Assets.LovelaceUnit].Should().Be(13586238); + valueLookup["f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add.ft"].Should().Be(expectedFtQuantity); + valueLookup["7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.nft0001"].Should().Be(1); + valueLookup["7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.nft9999"].Should().Be(1); + } - minUtxo.Should().Be(expectedMinUtxo); - } + [Theory] + [InlineData(0, AdaOnlyUtxoLovelaces)] + [InlineData(1000000, AdaOnlyUtxoLovelaces)] + [InlineData(821_945678, AdaOnlyUtxoLovelaces)] + [InlineData(5197820531_945678, AdaOnlyUtxoLovelaces)] + public void DeriveMinUtxoLovelace_Correctly_Given_All_Default_Params_When_Values_Have_Ada_Only( + long lovelaceValue, long expectedMinUtxo) + { + var values = new[] { new Value(Assets.LovelaceUnit, lovelaceValue) }; - [Theory] - [InlineData( - 1413762, "e9b6f907ea790ca51957eb513430eb0ec155f8df654d48e961d7ea3e.cryptoquokkas00002")] - [InlineData( - 1620654, "0a85dd1543465407852c90e66c074a3b52ea2d7c77a2346ddc20550a.cryptoroos123", - "91acca0a2614212d68a5ae7313c85962849994aab54e340d3a68aabb.cryptopossums99999")] - public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Multiple_NFTs_Under_Same_Policy_And_No_Data_Hash( - long expectedMinUtxoLovelace, params string[] customTokenUnits) - { - var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; - values.AddRange(customTokenUnits.Select(u => new Value(u, 1))); + var minUtxo = TxUtils.CalculateMinUtxoLovelace(values); - var minUtxo = TxUtils.CalculateMinUtxoLovelace(values.ToArray(), hasDataHash: false); + minUtxo.Should().Be(expectedMinUtxo); + } - minUtxo.Should().Be(expectedMinUtxoLovelace); - } + [Theory] + [InlineData( + 1413762, "e9b6f907ea790ca51957eb513430eb0ec155f8df654d48e961d7ea3e.cryptoquokkas00002")] + [InlineData( + 1620654, "0a85dd1543465407852c90e66c074a3b52ea2d7c77a2346ddc20550a.cryptoroos123", + "91acca0a2614212d68a5ae7313c85962849994aab54e340d3a68aabb.cryptopossums99999")] + public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Multiple_NFTs_Under_Same_Policy_And_No_Data_Hash( + long expectedMinUtxoLovelace, params string[] customTokenUnits) + { + var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; + values.AddRange(customTokenUnits.Select(u => new Value(u, 1))); - [Theory] - [InlineData( - 1689618, "89d6e39b026145fdab359296e1c8752960641d041b061bdbe80b9c11.nft1")] - [InlineData( - 1965474, "5bc031932ddb0e89b880569171da1e0e63c4c07867df8e35214e8213.cryptoemus0001", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.cryptoquokka99999")] - public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Multiple_NFTs_Under_Same_Policy_And_Data_Hash( - long expectedMinUtxoLovelace, params string[] customTokenUnits) - { - var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; - values.AddRange(customTokenUnits.Select(u => new Value(u, 1))); + var minUtxo = TxUtils.CalculateMinUtxoLovelace(values.ToArray(), hasDataHash: false); - var minUtxo = TxUtils.CalculateMinUtxoLovelace(values.ToArray(), hasDataHash: true); + minUtxo.Should().Be(expectedMinUtxoLovelace); + } - minUtxo.Should().Be(expectedMinUtxoLovelace); - } + [Theory] + [InlineData( + 1689618, "89d6e39b026145fdab359296e1c8752960641d041b061bdbe80b9c11.nft1")] + [InlineData( + 1965474, "5bc031932ddb0e89b880569171da1e0e63c4c07867df8e35214e8213.cryptoemus0001", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.cryptoquokka99999")] + public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Multiple_NFTs_Under_Same_Policy_And_Data_Hash( + long expectedMinUtxoLovelace, params string[] customTokenUnits) + { + var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; + values.AddRange(customTokenUnits.Select(u => new Value(u, 1))); - [Theory] - [InlineData( - 4_517142, 55, "34d825881c5a6465d0398dbbe301222427d3572f31ba36148e89ce54.cryptoemus")] - [InlineData( - 47_619642, 888, "5bc031932ddb0e89b880569171da1e0e63c4c07867df8e35214e8213.cryptowallabies")] - [InlineData( - 518_919618, 10000, "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.cryptokoalas")] - public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Many_NFTs_Under_Same_Policy_And_Data_Hash( - long expectedMinUtxoLovelace, int count, string customTokenUnitBase) - { - var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; - values.AddRange(Enumerable.Range(1, count).Select(i => new Value($"{customTokenUnitBase}i", 1))); + var minUtxo = TxUtils.CalculateMinUtxoLovelace(values.ToArray(), hasDataHash: true); - var minUtxo = TxUtils.CalculateMinUtxoLovelace(values.ToArray(), hasDataHash: true); + minUtxo.Should().Be(expectedMinUtxoLovelace); + } - minUtxo.Should().Be(expectedMinUtxoLovelace); - } + [Theory] + [InlineData( + 4_517142, 55, "34d825881c5a6465d0398dbbe301222427d3572f31ba36148e89ce54.cryptoemus")] + [InlineData( + 47_619642, 888, "5bc031932ddb0e89b880569171da1e0e63c4c07867df8e35214e8213.cryptowallabies")] + [InlineData( + 518_919618, 10000, "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.cryptokoalas")] + public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Many_NFTs_Under_Same_Policy_And_Data_Hash( + long expectedMinUtxoLovelace, int count, string customTokenUnitBase) + { + var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; + values.AddRange(Enumerable.Range(1, count).Select(i => new Value($"{customTokenUnitBase}i", 1))); + + var minUtxo = TxUtils.CalculateMinUtxoLovelace(values.ToArray(), hasDataHash: true); - // Verifying https://github.com/ilap/ShelleyStuffs#min-utxo-ada-calculation - [Theory] - [InlineData( - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.01234567890123456789045678901200", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f01.01234567890123456789045678901201", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f02.01234567890123456789045678901202", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f03.01234567890123456789045678901203", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f04.01234567890123456789045678901204", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f05.01234567890123456789045678901205", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f06.01234567890123456789045678901206", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f07.01234567890123456789045678901207", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f08.01234567890123456789045678901208", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f09.01234567890123456789045678901209", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f10.01234567890123456789045678901210", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f11.01234567890123456789045678901211", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f12.01234567890123456789045678901212", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f13.01234567890123456789045678901213", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f14.01234567890123456789045678901214", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f15.01234567890123456789045678901215", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f16.01234567890123456789045678901216", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f17.01234567890123456789045678901217", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f18.01234567890123456789045678901218", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f19.01234567890123456789045678901219", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f20.01234567890123456789045678901220", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f21.01234567890123456789045678901221", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f22.01234567890123456789045678901222", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f23.01234567890123456789045678901223", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f24.01234567890123456789045678901224", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f25.01234567890123456789045678901225", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f26.01234567890123456789045678901226", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f27.01234567890123456789045678901227", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f28.01234567890123456789045678901228", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f29.01234567890123456789045678901229", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f30.01234567890123456789045678901230", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f31.01234567890123456789045678901231", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f32.01234567890123456789045678901232", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f33.01234567890123456789045678901233", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f34.01234567890123456789045678901234", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f35.01234567890123456789045678901235", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f36.01234567890123456789045678901236", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f37.01234567890123456789045678901237", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f38.01234567890123456789045678901238", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f39.01234567890123456789045678901239", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f40.01234567890123456789045678901240", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f41.01234567890123456789045678901241", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f42.01234567890123456789045678901242", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f43.01234567890123456789045678901243", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f44.01234567890123456789045678901244", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f45.01234567890123456789045678901245", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f46.01234567890123456789045678901246", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f47.01234567890123456789045678901247", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f48.01234567890123456789045678901248", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f49.01234567890123456789045678901249", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f50.01234567890123456789045678901250", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f51.01234567890123456789045678901251", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f52.01234567890123456789045678901252", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f53.01234567890123456789045678901253", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f54.01234567890123456789045678901254", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f55.01234567890123456789045678901255", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f56.01234567890123456789045678901256", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f57.01234567890123456789045678901257", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f58.01234567890123456789045678901258", - "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f59.01234567890123456789045678901259")] - public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_60_Distinct_Policies_And_60_Distinct_AssetNames_And_DataHash( - params string[] units) + minUtxo.Should().Be(expectedMinUtxoLovelace); + } + + // Verifying https://github.com/ilap/ShelleyStuffs#min-utxo-ada-calculation + [Theory] + [InlineData( + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.01234567890123456789045678901200", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f01.01234567890123456789045678901201", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f02.01234567890123456789045678901202", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f03.01234567890123456789045678901203", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f04.01234567890123456789045678901204", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f05.01234567890123456789045678901205", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f06.01234567890123456789045678901206", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f07.01234567890123456789045678901207", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f08.01234567890123456789045678901208", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f09.01234567890123456789045678901209", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f10.01234567890123456789045678901210", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f11.01234567890123456789045678901211", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f12.01234567890123456789045678901212", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f13.01234567890123456789045678901213", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f14.01234567890123456789045678901214", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f15.01234567890123456789045678901215", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f16.01234567890123456789045678901216", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f17.01234567890123456789045678901217", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f18.01234567890123456789045678901218", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f19.01234567890123456789045678901219", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f20.01234567890123456789045678901220", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f21.01234567890123456789045678901221", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f22.01234567890123456789045678901222", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f23.01234567890123456789045678901223", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f24.01234567890123456789045678901224", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f25.01234567890123456789045678901225", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f26.01234567890123456789045678901226", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f27.01234567890123456789045678901227", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f28.01234567890123456789045678901228", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f29.01234567890123456789045678901229", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f30.01234567890123456789045678901230", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f31.01234567890123456789045678901231", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f32.01234567890123456789045678901232", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f33.01234567890123456789045678901233", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f34.01234567890123456789045678901234", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f35.01234567890123456789045678901235", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f36.01234567890123456789045678901236", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f37.01234567890123456789045678901237", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f38.01234567890123456789045678901238", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f39.01234567890123456789045678901239", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f40.01234567890123456789045678901240", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f41.01234567890123456789045678901241", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f42.01234567890123456789045678901242", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f43.01234567890123456789045678901243", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f44.01234567890123456789045678901244", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f45.01234567890123456789045678901245", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f46.01234567890123456789045678901246", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f47.01234567890123456789045678901247", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f48.01234567890123456789045678901248", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f49.01234567890123456789045678901249", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f50.01234567890123456789045678901250", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f51.01234567890123456789045678901251", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f52.01234567890123456789045678901252", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f53.01234567890123456789045678901253", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f54.01234567890123456789045678901254", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f55.01234567890123456789045678901255", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f56.01234567890123456789045678901256", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f57.01234567890123456789045678901257", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f58.01234567890123456789045678901258", + "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f59.01234567890123456789045678901259")] + public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_60_Distinct_Policies_And_60_Distinct_AssetNames_And_DataHash( + params string[] units) + { + var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; + values.AddRange(units.Select(u => new Value(u, 1))); + + var minUtxo = TxUtils.CalculateMinUtxoLovelace(values.ToArray(), hasDataHash: true); + + minUtxo.Should().Be(20103006); + } + + [Fact] + public void DeriveMinUtxoLovelace_For_Token_Bundle_When_Output_Has_Two_Assets_Under_The_Same_Policy() + { + var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; + values.Add(new Value("e8a447d4e19016ca2aa74d20b4c4de87adb1f21dfb5493bf2d7281a6.COND1", 20)); + values.Add(new Value("e8a447d4e19016ca2aa74d20b4c4de87adb1f21dfb5493bf2d7281a6.COND2", 20)); + + var bundle = new AggregateValue(100_000000, new[] { - var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; - values.AddRange(units.Select(u => new Value(u, 1))); + new NativeAssetValue("e8a447d4e19016ca2aa74d20b4c4de87adb1f21dfb5493bf2d7281a6", "434f4e4431", 20), + new NativeAssetValue("e8a447d4e19016ca2aa74d20b4c4de87adb1f21dfb5493bf2d7281a6", "434f4e4432", 20), + }); - var minUtxo = TxUtils.CalculateMinUtxoLovelace(values.ToArray(), hasDataHash: true); + var minUtxoOld = TxUtils.CalculateMinUtxoLovelace(values.ToArray()); + var minUtxoNew = TxUtils.CalculateMinUtxoLovelace(bundle); - minUtxo.Should().Be(20103006); - } + minUtxoOld.Should().Be(1413762); + minUtxoNew.Should().Be(1413762); + } + + [Theory] + [InlineData(1_000000UL, 10_000000UL)] + [InlineData(81_590452UL, 6032_591752UL)] + [InlineData(33_362_564_961_123456UL, 9362_564_961_394103UL)] + public void Consolidate_Output_Values_Calculating_Lovelaces_Correctly_When_No_Native_Assets_Exist(params ulong[] lovelaceValues) + { + var outputValues = lovelaceValues + .Select(lv => new AggregateValue(lv, Array.Empty())).ToArray(); + + var foldedOutputValue = outputValues.Sum(); + + // No Sum extension method for ulong types so casting to long is required + foldedOutputValue.Lovelaces.Should().Be((ulong)lovelaceValues.Sum(lv => (long)lv)); + } + + + [Theory] + [InlineData( + "{\"Lovelaces\": 81590452, \"NativeAssets\": [{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e646157545030313437543139\", \"Quantity\": 1}]}")] + [InlineData( + "{\"Lovelaces\": 81590452, \"NativeAssets\": [{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e646157545030313437543139\", \"Quantity\": 1}]}", + "{\"Lovelaces\": 10000000, \"NativeAssets\": [{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e6461575450303133355431\", \"Quantity\": 1},{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"Quantity\": 1}]}")] + [InlineData( + "{\"Lovelaces\": 81590452, \"NativeAssets\": [{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e646157545030313437543139\", \"Quantity\": 1}]}", + "{\"Lovelaces\": 10000000, \"NativeAssets\": [{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e6461575450303133355431\", \"Quantity\": 1}]}", + "{\"Lovelaces\": 10000000, \"NativeAssets\": [{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e6461575450303133345437\", \"Quantity\": 1}]}", + "{\"Lovelaces\": 10000000, \"NativeAssets\": [{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e6461575450303133345438\", \"Quantity\": 1}]}")] + public void Consolidate_Output_Values_Combining_Native_Assets_Correctly_When_Output_Values_Have_Distinct_Native_Assets(params string[] outputJson) + { + var outputValues = outputJson + .Select(json => JsonSerializer.Deserialize(json)) + .Select(ov => new AggregateValue(ov.Lovelaces, ov.NativeAssets.Select(na => new NativeAssetValue(na.PolicyId, na.AssetNameHex, na.Quantity)).ToArray())).ToArray(); + + var actualOutputValue = outputValues.Sum(); + + actualOutputValue.Lovelaces.Should().Be((ulong)outputValues.Sum(ov => (long)ov.Lovelaces)); + actualOutputValue.NativeAssets.Length.Should().Be(outputValues.SelectMany(ov => ov.NativeAssets).ToArray().Length); + } + + [Theory] + [InlineData( + "{\"Lovelaces\": 81590452, \"NativeAssets\": [{\"PolicyId\": \"da8c30857834c6ae7203935b89278c532b3995245295456f993e1d24\", \"AssetNameHex\": \"4c51\", \"Quantity\": 1243}]}", + "{\"Lovelaces\": 81590452, \"NativeAssets\": [{\"PolicyId\": \"da8c30857834c6ae7203935b89278c532b3995245295456f993e1d24\", \"AssetNameHex\": \"4c51\", \"Quantity\": 1243}]}")] + [InlineData( + "{\"Lovelaces\": 106547581, \"NativeAssets\": [{\"PolicyId\": \"f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add\", \"AssetNameHex\": \"566572697472656552617265\", \"Quantity\": 5005},{\"PolicyId\": \"da8c30857834c6ae7203935b89278c532b3995245295456f993e1d24\", \"AssetNameHex\": \"4c51\", \"Quantity\": 288}]}", + "{\"Lovelaces\": 81590452, \"NativeAssets\": [{\"PolicyId\": \"f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add\", \"AssetNameHex\": \"566572697472656552617265\", \"Quantity\": 5005},{\"PolicyId\": \"da8c30857834c6ae7203935b89278c532b3995245295456f993e1d24\", \"AssetNameHex\": \"4c51\", \"Quantity\": 32}]}", + "{\"Lovelaces\": 24957129, \"NativeAssets\": [{\"PolicyId\": \"da8c30857834c6ae7203935b89278c532b3995245295456f993e1d24\", \"AssetNameHex\": \"4c51\", \"Quantity\": 256}]}")] + [InlineData( + "{\"Lovelaces\":1525677267,\"NativeAssets\": [{\"PolicyId\": \"f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add\", \"AssetNameHex\": \"566572697472656552617265\", \"Quantity\": 579},{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e6461575450303133355431\", \"Quantity\": 1},{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e6461575450303133345437\", \"Quantity\": 1},{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e6461575450303133345438\", \"Quantity\": 1}]}", + "{\"Lovelaces\": 81590452, \"NativeAssets\": [{\"PolicyId\": \"f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add\", \"AssetNameHex\": \"566572697472656552617265\", \"Quantity\": 123}]}", + "{\"Lovelaces\": 10000000, \"NativeAssets\": [{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e6461575450303133355431\", \"Quantity\": 1}]}", + "{\"Lovelaces\":553947658, \"NativeAssets\": [{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e6461575450303133345437\", \"Quantity\": 1},{\"PolicyId\": \"f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add\", \"AssetNameHex\": \"566572697472656552617265\", \"Quantity\": 456}]}", + "{\"Lovelaces\":880139157, \"NativeAssets\": [{\"PolicyId\": \"a1c74e98c9a6618deed1e39f74062588ef2c43a19eaa2f11a1eda733\", \"AssetNameHex\": \"4a6f736570684d6972616e6461575450303133345438\", \"Quantity\": 1}]}")] + public void Consolidate_Output_Values_Combining_Native_Assets_Correctly_When_Output_Values_Have_Overlapping_Assets( + string expectedOutputJson, + params string[] outputValuesJson) + { + var outputValues = outputValuesJson + .Select(json => JsonSerializer.Deserialize(json)) + .Select(ov => new AggregateValue(ov.Lovelaces, ov.NativeAssets.Select(na => new NativeAssetValue(na.PolicyId, na.AssetNameHex, na.Quantity)).ToArray())).ToArray(); + + var actualOutputValue = outputValues.Sum(); + + var expectedFoldedOutputValue = JsonSerializer.Deserialize(expectedOutputJson); + actualOutputValue.Lovelaces.Should().Be(expectedFoldedOutputValue.Lovelaces); + actualOutputValue.NativeAssets.Length.Should().Be(expectedFoldedOutputValue.NativeAssets.Length); + outputValues.SelectMany(ov => ov.NativeAssets) + .All(na => actualOutputValue.NativeAssets.Count( + resultNativeAssets => resultNativeAssets.PolicyId == na.PolicyId && resultNativeAssets.AssetName== na.AssetName) == 1) + .Should().BeTrue(); + expectedFoldedOutputValue.NativeAssets + .All(ena => actualOutputValue.NativeAssets.Count( + ana => ana.PolicyId == ena.PolicyId && ana.AssetName== ena.AssetNameHex && ana.Quantity == ena.Quantity) == 1); } } + +public record OutputValueDto +{ + public ulong Lovelaces { get; set; } + public NativeAssetValueDto[] NativeAssets { get; set; } +} + +public record NativeAssetValueDto +{ + public string PolicyId { get; set; } + public string AssetNameHex { get; set; } + public ulong Quantity { get; set; } +} \ No newline at end of file From ca13a05ecb383e0ca68e872675da156fcddc65d9 Mon Sep 17 00:00:00 2001 From: safestak-keith Date: Sat, 18 Jun 2022 03:19:03 +1000 Subject: [PATCH 2/9] Updated to latest version of CardanoSharp with major refactoring --- Src/Abstractions/CardanoTypes.cs | 60 ++++- Src/Abstractions/IBlockfrostClient.cs | 23 +- Src/Abstractions/INetworkContextRetriever.cs | 9 + Src/Abstractions/INiftyDistributor.cs | 1 + Src/Abstractions/IUtxoRefunder.cs | 5 +- Src/Abstractions/IUtxoRetriever.cs | 2 +- Src/Abstractions/NiftyTypes.cs | 22 +- Src/DataAccess/Mappers/NiftyFileMapper.cs | 6 +- Src/DataAccess/Mintsafe.DataAccess.csproj | 4 +- Src/DataAccess/Models/NiftyFile.cs | 2 +- Src/DataAccess/Models/Sale.cs | 2 +- Src/DataImporter/Mintsafe.DataImporter.csproj | 2 +- Src/Lib/BlockfrostClient.cs | 87 +++++- Src/Lib/BlockfrostNetworkContextRetriever.cs | 62 +++++ Src/Lib/BlockfrostTxSubmitter.cs | 1 - Src/Lib/BlockfrostUtxoRetriever.cs | 52 ++-- Src/Lib/CardanoCliTxBuilder.cs | 27 +- Src/Lib/CardanoCliUtxoRetriever.cs | 34 +-- Src/Lib/CardanoSharpTxBuilder.cs | 220 +++++++++++++++ Src/Lib/DummyMintingKeychainRetriever.cs | 74 +++++ Src/Lib/EventIds.cs | 4 + Src/Lib/IMintingKeychainRetriever.cs | 12 + Src/Lib/ITransactionSigner.cs | 42 +++ Src/Lib/LocalNiftyDataService.cs | 96 +++++++ Src/Lib/MetadataBuilder.cs | 148 ++++++++++ Src/Lib/MetadataJsonBuilder.cs | 6 +- Src/Lib/Mintsafe.Lib.csproj | 2 + Src/Lib/MintsafeAppSettings.cs | 6 +- Src/Lib/NiftyAllocator.cs | 2 - Src/Lib/NiftyDistributor.cs | 253 ++++++++++++++++-- Src/Lib/PurchaseAttemptGenerator.cs | 2 +- Src/Lib/PurchaseExceptions.cs | 50 ++-- Src/Lib/SaleAllocationFileStore.cs | 8 +- Src/Lib/SimpleWalletService.cs | 156 +++++++++-- Src/Lib/TxUtils.cs | 78 +++++- Src/Lib/UtxoRefunder.cs | 49 ++-- Src/SaleWorker/ConfigTypes.cs | 8 + Src/SaleWorker/Mintsafe.SaleWorker.csproj | 6 +- Src/SaleWorker/Program.cs | 40 +-- Src/SaleWorker/SaleUtxoHandler.cs | 27 +- Src/SaleWorker/Worker.cs | 13 +- Src/SaleWorker/appsettings.json | 6 + Src/WasmApp/Mintsafe.WasmApp.csproj | 4 +- Src/WasmApp/Pages/AddressUtxo.razor | 2 +- Src/WasmApp/Pages/YoloWallet.razor | 2 +- Src/WasmApp/Program.cs | 1 + Src/WasmApp/Services/SimplePaymentService.cs | 11 +- .../Controllers/AddressUtxoController.cs | 2 +- .../Controllers/SimplePaymentController.cs | 3 - .../Mappers/NiftyCollectionMapperShould.cs | 1 - .../Mappers/NiftyFileMapperShould.cs | 6 +- .../Mappers/NiftyMapperShould.cs | 5 +- .../Mintsafe.DataAccess.UnitTests.csproj | 10 +- .../BlockfrostUtxoRetrieverShould.cs | 23 +- Tests/Lib.UnitTests/FakeGenerator.cs | 28 +- .../MetadataJsonBuilderShould.cs | 6 +- .../Mintsafe.Lib.UnitTests.csproj | 8 +- Tests/Lib.UnitTests/NiftyAllocatorShould.cs | 4 +- Tests/Lib.UnitTests/NiftyDistributorShould.cs | 177 ++++++------ .../PurchaseAttemptGeneratorShould.cs | 40 +-- .../SimpleWalletServiceShould.cs | 8 +- Tests/Lib.UnitTests/TimeUtilShould.cs | 11 +- Tests/Lib.UnitTests/TxUtilsShould.cs | 20 +- 63 files changed, 1664 insertions(+), 417 deletions(-) create mode 100644 Src/Abstractions/INetworkContextRetriever.cs create mode 100644 Src/Lib/BlockfrostNetworkContextRetriever.cs create mode 100644 Src/Lib/CardanoSharpTxBuilder.cs create mode 100644 Src/Lib/DummyMintingKeychainRetriever.cs create mode 100644 Src/Lib/IMintingKeychainRetriever.cs create mode 100644 Src/Lib/ITransactionSigner.cs create mode 100644 Src/Lib/MetadataBuilder.cs diff --git a/Src/Abstractions/CardanoTypes.cs b/Src/Abstractions/CardanoTypes.cs index 39a5cbb..df00930 100644 --- a/Src/Abstractions/CardanoTypes.cs +++ b/Src/Abstractions/CardanoTypes.cs @@ -1,21 +1,24 @@ using System; +using System.Collections.Generic; using System.Linq; namespace Mintsafe.Abstractions; +public enum Network { Mainnet, Testnet } + public static class Assets { public const string LovelaceUnit = "lovelace"; } -public record struct Value(string Unit, long Quantity); +public record struct Value(string Unit, ulong Quantity); public record Utxo(string TxHash, int OutputIndex, Value[] Values) { public override int GetHashCode() => ToString().GetHashCode(); public override string ToString() => $"{TxHash}_{OutputIndex}"; bool IEquatable.Equals(Utxo? other) => other != null && TxHash == other.TxHash && OutputIndex == other.OutputIndex; - public long Lovelaces => Values.First(v => v.Unit == Assets.LovelaceUnit).Quantity; + public ulong Lovelaces => Values.First(v => v.Unit == Assets.LovelaceUnit).Quantity; } // TODO: Ideal types @@ -33,19 +36,64 @@ bool IEquatable.Equals(UnspentTransactionOutput? other => other != null && TxHash == other.TxHash && OutputIndex == other.OutputIndex; public ulong Lovelaces => Value.Lovelaces; } +public record TransactionSummary(string TxHash, TransactionIo[] Inputs, TransactionIo[] Outputs); +public record TransactionIo(string Address, int OutputIndex, AggregateValue Values); + +public record BasicMintingPolicy(string[] PolicySigningKeysAll, uint ExpirySlot); +public record Mint(BasicMintingPolicy BasicMintingPolicy, NativeAssetValue[] NativeAssetsToMint); + +public record ProtocolParams(uint MajorVersion, uint MinorVersion, uint MinFeeA, uint MinFeeB, uint CoinsPerUtxoWord); +public record NetworkContext(uint LatestSlot, ProtocolParams ProtocolParams); + +public record BuildTransactionCommand( + UnspentTransactionOutput[] Inputs, + PendingTransactionOutput[] Outputs, + Mint[] Mint, + Dictionary> Metadata, + string[] PaymentSigningKeys, + Network Network, + uint TtlTipOffsetSlots = 5400); +public record BuiltTransaction(string TxHash, byte[] Bytes); // End TODO -public record TxIo(string Address, int OutputIndex, Value[] Values); +public record TxIo(string Address, uint OutputIndex, Value[] Values); public record TxInfo(string TxHash, TxIo[] Inputs, TxIo[] Outputs); -public record TxBuildOutput(string Address, Value[] Values, bool IsFeeDeducted = false); +public record TxBuildOutput(string Address, AggregateValue Values, bool IsFeeDeducted = false); public record TxBuildCommand( - Utxo[] Inputs, + UnspentTransactionOutput[] Inputs, TxBuildOutput[] Outputs, - Value[] Mint, + NativeAssetValue[] Mint, string MintingScriptPath, string MetadataJsonPath, long TtlSlot, string[] SigningKeyFiles); + +public interface INativeScript { } +//public class ScriptPubKey : INativeScript +//{ +// public byte[] KeyHash { get; init; } +//} +//public class ScriptInvalidAfter : INativeScript +//{ +// public ulong After { get; init; } +//} +//public class ScriptInvalidBefore : INativeScript +//{ +// public ulong Before { get; init; } +//} +//public class ScriptAll : INativeScript +//{ +// public INativeScript[] Scripts { get; init; } +//} +//public class ScriptAny : INativeScript +//{ +// public INativeScript[] Scripts { get; init; } +//} +//public class ScriptNofK : INativeScript +//{ +// public int N { get; init; } +// public INativeScript[] Scripts { get; init; } +//} \ No newline at end of file diff --git a/Src/Abstractions/IBlockfrostClient.cs b/Src/Abstractions/IBlockfrostClient.cs index a43ffb5..33ae35a 100644 --- a/Src/Abstractions/IBlockfrostClient.cs +++ b/Src/Abstractions/IBlockfrostClient.cs @@ -6,6 +6,8 @@ namespace Mintsafe.Abstractions; public interface IBlockfrostClient { + Task GetLatestBlockAsync(CancellationToken ct = default); + Task GetLatestProtocolParameters(CancellationToken ct = default); Task GetTransactionAsync(string txHash, CancellationToken ct = default); Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default); Task SubmitTransactionAsync(byte[] txSignedBinary, CancellationToken ct = default); @@ -27,6 +29,23 @@ public BlockfrostResponseException( } } +public class LatestBlock +{ + public uint? Epoch { get; init; } + public uint? Slot { get; init; } + public uint? Height { get; init; } + public string? Hash { get; init; } +} + +public class ProtocolParameters +{ + public uint? Protocol_major_ver { get; init; } + public uint? Protocol_minor_ver { get; init; } + public uint? Min_fee_a { get; init; } + public uint? Min_fee_b { get; init; } + public string? Coins_per_utxo_word { get; init; } +} + public class BlockFrostValue { public string? Unit { get; init; } @@ -36,7 +55,7 @@ public class BlockFrostValue public class BlockFrostTransactionIo { public string? Address { get; init; } - public int Output_Index { get; init; } + public uint Output_Index { get; init; } public BlockFrostValue[]? Amount { get; init; } } @@ -50,6 +69,6 @@ public class BlockFrostTransactionUtxoResponse public class BlockFrostAddressUtxo { public string? Tx_hash { get; init; } - public int Output_index { get; init; } + public uint Output_index { get; init; } public BlockFrostValue[]? Amount { get; init; } } diff --git a/Src/Abstractions/INetworkContextRetriever.cs b/Src/Abstractions/INetworkContextRetriever.cs new file mode 100644 index 0000000..17b72d9 --- /dev/null +++ b/Src/Abstractions/INetworkContextRetriever.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Mintsafe.Abstractions; + +public interface INetworkContextRetriever +{ + Task GetNetworkContext(CancellationToken ct = default); +} diff --git a/Src/Abstractions/INiftyDistributor.cs b/Src/Abstractions/INiftyDistributor.cs index af7dc08..1429506 100644 --- a/Src/Abstractions/INiftyDistributor.cs +++ b/Src/Abstractions/INiftyDistributor.cs @@ -9,5 +9,6 @@ Task DistributeNiftiesForSalePurchase( Nifty[] allocatedNfts, PurchaseAttempt purchaseRequest, SaleContext saleContext, + NetworkContext networkContext, CancellationToken ct = default); } diff --git a/Src/Abstractions/IUtxoRefunder.cs b/Src/Abstractions/IUtxoRefunder.cs index a5c86a4..3b4c29a 100644 --- a/Src/Abstractions/IUtxoRefunder.cs +++ b/Src/Abstractions/IUtxoRefunder.cs @@ -6,8 +6,9 @@ namespace Mintsafe.Abstractions; public interface IUtxoRefunder { Task ProcessRefundForUtxo( - Utxo utxo, - string signingKeyFilePath, + UnspentTransactionOutput utxo, + SaleContext saleContext, + NetworkContext networkContext, string reason, CancellationToken ct = default); } diff --git a/Src/Abstractions/IUtxoRetriever.cs b/Src/Abstractions/IUtxoRetriever.cs index 3a3bb16..08f6ff9 100644 --- a/Src/Abstractions/IUtxoRetriever.cs +++ b/Src/Abstractions/IUtxoRetriever.cs @@ -5,5 +5,5 @@ namespace Mintsafe.Abstractions; public interface IUtxoRetriever { - Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default); + Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default); } diff --git a/Src/Abstractions/NiftyTypes.cs b/Src/Abstractions/NiftyTypes.cs index b1dbf96..5dd3d26 100644 --- a/Src/Abstractions/NiftyTypes.cs +++ b/Src/Abstractions/NiftyTypes.cs @@ -41,7 +41,7 @@ public record NiftyFile( Guid NiftyId, string Name, string MediaType, - string Url, + string Src, string FileHash = ""); public record Royalty( @@ -54,7 +54,7 @@ public record Sale( bool IsActive, string Name, string Description, - long LovelacesPerToken, + ulong LovelacesPerToken, string SaleAddress, string CreatorAddress, string ProceedsAddress, @@ -73,18 +73,22 @@ public record SaleContext NiftyCollection Collection, List MintableTokens, List AllocatedTokens, - HashSet LockedUtxos, - HashSet SuccessfulUtxos, - HashSet RefundedUtxos, - HashSet FailedUtxos + HashSet LockedUtxos, + HashSet SuccessfulUtxos, + HashSet RefundedUtxos, + HashSet FailedUtxos ); public record PurchaseAttempt( Guid Id, Guid SaleId, - Utxo Utxo, + UnspentTransactionOutput Utxo, int NiftyQuantityRequested, - long ChangeInLovelace); + ulong ChangeInLovelace); + +public record MintingKeyChain( + string[] SigningKeys, + BasicMintingPolicy MintingPolicy); public enum NiftyDistributionOutcome { Successful = 1, @@ -103,7 +107,7 @@ public record NiftyDistributionResult( Nifty[]? NiftiesDistributed = null, Exception? Exception = null); -public record Mint( +public record MintRecord( Guid PurchaseAttemptId, Guid SaleId, string SaleAddress, diff --git a/Src/DataAccess/Mappers/NiftyFileMapper.cs b/Src/DataAccess/Mappers/NiftyFileMapper.cs index 5941ae0..de24aac 100644 --- a/Src/DataAccess/Mappers/NiftyFileMapper.cs +++ b/Src/DataAccess/Mappers/NiftyFileMapper.cs @@ -10,14 +10,14 @@ public static NiftyFile Map(Models.NiftyFile niftyFileDto) if (niftyFileDto.NiftyId == null) throw new ArgumentNullException(nameof(niftyFileDto.NiftyId)); if (niftyFileDto.Name == null) throw new ArgumentNullException(nameof(niftyFileDto.Name)); if (niftyFileDto.MediaType == null) throw new ArgumentNullException(nameof(niftyFileDto.MediaType)); - if (niftyFileDto.Url == null) throw new ArgumentNullException(nameof(niftyFileDto.Url)); + if (niftyFileDto.Src == null) throw new ArgumentNullException(nameof(niftyFileDto.Src)); return new NiftyFile( Guid.Parse(niftyFileDto.RowKey), Guid.Parse(niftyFileDto.NiftyId), niftyFileDto.Name, niftyFileDto.MediaType, - niftyFileDto.Url, + niftyFileDto.Src, niftyFileDto.FileHash ?? string.Empty ); } @@ -31,7 +31,7 @@ public static Models.NiftyFile Map(Guid collectionId, NiftyFile niftyFile) NiftyId = niftyFile.NiftyId.ToString(), Name = niftyFile.Name, MediaType = niftyFile.MediaType, - Url = niftyFile.Url, + Src = niftyFile.Src, FileHash = niftyFile.FileHash }; } diff --git a/Src/DataAccess/Mintsafe.DataAccess.csproj b/Src/DataAccess/Mintsafe.DataAccess.csproj index 553b094..434b772 100644 --- a/Src/DataAccess/Mintsafe.DataAccess.csproj +++ b/Src/DataAccess/Mintsafe.DataAccess.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/Src/DataAccess/Models/NiftyFile.cs b/Src/DataAccess/Models/NiftyFile.cs index 6019dd8..a9b48e6 100644 --- a/Src/DataAccess/Models/NiftyFile.cs +++ b/Src/DataAccess/Models/NiftyFile.cs @@ -11,7 +11,7 @@ public class NiftyFile : ITableEntity public string? NiftyId { get; set; } public string? Name { get; set; } public string? MediaType { get; set; } - public string? Url { get; set; } + public string? Src { get; set; } public string? FileHash { get; set; } public DateTimeOffset? Timestamp { get; set; } diff --git a/Src/DataAccess/Models/Sale.cs b/Src/DataAccess/Models/Sale.cs index 6ce61b4..3e3380f 100644 --- a/Src/DataAccess/Models/Sale.cs +++ b/Src/DataAccess/Models/Sale.cs @@ -11,7 +11,7 @@ public class Sale : ITableEntity public bool IsActive { get; set; } public string? Name { get; set; } public string? Description { get; set; } - public long LovelacesPerToken { get; set; } + public ulong LovelacesPerToken { get; set; } public string? SaleAddress { get; set; } public string? CreatorAddress { get; set; } public string? ProceedsAddress { get; set; } diff --git a/Src/DataImporter/Mintsafe.DataImporter.csproj b/Src/DataImporter/Mintsafe.DataImporter.csproj index a659aa3..98c08b5 100644 --- a/Src/DataImporter/Mintsafe.DataImporter.csproj +++ b/Src/DataImporter/Mintsafe.DataImporter.csproj @@ -8,7 +8,7 @@ - + diff --git a/Src/Lib/BlockfrostClient.cs b/Src/Lib/BlockfrostClient.cs index 0cb1023..465a5fb 100644 --- a/Src/Lib/BlockfrostClient.cs +++ b/Src/Lib/BlockfrostClient.cs @@ -29,12 +29,12 @@ public BlockfrostClient( ILogger logger, IInstrumentor instrumentor, MintsafeAppSettings settings, - HttpClient httpClient) + IHttpClientFactory factory) { _logger = logger; _instrumentor = instrumentor; _settings = settings; - _httpClient = httpClient; + _httpClient = factory.CreateClient(nameof(BlockfrostClient)); } public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) @@ -174,4 +174,87 @@ public async Task SubmitTransactionAsync(byte[] txSignedBinary, Cancella customProperties: dependencyProperties); } } + + public async Task GetLatestBlockAsync(CancellationToken ct = default) + { + var relativePath = $"api/v0/blocks/latest"; + + var isSuccessful = false; + LatestBlock? bfResponse = null; + var responseCode = 0; + var sw = Stopwatch.StartNew(); + try + { + var response = await _httpClient.GetAsync(relativePath, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + throw new BlockfrostResponseException($"Unsuccessful Blockfrost response:{responseBody}", (int)response.StatusCode, responseBody); + } + _logger.LogDebug($"{nameof(BlockfrostClient)}.{nameof(GetLatestBlockAsync)} from {relativePath} reponse: {responseCode}"); + bfResponse = await response.Content.ReadFromJsonAsync(SerialiserOptions, ct).ConfigureAwait(false); + if (bfResponse == null) + { + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + throw new BlockfrostResponseException($"BlockFrost response is null or cannot be deserialised {json}", responseCode, json); + } + isSuccessful = true; + return bfResponse; + } + finally + { + _instrumentor.TrackDependency( + EventIds.NetworkTipRetrievalElapsed, + sw.ElapsedMilliseconds, + DateTime.UtcNow, + nameof(BlockfrostClient), + relativePath, + nameof(GetLatestBlockAsync), + isSuccessful: isSuccessful, + customProperties: bfResponse != null + ? new Dictionary { { "Slot", bfResponse?.Slot ?? 0 } } + : null); + } + } + + public async Task GetLatestProtocolParameters(CancellationToken ct = default) + { + var relativePath = $"api/v0/epochs/latest/parameters"; + var isSuccessful = false; + ProtocolParameters? bfResponse = null; + var responseCode = 0; + var sw = Stopwatch.StartNew(); + try + { + var response = await _httpClient.GetAsync(relativePath, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + throw new BlockfrostResponseException($"Unsuccessful Blockfrost response:{responseBody}", (int)response.StatusCode, responseBody); + } + _logger.LogDebug($"{nameof(BlockfrostClient)}.{nameof(GetLatestProtocolParameters)} from {relativePath} reponse: {responseCode}"); + bfResponse = await response.Content.ReadFromJsonAsync(SerialiserOptions, ct).ConfigureAwait(false); + if (bfResponse == null) + { + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + throw new BlockfrostResponseException($"BlockFrost response is null or cannot be deserialised {json}", responseCode, json); + } + isSuccessful = true; + return bfResponse; + } + finally + { + _instrumentor.TrackDependency( + EventIds.NetworkProtocolParamsRetrievalElapsed, + sw.ElapsedMilliseconds, + DateTime.UtcNow, + nameof(BlockfrostClient), + relativePath, + nameof(GetLatestProtocolParameters), + isSuccessful: isSuccessful, + customProperties: bfResponse != null + ? new Dictionary { { "MajorVersion", bfResponse?.Protocol_major_ver ?? 0 } } + : null); + } + } } diff --git a/Src/Lib/BlockfrostNetworkContextRetriever.cs b/Src/Lib/BlockfrostNetworkContextRetriever.cs new file mode 100644 index 0000000..1dc2460 --- /dev/null +++ b/Src/Lib/BlockfrostNetworkContextRetriever.cs @@ -0,0 +1,62 @@ +using Mintsafe.Abstractions; +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Mintsafe.Lib; + +public class BlockfrostNetworkContextRetriever : INetworkContextRetriever +{ + private readonly IBlockfrostClient _blockFrostClient; + private readonly IInstrumentor _instrumentor; + + public BlockfrostNetworkContextRetriever(IBlockfrostClient blockFrostClient, IInstrumentor instrumentor) + { + _blockFrostClient = blockFrostClient; + _instrumentor = instrumentor; + } + + public async Task GetNetworkContext(CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + try + { + var tipTask = _blockFrostClient.GetLatestBlockAsync(ct).ConfigureAwait(false); + var protocolParamsTask = _blockFrostClient.GetLatestProtocolParameters(ct).ConfigureAwait(false); + var tip = await tipTask; + var protocolParams = await protocolParamsTask; + + // Null checks in case Blockfrost gives dodgy responses + if (tip.Slot == null) + throw new BlockfrostResponseException("BlockFrost response contains null fields", 200); + if (protocolParams.Protocol_major_ver == null || protocolParams.Protocol_minor_ver == null + || protocolParams.Min_fee_a == null || protocolParams.Min_fee_b == null || protocolParams.Coins_per_utxo_word == null) + throw new BlockfrostResponseException("BlockFrost response contains null fields", 200); + + return new NetworkContext( + LatestSlot: tip.Slot.Value, + new ProtocolParams( + MajorVersion: protocolParams.Protocol_major_ver.Value, + MinorVersion: protocolParams.Protocol_minor_ver.Value, + MinFeeA: protocolParams.Min_fee_a.Value, + MinFeeB: protocolParams.Min_fee_b.Value, + CoinsPerUtxoWord: uint.Parse(protocolParams.Coins_per_utxo_word))); + } + catch (Exception ex) + { + throw; + } + finally + { + _instrumentor.TrackDependency( + EventIds.NetworkContextRetrievalElapsed, + sw.ElapsedMilliseconds, + DateTime.UtcNow, + nameof(BlockfrostNetworkContextRetriever), + nameof(BlockfrostClient), + nameof(GetNetworkContext), + isSuccessful: true); + } + } +} diff --git a/Src/Lib/BlockfrostTxSubmitter.cs b/Src/Lib/BlockfrostTxSubmitter.cs index 582de0e..d304ab7 100644 --- a/Src/Lib/BlockfrostTxSubmitter.cs +++ b/Src/Lib/BlockfrostTxSubmitter.cs @@ -16,7 +16,6 @@ public BlockfrostTxSubmitter(IBlockfrostClient blockFrostClient) public async Task SubmitTxAsync(byte[] txSignedBinary, CancellationToken ct = default) { var txHash = await _blockFrostClient.SubmitTransactionAsync(txSignedBinary, ct); - return txHash; } } diff --git a/Src/Lib/BlockfrostUtxoRetriever.cs b/Src/Lib/BlockfrostUtxoRetriever.cs index 66b2599..3b36e35 100644 --- a/Src/Lib/BlockfrostUtxoRetriever.cs +++ b/Src/Lib/BlockfrostUtxoRetriever.cs @@ -20,7 +20,7 @@ public BlockfrostUtxoRetriever( _blockFrostClient = blockFrostClient; } - public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) + public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) { var bfResult = Array.Empty(); try @@ -32,36 +32,42 @@ public async Task GetUtxosAtAddressAsync(string address, CancellationTok catch (Exception ex) { _logger.LogError(EventIds.UtxoRetrievalError, ex, "Unhandled exception from the BlockfrostClient"); - return Array.Empty(); + return Array.Empty(); } return bfResult.Select(MapBlockFrostUtxoToUtxo).ToArray(); } - private static Utxo MapBlockFrostUtxoToUtxo(BlockFrostAddressUtxo bfUtxo) + private static UnspentTransactionOutput MapBlockFrostUtxoToUtxo(BlockFrostAddressUtxo bfUtxo) { - static Value MapValueFromAmount(BlockFrostValue bfVal) - { - if (bfVal.Quantity == null) - throw new BlockfrostResponseException("Blockfrost response has null amount.quantity", 0); - if (bfVal.Unit == null) - throw new BlockfrostResponseException("Blockfrost response has null amount.unit", 0); - - // Add a dividing '.' for cardano-cli compatibility - var unit = bfVal.Unit == Assets.LovelaceUnit - ? Assets.LovelaceUnit : bfVal.Unit.Insert(56, "."); - return new Value( - unit, - long.Parse(bfVal.Quantity)); - } - + if (bfUtxo.Tx_hash == null) + throw new BlockfrostResponseException("Blockfrost response has null txhash", 0); if (bfUtxo.Amount == null) throw new BlockfrostResponseException("Blockfrost response has null amount", 0); - return new Utxo( - bfUtxo.Tx_hash ?? throw new BlockfrostResponseException("Blockfrost response has null tx_hash", 0), - bfUtxo.Output_index, - bfUtxo.Amount - .Select(MapValueFromAmount).ToArray()); + var lovelaces = 0UL; + var index = 0; + var nativeAssets = new NativeAssetValue[bfUtxo.Amount.Length - 1]; + foreach (var val in bfUtxo.Amount) + { + if (val.Unit == null) + throw new BlockfrostResponseException("Blockfrost response has null unit", 0); + if (val.Quantity == null) + throw new BlockfrostResponseException("Blockfrost response has null quantity", 0); + if (val.Unit == Assets.LovelaceUnit) + { + lovelaces = ulong.Parse(val.Quantity); + continue; + } + var policyId = val.Unit[..56]; + var assetName = val.Unit[56..]; + nativeAssets[index++] = new NativeAssetValue(policyId, assetName, ulong.Parse(val.Quantity)); + } + var aggValue = new AggregateValue(lovelaces, nativeAssets); + + return new UnspentTransactionOutput( + bfUtxo.Tx_hash, + (uint)bfUtxo.Output_index, + aggValue); } } diff --git a/Src/Lib/CardanoCliTxBuilder.cs b/Src/Lib/CardanoCliTxBuilder.cs index 9106e57..4b1855f 100644 --- a/Src/Lib/CardanoCliTxBuilder.cs +++ b/Src/Lib/CardanoCliTxBuilder.cs @@ -2,10 +2,8 @@ using Mintsafe.Abstractions; using SimpleExec; using System; -using System.ComponentModel; using System.Diagnostics; using System.IO; -using System.Linq; using System.Text; using System.Text.Json; using System.Threading; @@ -102,7 +100,7 @@ await Command.ReadAsync( feeCalculationArgs, noEcho: true, cancellationToken: ct); - var feeLovelaceQuantity = long.Parse(feeCalculationCliOutput.Split(' ')[0]); // Parse "199469 Lovelace" + var feeLovelaceQuantity = ulong.Parse(feeCalculationCliOutput.Split(' ')[0]); // Parse "199469 Lovelace" _logger.LogDebug($"Fee Calculated {feeCalculationArgs}{Environment.NewLine}{feeCalculationCliOutput}"); var actualTxBodyOutputPath = Path.Combine(_settings.BasePath, $"{buildId}.txraw"); @@ -186,13 +184,13 @@ private static string GetTxInArgs(TxBuildCommand command) return sb.ToString(); } - private static string GetTxOutArgs(TxBuildCommand command, long fee = 0) + private static string GetTxOutArgs(TxBuildCommand command, ulong fee = 0) { var sb = new StringBuilder(); foreach (var output in command.Outputs) { // Determine the txout that will pay for the fee (i.e. the sale proceeds address and not the buyer) - var lovelacesOut = output.Values.First(v => v.Unit == Assets.LovelaceUnit).Quantity; + var lovelacesOut = output.Values.Lovelaces; if (output.IsFeeDeducted) { lovelacesOut -= fee; @@ -200,10 +198,10 @@ private static string GetTxOutArgs(TxBuildCommand command, long fee = 0) sb.Append($"--tx-out \"{output.Address}+{lovelacesOut}"); - var nativeTokens = output.Values.Where(o => o.Unit != Assets.LovelaceUnit).ToArray(); + var nativeTokens = output.Values.NativeAssets; foreach (var value in nativeTokens) { - sb.Append($"+{value.Quantity} {value.Unit}"); + sb.Append($"+{value.Quantity} {value.PolicyId}.{value.AssetName}"); } sb.Append("\" "); } @@ -220,7 +218,7 @@ private static string GetMintArgs(TxBuildCommand command) sb.Append($"--mint \""); foreach (var value in command.Mint) { - sb.Append($"{value.Quantity} {value.Unit}+"); + sb.Append($"{value.Quantity} {value.PolicyId}.{value.AssetName}+"); } sb.Remove(sb.Length - 1, 1); // trim trailing + sb.Append('"'); @@ -349,7 +347,7 @@ public async Task BuildTxAsync( _logger.LogDebug("Calculating fee using fee calculation tx (199469) from:"); _logger.LogDebug(feeCalculationArgs); - var feeLovelaceQuantity = 199469; + var feeLovelaceQuantity = 199469UL; var actualTxBodyPath = Path.Combine(_settings.BasePath, $"mint-{buildId}.txraw"); var txBuildArgs = string.Join(" ", @@ -389,13 +387,13 @@ private static string GetTxInArgs(TxBuildCommand command) return sb.ToString(); } - private static string GetTxOutArgs(TxBuildCommand command, long fee = 0) + private static string GetTxOutArgs(TxBuildCommand command, ulong fee = 0) { var sb = new StringBuilder(); foreach (var output in command.Outputs) { // Determine the txout that will pay for the fee (i.e. the sale proceeds address and not the buyer) - var lovelacesOut = output.Values.First(v => v.Unit == Assets.LovelaceUnit).Quantity; + var lovelacesOut = output.Values.Lovelaces; if (output.IsFeeDeducted) { lovelacesOut -= fee; @@ -403,11 +401,12 @@ private static string GetTxOutArgs(TxBuildCommand command, long fee = 0) sb.Append($"--tx-out \"{output.Address}+{lovelacesOut}"); - var nativeTokens = output.Values.Where(o => o.Unit != Assets.LovelaceUnit).ToArray(); + var nativeTokens = output.Values.NativeAssets; foreach (var value in nativeTokens) { - sb.Append($"+{value.Quantity} {value.Unit}"); + sb.Append($"+{value.Quantity} {value.PolicyId}.{value.AssetName}"); } + sb.Append("\" "); } sb.Remove(sb.Length - 1, 1); // trim trailing space @@ -423,7 +422,7 @@ private static string GetMintArgs(TxBuildCommand command) sb.Append($"--mint \""); foreach (var value in command.Mint) { - sb.Append($"{value.Quantity} {value.Unit}+"); + sb.Append($"{value.Quantity} {value.PolicyId}.{value.AssetName}+"); } sb.Remove(sb.Length - 1, 1); // trim trailing + sb.Append('"'); diff --git a/Src/Lib/CardanoCliUtxoRetriever.cs b/Src/Lib/CardanoCliUtxoRetriever.cs index 41b2606..fc8b0ff 100644 --- a/Src/Lib/CardanoCliUtxoRetriever.cs +++ b/Src/Lib/CardanoCliUtxoRetriever.cs @@ -31,7 +31,7 @@ public CardanoCliUtxoRetriever( : "--testnet-magic 1097911063"; } - public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) + public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) { var isSuccessful = false; var sw = Stopwatch.StartNew(); @@ -72,43 +72,45 @@ public async Task GetUtxosAtAddressAsync(string address, CancellationTok } var lines = rawUtxoResponse.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - var utxos = new Utxo[lines.Length - 2]; + var utxos = new UnspentTransactionOutput[lines.Length - 2]; var insertionIndex = 0; foreach (var utxoLine in lines[2..]) // skip the headers { var contentSegments = utxoLine.Split(' ', StringSplitOptions.RemoveEmptyEntries); - var values = ParseValues(contentSegments).ToArray(); + var values = ParseValues(contentSegments); - utxos[insertionIndex++] = new Utxo( + utxos[insertionIndex++] = new UnspentTransactionOutput( TxHash: contentSegments[0], - OutputIndex: int.Parse(contentSegments[1]), - Values: values); + OutputIndex: uint.Parse(contentSegments[1]), + Value: values); } return utxos; } - private static IEnumerable ParseValues(string[] utxoLineSegments) + private static AggregateValue ParseValues(string[] utxoLineSegments) { // Must always contain an ADA/lovelace UTXO value - var lovelaceValue = new Value(Assets.LovelaceUnit, long.Parse(utxoLineSegments[2])); - yield return lovelaceValue; + var lovelaceValue = ulong.Parse(utxoLineSegments[2]); + var nativeAssets = new List(); var currentSegmentIndex = 4; // 4 comes frrom skipping [0]{txHash} [1]{txOutputIndex} [2]{txOutputLovelaceValue} [3]lovelace while (utxoLineSegments[currentSegmentIndex] == "+" && utxoLineSegments[currentSegmentIndex + 1] != "TxOutDatumNone") { //_logger.LogDebug($"FOUND {utxoLineSegments[currentSegmentIndex]} AND {utxoLineSegments[currentSegmentIndex + 1]}"); - var quantity = long.Parse(utxoLineSegments[currentSegmentIndex + 1]); + var quantity = ulong.Parse(utxoLineSegments[currentSegmentIndex + 1]); var unit = utxoLineSegments[currentSegmentIndex + 2]; - yield return new Value(unit, quantity); + var unitParts = unit.Split('.'); + nativeAssets.Add(new NativeAssetValue(unitParts[0], unitParts[1], quantity)); currentSegmentIndex += 3; // skip "+ {quantity} {unit}" } + return new AggregateValue(lovelaceValue, nativeAssets.ToArray()); } } public class FakeUtxoRetriever : IUtxoRetriever { - public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) + public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) { await Task.Delay(1000, ct).ConfigureAwait(false); return GenerateUtxos(3, @@ -117,16 +119,16 @@ public async Task GetUtxosAtAddressAsync(string address, CancellationTok 60_000000); } - private static Utxo[] GenerateUtxos(int count, params long[] values) + private static UnspentTransactionOutput[] GenerateUtxos(int count, params ulong[] values) { if (values.Length != count) throw new ArgumentException($"{nameof(values)} must be the same length as count", nameof(values)); return Enumerable.Range(0, count) - .Select(i => new Utxo( + .Select(i => new UnspentTransactionOutput( "127745e23b81a5a5e22a409ce17ae8672b234dda7be1f09fc9e3a11906bd3a11", - i, - new[] { new Value(Assets.LovelaceUnit, values[i]) })) + (uint)i, + new AggregateValue(values[i], Array.Empty()))) .ToArray(); } } diff --git a/Src/Lib/CardanoSharpTxBuilder.cs b/Src/Lib/CardanoSharpTxBuilder.cs new file mode 100644 index 0000000..02917c0 --- /dev/null +++ b/Src/Lib/CardanoSharpTxBuilder.cs @@ -0,0 +1,220 @@ +using CardanoSharp.Wallet.Extensions; +using CardanoSharp.Wallet.Extensions.Models; +using CardanoSharp.Wallet.Extensions.Models.Transactions; +using CardanoSharp.Wallet.Models.Addresses; +using CardanoSharp.Wallet.Models.Keys; +using CardanoSharp.Wallet.TransactionBuilding; +using CardanoSharp.Wallet.Utilities; +using Microsoft.Extensions.Logging; +using Mintsafe.Abstractions; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Mintsafe.Lib; + +public class CardanoSharpException : ApplicationException +{ + public string Network { get; } + public BuildTransactionCommand Command { get; } + + public CardanoSharpException( + string message, + BuildTransactionCommand command, + Exception inner, + string network) : base(message, inner) + { + Network = network; + Command = command; + } +} + +public interface ITransactionBuilder +{ + BuiltTransaction BuildTx( + BuildTransactionCommand buildCommand, + NetworkContext networkContext); +} + +public class CardanoSharpTxBuilder : ITransactionBuilder +{ + private const ulong FeePadding = 280; + private readonly ILogger _logger; + private readonly IInstrumentor _instrumentor; + private readonly MintsafeAppSettings _settings; + + public CardanoSharpTxBuilder( + ILogger logger, + IInstrumentor instrumentor, + MintsafeAppSettings settings) + { + _logger = logger; + _instrumentor = instrumentor; + _settings = settings; + } + + public BuiltTransaction BuildTx( + BuildTransactionCommand buildCommand, + NetworkContext networkContext) + { + var isSuccessful = false; + var sw = Stopwatch.StartNew(); + try + { + var txInputs = buildCommand.Inputs; + var consolidatedInputValue = BuildConsolidatedTxInputValue( + txInputs, buildCommand.Mint.SelectMany(m => m.NativeAssetsToMint).ToArray()); + // Outputs + var txOutputs = buildCommand.Outputs; + var consolidatedOutputValue = txOutputs.Select(txOut => txOut.Value).Sum(); + var valueDifference = consolidatedInputValue.Subtract(consolidatedOutputValue); + if (valueDifference.Lovelaces != 0 && !valueDifference.NativeAssets.All(na => na.Quantity == 0)) + { + throw new InputOutputValueMismatchException( + "Input/Output value mismatch", buildCommand.Inputs, buildCommand.Outputs); + } + + // Start building transaction body using CardanoSharp + var txBodyBuilder = TransactionBodyBuilder.Create + .SetFee(0) + .SetTtl(networkContext.LatestSlot + buildCommand.TtlTipOffsetSlots); + // TxInputs + foreach (var txInput in txInputs) + { + txBodyBuilder.AddInput(txInput.TxHash, txInput.OutputIndex); + } + // TxOutputs + foreach (var txOutput in txOutputs) + { + var tokenBundleBuilder = (txOutput.Value.NativeAssets.Length > 0) + ? GetTokenBundleBuilderFromNativeAssets(txOutput.Value.NativeAssets) + : null; + txBodyBuilder.AddOutput(new Address(txOutput.Address), txOutput.Value.Lovelaces, tokenBundleBuilder); + } + // TxMint + if (buildCommand.Mint.Length > 0) + { + // Build Cardano Native Assets from TestResults + var freshMintTokenBundleBuilder = TokenBundleBuilder.Create; + foreach (var newAssetMint in buildCommand.Mint.SelectMany(m => m.NativeAssetsToMint)) + { + freshMintTokenBundleBuilder = freshMintTokenBundleBuilder + .AddToken(newAssetMint.PolicyId.HexToByteArray(), newAssetMint.AssetName.HexToByteArray(), 1); + } + txBodyBuilder.SetMint(freshMintTokenBundleBuilder); + } + // TxWitnesses + var witnesses = TransactionWitnessSetBuilder.Create; + foreach (var signingKey in buildCommand.PaymentSigningKeys) + { + var paymentSkey = TxUtils.GetPrivateKeyFromBech32SigningKey(signingKey); + witnesses.AddVKeyWitness(paymentSkey.GetPublicKey(false), paymentSkey); + } + foreach (var policy in buildCommand.Mint.Select(m => m.BasicMintingPolicy)) + { + foreach (var policySigningKey in policy.PolicySigningKeysAll) + { + var policyKey = TxUtils.GetPrivateKeyFromBech32SigningKey(policySigningKey); + witnesses.AddVKeyWitness(policyKey.GetPublicKey(false), policyKey); + } + // Build NativeScript + var policyScriptAllBuilder = GetScriptAllBuilder( + policy.PolicySigningKeysAll.Select(TxUtils.GetPrivateKeyFromBech32SigningKey), + policy.ExpirySlot); + witnesses.SetScriptAllNativeScript(policyScriptAllBuilder); + } + + // Build Tx for fee calculation + var txBuilder = TransactionBuilder.Create + .SetBody(txBodyBuilder) + .SetWitnesses(witnesses); + // Metadata + var auxDataBuilder = AuxiliaryDataBuilder.Create; + foreach (var key in buildCommand.Metadata.Keys) + { + auxDataBuilder = auxDataBuilder.AddMetadata(key, buildCommand.Metadata[key]); + } + txBuilder = txBuilder.SetAuxData(auxDataBuilder); + _logger.LogInformation("Build Metadata {txMetadata}", auxDataBuilder); + // Calculate and update change Utxo + var tx = txBuilder.Build(); + var fee = tx.CalculateFee(networkContext.ProtocolParams.MinFeeA, networkContext.ProtocolParams.MinFeeB) + FeePadding; + txBodyBuilder.SetFee(fee); + tx.TransactionBody.TransactionOutputs.Last().Value.Coin -= fee; + var txBytes = tx.Serialize(); + var txHash = HashUtility.Blake2b256(tx.TransactionBody.Serialize(auxDataBuilder.Build())).ToStringHex(); + _logger.LogInformation("Built mint tx {elapnsed}ms", sw.ElapsedMilliseconds); + var cborHex = txBytes.ToStringHex(); + return new BuiltTransaction(txHash, txBytes); + } + catch (Exception ex) + { + throw new CardanoSharpException( + $"Unhandled exception in {nameof(CardanoSharpTxBuilder)}", + command: buildCommand, + ex, + _settings.Network.ToString()); + } + finally + { + _instrumentor.TrackDependency( + EventIds.TxBuilderElapsed, + sw.ElapsedMilliseconds, + DateTime.UtcNow, + nameof(CardanoSharpTxBuilder), + nameof(CardanoSharp), + nameof(BuildTx), + isSuccessful: isSuccessful); + } + } + + private static AggregateValue BuildConsolidatedTxInputValue( + UnspentTransactionOutput[] sourceAddressUtxos, + NativeAssetValue[]? nativeAssetsToMint) + { + if (nativeAssetsToMint != null && nativeAssetsToMint.Length > 0) + { + return sourceAddressUtxos + .Select(utxo => utxo.Value) + .Concat(new[] { new AggregateValue(0, nativeAssetsToMint) }) + .Sum(); + } + return sourceAddressUtxos.Select(utxo => utxo.Value).Sum(); + } + + private static ITokenBundleBuilder? GetTokenBundleBuilderFromNativeAssets(NativeAssetValue[] nativeAssets) + { + if (nativeAssets.Length == 0) + return null; + + var tokenBundleBuilder = TokenBundleBuilder.Create; + foreach (var nativeAsset in nativeAssets) + { + tokenBundleBuilder = tokenBundleBuilder.AddToken( + nativeAsset.PolicyId.HexToByteArray(), + nativeAsset.AssetName.HexToByteArray(), + nativeAsset.Quantity); + } + return tokenBundleBuilder; + } + + private static IScriptAllBuilder GetScriptAllBuilder( + IEnumerable policySKeys, ulong? policyExpiry = null) + { + var scriptAllBuilder = ScriptAllBuilder.Create; + if (policyExpiry.HasValue) + { + scriptAllBuilder.SetScript( + NativeScriptBuilder.Create.SetInvalidAfter((uint)policyExpiry.Value)); + } + foreach (var policySKey in policySKeys) + { + var policyVKey = policySKey.GetPublicKey(false); + var policyVKeyHash = HashUtility.Blake2b224(policyVKey.Key); + scriptAllBuilder = scriptAllBuilder.SetScript( + NativeScriptBuilder.Create.SetKeyHash(policyVKeyHash)); + } + return scriptAllBuilder; + } +} diff --git a/Src/Lib/DummyMintingKeychainRetriever.cs b/Src/Lib/DummyMintingKeychainRetriever.cs new file mode 100644 index 0000000..ebb4ec5 --- /dev/null +++ b/Src/Lib/DummyMintingKeychainRetriever.cs @@ -0,0 +1,74 @@ +using Azure.Core; +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Logging; +using Mintsafe.Abstractions; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Mintsafe.Lib; + +public class KeyVaultMintingKeychainRetriever : IMintingKeychainRetriever +{ + private readonly ILogger _logger; + private readonly IInstrumentor _instrumentor; + private readonly MintsafeAppSettings _settings; + + public KeyVaultMintingKeychainRetriever( + ILogger logger, + IInstrumentor instrumentor, + MintsafeAppSettings settings) + { + _logger = logger; + _instrumentor = instrumentor; + _settings = settings; + } + + public async Task GetMintingKeyChainAsync( + SaleContext saleContext, + CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + + var client = new SecretClient( + new Uri(_settings.KeyVaultUrl ?? throw new ApplicationException("KeyVault settings invalid - null URL")), + new DefaultAzureCredential(), + new SecretClientOptions + { + Retry = + { + Delay= TimeSpan.FromSeconds(2), + MaxDelay = TimeSpan.FromSeconds(16), + MaxRetries = 5, + Mode = RetryMode.Exponential + } + }); + + var signingKeyResponse = (await client.GetSecretAsync($"{saleContext.Collection.PolicyId}-MintSigningKey", cancellationToken: ct)); + var policyKeyResponse = (await client.GetSecretAsync($"{saleContext.Collection.PolicyId}-MintPolicyKey", cancellationToken: ct)); + if (signingKeyResponse == null || signingKeyResponse.GetRawResponse().IsError + || policyKeyResponse == null || policyKeyResponse.GetRawResponse().IsError) + { + throw new ApplicationException("Unsuccessful responses retrieving minting keychain secrets"); + } + + _instrumentor.TrackDependency( + EventIds.KeychainRetrievalElapsed, + sw.ElapsedMilliseconds, + DateTime.UtcNow, + nameof(KeyVaultMintingKeychainRetriever), + saleContext.Sale.Id.ToString(), + nameof(GetMintingKeyChainAsync), + isSuccessful: true); + + return new MintingKeyChain( + new[] { signingKeyResponse.Value.Value }, + new BasicMintingPolicy(new[] { policyKeyResponse.Value.Value }, (uint)saleContext.Collection.SlotExpiry)); + } +} + diff --git a/Src/Lib/EventIds.cs b/Src/Lib/EventIds.cs index 3cff0d9..f24e605 100644 --- a/Src/Lib/EventIds.cs +++ b/Src/Lib/EventIds.cs @@ -22,6 +22,9 @@ public static class EventIds public const int SaleContextReleaseElapsed = 120016; public const int UtxoRetrievalError = 130000; public const int UtxoRetrievalElapsed = 130004; + public const int NetworkContextRetrievalElapsed = 130005; + public const int NetworkTipRetrievalElapsed = 130006; + public const int NetworkProtocolParamsRetrievalElapsed = 130007; public const int SaleHandlerUnhandledError = 140000; public const int SaleHandlerElapsed = 140004; public const int SaleInactive = 140011; @@ -41,6 +44,7 @@ public static class EventIds public const int UtxoRefunderError = 200000; public const int UtxoRefunderElapsed = 200004; public const int PaymentElapsed = 210004; + public const int KeychainRetrievalElapsed = 220004; public const int BlockfrostServerErrorResponse = 30001; public const int BlockfrostBadRequestResponse = 30002; public const int BlockfrostTooManyRequestsResponse = 30003; diff --git a/Src/Lib/IMintingKeychainRetriever.cs b/Src/Lib/IMintingKeychainRetriever.cs new file mode 100644 index 0000000..fe8780c --- /dev/null +++ b/Src/Lib/IMintingKeychainRetriever.cs @@ -0,0 +1,12 @@ +using Mintsafe.Abstractions; +using System.Threading; +using System.Threading.Tasks; + +namespace Mintsafe.Lib; + +public interface IMintingKeychainRetriever +{ + Task GetMintingKeyChainAsync( + SaleContext saleContext, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/Src/Lib/ITransactionSigner.cs b/Src/Lib/ITransactionSigner.cs new file mode 100644 index 0000000..b3de4ca --- /dev/null +++ b/Src/Lib/ITransactionSigner.cs @@ -0,0 +1,42 @@ +using CardanoSharp.Wallet.Extensions.Models.Transactions; +using CardanoSharp.Wallet.Models.Transactions; +using Microsoft.Extensions.Logging; +using Mintsafe.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mintsafe.Lib; + +public class TransactionSigner +{ + private readonly ILogger _logger; + private readonly IInstrumentor _instrumentor; + private readonly MintsafeAppSettings _settings; + + public TransactionSigner( + ILogger logger, + IInstrumentor instrumentor, + MintsafeAppSettings settings) + { + _logger = logger; + _instrumentor = instrumentor; + _settings = settings; + } + + public byte[] AppendWitnesses(byte[] transactionBytes) + { + var tx = transactionBytes.DeserializeTransaction(); + + //tx.TransactionWitnessSet.VKeyWitnesses.Add( + // new VKeyWitness + // { + + // }); + + return transactionBytes; + } + +} diff --git a/Src/Lib/LocalNiftyDataService.cs b/Src/Lib/LocalNiftyDataService.cs index 5a3eabb..29c2cab 100644 --- a/Src/Lib/LocalNiftyDataService.cs +++ b/Src/Lib/LocalNiftyDataService.cs @@ -2,7 +2,10 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; @@ -127,3 +130,96 @@ Dictionary GetAttributesForIndex(int i) .ToArray(); } } + +public class TacfDataService : INiftyDataService +{ + public const string TicketCollectionId = "9e0d7f84-9b47-404a-b32e-c86f62d512dc"; + public const string TicketSaleId = "cb0885b1-48c4-433a-ac56-cd12fccef663"; + + public Task GetCollectionAggregateAsync(Guid collectionId, CancellationToken ct = default) + { + var fakeCollectionId = Guid.Parse(TicketCollectionId); + + var sale = new Sale( + Id: Guid.Parse(TicketSaleId), + CollectionId: fakeCollectionId, + IsActive: true, + Name: "Ticket Sale #1", + Description: "", + LovelacesPerToken: 125_000000, + Start: new DateTime(2022, 6, 1, 0, 0, 0, DateTimeKind.Utc), + End: new DateTime(2023, 7, 23, 0, 0, 0, DateTimeKind.Utc), + SaleAddress: "addr_test1vq3qme7zjxj8xsn547s7mf0j9t9x8h2x30f00vsvhag3g6snqkn9v", // addr1vy3qme7zjxj8xsn547s7mf0j9t9x8h2x30f00vsvhag3g6sggz02f + CreatorAddress: "addr_test1qpegeshnlug9g88xla67d9f6xz47z4fsx9jfyy35z04ekrzjdx9mm3ju60d74u9p86uukq9ldvjza3ycr5d9jlvqxeqsdwyruy", // ? + ProceedsAddress: "addr_test1vz93vkgv8kg5lralfmspl92c039hlu7y3vpjrccpcjg3l7qzflgnf", // addr1vx93vkgv8kg5lralfmspl92c039hlu7y3vpjrccpcjg3l7qept5uv + PostPurchaseMargin: 0.1m, + TotalReleaseQuantity: 100, + MaxAllowedPurchaseQuantity: 1); + + var tokens = Enumerable.Range(1, 100) + .Select(i => + new Nifty( + Guid.NewGuid(), + fakeCollectionId, + true, + $"TACF_GCST_C_{i}", + $"TACF Global Concert Series Ticket C{i}", + null, + new[] { "takialsop.org" }, + "data:image/svg+xml;utf8,", + "image/svg+xml", + Array.Empty(), + new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), + "1.0", + new[]{ + new KeyValuePair("Organization", "Taki Alsop Conducting Fellowship"), + new KeyValuePair("Series", "TACF Global Concert Series")})) + .ToArray(); + + var collection = new NiftyCollection( + Id: fakeCollectionId, + PolicyId: "19bb7dd38a3ef5ddbe646d36bd376ebed20ef370c4eb81d9b08e6b33",// "b1e785ba20061e1320693dce777c9939eea8cfae74a9120c844b8b1b", + Name: "TACF Global Concert Series", + Description: "125 ADA, 115 NFTs, 23th July 2023 Expiry", + IsActive: true, + Publishers: new[] { "takialsop.org", "mintsafe.io" }, + BrandImage: "", + CreatedAt: new DateTime(2022, 01, 06, 0, 0, 0, DateTimeKind.Utc), + LockedAt: new DateTime(2023, 7, 23, 0, 0, 0, DateTimeKind.Utc), + SlotExpiry: 96997186, //98504109 + Royalty: new Royalty(0.08, "addr1q9kjqwrp8fy5ea8qlac6dlecz2dc4grchm8q6rgvv4gdup9zflwyjvg8uhpyfhh46khtt90cz4wzsvqqc44cs4qmyx6qts4he6")); + + var activeSales = collection.IsActive && IsSaleOpen(sale) ? new[] { sale } : Array.Empty(); + + return Task.FromResult(new CollectionAggregate(collection, tokens, ActiveSales: activeSales)); + } + + public Task InsertCollectionAggregateAsync(CollectionAggregate collectionAggregate, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + private List LoadDynamicJsonFromDirAsync(string path) + { + var files = Directory.GetFiles(path); + var list = new List(); + foreach (var filePath in files) + { + var raw = File.ReadAllText(filePath); + var model = JsonSerializer.Deserialize(raw); + list.Add(model); + } + + return list.Where(x => x != null).ToList(); + } + + private static bool IsSaleOpen(Sale sale) + { + if (!sale.IsActive + || (sale.Start > DateTime.UtcNow) + || (sale.End.HasValue && sale.End < DateTime.UtcNow)) + return false; + + return true; + } +} diff --git a/Src/Lib/MetadataBuilder.cs b/Src/Lib/MetadataBuilder.cs new file mode 100644 index 0000000..40ff910 --- /dev/null +++ b/Src/Lib/MetadataBuilder.cs @@ -0,0 +1,148 @@ +using Mintsafe.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mintsafe.Lib; + +public static class MetadataBuilder +{ + public const int MaxMetadataStringLength = 64; + public const int NftStandardKey = 721; + public const int MessageStandardKey = 674; + public const int NftRoyaltyStandardKey = 777; + + public static Dictionary> BuildNftMintMetadata( + Nifty[] nifties, NiftyCollection collection) + { + var assetNameDictionary = new Dictionary>(); + foreach (var nft in nifties) + { + var metadataDictionary = new Dictionary + { + { nameof(nft.Name), nft.Name }, + }; + if (!string.IsNullOrWhiteSpace(nft.Description)) + { + metadataDictionary.Add( + nameof(nft.Description), + nft.Description.Length > MaxMetadataStringLength + ? SplitStringToChunks(nft.Description) + : nft.Description); + } + if (nft.Creators.Any()) + { + metadataDictionary.Add(nameof(nft.Creators), nft.Creators); + } + if (collection.Publishers.Any()) + { + metadataDictionary.Add(nameof(collection.Publishers), collection.Publishers); + } + if (!string.IsNullOrWhiteSpace(nft.Image)) + { + metadataDictionary.Add( + "image", + nft.Image.Length > MaxMetadataStringLength + ? SplitStringToChunks(nft.Image) + : nft.Image); + } + if (!string.IsNullOrWhiteSpace(nft.MediaType)) + { + metadataDictionary.Add("mediaType", nft.MediaType); + } + if (nft.Files.Any()) + { + foreach (var file in nft.Files) + { + var fileDictionary = new Dictionary(); + if (!string.IsNullOrWhiteSpace(file.Name)) + { + fileDictionary.Add("name", file.Name); + } + if (!string.IsNullOrWhiteSpace(file.MediaType)) + { + fileDictionary.Add("mediaType", file.MediaType); + } + if (!string.IsNullOrWhiteSpace(file.FileHash)) + { + fileDictionary.Add(nameof(file.FileHash), file.FileHash); + } + if (!string.IsNullOrWhiteSpace(file.Src)) + { + fileDictionary.Add( + "src", + file.Src.Length > MaxMetadataStringLength + ? SplitStringToChunks(file.Src) + : file.Src); + } + } + } + if (nft.Attributes.Length > 1) + { + foreach (var attribute in nft.Attributes) + { + metadataDictionary.Add(attribute.Key, attribute.Value); + } + } + assetNameDictionary.Add(nft.AssetName, metadataDictionary); + } + + var policyMetadata = new Dictionary + { + { collection.PolicyId, assetNameDictionary } + }; + + var nftStandardMetadata = new Dictionary>(); + nftStandardMetadata.Add(NftStandardKey, policyMetadata); + return nftStandardMetadata; + } + + public static Dictionary> BuildNftRoyaltyMetadata(Royalty royalty) + { + var nftRoyaltyMetadata = new Dictionary>(); + var royaltyDictionary = new Dictionary + { + { "rate", royalty.PortionOfSale.ToString() }, + { "addr", royalty.Address.Length > 64 ? SplitStringToChunks(royalty.Address) : royalty.Address } + }; + nftRoyaltyMetadata.Add(NftRoyaltyStandardKey, royaltyDictionary); + return nftRoyaltyMetadata; + } + + public static Dictionary> BuildMessageMetadata(string message) + { + var messageBodyMetadata = new Dictionary + { + { "msg", message.Length > 64 ? SplitStringToChunks(message) : message }, + { "at", DateTime.UtcNow.ToString("o") }, + }; + var messageMetadata = new Dictionary> + { { MessageStandardKey, messageBodyMetadata } }; + + return messageMetadata; + } + + private static string[] SplitStringToChunks(string? value, int maxLength = MaxMetadataStringLength) + { + if (value == null) + return Array.Empty(); + if (value.Length <= maxLength) + return new[] { value }; + + var offsetLength = maxLength - 1; + var itemsLength = (value.Length + offsetLength) / maxLength; + var items = new string[itemsLength]; + for (var i = 0; i < itemsLength; i++) + { + var substringStartIndex = i * maxLength; + var substringLength = (substringStartIndex + maxLength) <= value.Length + ? maxLength + : value.Length % maxLength; // take remainder mod to prevent index-out-of-bounds + var segment = value.Substring(substringStartIndex, substringLength); + items[i] = segment; + } + return items; + } +} diff --git a/Src/Lib/MetadataJsonBuilder.cs b/Src/Lib/MetadataJsonBuilder.cs index 6200c38..7290d25 100644 --- a/Src/Lib/MetadataJsonBuilder.cs +++ b/Src/Lib/MetadataJsonBuilder.cs @@ -98,7 +98,7 @@ public string GenerateNftStandardJson( { var hasOnChainNifties = nfts.Any( n => (n.Image != null && n.Image.Length > MaxMetadataStringLength) - || n.Files.Any(nf => nf.Url.Length > MaxMetadataStringLength)); + || n.Files.Any(nf => nf.Src.Length > MaxMetadataStringLength)); return hasOnChainNifties ? GetOnChainNftStandardJson(nfts, collection) @@ -134,7 +134,7 @@ private string GetOffChainNftStandardJson(Nifty[] nfts, NiftyCollection collecti Publishers = collection.Publishers, Files = nft.Files.Length == 0 ? null // don't serialise empty arrays : nft.Files.Select( - f => new CnftStandardFile { Name = f.Name, MediaType = f.MediaType, Src = f.Url, Hash = f.FileHash }).ToArray(), + f => new CnftStandardFile { Name = f.Name, MediaType = f.MediaType, Src = f.Src, Hash = f.FileHash }).ToArray(), Attributes = nft.Attributes.Length == 0 ? null : nft.Attributes }; nftDictionary.Add(nft.AssetName, nftAsset); @@ -179,7 +179,7 @@ private string GetOnChainNftStandardJson( Publishers = collection.Publishers, Files = nft.Files.Length == 0 ? null // don't serialise empty arrays : nft.Files.Select( - f => new CnftOnChainStandardFile { Name = f.Name, MediaType = f.MediaType, Src = SplitStringToChunks(f.Url), Hash = f.FileHash }).ToArray(), + f => new CnftOnChainStandardFile { Name = f.Name, MediaType = f.MediaType, Src = SplitStringToChunks(f.Src), Hash = f.FileHash }).ToArray(), Attributes = nft.Attributes.Length == 0 ? null : nft.Attributes }; nftDictionary.Add(nft.AssetName, nftAsset); diff --git a/Src/Lib/Mintsafe.Lib.csproj b/Src/Lib/Mintsafe.Lib.csproj index 3fa223f..c5002e4 100644 --- a/Src/Lib/Mintsafe.Lib.csproj +++ b/Src/Lib/Mintsafe.Lib.csproj @@ -7,6 +7,8 @@ + + diff --git a/Src/Lib/MintsafeAppSettings.cs b/Src/Lib/MintsafeAppSettings.cs index ef5fb02..15349be 100644 --- a/Src/Lib/MintsafeAppSettings.cs +++ b/Src/Lib/MintsafeAppSettings.cs @@ -1,9 +1,8 @@ -using System; +using Mintsafe.Abstractions; +using System; namespace Mintsafe.Lib; -public enum Network { Mainnet, Testnet } - public record MintsafeAppSettings { public Network Network { get; init; } @@ -12,4 +11,5 @@ public record MintsafeAppSettings public string? BlockFrostApiKey { get; init; } public string? AppInsightsInstrumentationKey { get; init; } public Guid CollectionId { get; init; } + public string? KeyVaultUrl { get; init; } } diff --git a/Src/Lib/NiftyAllocator.cs b/Src/Lib/NiftyAllocator.cs index 4cc67c9..1eef67a 100644 --- a/Src/Lib/NiftyAllocator.cs +++ b/Src/Lib/NiftyAllocator.cs @@ -1,8 +1,6 @@ using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; diff --git a/Src/Lib/NiftyDistributor.cs b/Src/Lib/NiftyDistributor.cs index b3a8d96..9f7254f 100644 --- a/Src/Lib/NiftyDistributor.cs +++ b/Src/Lib/NiftyDistributor.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -46,6 +47,7 @@ public async Task DistributeNiftiesForSalePurchase( Nifty[] nfts, PurchaseAttempt purchaseAttempt, SaleContext saleContext, + NetworkContext networkContext, CancellationToken ct = default) { var swTotal = Stopwatch.StartNew(); @@ -62,7 +64,8 @@ public async Task DistributeNiftiesForSalePurchase( Exception: buyerAddressException); } - var tokenMintValues = nfts.Select(n => new Value($"{saleContext.Collection.PolicyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName))}", 1)).ToArray(); + var tokenMintValues = nfts.Select(n => new NativeAssetValue( + saleContext.Collection.PolicyId, Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName)), 1)).ToArray(); var txBuildOutputs = GetTxBuildOutputs(saleContext.Sale, purchaseAttempt, address, tokenMintValues); var policyScriptPath = Path.Combine(_settings.BasePath, $"{saleContext.Collection.PolicyId}.policy.script"); var metadataJsonPath = Path.Combine(_settings.BasePath, $"metadata-mint-{purchaseAttempt.Utxo}.json"); @@ -195,30 +198,18 @@ public async Task DistributeNiftiesForSalePurchase( } } - private static Value[] GetBuyerTxOutputUtxoValues( - Value[] tokenMintValues, long lovelacesReturned) - { - var tokenOutputUtxoValues = new Value[tokenMintValues.Length + 1]; - tokenOutputUtxoValues[0] = new Value(Assets.LovelaceUnit, lovelacesReturned); - for (var i = 0; i < tokenMintValues.Length; i++) - { - tokenOutputUtxoValues[i+1] = tokenMintValues[i]; - } - return tokenOutputUtxoValues; - } - private static TxBuildOutput[] GetTxBuildOutputs( Sale sale, PurchaseAttempt purchaseAttempt, string buyerAddress, - Value[] tokenMintValues) + NativeAssetValue[] tokenMintValues) { // Chicken-and-egg bit to calculate the minimum output lovelace value after building the tx output back to the buyer // Then mutating the lovelace value quantity with the calculated minLovelaceUtxo - var buyerOutputUtxoValues = GetBuyerTxOutputUtxoValues(tokenMintValues, lovelacesReturned: 0); + var buyerOutputUtxoValues = new AggregateValue(0, tokenMintValues); var minLovelaceUtxo = TxUtils.CalculateMinUtxoLovelace(buyerOutputUtxoValues); - long buyerLovelacesReturned = minLovelaceUtxo + purchaseAttempt.ChangeInLovelace; - buyerOutputUtxoValues[0].Quantity = buyerLovelacesReturned; + ulong buyerLovelacesReturned = minLovelaceUtxo + purchaseAttempt.ChangeInLovelace; + buyerOutputUtxoValues.Lovelaces = buyerLovelacesReturned; var saleLovelaces = purchaseAttempt.Utxo.Lovelaces - buyerLovelacesReturned; // No NFT creator address specified or we take 100% of the cut @@ -228,16 +219,16 @@ private static TxBuildOutput[] GetTxBuildOutputs( new TxBuildOutput(buyerAddress, buyerOutputUtxoValues), new TxBuildOutput( sale.ProceedsAddress, - new[] { new Value(Assets.LovelaceUnit, saleLovelaces) }, + new AggregateValue(saleLovelaces, Array.Empty()), IsFeeDeducted: true) }; } // Calculate proceeds of ADA from saleContext.Sale to creator and proceeds cut - var proceedsCutLovelaces = (int)(saleLovelaces * sale.PostPurchaseMargin); + var proceedsCutLovelaces = (ulong)(saleLovelaces * sale.PostPurchaseMargin); var creatorCutLovelaces = saleLovelaces - proceedsCutLovelaces; - var creatorAddressUtxoValues = new[] { new Value(Assets.LovelaceUnit, creatorCutLovelaces) }; - var proceedsAddressUtxoValues = new[] { new Value(Assets.LovelaceUnit, proceedsCutLovelaces) }; + var creatorAddressUtxoValues = new AggregateValue(creatorCutLovelaces, Array.Empty()); + var proceedsAddressUtxoValues = new AggregateValue(proceedsCutLovelaces, Array.Empty()); return new[] { new TxBuildOutput(buyerAddress, buyerOutputUtxoValues), new TxBuildOutput(sale.CreatorAddress, creatorAddressUtxoValues, IsFeeDeducted: true), @@ -259,3 +250,223 @@ private static long GetUtxoSlotExpiry( : TimeUtil.GetTestnetSlotAt(collection.LockedAt); } } + +public class CardanoSharpNiftyDistributor : INiftyDistributor +{ + private readonly ILogger _logger; + private readonly IInstrumentor _instrumentor; + private readonly MintsafeAppSettings _settings; + private readonly ITxInfoRetriever _txRetriever; + private readonly IMintingKeychainRetriever _keychainRetriever; + private readonly ITransactionBuilder _txBuilder; + private readonly ITxSubmitter _txSubmitter; + private readonly ISaleAllocationStore _saleContextStore; + + public CardanoSharpNiftyDistributor( + ILogger logger, + IInstrumentor instrumentor, + MintsafeAppSettings settings, + ITxInfoRetriever txRetriever, + IMintingKeychainRetriever keychainRetriever, + ITransactionBuilder txBuilder, + ITxSubmitter txSubmitter, + ISaleAllocationStore saleContextStore) + { + _logger = logger; + _instrumentor = instrumentor; + _settings = settings; + _txRetriever = txRetriever; + _keychainRetriever = keychainRetriever; + _txBuilder = txBuilder; + _txSubmitter = txSubmitter; + _saleContextStore = saleContextStore; + } + + public async Task DistributeNiftiesForSalePurchase( + Nifty[] nfts, + PurchaseAttempt purchaseAttempt, + SaleContext saleContext, + NetworkContext networkContext, + CancellationToken ct = default) + { + var swTotal = Stopwatch.StartNew(); + + // Derive buyer address after getting source UTxO details + var (address, buyerAddressException) = await TryGetBuyerAddressAsync( + nfts, purchaseAttempt, saleContext, ct).ConfigureAwait(false); + if (address == null) + { + return new NiftyDistributionResult( + NiftyDistributionOutcome.FailureTxInfo, + purchaseAttempt, + string.Empty, + Exception: buyerAddressException); + } + + // Construct BuildTransactionCommand + var mintingKeychain = await _keychainRetriever.GetMintingKeyChainAsync(saleContext, ct).ConfigureAwait(false); + var tokensToMint = nfts.Select(n => new NativeAssetValue(saleContext.Collection.PolicyId, Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName)), 1)).ToArray(); + var mint = new[] { new Mint(mintingKeychain.MintingPolicy, tokensToMint) }; + var txCommand = new BuildTransactionCommand( + Inputs: new[] { purchaseAttempt.Utxo }, + Outputs: GetTxBuildOutputs(saleContext.Sale, purchaseAttempt, address, tokensToMint), + Mint: mint, + Metadata: MetadataBuilder.BuildNftMintMetadata(nfts, saleContext.Collection), + Network: _settings.Network, + PaymentSigningKeys: mintingKeychain.SigningKeys); + + var (tx, txRawException) = await TryBuildTxRawBytesAsync( + txCommand, nfts, saleContext, networkContext, ct).ConfigureAwait(false); + if (tx == null) + { + return new NiftyDistributionResult( + NiftyDistributionOutcome.FailureTxBuild, + purchaseAttempt, + null, + Exception: txRawException); + } + + var (txHash, txSubmissionException) = await TrySubmitTxAsync( + tx.Bytes, nfts, saleContext, ct).ConfigureAwait(false); + if (txHash == null) + { + // TODO: Record a mint in our table storage (see NiftyTypes) + return new NiftyDistributionResult( + NiftyDistributionOutcome.FailureTxSubmit, + purchaseAttempt, + Convert.ToHexString(tx.Bytes), + Exception: txSubmissionException); + } + if (txHash != tx.TxHash) + { + _logger.LogWarning("Submitted TxHash {txHashSubmitted} is different to calculated TxHash {txHashCalculated}", txHash, tx.TxHash); + } + + _instrumentor.TrackDependency( + EventIds.DistributorElapsed, + swTotal.ElapsedMilliseconds, + DateTime.UtcNow, + nameof(NiftyDistributor), + address, + nameof(DistributeNiftiesForSalePurchase), + isSuccessful: true); + + return new NiftyDistributionResult( + NiftyDistributionOutcome.Successful, + purchaseAttempt, + Convert.ToHexString(tx.Bytes), + MintTxHash: tx.TxHash, + BuyerAddress: address, + NiftiesDistributed: nfts); + } + + private async Task<(string? Address, Exception? Ex)> TryGetBuyerAddressAsync( + Nifty[] nfts, PurchaseAttempt purchaseAttempt, SaleContext saleContext, CancellationToken ct) + { + try + { + var txIo = await _txRetriever.GetTxInfoAsync(purchaseAttempt.Utxo.TxHash, ct).ConfigureAwait(false); + return (txIo.Inputs.First().Address, null); + } + catch (Exception ex) + { + _logger.LogError(EventIds.TxInfoRetrievalError, ex, $"Failed TxInfo Restrieval"); + await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); + return (null, ex); + } + } + + private static PendingTransactionOutput[] GetTxBuildOutputs( + Sale sale, + PurchaseAttempt purchaseAttempt, + string buyerAddress, + NativeAssetValue[] tokenMintValues) + { + var minLovelaceUtxo = TxUtils.CalculateMinUtxoLovelace(tokenMintValues); + ulong buyerLovelacesReturned = minLovelaceUtxo + purchaseAttempt.ChangeInLovelace; + var buyerOutputUtxoValues = new AggregateValue(buyerLovelacesReturned, tokenMintValues); + + var saleLovelaces = purchaseAttempt.Utxo.Lovelaces - buyerLovelacesReturned; + // No NFT creator address specified or we take 100% of the cut + if (string.IsNullOrWhiteSpace(sale.CreatorAddress) || sale.PostPurchaseMargin == 1) + { + return new[] { + new PendingTransactionOutput(buyerAddress, buyerOutputUtxoValues), + new PendingTransactionOutput( + sale.ProceedsAddress, + new AggregateValue(saleLovelaces, Array.Empty())) + }; + } + + // Calculate proceeds of ADA from saleContext.Sale to creator and proceeds cut + var proceedsCutLovelaces = (ulong)(saleLovelaces * sale.PostPurchaseMargin); + var creatorCutLovelaces = saleLovelaces - proceedsCutLovelaces; + var creatorAddressUtxoValues = new AggregateValue(creatorCutLovelaces, Array.Empty()); + var proceedsAddressUtxoValues = new AggregateValue(proceedsCutLovelaces, Array.Empty()); + return new[] { + new PendingTransactionOutput(buyerAddress, buyerOutputUtxoValues), + new PendingTransactionOutput(sale.CreatorAddress, creatorAddressUtxoValues), + new PendingTransactionOutput(sale.ProceedsAddress, proceedsAddressUtxoValues) + }; + } + + private static long GetUtxoSlotExpiry( + NiftyCollection collection, Network network) + { + // Can override the slot expiry at the collection level + if (collection.SlotExpiry >= 0) + { + return collection.SlotExpiry; + } + // Derive from LockedAt date + return network == Network.Mainnet + ? TimeUtil.GetMainnetSlotAt(collection.LockedAt) + : TimeUtil.GetTestnetSlotAt(collection.LockedAt); + } + + private async Task<(BuiltTransaction? BuiltTx, Exception? Ex)> TryBuildTxRawBytesAsync( + BuildTransactionCommand txBuildCommand, + Nifty[] nfts, + SaleContext saleContext, + NetworkContext networkContext, + CancellationToken ct) + { + try + { + var raw = _txBuilder.BuildTx(txBuildCommand, networkContext); + return (raw, null); + } + catch (CardanoSharpException ex) + { + _logger.LogError(EventIds.TxBuilderError, ex, "Failed Tx Build"); + await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); + return (null, ex); + } + catch (Exception ex) + { + _logger.LogError(EventIds.TxBuilderError, ex, "Failed Tx Build"); + await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); + return (null, ex); + } + } + + private async Task<(string? TxHash, Exception? Ex)> TrySubmitTxAsync( + byte[] txRawBytes, + Nifty[] nfts, + SaleContext saleContext, + CancellationToken ct) + { + try + { + var txHash = await _txSubmitter.SubmitTxAsync(txRawBytes, ct).ConfigureAwait(false); + _logger.LogDebug($"{nameof(_txSubmitter.SubmitTxAsync)} completed with txHash:{txHash}"); + return (txHash.TrimStart('"').TrimEnd('"'), null); // Both Blockfrost and Koios add extra " chars at the start and end + } + catch (Exception ex) + { + _logger.LogError(EventIds.TxSubmissionError, ex, $"Failed Tx Submission"); + await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); + return (null, ex); + } + } +} \ No newline at end of file diff --git a/Src/Lib/PurchaseAttemptGenerator.cs b/Src/Lib/PurchaseAttemptGenerator.cs index 92d9043..570392c 100644 --- a/Src/Lib/PurchaseAttemptGenerator.cs +++ b/Src/Lib/PurchaseAttemptGenerator.cs @@ -7,7 +7,7 @@ public static class PurchaseAttemptGenerator { private const int QuantityHardLimit = 64; // Rough guess based on 16KB Tx limit - public static PurchaseAttempt FromUtxo(Utxo utxo, Sale sale) + public static PurchaseAttempt FromUtxo(UnspentTransactionOutput utxo, Sale sale) { if (!sale.IsActive) throw new SaleInactiveException("Sale is inactive", sale.Id, utxo); diff --git a/Src/Lib/PurchaseExceptions.cs b/Src/Lib/PurchaseExceptions.cs index bcfb4bd..af75c53 100644 --- a/Src/Lib/PurchaseExceptions.cs +++ b/Src/Lib/PurchaseExceptions.cs @@ -9,11 +9,11 @@ public class CannotAllocateMoreThanSaleReleaseException : ApplicationException public long SaleReleaseQuantity { get; } public long SaleAllocatedQuantity { get; } public Guid SaleId { get; } - public Utxo PurchaseAttemptUtxo { get; } + public UnspentTransactionOutput PurchaseAttemptUtxo { get; } public CannotAllocateMoreThanSaleReleaseException( string message, - Utxo purchaseAttemptUtxo, + UnspentTransactionOutput purchaseAttemptUtxo, Guid saleId, int saleReleaseQuantity, int saleAllocatedQuantity, @@ -29,15 +29,15 @@ public CannotAllocateMoreThanSaleReleaseException( public class InsufficientPaymentException : ApplicationException { - public long QuantityPerToken { get; } + public ulong QuantityPerToken { get; } public Guid SaleId { get; } - public Utxo PurchaseAttemptUtxo { get; } + public UnspentTransactionOutput PurchaseAttemptUtxo { get; } public InsufficientPaymentException( string message, Guid saleId, - Utxo purchaseAttemptUtxo, - long quantityPerToken) : base(message) + UnspentTransactionOutput purchaseAttemptUtxo, + ulong quantityPerToken) : base(message) { QuantityPerToken = quantityPerToken; SaleId = saleId; @@ -49,11 +49,11 @@ public class PurchaseQuantityHardLimitException : ApplicationException { public long RequestedQuantity { get; } public Guid SaleId { get; } - public Utxo PurchaseAttemptUtxo { get; } + public UnspentTransactionOutput PurchaseAttemptUtxo { get; } public PurchaseQuantityHardLimitException( string message, - Utxo purchaseAttemptUtxo, + UnspentTransactionOutput purchaseAttemptUtxo, Guid saleId, int requestedQuantity) : base(message) { @@ -68,12 +68,12 @@ public class MaxAllowedPurchaseQuantityExceededException : ApplicationException public int MaxQuantity { get; } public int DerivedQuantity { get; } public Guid SaleId { get; } - public Utxo PurchaseAttemptUtxo { get; } + public UnspentTransactionOutput PurchaseAttemptUtxo { get; } public MaxAllowedPurchaseQuantityExceededException( string message, Guid saleId, - Utxo purchaseAttemptUtxo, + UnspentTransactionOutput purchaseAttemptUtxo, int maxQuantity, int derivedQuantity) : base(message) { @@ -90,12 +90,12 @@ public class SalePeriodOutOfRangeException : ApplicationException public DateTime? SaleEndDateTime { get; } public DateTime PurchaseAttemptedAt { get; } public Guid SaleId { get; } - public Utxo PurchaseAttemptUtxo { get; } + public UnspentTransactionOutput PurchaseAttemptUtxo { get; } public SalePeriodOutOfRangeException( string message, Guid saleId, - Utxo purchaseAttemptUtxo, + UnspentTransactionOutput purchaseAttemptUtxo, DateTime? saleStartDateTime, DateTime? saleEndDateTime) : base(message) { @@ -109,13 +109,13 @@ public SalePeriodOutOfRangeException( public class SaleInactiveException : ApplicationException { - public Utxo PurchaseAttemptUtxo { get; } + public UnspentTransactionOutput PurchaseAttemptUtxo { get; } public Guid SaleId { get; } public SaleInactiveException( string message, Guid saleId, - Utxo purchaseAttemptUtxo) : base(message) + UnspentTransactionOutput purchaseAttemptUtxo) : base(message) { SaleId = saleId; PurchaseAttemptUtxo = purchaseAttemptUtxo; @@ -124,16 +124,34 @@ public SaleInactiveException( public class FailedUtxoRefundException : ApplicationException { - public Utxo PurchaseAttemptUtxo { get; } + public UnspentTransactionOutput PurchaseAttemptUtxo { get; } public Guid SaleId { get; } public FailedUtxoRefundException( string message, Guid saleId, - Utxo purchaseAttemptUtxo, + UnspentTransactionOutput purchaseAttemptUtxo, Exception? innerException) : base(message, innerException) { SaleId = saleId; PurchaseAttemptUtxo = purchaseAttemptUtxo; } +} + +public class InputOutputValueMismatchException : ApplicationException +{ + public UnspentTransactionOutput[] Inputs { get; } + public PendingTransactionOutput[] Outputs { get; } + public string CorrelationId { get; } + + public InputOutputValueMismatchException( + string message, + UnspentTransactionOutput[] inputs, + PendingTransactionOutput[] outputs, + string? correlationId = null) : base(message) + { + Inputs = inputs; + Outputs = outputs; + CorrelationId = correlationId ?? Guid.NewGuid().ToString(); + } } \ No newline at end of file diff --git a/Src/Lib/SaleAllocationFileStore.cs b/Src/Lib/SaleAllocationFileStore.cs index 1c930e7..0189324 100644 --- a/Src/Lib/SaleAllocationFileStore.cs +++ b/Src/Lib/SaleAllocationFileStore.cs @@ -71,10 +71,10 @@ public async Task GetOrRestoreSaleContextAsync( collectionAggregate.Collection, mintableTokens, allocatedNfts, - new HashSet(), - new HashSet(), - new HashSet(), - new HashSet()); + new HashSet(), + new HashSet(), + new HashSet(), + new HashSet()); _instrumentor.TrackDependency( EventIds.SaleContextGetOrRestoreElapsed, diff --git a/Src/Lib/SimpleWalletService.cs b/Src/Lib/SimpleWalletService.cs index 94a8b3c..d045190 100644 --- a/Src/Lib/SimpleWalletService.cs +++ b/Src/Lib/SimpleWalletService.cs @@ -6,21 +6,15 @@ using CardanoSharp.Wallet.Extensions.Models.Transactions; using CardanoSharp.Wallet.Models.Addresses; using CardanoSharp.Wallet.Models.Keys; -using CardanoSharp.Wallet.Models.Transactions; -using CardanoSharp.Wallet.Models.Transactions.Scripts; -using CardanoSharp.Wallet.Models.Transactions.TransactionWitness.Scripts; using CardanoSharp.Wallet.TransactionBuilding; using CardanoSharp.Wallet.Utilities; using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; -using PeterO.Cbor2; using Refit; using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -157,8 +151,10 @@ public SimpleWalletService( var auxDataBuilder = AuxiliaryDataBuilder.Create; if (metadata != null && metadata.Any()) { - var tag = metadata.Keys.First(); - auxDataBuilder = auxDataBuilder.AddMetadata(tag, metadata[tag]); + foreach (var key in metadata.Keys) + { + auxDataBuilder = auxDataBuilder.AddMetadata(key, metadata[key]); + } txBuilder = txBuilder.SetAuxData(auxDataBuilder); _logger.LogInformation("Build Metadata {txMetadata}", auxDataBuilder); } @@ -176,20 +172,140 @@ public SimpleWalletService( try { sw.Restart(); - using var stream = new MemoryStream(txBytes); - var response = await txClient.Submit(stream).ConfigureAwait(false); - if (!response.IsSuccessStatusCode || response.Content == null) - { - _logger.LogWarning("Failed tx submission status={statusCode}, error={errorContent}", response.Error.StatusCode, response.Error.Content); - return null; - } - var txId = response.Content.TrimStart('"').TrimEnd('"'); - if (txId != txHash) + //using var stream = new MemoryStream(txBytes); + //var response = await txClient.Submit(stream).ConfigureAwait(false); + //if (!response.IsSuccessStatusCode || response.Content == null) + //{ + // _logger.LogWarning("Failed tx submission status={statusCode}, error={errorContent}", response.Error.StatusCode, response.Error.Content); + // return null; + //} + //var txId = response.Content.TrimStart('"').TrimEnd('"'); + //if (txId != txHash) + //{ + // _logger.LogWarning("TxId {txId} from txClient.Submit is different to calculated TxHash {txHash}", txId, txHash); + //} + //_logger.LogInformation("Submitted mint tx {elapnsed}ms TxId: {txId} ({txBytesLength}bytes)", sw.ElapsedMilliseconds, txId, txBytes.Length); + //return txId; + return txHash; + } + catch (ApiException ex) + { + _logger.LogError(ex, "Failed tx submission {error}", ex.Content); + return null; + } + } + + public async Task SubmitTransactionAsync( + BuildTransactionCommand txCommand, CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + (var epochClient, var networkClient, var addressClient, var txClient) = GetKoiosClients(txCommand.Network); + var tip = (await networkClient.GetChainTip()).Content.First(); + var protocolParams = (await epochClient.GetProtocolParameters(tip.Epoch.ToString())).Content.First(); + _logger.LogInformation( + "Queried Koios {elapsedMs}ms - Epoch: {Epoch}, AbsSlot: {AbsSlot}", + sw.ElapsedMilliseconds, tip.Epoch, tip.AbsSlot); + + // Inputs TODO: Coin selection? + var txInputs = txCommand.Inputs; + var consolidatedInputValue = BuildConsolidatedTxInputValue( + txInputs, txCommand.Mint.SelectMany(m => m.NativeAssetsToMint).ToArray()); + // Outputs + var txOutputs = txCommand.Outputs; + var consolidatedOutputValue = txOutputs.Select(txOut => txOut.Value).Sum(); + var valueDifference = consolidatedInputValue.Subtract(consolidatedOutputValue); + if (valueDifference.Lovelaces != 0 && !valueDifference.NativeAssets.All(na => na.Quantity == 0)) + { + throw new InputOutputValueMismatchException( + "Input/Output value mismatch", txCommand.Inputs, txCommand.Outputs); + } + + // Start building transaction body using CardanoSharp + var txBodyBuilder = TransactionBodyBuilder.Create + .SetFee(0) + .SetTtl((uint)tip.AbsSlot + txCommand.TtlTipOffsetSlots); + // TxInputs + foreach (var txInput in txInputs) + { + txBodyBuilder.AddInput(txInput.TxHash, txInput.OutputIndex); + } + // TxOutputs + foreach (var txOutput in txOutputs) + { + var tokenBundleBuilder = (txOutput.Value.NativeAssets.Length > 0) + ? GetTokenBundleBuilderFromNativeAssets(txOutput.Value.NativeAssets) + : null; + txBodyBuilder.AddOutput(new Address(txOutput.Address), txOutput.Value.Lovelaces, tokenBundleBuilder); + } + // TxMint + if (txCommand.Mint.Length > 0) + { + // Build Cardano Native Assets from TestResults + var freshMintTokenBundleBuilder = TokenBundleBuilder.Create; + foreach (var newAssetMint in txCommand.Mint.SelectMany(m => m.NativeAssetsToMint)) { - _logger.LogWarning("TxId {txId} from txClient.Submit is different to calculated TxHash {txHash}", txId, txHash); + freshMintTokenBundleBuilder = freshMintTokenBundleBuilder + .AddToken(newAssetMint.PolicyId.HexToByteArray(), newAssetMint.AssetName.HexToByteArray(), 1); } - _logger.LogInformation("Submitted mint tx {elapnsed}ms TxId: {txId} ({txBytesLength}bytes)", sw.ElapsedMilliseconds, txId, txBytes.Length); - return txId; + txBodyBuilder.SetMint(freshMintTokenBundleBuilder); + } + // TxWitnesses + var witnesses = TransactionWitnessSetBuilder.Create; + foreach (var signingKey in txCommand.PaymentSigningKeys) + { + var paymentSkey = GetPrivateKeyFromBech32SigningKey(signingKey); + witnesses.AddVKeyWitness(paymentSkey.GetPublicKey(false), paymentSkey); + } + foreach (var policy in txCommand.Mint.Select(m => m.BasicMintingPolicy)) + { + var policyKey = GetPrivateKeyFromBech32SigningKey(policy.PolicySigningKeysAll.First()); + witnesses.AddVKeyWitness(policyKey.GetPublicKey(false), policyKey); + var policyScriptAllBuilder = GetScriptAllBuilder( + policy.PolicySigningKeysAll.Select(GetPrivateKeyFromBech32SigningKey), + policy.ExpirySlot); + witnesses.SetNativeScript(policyScriptAllBuilder); + } + + // Build Tx for fee calculation + var txBuilder = TransactionBuilder.Create + .SetBody(txBodyBuilder) + .SetWitnesses(witnesses); + // Metadata + var auxDataBuilder = AuxiliaryDataBuilder.Create; + foreach (var key in txCommand.Metadata.Keys) + { + auxDataBuilder = auxDataBuilder.AddMetadata(key, txCommand.Metadata[key]); + } + txBuilder = txBuilder.SetAuxData(auxDataBuilder); + _logger.LogInformation("Build Metadata {txMetadata}", auxDataBuilder); + // Calculate and update change Utxo + var tx = txBuilder.Build(); + var fee = tx.CalculateFee(protocolParams.MinFeeA, protocolParams.MinFeeB); + txBodyBuilder.SetFee(fee); + tx.TransactionBody.TransactionOutputs.Last().Value.Coin -= fee; + var txBytes = tx.Serialize(); + var txHash = HashUtility.Blake2b256(tx.TransactionBody.Serialize(auxDataBuilder.Build())).ToStringHex(); + _logger.LogInformation("Built mint tx {elapnsed}ms", sw.ElapsedMilliseconds); + + // Submit Tx + try + { + sw.Restart(); + //using var stream = new MemoryStream(txBytes); + //var response = await txClient.Submit(stream).ConfigureAwait(false); + //if (!response.IsSuccessStatusCode || response.Content == null) + //{ + // _logger.LogWarning("Failed tx submission status={statusCode}, error={errorContent}", response.Error.StatusCode, response.Error.Content); + // return null; + //} + //var txId = response.Content.TrimStart('"').TrimEnd('"'); + //if (txId != txHash) + //{ + // _logger.LogWarning("TxId {txId} from txClient.Submit is different to calculated TxHash {txHash}", txId, txHash); + //} + //_logger.LogInformation("Submitted mint tx {elapnsed}ms TxId: {txId} ({txBytesLength}bytes)", sw.ElapsedMilliseconds, txId, txBytes.Length); + //return txId; + return txHash; } catch (ApiException ex) { diff --git a/Src/Lib/TxUtils.cs b/Src/Lib/TxUtils.cs index 5e370af..a5ceb08 100644 --- a/Src/Lib/TxUtils.cs +++ b/Src/Lib/TxUtils.cs @@ -1,4 +1,6 @@ -using Mintsafe.Abstractions; +using CardanoSharp.Wallet.Encoding; +using CardanoSharp.Wallet.Models.Keys; +using Mintsafe.Abstractions; using System; using System.Collections.Generic; using System.Linq; @@ -45,7 +47,7 @@ public static AggregateValue Sum(this IEnumerable values) } } return new AggregateValue( - lovelaces, + lovelaces, nativeAssets.Select(nav => new NativeAssetValue(nav.Key.PolicyId, nav.Key.AssetNameHex, nav.Value)).ToArray()); } @@ -70,7 +72,7 @@ static NativeAssetValue SubtractSingleValue(NativeAssetValue lhsValue, NativeAss var nativeAssets = lhsValue.NativeAssets .Select(lv => SubtractSingleValue( - lv, + lv, rhsValue.NativeAssets.FirstOrDefault( rv => rv.PolicyId == lv.PolicyId && rv.AssetName == lv.AssetName))) .Where(na => na.Quantity != 0) @@ -78,9 +80,9 @@ static NativeAssetValue SubtractSingleValue(NativeAssetValue lhsValue, NativeAss return new AggregateValue(lhsValue.Lovelaces - rhsValue.Lovelaces, nativeAssets); } - public static long CalculateMinUtxoLovelace( - Value[] outputValues, - int lovelacePerUtxoWord = 34482, + public static ulong CalculateMinUtxoLovelace( + Value[] outputValues, + int lovelacePerUtxoWord = 34482, int policyIdBytes = 28, bool hasDataHash = false) { @@ -89,7 +91,7 @@ public static long CalculateMinUtxoLovelace( const int fixedUtxoPrefix = 6; const int fixedPerTokenCost = 12; const int utxoEntrySizeWithoutVal = 27; - const int coinSize = 2; + const int coinSize = 2; const int adaOnlyUtxoSizeWords = utxoEntrySizeWithoutVal + coinSize; const int byteRoundUpAddition = 7; const int byteLength = 8; @@ -97,7 +99,7 @@ public static long CalculateMinUtxoLovelace( var isAdaOnlyUtxo = outputValues.Length == 1 && outputValues[0].Unit == Assets.LovelaceUnit; if (isAdaOnlyUtxo) - return lovelacePerUtxoWord * adaOnlyUtxoSizeWords; // 999978 lovelaces or 0.999978 ADA + return (ulong)lovelacePerUtxoWord * adaOnlyUtxoSizeWords; // 999978 lovelaces or 0.999978 ADA var customTokens = outputValues.Where(v => v.Unit != Assets.LovelaceUnit).ToArray(); var policyIds = new HashSet(); @@ -111,15 +113,15 @@ public static long CalculateMinUtxoLovelace( var sumAssetNameLengths = assetNames.Sum(an => an.Length); var valueSize = fixedUtxoPrefix + ( - (policyIds.Count * policyIdBytes) - + (customTokens.Length * fixedPerTokenCost) + (policyIds.Count * policyIdBytes) + + (customTokens.Length * fixedPerTokenCost) + sumAssetNameLengths + byteRoundUpAddition) / byteLength; var dataHashSize = hasDataHash ? dataHashSizeWords : 0; var minUtxoLovelace = lovelacePerUtxoWord * (utxoEntrySizeWithoutVal + valueSize + dataHashSize); - return minUtxoLovelace; + return (ulong)minUtxoLovelace; } public static ulong CalculateMinUtxoLovelace( @@ -134,7 +136,7 @@ public static ulong CalculateMinUtxoLovelace( const int coinSizeWords = 2; // since updated from 0 in docs.cardano.org/native-tokens/minimum-ada-value-requirement const int adaOnlyUtxoSizeWords = fixedUtxoEntryWithoutValueSizeWords + coinSizeWords; const int fixedPerTokenCost = 12; - const int byteRoundUpAddition = 7; + const int byteRoundUpAddition = 7; const int bytesPerWord = 8; // One "word" is 8 bytes (64-bit) const int fixedDataHashSizeWords = 10; @@ -159,9 +161,59 @@ public static ulong CalculateMinUtxoLovelace( + tokensNameLen + byteRoundUpAddition) / bytesPerWord; var dataHashSizeWords = hasDataHash ? fixedDataHashSizeWords : 0; - var minUtxoLovelace = lovelacePerUtxoWord + var minUtxoLovelace = lovelacePerUtxoWord + * (fixedUtxoEntryWithoutValueSizeWords + valueSizeWords + dataHashSizeWords); + + return (ulong)minUtxoLovelace; + } + + public static ulong CalculateMinUtxoLovelace( + IEnumerable nativeAssets, + int lovelacePerUtxoWord = 34482, // utxoCostPerWord in protocol params (could change in the future) + int policyIdSizeBytes = 28, // 224 bit policyID (won't in forseeable future) + bool hasDataHash = false) // for UTxOs with smart contract datum + { + // https://docs.cardano.org/native-tokens/minimum-ada-value-requirement#min-ada-valuecalculation + const int fixedUtxoPrefixWords = 6; + const int fixedUtxoEntryWithoutValueSizeWords = 27; // The static parts of a UTxO: 6 + 7 + 14 words + const int coinSizeWords = 2; // since updated from 0 in docs.cardano.org/native-tokens/minimum-ada-value-requirement + const int adaOnlyUtxoSizeWords = fixedUtxoEntryWithoutValueSizeWords + coinSizeWords; + const int fixedPerTokenCost = 12; + const int byteRoundUpAddition = 7; + const int bytesPerWord = 8; // One "word" is 8 bytes (64-bit) + const int fixedDataHashSizeWords = 10; + + if (!nativeAssets.Any()) + return (ulong)lovelacePerUtxoWord * adaOnlyUtxoSizeWords; // 999978 lovelaces or 0.999978 ADA + + // Get distinct policyIDs and assetNames + var nativeAssetsTotalCount = 0; + var policyIds = new HashSet(); + var assetNameHexadecimals = new HashSet(); + foreach (var customToken in nativeAssets) + { + policyIds.Add(customToken.PolicyId); + assetNameHexadecimals.Add(customToken.AssetName); + nativeAssetsTotalCount++; + } + + // Calculate (prefix + (numDistinctPids * 28(policyIdSizeBytes) + numTokens * 12(fixedPerTokenCost) + tokensNameLen + 7) ~/8) + var tokensNameLen = assetNameHexadecimals.Sum(an => an.Length) / 2; // 2 hexadecimal chars = 1 Byte + var valueSizeWords = fixedUtxoPrefixWords + ( + (policyIds.Count * policyIdSizeBytes) + + (nativeAssetsTotalCount * fixedPerTokenCost) + + tokensNameLen + byteRoundUpAddition) / bytesPerWord; + var dataHashSizeWords = hasDataHash ? fixedDataHashSizeWords : 0; + + var minUtxoLovelace = lovelacePerUtxoWord * (fixedUtxoEntryWithoutValueSizeWords + valueSizeWords + dataHashSizeWords); return (ulong)minUtxoLovelace; } + + public static PrivateKey GetPrivateKeyFromBech32SigningKey(string bech32EncodedSigningKey) + { + var keyBytes = Bech32.Decode(bech32EncodedSigningKey, out _, out _); + return new PrivateKey(keyBytes[..64], keyBytes[64..]); + } } diff --git a/Src/Lib/UtxoRefunder.cs b/Src/Lib/UtxoRefunder.cs index c3f0ca5..0925c41 100644 --- a/Src/Lib/UtxoRefunder.cs +++ b/Src/Lib/UtxoRefunder.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; using System; +using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,31 +16,37 @@ public class UtxoRefunder : IUtxoRefunder private readonly ILogger _logger; private readonly MintsafeAppSettings _settings; private readonly ITxInfoRetriever _txRetriever; + private readonly IMintingKeychainRetriever _keychainRetriever; private readonly IMetadataFileGenerator _metadataGenerator; private readonly ITxSubmitter _txSubmitter; - private readonly ITxBuilder _txBuilder; + private readonly ITransactionBuilder _txBuilder; public UtxoRefunder( ILogger logger, MintsafeAppSettings settings, ITxInfoRetriever txRetriever, + IMintingKeychainRetriever keychainRetriever, IMetadataFileGenerator metadataGenerator, - ITxBuilder txBuilder, + ITransactionBuilder txBuilder, ITxSubmitter txSubmitter) { _logger = logger; _settings = settings; _txRetriever = txRetriever; + _keychainRetriever = keychainRetriever; _metadataGenerator = metadataGenerator; _txBuilder = txBuilder; _txSubmitter = txSubmitter; } public async Task ProcessRefundForUtxo( - Utxo utxo, string signingKeyFilePath, string reason, CancellationToken ct = default) + UnspentTransactionOutput utxo, + SaleContext saleContext, + NetworkContext networkContext, + string reason, + CancellationToken ct = default) { _logger.LogDebug($"Processing refund for {utxo} with {utxo.Lovelaces}lovelaces ({reason})"); - if (utxo.Lovelaces < MinLovelace) { _logger.LogWarning($"Cannot refund {utxo.Lovelaces} because of minimum Utxo lovelace value requirement ({MinLovelace})"); @@ -52,34 +58,21 @@ public async Task ProcessRefundForUtxo( var buyerAddress = txIo.Inputs.First().Address; _logger.LogDebug($"{nameof(_txRetriever.GetTxInfoAsync)} completed after {sw.ElapsedMilliseconds}ms"); - // Generate refund message metadata - var metadataJsonFileName = $"metadata-refund-{utxo}.json"; - var metadataJsonPath = Path.Combine(_settings.BasePath, metadataJsonFileName); - var message = new[] { - $"mintsafe.io refund", - utxo.TxHash, - $"#{utxo.OutputIndex}", - reason - }; - sw.Restart(); - await _metadataGenerator.GenerateMessageMetadataJsonFile(message, metadataJsonPath, ct).ConfigureAwait(false); - _logger.LogDebug($"{nameof(_metadataGenerator.GenerateMessageMetadataJsonFile)} generated at {metadataJsonPath} after {sw.ElapsedMilliseconds}ms"); - - var txRefundCommand = new TxBuildCommand( + var mintingKeychain = await _keychainRetriever.GetMintingKeyChainAsync(saleContext, ct).ConfigureAwait(false); + var txRefundCommand = new BuildTransactionCommand( Inputs: new[] { utxo }, - Outputs: new[] { new TxBuildOutput(buyerAddress, utxo.Values, IsFeeDeducted: true) }, - Mint: Array.Empty(), - MintingScriptPath: string.Empty, - MetadataJsonPath: metadataJsonPath, - TtlSlot: 0, - SigningKeyFiles: new[] { signingKeyFilePath }); + Outputs: new[] { new PendingTransactionOutput(buyerAddress, utxo.Value) }, + Mint: Array.Empty(), + Metadata: MetadataBuilder.BuildMessageMetadata($"mintsafe.io refund {reason}"), + Network: _settings.Network, + PaymentSigningKeys: mintingKeychain.SigningKeys); sw.Restart(); - var submissionPayload = await _txBuilder.BuildTxAsync(txRefundCommand, ct).ConfigureAwait(false); - _logger.LogDebug($"{nameof(_txBuilder.BuildTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); + var tx = _txBuilder.BuildTx(txRefundCommand, networkContext); + _logger.LogDebug($"{nameof(_txBuilder.BuildTx)} completed after {sw.ElapsedMilliseconds}ms"); sw.Restart(); - var txHash = await _txSubmitter.SubmitTxAsync(submissionPayload, ct).ConfigureAwait(false); + var txHash = await _txSubmitter.SubmitTxAsync(tx.Bytes, ct).ConfigureAwait(false); _logger.LogDebug($"{nameof(_txSubmitter.SubmitTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); _logger.LogDebug($"TxID:{txHash} Successfully refunded {utxo.Lovelaces} to {buyerAddress}"); diff --git a/Src/SaleWorker/ConfigTypes.cs b/Src/SaleWorker/ConfigTypes.cs index 8c4eac8..5a0a51d 100644 --- a/Src/SaleWorker/ConfigTypes.cs +++ b/Src/SaleWorker/ConfigTypes.cs @@ -21,6 +21,14 @@ public class BlockfrostApiConfig public string? ApiKey { get; init; } } +public class KeychainConfig +{ + public string? KeyVaultUrl { get; init; } + public int? RetrievalMaxRetries { get; init; } + public int? RetrievalRetryDelaySeconds { get; init; } + public int? RetrievalRetryMaxDelaySeconds { get; init; } +} + public class ApplicationInsightsConfig { public bool Enabled { get; init; } diff --git a/Src/SaleWorker/Mintsafe.SaleWorker.csproj b/Src/SaleWorker/Mintsafe.SaleWorker.csproj index 2afb908..0880001 100644 --- a/Src/SaleWorker/Mintsafe.SaleWorker.csproj +++ b/Src/SaleWorker/Mintsafe.SaleWorker.csproj @@ -7,10 +7,10 @@ - - + + - + diff --git a/Src/SaleWorker/Program.cs b/Src/SaleWorker/Program.cs index d44cfac..b6a9d4a 100644 --- a/Src/SaleWorker/Program.cs +++ b/Src/SaleWorker/Program.cs @@ -1,15 +1,9 @@ using Microsoft.ApplicationInsights.WorkerService; -using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; -using Mintsafe.DataAccess; -using Mintsafe.DataAccess.Composers; -using Mintsafe.DataAccess.Extensions; -using Mintsafe.DataAccess.Repositories; -using Mintsafe.DataAccess.Supporting; using Mintsafe.Lib; using Mintsafe.SaleWorker; using System; @@ -54,7 +48,13 @@ .GetSection("BlockfrostApi") .Get(); if (blockfrostApiConfig.BaseUrl == null) - throw new MintSafeConfigException("BaseUrl is missing in BlockfrostApiConfig", "MintsafeWorker.BlockfrostApiConfig"); + throw new MintSafeConfigException("BaseUrl is missing in BlockfrostApiConfig", "MintsafeWorker.BlockfrostApi"); + + var keychainConfig = hostContext.Configuration + .GetSection("Keychain") + .Get(); + if (string.IsNullOrWhiteSpace(keychainConfig.KeyVaultUrl)) + throw new MintSafeConfigException("KeyVaultUrl is missing in KeychainConfig", "MintsafeWorker.Keychain.KeyVaultUrl"); var mintsafeWorkerConfig = hostContext.Configuration .GetSection("MintsafeWorker") @@ -67,7 +67,8 @@ BlockFrostApiKey = blockfrostApiConfig.ApiKey, BasePath = mintsafeWorkerConfig.MintBasePath, PollingIntervalSeconds = mintsafeWorkerConfig.PollingIntervalSeconds.HasValue ? mintsafeWorkerConfig.PollingIntervalSeconds.Value : 10, - CollectionId = Guid.Parse(mintsafeWorkerConfig.CollectionId) + CollectionId = Guid.Parse(mintsafeWorkerConfig.CollectionId), + KeyVaultUrl = keychainConfig.KeyVaultUrl }; services.AddSingleton(settings); @@ -101,24 +102,27 @@ services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // Fakes - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); + //services.AddSingleton(); //// Reals - //services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); //services.AddSingleton(); - //services.AddSingleton(); - //services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); //services.AddSingleton(); - //services.AddSingleton(); + services.AddSingleton(); //services.AddSingleton(); //services.AddAzureClients(clientBuilder => //{ diff --git a/Src/SaleWorker/SaleUtxoHandler.cs b/Src/SaleWorker/SaleUtxoHandler.cs index 3ec8a1d..d2f9918 100644 --- a/Src/SaleWorker/SaleUtxoHandler.cs +++ b/Src/SaleWorker/SaleUtxoHandler.cs @@ -14,7 +14,11 @@ namespace Mintsafe.SaleWorker; public interface ISaleUtxoHandler { - Task HandleAsync(Utxo saleUtxo, SaleContext saleContext, CancellationToken ct); + Task HandleAsync( + UnspentTransactionOutput saleUtxo, + SaleContext saleContext, + NetworkContext networkContext, + CancellationToken ct); } public class SaleUtxoHandler : ISaleUtxoHandler @@ -43,8 +47,9 @@ public SaleUtxoHandler( } public async Task HandleAsync( - Utxo saleUtxo, + UnspentTransactionOutput saleUtxo, SaleContext saleContext, + NetworkContext networkContext, CancellationToken ct) { var isSuccessful = false; @@ -70,7 +75,7 @@ public async Task HandleAsync( // Distribute tokens var distributionResult = await _tokenDistributor.DistributeNiftiesForSalePurchase( - tokens, purchase, saleContext, ct).ConfigureAwait(false); + tokens, purchase, saleContext, networkContext, ct).ConfigureAwait(false); handlingOutcome = distributionResult.Outcome.ToString(); var utxoDistributionPath = Path.Combine(utxoFolderPath, "distribution.json"); await File.WriteAllTextAsync(utxoDistributionPath, JsonSerializer.Serialize( @@ -135,7 +140,7 @@ await File.WriteAllTextAsync(utxoDistributionPath, JsonSerializer.Serialize( } if (shouldRefundUtxo) { - await RefundUtxo(saleUtxo, saleContext, handlingOutcome, ct).ConfigureAwait(false); + await RefundUtxo(saleUtxo, saleContext, networkContext, handlingOutcome, ct).ConfigureAwait(false); } LogHandlingRequest(saleUtxo.ToString(), isSuccessful, sw.ElapsedMilliseconds, handlingOutcome, saleContext); } @@ -173,21 +178,25 @@ private void LogHandlingRequest( } private async Task RefundUtxo( - Utxo saleUtxo, SaleContext saleContext, string refundReason, CancellationToken ct) + UnspentTransactionOutput utxo, + SaleContext saleContext, + NetworkContext networkContext, + string refundReason, + CancellationToken ct) { try { // TODO: better way to do refunds? Use Channels? - var saleAddressSigningKey = Path.Combine(_settings.BasePath, $"{saleContext.Sale.Id}.sale.skey"); - var refundTxHash = await _utxoRefunder.ProcessRefundForUtxo(saleUtxo, saleAddressSigningKey, refundReason, ct).ConfigureAwait(false); + var refundTxHash = await _utxoRefunder.ProcessRefundForUtxo( + utxo, saleContext, networkContext, refundReason, ct).ConfigureAwait(false); if (!string.IsNullOrEmpty(refundTxHash)) { - saleContext.RefundedUtxos.Add(saleUtxo); + saleContext.RefundedUtxos.Add(utxo); } } catch (Exception ex) { - _logger.LogError(EventIds.UtxoRefunderError, ex, $"Refund error for {saleUtxo} {refundReason}"); + _logger.LogError(EventIds.UtxoRefunderError, ex, $"Refund error for {utxo} {refundReason}"); } } } diff --git a/Src/SaleWorker/Worker.cs b/Src/SaleWorker/Worker.cs index 3b88b0c..5bc7e65 100644 --- a/Src/SaleWorker/Worker.cs +++ b/Src/SaleWorker/Worker.cs @@ -3,10 +3,6 @@ using Mintsafe.Abstractions; using Mintsafe.Lib; using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,10 +12,10 @@ public class Worker : BackgroundService { private readonly IHostApplicationLifetime _hostApplicationLifetime; private readonly ILogger _logger; - private readonly IInstrumentor _instrumentor; private readonly MintsafeAppSettings _settings; private readonly INiftyDataService _niftyDataService; private readonly ISaleAllocationStore _saleContextDataStorage; + private readonly INetworkContextRetriever _networkContextRetriever; private readonly IUtxoRetriever _utxoRetriever; private readonly ISaleUtxoHandler _saleUtxoHandler; private readonly Guid _workerId; @@ -27,19 +23,19 @@ public class Worker : BackgroundService public Worker( IHostApplicationLifetime hostApplicationLifetime, ILogger logger, - IInstrumentor instrumentor, MintsafeAppSettings settings, INiftyDataService niftyDataService, ISaleAllocationStore saleContextDataStorage, + INetworkContextRetriever networkContextRetriever, IUtxoRetriever utxoRetriever, ISaleUtxoHandler saleUtxoHandler) { _hostApplicationLifetime = hostApplicationLifetime; _logger = logger; - _instrumentor = instrumentor; _settings = settings; _niftyDataService = niftyDataService; _saleContextDataStorage = saleContextDataStorage; + _networkContextRetriever = networkContextRetriever; _utxoRetriever = utxoRetriever; _saleUtxoHandler = saleUtxoHandler; _workerId = Guid.NewGuid(); @@ -71,6 +67,7 @@ protected override async Task ExecuteAsync(CancellationToken ct) var timer = new PeriodicTimer(TimeSpan.FromSeconds(_settings.PollingIntervalSeconds)); do { + var networkContext = await _networkContextRetriever.GetNetworkContext(ct); var saleUtxos = await _utxoRetriever.GetUtxosAtAddressAsync(saleContext.Sale.SaleAddress, ct); _logger.LogDebug($"Querying SaleAddress UTxOs for sale {saleContext.Sale.Name} of {collection.Collection.Name} by {string.Join(",", collection.Collection.Publishers)}"); _logger.LogDebug($"Found {saleUtxos.Length} UTxOs at {saleContext.Sale.SaleAddress}"); @@ -81,7 +78,7 @@ protected override async Task ExecuteAsync(CancellationToken ct) _logger.LogDebug($"Utxo {saleUtxo.TxHash}[{saleUtxo.OutputIndex}]({saleUtxo.Lovelaces}) skipped (already locked)"); continue; } - await _saleUtxoHandler.HandleAsync(saleUtxo, saleContext, ct); + await _saleUtxoHandler.HandleAsync(saleUtxo, saleContext, networkContext, ct); } _logger.LogDebug( $"Successful: {saleContext.SuccessfulUtxos.Count} UTxOs | Refunded: {saleContext.RefundedUtxos.Count} UTxOs | Failed: {saleContext.FailedUtxos.Count} UTxOs | Locked: {saleContext.LockedUtxos.Count} UTxOs"); diff --git a/Src/SaleWorker/appsettings.json b/Src/SaleWorker/appsettings.json index 22f3559..be2da67 100644 --- a/Src/SaleWorker/appsettings.json +++ b/Src/SaleWorker/appsettings.json @@ -16,6 +16,12 @@ "RetryCount": "2" } }, + "Keychain": { + "KeyVaultUrl": "https://safe-tn-eun-ms-mint-kv.vault.azure.net/", + "RetrievalMaxRetries": 5, + "RetrievalRetryDelaySeconds": 2, + "RetrievalRetryMaxDelaySeconds": 16 + }, "ApplicationInsights": { "Enabled": false, "InstrumentationKey": "" diff --git a/Src/WasmApp/Mintsafe.WasmApp.csproj b/Src/WasmApp/Mintsafe.WasmApp.csproj index 6c6caa3..6a6d5ef 100644 --- a/Src/WasmApp/Mintsafe.WasmApp.csproj +++ b/Src/WasmApp/Mintsafe.WasmApp.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/Src/WasmApp/Pages/AddressUtxo.razor b/Src/WasmApp/Pages/AddressUtxo.razor index a08849e..6cd148d 100644 --- a/Src/WasmApp/Pages/AddressUtxo.razor +++ b/Src/WasmApp/Pages/AddressUtxo.razor @@ -7,7 +7,7 @@ -@(Utxos?.Sum(u => u.Lovelaces)/1000000) ADA +@*@(Utxos?.Sum(u => u.Lovelaces)/1000000) ADA*@ @if (Utxos != null) { diff --git a/Src/WasmApp/Pages/YoloWallet.razor b/Src/WasmApp/Pages/YoloWallet.razor index ef09875..6d3fa75 100644 --- a/Src/WasmApp/Pages/YoloWallet.razor +++ b/Src/WasmApp/Pages/YoloWallet.razor @@ -7,7 +7,7 @@ -@(Utxos?.Sum(u => u.Lovelaces)/1000000) ADA +@*@(Utxos?.Sum(u => u.Lovelaces)/1000000) ADA*@ diff --git a/Src/WasmApp/Program.cs b/Src/WasmApp/Program.cs index 648d1ce..ddc1ca0 100644 --- a/Src/WasmApp/Program.cs +++ b/Src/WasmApp/Program.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Mintsafe.Abstractions; using Mintsafe.Lib; using Mintsafe.WasmApp; using Mintsafe.WasmApp.Services; diff --git a/Src/WasmApp/Services/SimplePaymentService.cs b/Src/WasmApp/Services/SimplePaymentService.cs index 09a5bf4..696f768 100644 --- a/Src/WasmApp/Services/SimplePaymentService.cs +++ b/Src/WasmApp/Services/SimplePaymentService.cs @@ -1,15 +1,14 @@ -using Mintsafe.Abstractions; -using System.Net.Http.Json; +using System.Net.Http.Json; namespace Mintsafe.WasmApp.Services; public record SimplePayment { - public string SourcePaymentAddress { get; init; } - public string SourcePaymentAddressSigningKey { get; init; } - public string DestinationPaymentAddress { get; init; } + public string? SourcePaymentAddress { get; init; } + public string? SourcePaymentAddressSigningKey { get; init; } + public string? DestinationPaymentAddress { get; init; } public ulong PaymentLovelaces { get; init; } - public string Comment { get; init; } + public string? Comment { get; init; } } public interface ISimplePaymentService diff --git a/Src/WebApi/Controllers/AddressUtxoController.cs b/Src/WebApi/Controllers/AddressUtxoController.cs index 39d2c9f..1e23734 100644 --- a/Src/WebApi/Controllers/AddressUtxoController.cs +++ b/Src/WebApi/Controllers/AddressUtxoController.cs @@ -22,7 +22,7 @@ public AddressUtxoController( } [HttpGet("{address}")] - public async Task Get(string address, CancellationToken ct) + public async Task Get(string address, CancellationToken ct) { var utxos = await _utxoRetriever.GetUtxosAtAddressAsync(address, ct); diff --git a/Src/WebApi/Controllers/SimplePaymentController.cs b/Src/WebApi/Controllers/SimplePaymentController.cs index a8a1086..59bcb9c 100644 --- a/Src/WebApi/Controllers/SimplePaymentController.cs +++ b/Src/WebApi/Controllers/SimplePaymentController.cs @@ -1,9 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using Mintsafe.Abstractions; using Mintsafe.Lib; -using System.Threading; -using System.Threading.Tasks; namespace Mintsafe.WebApi.Controllers { diff --git a/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs b/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs index 6693e3e..8dc169f 100644 --- a/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs +++ b/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs @@ -2,7 +2,6 @@ using FluentAssertions; using Mintsafe.Abstractions; using Mintsafe.DataAccess.Mappers; -using Mintsafe.DataAccess.Models; using Xunit; namespace Mintsafe.DataAccess.UnitTests.Mappers diff --git a/Tests/DataAccess.UnitTests/Mappers/NiftyFileMapperShould.cs b/Tests/DataAccess.UnitTests/Mappers/NiftyFileMapperShould.cs index c6962a3..d0a8fec 100644 --- a/Tests/DataAccess.UnitTests/Mappers/NiftyFileMapperShould.cs +++ b/Tests/DataAccess.UnitTests/Mappers/NiftyFileMapperShould.cs @@ -21,7 +21,7 @@ public void Map_Dto_Correctly() NiftyId = niftyId.ToString(), Name = "Name", MediaType = "jpeg", - Url = "test.com", + Src = "test.com", FileHash = "hash" }; @@ -32,7 +32,7 @@ public void Map_Dto_Correctly() model.NiftyId.Should().Be(niftyId); model.Name.Should().Be("Name"); model.MediaType.Should().Be("jpeg"); - model.Url.Should().Be("test.com"); + model.Src.Should().Be("test.com"); model.FileHash.Should().Be("hash"); } @@ -60,7 +60,7 @@ public void Map_Model_Correctly() model.NiftyId.Should().Be(niftyId.ToString()); model.Name.Should().Be("Name"); model.MediaType.Should().Be("jpeg"); - model.Url.Should().Be("test.com"); + model.Src.Should().Be("test.com"); model.FileHash.Should().Be("hash"); } } diff --git a/Tests/DataAccess.UnitTests/Mappers/NiftyMapperShould.cs b/Tests/DataAccess.UnitTests/Mappers/NiftyMapperShould.cs index 3872852..16f78a0 100644 --- a/Tests/DataAccess.UnitTests/Mappers/NiftyMapperShould.cs +++ b/Tests/DataAccess.UnitTests/Mappers/NiftyMapperShould.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using FluentAssertions; -using Mintsafe.Abstractions; using Mintsafe.DataAccess.Mappers; using Xunit; using Nifty = Mintsafe.DataAccess.Models.Nifty; @@ -45,7 +44,7 @@ public void Map_Dto_Correctly() NiftyId = niftyId.ToString(), Name = "Name", MediaType = "jpeg", - Url = "test.com", + Src = "test.com", FileHash = "hash" } }; @@ -72,7 +71,7 @@ public void Map_Dto_Correctly() model.Files.First().NiftyId.Should().Be(niftyId); model.Files.First().Name.Should().Be("Name"); model.Files.First().MediaType.Should().Be("jpeg"); - model.Files.First().Url.Should().Be("test.com"); + model.Files.First().Src.Should().Be("test.com"); model.Files.First().FileHash.Should().Be("hash"); } diff --git a/Tests/DataAccess.UnitTests/Mintsafe.DataAccess.UnitTests.csproj b/Tests/DataAccess.UnitTests/Mintsafe.DataAccess.UnitTests.csproj index 76550e1..e193947 100644 --- a/Tests/DataAccess.UnitTests/Mintsafe.DataAccess.UnitTests.csproj +++ b/Tests/DataAccess.UnitTests/Mintsafe.DataAccess.UnitTests.csproj @@ -9,12 +9,12 @@ - - - - + + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Tests/Lib.UnitTests/BlockfrostUtxoRetrieverShould.cs b/Tests/Lib.UnitTests/BlockfrostUtxoRetrieverShould.cs index e911946..8a94199 100644 --- a/Tests/Lib.UnitTests/BlockfrostUtxoRetrieverShould.cs +++ b/Tests/Lib.UnitTests/BlockfrostUtxoRetrieverShould.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Text; +using System.Threading.Tasks; using Mintsafe.Abstractions; using Moq; using Microsoft.Extensions.Logging.Abstractions; @@ -24,11 +20,11 @@ public BlockfrostUtxoRetrieverShould() } [Theory] - [InlineData("d29bbb14dbe448eda8156f5439335ce6c800f39f4812dfc6f8293274871d6e52", 0, "540f107c7a3df20d2111a41c3bc407cce3e63c10c8dd673d51a02c22434f4e4431", "1", "2172289", - "540f107c7a3df20d2111a41c3bc407cce3e63c10c8dd673d51a02c22.434f4e4431", 1, 2172289)] + [InlineData("d29bbb14dbe448eda8156f5439335ce6c800f39f4812dfc6f8293274871d6e52", 0U, "540f107c7a3df20d2111a41c3bc407cce3e63c10c8dd673d51a02c22434f4e4431", "1", "2172289", + "540f107c7a3df20d2111a41c3bc407cce3e63c10c8dd673d51a02c22", "434f4e4431", 1, 2172289)] public async Task Should_Map_Utxo_Values_Correctly( - string bfTxHash, int bfOutputIndex, string bfNativeAssetUnit, string bfNativeAssetQuantity, string bfLovelaceQuantity, - string expectedNativeAssetUnit, long expectedNativeAssetQuantity, long expectedLovelaceQuantity) + string bfTxHash, uint bfOutputIndex, string bfNativeAssetUnit, string bfNativeAssetQuantity, string bfLovelaceQuantity, + string expectedNativeAssetPolicyId, string expectedNativeAssetName, ulong expectedNativeAssetQuantity, ulong expectedLovelaceQuantity) { _mockBlockfrostClient.Setup(m => m.GetUtxosAtAddressAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new[] { @@ -57,10 +53,11 @@ public async Task Should_Map_Utxo_Values_Correctly( Assert.NotNull(utxos); utxos.Length.Should().Be(1); var utxo = utxos[0]; - utxo.Values[0].Unit.Should().Be("lovelace"); - utxo.Values[0].Quantity.Should().Be(expectedLovelaceQuantity); - utxo.Values[1].Unit.Should().Be(expectedNativeAssetUnit); - utxo.Values[1].Quantity.Should().Be(expectedNativeAssetQuantity); + utxo.Lovelaces.Should().Be(expectedLovelaceQuantity); + utxo.Value.Lovelaces.Should().Be(expectedLovelaceQuantity); + utxo.Value.NativeAssets[0].PolicyId.Should().Be(expectedNativeAssetPolicyId); + utxo.Value.NativeAssets[0].AssetName.Should().Be(expectedNativeAssetName); + utxo.Value.NativeAssets[0].Quantity.Should().Be(expectedNativeAssetQuantity); } } } diff --git a/Tests/Lib.UnitTests/FakeGenerator.cs b/Tests/Lib.UnitTests/FakeGenerator.cs index 82ce4b6..3afd9a8 100644 --- a/Tests/Lib.UnitTests/FakeGenerator.cs +++ b/Tests/Lib.UnitTests/FakeGenerator.cs @@ -1,4 +1,5 @@ -using Mintsafe.Abstractions; +using CardanoSharp.Wallet.Common; +using Mintsafe.Abstractions; using System; using System.Collections.Generic; using System.Linq; @@ -80,16 +81,16 @@ public static List GenerateOnChainTokens( .ToList(); } - public static Utxo[] GenerateUtxos(int count, params long[] values) + public static UnspentTransactionOutput[] GenerateUtxos(int count, params ulong[] values) { if (values.Length != count) throw new ArgumentException($"{nameof(values)} must be the same length as count", nameof(values)); return Enumerable.Range(0, count) - .Select(i => new Utxo( + .Select(i => new UnspentTransactionOutput( "127745e23b81a5a5e22a409ce17ae8672b234dda7be1f09fc9e3a11906bd3a11", - i, - new[] { new Value(Assets.LovelaceUnit, values[i]) })) + (uint)i, + new AggregateValue(values[i], Array.Empty()))) .ToArray(); } @@ -98,7 +99,7 @@ public static Sale GenerateSale( int totalReleaseQuantity = 500, int maxAllowedPurchaseQuantity = 10, bool isActive = true, - long lovelacesPerToken = 15000000, + ulong lovelacesPerToken = 15000000, string creatorAddress = "addr_test1vz0hx28mmdz0ey3pzqe5nxg08urjhzydpvvmcx4v4we5mvg6733n5", string proceedsAddress = "addr_test1vzj4c522pr5n6texvcl24kl9enntr4knl4ucecd7pkt24mglna4pz", DateTime? start = null, @@ -134,17 +135,26 @@ public static SaleContext GenerateSaleContext( collection ?? GenerateCollection(), mintableTokens ?? GenerateTokens(10), allocatedTokens ?? new List(), - new HashSet(), new HashSet(), new HashSet(), new HashSet()); + new HashSet(), new HashSet(), new HashSet(), new HashSet()); return context; } + public static NetworkContext GenerateNetworkContext( + uint latestSlot = 63735444, uint protocolMajor = 6, uint protocolMinor = 0, + uint minFeeA = FeeStructure.Coefficient, uint minFeeB = FeeStructure.Constant, uint coinsPerUtxoWord = 34482) + { + return new NetworkContext( + latestSlot, + new ProtocolParams(protocolMajor, protocolMinor, minFeeA, minFeeB, coinsPerUtxoWord)); + } + public static TxInfo GenerateTxIoAggregate( string txHash = "01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279", string inputAddress = "addr_test1vrfxxeuzqfuknfz4hu0ym4fe4l3axvqd7t5agd6pfzml59q30qc4x", - long inputLovelaceQuantity = 10200000, + ulong inputLovelaceQuantity = 10200000, string outputAddress = "addr_test1vre6wmde3qz7h7eerk98lgtkuzjd5nfqj4wy0fwntymr20qee2cxk", - long outputLovelaceQuantity = 10000000) + ulong outputLovelaceQuantity = 10000000) { return new TxInfo( txHash, diff --git a/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs b/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs index 3942e81..73523f3 100644 --- a/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs +++ b/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs @@ -1,9 +1,7 @@ using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; -using Mintsafe.Abstractions; using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Text.Json; using Xunit; @@ -70,7 +68,7 @@ public void Generate_The_Right_Json_With_Correct_Token_Metadata(int nftCount) throw new Exception("Files cannot be null"); var assetFile = asset.Files.First(f => f.Name == file.Name); assetFile.Name.Should().Be(file.Name); - assetFile.Src.Should().Be(file.Url); + assetFile.Src.Should().Be(file.Src); assetFile.MediaType.Should().Be(file.MediaType); assetFile.Hash.Should().Be(file.FileHash); } @@ -123,7 +121,7 @@ public void Generate_The_Right_Json_With_Correct_Token_OnChain_Metadata(int nftC throw new Exception("Files cannot be null"); var assetFile = asset.Files.First(f => f.Name == file.Name); assetFile.Name.Should().Be(file.Name); - assetFile.Src.Should().BeEquivalentTo(MetadataJsonBuilder.SplitStringToChunks(file.Url)); + assetFile.Src.Should().BeEquivalentTo(MetadataJsonBuilder.SplitStringToChunks(file.Src)); assetFile.MediaType.Should().Be(file.MediaType); assetFile.Hash.Should().Be(file.FileHash); } diff --git a/Tests/Lib.UnitTests/Mintsafe.Lib.UnitTests.csproj b/Tests/Lib.UnitTests/Mintsafe.Lib.UnitTests.csproj index cfd09d4..b57cc3c 100644 --- a/Tests/Lib.UnitTests/Mintsafe.Lib.UnitTests.csproj +++ b/Tests/Lib.UnitTests/Mintsafe.Lib.UnitTests.csproj @@ -8,11 +8,11 @@ - - - + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Tests/Lib.UnitTests/NiftyAllocatorShould.cs b/Tests/Lib.UnitTests/NiftyAllocatorShould.cs index 6e6e95f..50d3b98 100644 --- a/Tests/Lib.UnitTests/NiftyAllocatorShould.cs +++ b/Tests/Lib.UnitTests/NiftyAllocatorShould.cs @@ -78,7 +78,7 @@ public async Task Throws_CannotAllocateMoreThanSaleReleaseException_When_Request var request = new PurchaseAttempt( Guid.NewGuid(), Guid.NewGuid(), - new Utxo("", 0, new[] { new Value(Assets.LovelaceUnit, 1000000) }), + new UnspentTransactionOutput("", 0, new AggregateValue(1000000, Array.Empty())), requestedQuantity, 0); @@ -110,7 +110,7 @@ public async Task Throws_ArgumentException_When_Requesting_Zero_Or_Negative_Toke var request = new PurchaseAttempt( Guid.NewGuid(), Guid.NewGuid(), - new Utxo("", 0, new[] { new Value(Assets.LovelaceUnit, 1000000) }), + new UnspentTransactionOutput("", 0, new AggregateValue(1000000, Array.Empty())), requestedQuantity, 0); diff --git a/Tests/Lib.UnitTests/NiftyDistributorShould.cs b/Tests/Lib.UnitTests/NiftyDistributorShould.cs index a4bc008..0e21bbc 100644 --- a/Tests/Lib.UnitTests/NiftyDistributorShould.cs +++ b/Tests/Lib.UnitTests/NiftyDistributorShould.cs @@ -61,7 +61,8 @@ public async Task Distribute_Nifties_For_SalePurchase_Given_Active_Sale_When_Pur var distributionResult = await _distributor.DistributeNiftiesForSalePurchase( nfts: allocatedNifties, purchaseAttempt: purchaseAttempt, - saleContext: GenerateSaleContext()); + saleContext: GenerateSaleContext(), + networkContext: GenerateNetworkContext()); distributionResult.Outcome.Should().Be(NiftyDistributionOutcome.Successful); distributionResult.PurchaseAttempt.Should().Be(purchaseAttempt); @@ -80,7 +81,7 @@ public async Task Distribute_Nifties_For_SalePurchase_Given_Active_Sale_When_Pur [InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0, 10000000)] [InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 2, 35000000)] public async Task Build_Correct_Tx_Input_For_Buyer_SalePurchase_Given_Active_Sale_When_Purchase_Is_Valid( - string purchaseTxHash, int purchaseOutputIndex, long purchaseUtxoLovelaceValue) + string purchaseTxHash, uint purchaseOutputIndex, ulong purchaseUtxoLovelaceValue) { var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; _mockTxIoRetriever @@ -92,15 +93,16 @@ public async Task Build_Correct_Tx_Input_For_Buyer_SalePurchase_Given_Active_Sal _mockTxSubmitter .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); - var purchaseUtxo = new Utxo( + var purchaseUtxo = new UnspentTransactionOutput( purchaseTxHash, purchaseOutputIndex, - new[] { new Value(Assets.LovelaceUnit, purchaseUtxoLovelaceValue) }); + new AggregateValue(purchaseUtxoLovelaceValue, Array.Empty())); var txHash = await _distributor.DistributeNiftiesForSalePurchase( nfts: GenerateTokens(3).ToArray(), purchaseAttempt: new PurchaseAttempt(Guid.NewGuid(), Guid.NewGuid(), purchaseUtxo, 3, 0), - saleContext: GenerateSaleContext()); + saleContext: GenerateSaleContext(), + networkContext: GenerateNetworkContext()); _mockTxBuilder .Verify( @@ -109,45 +111,46 @@ public async Task Build_Correct_Tx_Input_For_Buyer_SalePurchase_Given_Active_Sal "Input should be the purchase UTxO"); } - [Theory] - [InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0, 1, "0ac248e17f0fc35be4d2a7d186a84cdcda5b88d7ad2799ebe98a98b2")] - [InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 1000000, 3, "629718e24d22e0c02c2efd27290e1a58ebc2972635a7c523aee2d8fc")] - public async Task Build_Correct_Tx_Output_For_Buyer_SalePurchase_Given_Active_Sale_When_Purchase_Is_Valid( - string buyerAddress, int changeInLovelace, int niftyCount, string policyId) - { - var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; - _mockTxIoRetriever - .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(GenerateTxIoAggregate(inputAddress: buyerAddress)); - _mockTxBuilder - .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(buildTxOutputBytes); - _mockTxSubmitter - .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) - .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); - var nifties = GenerateTokens(niftyCount).ToArray(); - - var txHash = await _distributor.DistributeNiftiesForSalePurchase( - nfts: nifties, - purchaseAttempt: new PurchaseAttempt( - Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, 10000000).First(), niftyCount, changeInLovelace), - saleContext: GenerateSaleContext(collection: GenerateCollection(policyId: policyId))); - - _mockTxBuilder - .Verify( - t => t.BuildTxAsync( - It.Is(b => IsBuyerOutputCorrect(b, buyerAddress, changeInLovelace, nifties, policyId)), - It.IsAny()), - Times.Once, - "Output should have buyer address and correct values"); - } + //[Theory] + //[InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0, 1, "0ac248e17f0fc35be4d2a7d186a84cdcda5b88d7ad2799ebe98a98b2")] + //[InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 1000000, 3, "629718e24d22e0c02c2efd27290e1a58ebc2972635a7c523aee2d8fc")] + //public async Task Build_Correct_Tx_Output_For_Buyer_SalePurchase_Given_Active_Sale_When_Purchase_Is_Valid( + // string buyerAddress, ulong changeInLovelace, int niftyCount, string policyId) + //{ + // var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; + // _mockTxIoRetriever + // .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(GenerateTxIoAggregate(inputAddress: buyerAddress)); + // _mockTxBuilder + // .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(buildTxOutputBytes); + // _mockTxSubmitter + // .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) + // .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); + // var nifties = GenerateTokens(niftyCount).ToArray(); + + // var txHash = await _distributor.DistributeNiftiesForSalePurchase( + // nfts: nifties, + // purchaseAttempt: new PurchaseAttempt( + // Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, 10000000).First(), niftyCount, changeInLovelace), + // saleContext: GenerateSaleContext(collection: GenerateCollection(policyId: policyId)), + // networkContext: GenerateNetworkContext()); + + // _mockTxBuilder + // .Verify( + // t => t.BuildTxAsync( + // It.Is(b => IsBuyerOutputCorrect(b, buyerAddress, changeInLovelace, nifties, policyId)), + // It.IsAny()), + // Times.Once, + // "Output should have buyer address and correct values"); + //} [Theory] [InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0.1, "addr_test1qrup2zgu69knkts7m3y3ghhwdzmgaus5u3s28vcsdegajr9wv6zerpdre7qdyvf68dcjyslazq0tfj5rq80v02tm5mysd3xc2u", 10000000, 0, 1)] [InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 0.078, "addr_test1qp06h7um737tlp2s5um8fvwef5rmx6jh7auchrcwgct3w43w7gtapvad5hzgkvr3ksnzpu6a2ejaew5ypeurygknqs5qhjuvf6", 36000000, 2000000, 3)] [InlineData("addr_test1vrfxxeuzqfuknfz4hu0ym4fe4l3axvqd7t5agd6pfzml59q30qc4x", 0.0088, "addr_test1vrldgv89yh0edkuwrvkkhc3yx4npfccdvtz7dfkn85a78rsu9nkm4", 50500000, 2500000, 5)] public async Task Build_Correct_Tx_Output_For_Creator_Address_Given_Active_Sale_When_Purchase_Is_Valid( - string creatorAddress, double margin, string buyerAddress, long purchaseLovelaces, int changeInLovelace, int niftyCount) + string creatorAddress, double margin, string buyerAddress, ulong purchaseLovelaces, ulong changeInLovelace, int niftyCount) { var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; var txInfo = GenerateTxIoAggregate(inputAddress: buyerAddress); @@ -167,7 +170,8 @@ public async Task Build_Correct_Tx_Output_For_Creator_Address_Given_Active_Sale_ purchaseAttempt: new PurchaseAttempt( Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, purchaseLovelaces).First(), niftyCount, changeInLovelace), saleContext: GenerateSaleContext( - sale: GenerateSale(creatorAddress: creatorAddress, postPurchaseMargin: (decimal)margin))); + sale: GenerateSale(creatorAddress: creatorAddress, postPurchaseMargin: (decimal)margin)), + networkContext: GenerateNetworkContext()); _mockTxBuilder .Verify( @@ -184,7 +188,7 @@ public async Task Build_Correct_Tx_Output_For_Creator_Address_Given_Active_Sale_ [InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 0.078,"addr_test1qp06h7um737tlp2s5um8fvwef5rmx6jh7auchrcwgct3w43w7gtapvad5hzgkvr3ksnzpu6a2ejaew5ypeurygknqs5qhjuvf6", 36000000, 2000000, 3)] [InlineData("addr_test1vrfxxeuzqfuknfz4hu0ym4fe4l3axvqd7t5agd6pfzml59q30qc4x", 0.0088, "addr_test1vrldgv89yh0edkuwrvkkhc3yx4npfccdvtz7dfkn85a78rsu9nkm4", 50500000, 2500000, 5)] public async Task Build_Correct_Tx_Output_For_Proceeds_Address_Given_Active_Sale_When_Purchase_Is_Valid( - string proceedsAddress, double margin, string buyerAddress, long purchaseLovelaces, int changeInLovelace, int niftyCount) + string proceedsAddress, double margin, string buyerAddress, ulong purchaseLovelaces, ulong changeInLovelace, int niftyCount) { var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; var txInfo = GenerateTxIoAggregate(inputAddress: buyerAddress); @@ -204,7 +208,8 @@ public async Task Build_Correct_Tx_Output_For_Proceeds_Address_Given_Active_Sale nfts: allocatedNifties, purchaseAttempt: new PurchaseAttempt( Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, purchaseLovelaces).First(), niftyCount, changeInLovelace), - saleContext: GenerateSaleContext(sale: sale)); + saleContext: GenerateSaleContext(sale: sale), + networkContext: GenerateNetworkContext()); _mockTxBuilder .Verify( @@ -216,53 +221,53 @@ public async Task Build_Correct_Tx_Output_For_Proceeds_Address_Given_Active_Sale "Output should have proceeds address and correct lovelace value"); } - [Theory] - [InlineData(1, "0ac248e17f0fc35be4d2a7d186a84cdcda5b88d7ad2799ebe98a98b2")] - [InlineData(3, "629718e24d22e0c02c2efd27290e1a58ebc2972635a7c523aee2d")] - public async Task Build_Correct_Tx_Mint_For_Nifities_Given_Active_Sale_When_Purchase_Is_Valid( - int niftyCount, string policyId) - { - var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; - _mockTxIoRetriever - .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(GenerateTxIoAggregate()); - _mockTxBuilder - .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(buildTxOutputBytes); - _mockTxSubmitter - .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) - .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); - var nifties = GenerateTokens(niftyCount).ToArray(); - - var txHash = await _distributor.DistributeNiftiesForSalePurchase( - nfts: nifties, - purchaseAttempt: new PurchaseAttempt( - Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, 1000000).First(), 3, 0), - saleContext: GenerateSaleContext(collection: GenerateCollection(policyId: policyId))); - - _mockTxBuilder - .Verify( - t => t.BuildTxAsync( - It.Is(b => IsMintCorrect(b, nifties, policyId)), It.IsAny()), - Times.Once, - "Should have correctly mapped mint parameters for nifties"); - } + //[Theory] + //[InlineData(1, "0ac248e17f0fc35be4d2a7d186a84cdcda5b88d7ad2799ebe98a98b2")] + //[InlineData(3, "629718e24d22e0c02c2efd27290e1a58ebc2972635a7c523aee2d")] + //public async Task Build_Correct_Tx_Mint_For_Nifities_Given_Active_Sale_When_Purchase_Is_Valid( + // int niftyCount, string policyId) + //{ + // var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; + // _mockTxIoRetriever + // .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(GenerateTxIoAggregate()); + // _mockTxBuilder + // .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(buildTxOutputBytes); + // _mockTxSubmitter + // .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) + // .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); + // var nifties = GenerateTokens(niftyCount).ToArray(); + + // var txHash = await _distributor.DistributeNiftiesForSalePurchase( + // nfts: nifties, + // purchaseAttempt: new PurchaseAttempt( + // Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, 1000000).First(), 3, 0), + // saleContext: GenerateSaleContext(collection: GenerateCollection(policyId: policyId)), + // networkContext: GenerateNetworkContext()); + + // _mockTxBuilder + // .Verify( + // t => t.BuildTxAsync( + // It.Is(b => IsMintCorrect(b, nifties, policyId)), It.IsAny()), + // Times.Once, + // "Should have correctly mapped mint parameters for nifties"); + //} private static bool IsBuyerOutputCorrect( TxBuildCommand buildCommand, string buyerAddress, - long changeInLovelace, + ulong changeInLovelace, Nifty[] nifties, string policyId) { var buyerOutput = buildCommand.Outputs.First(output => output.Address == buyerAddress); - var buyerOutputLovelace = buyerOutput.Values.First(v => v.Unit == Assets.LovelaceUnit).Quantity; + var buyerOutputLovelace = buyerOutput.Values.Lovelaces; var minUtxoLovelaceQuantity = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName))}").ToArray(); - var allSingleNiftyOutputs = buyerOutput.Values - .Where(v => v.Unit != Assets.LovelaceUnit) - .All(v => expectedNiftyAssetNames.Contains(v.Unit) && v.Quantity == 1); + var allSingleNiftyOutputs = buyerOutput.Values.NativeAssets + .All(v => expectedNiftyAssetNames.Contains($"{v.PolicyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(v.AssetName))}") && v.Quantity == 1); return (buyerOutputLovelace == changeInLovelace + minUtxoLovelaceQuantity) && allSingleNiftyOutputs; } @@ -272,16 +277,16 @@ private static bool IsProceedsOutputCorrect( string proceedsAddress, double margin, string buyerAddress, - long purchaseLovelaces, - long changeInLovelace) + ulong purchaseLovelaces, + ulong changeInLovelace) { var buyerOutput = buildCommand.Outputs.First(output => output.Address == buyerAddress); var proceedsOutput = buildCommand.Outputs.First(output => output.Address == proceedsAddress); - var proceedsOutputLovelaces = proceedsOutput.Values.First(v => v.Unit == Assets.LovelaceUnit).Quantity; + var proceedsOutputLovelaces = proceedsOutput.Values.Lovelaces; var minUtxoLovelaces = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); var saleLovelaces = purchaseLovelaces - changeInLovelace - minUtxoLovelaces; - var proceedsCutLovelaces = (int)(saleLovelaces * margin); + var proceedsCutLovelaces = (ulong)(saleLovelaces * margin); return proceedsOutputLovelaces == proceedsCutLovelaces; } @@ -291,16 +296,16 @@ private static bool IsCreatorOutputCorrect( string creatorAddress, double margin, string buyerAddress, - long purchaseLovelaces, - long changeInLovelace) + ulong purchaseLovelaces, + ulong changeInLovelace) { var buyerOutput = buildCommand.Outputs.First(output => output.Address == buyerAddress); var creatorOutput = buildCommand.Outputs.First(output => output.Address == creatorAddress); - var creatorOutputLovelaces = creatorOutput.Values.First(v => v.Unit == Assets.LovelaceUnit).Quantity; + var creatorOutputLovelaces = creatorOutput.Values.Lovelaces; var minUtxoLovelaces = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); var saleLovelaces = purchaseLovelaces - changeInLovelace - minUtxoLovelaces; - var proceedsCutLovelaces = (int)(saleLovelaces * margin); + var proceedsCutLovelaces = (ulong)(saleLovelaces * margin); return creatorOutputLovelaces == saleLovelaces - proceedsCutLovelaces; } @@ -312,7 +317,7 @@ private static bool IsMintCorrect( { var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName))}").ToArray(); var allSingleNiftyMints = buildCommand.Mint - .All(v => expectedNiftyAssetNames.Contains(v.Unit) && v.Quantity == 1); + .All(v => expectedNiftyAssetNames.Contains($"{v.PolicyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(v.AssetName))}") && v.Quantity == 1); return allSingleNiftyMints; } diff --git a/Tests/Lib.UnitTests/PurchaseAttemptGeneratorShould.cs b/Tests/Lib.UnitTests/PurchaseAttemptGeneratorShould.cs index 3cb1686..9424cc6 100644 --- a/Tests/Lib.UnitTests/PurchaseAttemptGeneratorShould.cs +++ b/Tests/Lib.UnitTests/PurchaseAttemptGeneratorShould.cs @@ -12,15 +12,15 @@ public class PurchaseAttemptGeneratorShould [InlineData(39000000, 15000000, 2)] [InlineData(50000000, 10000000, 5)] public void Correctly_Calculate_Quantity( - long utxoValueLovelace, long costPerTokenLovelace, int expectedQuantity) + ulong utxoValueLovelace, ulong costPerTokenLovelace, int expectedQuantity) { var sale = FakeGenerator.GenerateSale(lovelacesPerToken: costPerTokenLovelace); var salePurchase = PurchaseAttemptGenerator.FromUtxo( - new Utxo( + new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new[] { new Value(Assets.LovelaceUnit, utxoValueLovelace) }), + new AggregateValue(utxoValueLovelace, Array.Empty())), sale); salePurchase.NiftyQuantityRequested.Should().Be(expectedQuantity); @@ -31,15 +31,15 @@ public void Correctly_Calculate_Quantity( [InlineData(34000001, 34000000, 1)] [InlineData(25000000, 10000000, 5000000)] public void Correctly_Calculate_Change( - long utxoValueLovelace, long costPerTokenLovelace, int expectedChange) + ulong utxoValueLovelace, ulong costPerTokenLovelace, ulong expectedChange) { var sale = FakeGenerator.GenerateSale(lovelacesPerToken: costPerTokenLovelace); var salePurchase = PurchaseAttemptGenerator.FromUtxo( - new Utxo( + new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new[] { new Value(Assets.LovelaceUnit, utxoValueLovelace) }), + new AggregateValue(utxoValueLovelace, Array.Empty())), sale); salePurchase.ChangeInLovelace.Should().Be(expectedChange); @@ -58,10 +58,10 @@ public void Correctly_Maps_Values_When_Sale_Is_Active_And_Within_Start_End_Dates end: DateTime.UtcNow.AddSeconds(secondsBeforeEnd)); var salePurchase = PurchaseAttemptGenerator.FromUtxo( - new Utxo( + new UnspentTransactionOutput( txHash, 0, - new[] { new Value(Assets.LovelaceUnit, 10000000) }), + new AggregateValue(10000000, Array.Empty())), sale); salePurchase.Utxo.TxHash.Should().Be(txHash); @@ -72,17 +72,17 @@ public void Correctly_Maps_Values_When_Sale_Is_Active_And_Within_Start_End_Dates [InlineData(1500000, 1500001)] [InlineData(34999999, 35000000)] public void Throws_InsufficientPaymentException_When_Utxo_Value_Is_Less_Than_LovelacesPerToken( - long utxoValueLovelace, long costPerTokenLovelace) + ulong utxoValueLovelace, ulong costPerTokenLovelace) { var sale = FakeGenerator.GenerateSale(lovelacesPerToken: costPerTokenLovelace); Action action = () => { PurchaseAttemptGenerator.FromUtxo( - new Utxo( + new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new[] { new Value(Assets.LovelaceUnit, utxoValueLovelace) }), + new AggregateValue(utxoValueLovelace, Array.Empty())), sale); }; @@ -97,10 +97,10 @@ public void Throws_SaleInactiveException_When_Sale_Is_Inactive() Action action = () => { PurchaseAttemptGenerator.FromUtxo( - new Utxo( + new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new[] { new Value(Assets.LovelaceUnit, 100000000) }), + new AggregateValue(100000000, Array.Empty())), sale); }; @@ -111,17 +111,17 @@ public void Throws_SaleInactiveException_When_Sale_Is_Inactive() [InlineData(20000000, 10000000, 1)] [InlineData(80000000, 20000000, 3)] public void Throws_MaxAllowedPurchaseQuantityExceededException_When_Quantity_Exceeds_Max_Allowed( - long utxoValueLovelace, long costPerTokenLovelace, int maxAllowedPurchaseQuantity) + ulong utxoValueLovelace, ulong costPerTokenLovelace, int maxAllowedPurchaseQuantity) { var sale = FakeGenerator.GenerateSale(lovelacesPerToken: costPerTokenLovelace, maxAllowedPurchaseQuantity: maxAllowedPurchaseQuantity); Action action = () => { PurchaseAttemptGenerator.FromUtxo( - new Utxo( + new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new[] { new Value(Assets.LovelaceUnit, utxoValueLovelace) }), + new AggregateValue(utxoValueLovelace, Array.Empty())), sale); }; @@ -139,10 +139,10 @@ public void Throws_SalePeriodOutOfRangeException_When_Sale_Has_Not_Started( Action action = () => { PurchaseAttemptGenerator.FromUtxo( - new Utxo( + new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new[] { new Value(Assets.LovelaceUnit, 100000000) }), + new AggregateValue(100000000, Array.Empty())), sale); }; @@ -160,10 +160,10 @@ public void Throws_SalePeriodOutOfRangeException_When_Sale_Has_Already_Ended( Action action = () => { PurchaseAttemptGenerator.FromUtxo( - new Utxo( + new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new[] { new Value(Assets.LovelaceUnit, 100000000) }), + new AggregateValue(100000000, Array.Empty())), sale); }; diff --git a/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs index 21a7c37..72ea3d9 100644 --- a/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs +++ b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs @@ -10,8 +10,6 @@ using Mintsafe.Abstractions; using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; using Xunit; using static Mintsafe.Lib.UnitTests.FakeGenerator; @@ -23,7 +21,7 @@ public class SimpleWalletServiceShould private const int MessageMetadataStandardKey = 674; private const int NftRoyaltyMetadataStandardKey = 777; - [Fact] + [Fact(Skip = "Live integration test - can fail if run concurrently with other tests")] public async Task Submit_Transaction_Successfully_When_Consolidating_Own_Address_Utxos() { var simpleWalletService = new SimpleWalletService(NullLogger.Instance, NullInstrumentor.Instance); @@ -44,7 +42,7 @@ public async Task Submit_Transaction_Successfully_When_Consolidating_Own_Address Assert.NotNull(txId); } - [Fact] + [Fact(Skip = "Live integration test - can fail if run concurrently with other tests")] public async Task Submit_Transaction_Successfully_When_Making_Simple_Ada_Payment_To_One_Address() { var simpleWalletService = new SimpleWalletService(NullLogger.Instance, NullInstrumentor.Instance); @@ -69,7 +67,7 @@ public async Task Submit_Transaction_Successfully_When_Making_Simple_Ada_Payment Assert.NotNull(txId); } - [Fact] + [Fact(Skip = "Live integration test - can fail if run concurrently with other tests")] public async Task Submit_Transaction_Successfully_When_Minting_Nft_Royalty_Asset() { var simpleWalletService = new SimpleWalletService(NullLogger.Instance, NullInstrumentor.Instance); diff --git a/Tests/Lib.UnitTests/TimeUtilShould.cs b/Tests/Lib.UnitTests/TimeUtilShould.cs index c322aab..5c2ba71 100644 --- a/Tests/Lib.UnitTests/TimeUtilShould.cs +++ b/Tests/Lib.UnitTests/TimeUtilShould.cs @@ -8,11 +8,10 @@ namespace Mintsafe.Lib.UnitTests; public class TimeUtilShould { [Theory] - //[InlineData(2021, 10, 28, 14, 0, 4, 41060390)] - //[InlineData(2022, 1, 28, 19, 0, 0, 49027186)] - //[InlineData(2022, 12, 25, 0, 0, 0, 77557186)] - [InlineData(2022, 6, 6, 7, 53, 0, 77557186)] - public void Return_Correct_Testnet_Slot( + [InlineData(2021, 10, 28, 14, 0, 4, 41060390)] + [InlineData(2022, 1, 28, 19, 0, 0, 49027186)] + [InlineData(2023, 7, 23, 0, 0, 0, 95701186)] + public void Return_Correct_Testnet_Slot_Utc_DateTime( int year, int month, int day, int hour, int minute, int second, int expectedSlot) { @@ -40,6 +39,7 @@ public void Return_Correct_Utc_Time_From_Testnet_Slot( [Theory] [InlineData(2021, 10, 28, 14, 1, 0, 43863369)] [InlineData(2022, 4, 1, 0, 0, 0, 57204909)] + [InlineData(2023, 7, 23, 0, 0, 0, 98504109)] public void Return_Correct_Mainnet_Slot_For_Utc_DateTime( int year, int month, int day, int hour, int minute, int second, int expectedSlot) @@ -54,6 +54,7 @@ public void Return_Correct_Mainnet_Slot_For_Utc_DateTime( [Theory] [InlineData(43863369, 2021, 10, 28, 14, 1, 0)] [InlineData(51830109, 2022, 1, 28, 19, 0, 0)] + [InlineData(98504109, 2023, 7, 23, 0, 0, 0)] public void Return_Correct_Utc_Time_From_Mainnet_Slot( int slot, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute, int expectedSecond) { diff --git a/Tests/Lib.UnitTests/TxUtilsShould.cs b/Tests/Lib.UnitTests/TxUtilsShould.cs index 517ed4c..96286f1 100644 --- a/Tests/Lib.UnitTests/TxUtilsShould.cs +++ b/Tests/Lib.UnitTests/TxUtilsShould.cs @@ -25,7 +25,7 @@ public class TxUtilsShould "91acca0a2614212d68a5ae7313c85962849994aab54e340d3a68aabb.cryptokoalas0087", "e8209a96a456202276f66224241a703676122d606d208fe464f2e09f.cryptowombats27699")] public void SubtractValues_With_No_Effect_When_Rhs_Values_Are_Empty( - long lovelaceQuantity, params string[] customTokenUnits) + ulong lovelaceQuantity, params string[] customTokenUnits) { var valuesLhs = new List { new Value(Assets.LovelaceUnit, lovelaceQuantity) }; valuesLhs.AddRange(customTokenUnits.Select(u => new Value(u, 1))); @@ -50,7 +50,7 @@ public void SubtractValues_With_No_Effect_When_Rhs_Values_Are_Empty( [InlineData(15000000, 1413762, 13586238)] [InlineData(14946_734549, 2299_663323, 12647071226)] public void SubtractValues_Correctly_When_Only_Ada_Values( - long lovelaceQuantityLhs, long lovelaceQuantityRhs, long expectedLovelaceValues) + ulong lovelaceQuantityLhs, ulong lovelaceQuantityRhs, ulong expectedLovelaceValues) { var valuesLhs = new[] { new Value(Assets.LovelaceUnit, lovelaceQuantityLhs) }; var valuesRhs = new[] { new Value(Assets.LovelaceUnit, lovelaceQuantityRhs) }; @@ -65,7 +65,7 @@ public void SubtractValues_Correctly_When_Only_Ada_Values( [InlineData(761792, 43284, 718508)] [InlineData(9014946734549, 8822996633231, 191950101318)] public void SubtractValues_Correctly_When_Ada_And_Custom_Fungible_Token_Values_Are_Given( - long ftQuantityLhs, long ftQuantityRhs, long expectedFtQuantity) + ulong ftQuantityLhs, ulong ftQuantityRhs, ulong expectedFtQuantity) { var valuesLhs = new[] { new Value(Assets.LovelaceUnit, 15000000), @@ -94,7 +94,7 @@ public void SubtractValues_Correctly_When_Ada_And_Custom_Fungible_Token_Values_A [InlineData(821_945678, AdaOnlyUtxoLovelaces)] [InlineData(5197820531_945678, AdaOnlyUtxoLovelaces)] public void DeriveMinUtxoLovelace_Correctly_Given_All_Default_Params_When_Values_Have_Ada_Only( - long lovelaceValue, long expectedMinUtxo) + ulong lovelaceValue, ulong expectedMinUtxo) { var values = new[] { new Value(Assets.LovelaceUnit, lovelaceValue) }; @@ -110,7 +110,7 @@ public void DeriveMinUtxoLovelace_Correctly_Given_All_Default_Params_When_Values 1620654, "0a85dd1543465407852c90e66c074a3b52ea2d7c77a2346ddc20550a.cryptoroos123", "91acca0a2614212d68a5ae7313c85962849994aab54e340d3a68aabb.cryptopossums99999")] public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Multiple_NFTs_Under_Same_Policy_And_No_Data_Hash( - long expectedMinUtxoLovelace, params string[] customTokenUnits) + ulong expectedMinUtxoLovelace, params string[] customTokenUnits) { var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; values.AddRange(customTokenUnits.Select(u => new Value(u, 1))); @@ -127,7 +127,7 @@ public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Multiple_ 1965474, "5bc031932ddb0e89b880569171da1e0e63c4c07867df8e35214e8213.cryptoemus0001", "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.cryptoquokka99999")] public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Multiple_NFTs_Under_Same_Policy_And_Data_Hash( - long expectedMinUtxoLovelace, params string[] customTokenUnits) + ulong expectedMinUtxoLovelace, params string[] customTokenUnits) { var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; values.AddRange(customTokenUnits.Select(u => new Value(u, 1))); @@ -145,7 +145,7 @@ public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Multiple_ [InlineData( 518_919618, 10000, "7cb31677481b1112db5aaa2acdffbe624d8195d416da8b788cb51f00.cryptokoalas")] public void DeriveMinUtxoLovelace_Given_Default_Values_When_Output_Has_Many_NFTs_Under_Same_Policy_And_Data_Hash( - long expectedMinUtxoLovelace, int count, string customTokenUnitBase) + ulong expectedMinUtxoLovelace, int count, string customTokenUnitBase) { var values = new List { new Value(Assets.LovelaceUnit, 100_000000) }; values.AddRange(Enumerable.Range(1, count).Select(i => new Value($"{customTokenUnitBase}i", 1))); @@ -328,12 +328,12 @@ public void Consolidate_Output_Values_Combining_Native_Assets_Correctly_When_Out public record OutputValueDto { public ulong Lovelaces { get; set; } - public NativeAssetValueDto[] NativeAssets { get; set; } + public NativeAssetValueDto[]? NativeAssets { get; set; } } public record NativeAssetValueDto { - public string PolicyId { get; set; } - public string AssetNameHex { get; set; } + public string? PolicyId { get; set; } + public string? AssetNameHex { get; set; } public ulong Quantity { get; set; } } \ No newline at end of file From 5e236d36364d7ddffa0c1c03e236aa544d8388a8 Mon Sep 17 00:00:00 2001 From: safestak-keith Date: Tue, 21 Jun 2022 04:37:17 +1000 Subject: [PATCH 3/9] Added the ability to handle multiple sales in one worker instance. Keyvault integration and general tidy up --- .gitignore | 2 + Deploy/README.md | 52 +++++ Mintsafe.sln | 5 + Src/Abstractions/INiftyDataService.cs | 5 +- Src/Abstractions/ISaleAllocationStore.cs | 5 +- Src/Abstractions/ITxBuilder.cs | 7 + Src/Abstractions/NiftyTypes.cs | 11 +- Src/DataAccess/Composers/AggregateComposer.cs | 72 +++++++ .../Composers/CollectionAggregateComposer.cs | 44 ----- .../Mappers/NiftyCollectionMapper.cs | 1 - Src/DataAccess/Repositories/SaleRepository.cs | 9 +- Src/DataAccess/Supporting/Constants.cs | 27 ++- Src/DataAccess/TableStorageDataService.cs | 179 +++++++++++------- Src/DataImporter/Program.cs | 70 ++++--- Src/Lib/CardanoSharpTxBuilder.cs | 11 +- Src/Lib/ITransactionSigner.cs | 6 - ...cs => KeyVaultMintingKeychainRetriever.cs} | 3 - Src/Lib/LocalNiftyDataService.cs | 107 +---------- Src/Lib/MetadataBuilder.cs | 2 - Src/Lib/MintsafeAppSettings.cs | 2 + Src/Lib/NiftyDistributor.cs | 17 +- Src/Lib/SaleAllocationFileStore.cs | 73 ++++++- Src/Lib/SimpleWalletService.cs | 5 +- Src/Lib/UtxoRefunder.cs | 5 +- Src/SaleWorker/ConfigTypes.cs | 1 + Src/SaleWorker/Program.cs | 48 +++-- Src/SaleWorker/Worker.cs | 106 ++++++++--- Src/SaleWorker/appsettings.Local.json | 41 ---- Src/SaleWorker/appsettings.json | 20 +- .../Controllers/DataAccessTestController.cs | 6 +- Src/WebApi/Program.cs | 2 +- .../TableStorageDataServiceShould.cs | 14 +- .../SimpleWalletServiceShould.cs | 12 +- 33 files changed, 560 insertions(+), 410 deletions(-) create mode 100644 Deploy/README.md create mode 100644 Src/DataAccess/Composers/AggregateComposer.cs delete mode 100644 Src/DataAccess/Composers/CollectionAggregateComposer.cs rename Src/Lib/{DummyMintingKeychainRetriever.cs => KeyVaultMintingKeychainRetriever.cs} (97%) delete mode 100644 Src/SaleWorker/appsettings.Local.json diff --git a/.gitignore b/.gitignore index 8afdcb6..5bfeb05 100644 --- a/.gitignore +++ b/.gitignore @@ -452,3 +452,5 @@ $RECYCLE.BIN/ !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json + +appsettings.Local.json \ No newline at end of file diff --git a/Deploy/README.md b/Deploy/README.md new file mode 100644 index 0000000..b0c93e3 --- /dev/null +++ b/Deploy/README.md @@ -0,0 +1,52 @@ +# Deployment Guide + +## Mintsafe.SaleWorker +The SaleWorker is a BackgroundService that should be published as a single executable with the appropriate config files. + +### Publish Single Executable +``` +dotnet publish -r linux-x64 Src/SaleWorker/Mintsafe.SaleWorker.csproj -c Release -o release -p:PublishSingleFile=true --self-contained true +``` + +### Update Config +Go to the `release` folder, update the `hostsettings.json` and `appsettings.{environment}.json` config files accordingly depending on your requirements. + +### Transfer files to instance +``` +scp -i ssh.pem release/* {USER}@{INSTANCE}:/home/{USER}/{FILES} +``` + +### Create Service File +``` +touch mintsafe.saleworker.service +echo "[Unit]" >> mintsafe.saleworker.service +echo "Description=Mintsafe.SaleWorker Service TACF" >> mintsafe.saleworker.service +echo "" >> mintsafe.saleworker.service +echo "[Service]" >> mintsafe.saleworker.service +echo "WorkingDirectory=/home/user/mintsafe/saleworker" >> mintsafe.saleworker.service +echo "ExecStart=/home/user/mintsafe/saleworker/mintsafe.saleworker" >> mintsafe.saleworker.service +echo "" >> mintsafe.saleworker.service +echo "Restart=on-failure" >> mintsafe.saleworker.service +echo "RestartSec=10" >> mintsafe.saleworker.service +echo "KillSignal=SIGINT" >> mintsafe.saleworker.service +echo "RestartKillSignal=SIGINT" >> mintsafe.saleworker.service +echo "" >> mintsafe.saleworker.service +echo "StandardOutput=journal" >> mintsafe.saleworker.service +echo "StandardError=journal" >> mintsafe.saleworker.service +echo "SyslogIdentifier=mintsafe.saleworker.tacf" >> mintsafe.saleworker.service +echo "" >> mintsafe.saleworker.service +echo "User=ss" >> mintsafe.saleworker.service +echo "Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false" >> mintsafe.saleworker.service +echo "" >> mintsafe.saleworker.service +echo "[Install]" >> mintsafe.saleworker.service +echo "WantedBy=multi-user.target" >> mintsafe.saleworker.service +chmod 400 mintsafe.saleworker.service +sudo cp mintsafe.saleworker.service /etc/systemd/system/ +``` + +### Start Service +``` +sudo systemctl start mintsafe.saleworker.service +sudo systemctl enable mintsafe.saleworker.service +sudo systemctl status mintsafe.saleworker.service +``` \ No newline at end of file diff --git a/Mintsafe.sln b/Mintsafe.sln index a1e54c0..ca3bd68 100644 --- a/Mintsafe.sln +++ b/Mintsafe.sln @@ -41,6 +41,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mintsafe.DataImporter", "Sr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CardanoSharp.Wallet", "..\cardanosharp-wallet\CardanoSharp.Wallet\CardanoSharp.Wallet.csproj", "{C46A2637-A636-41F8-8CF4-F71562CDE785}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Deploy", "Deploy", "{F7454B84-B79D-4359-9202-1ADB836359FB}" + ProjectSection(SolutionItems) = preProject + Deploy\README.md = Deploy\README.md + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/Src/Abstractions/INiftyDataService.cs b/Src/Abstractions/INiftyDataService.cs index caecf23..100e574 100644 --- a/Src/Abstractions/INiftyDataService.cs +++ b/Src/Abstractions/INiftyDataService.cs @@ -6,6 +6,7 @@ namespace Mintsafe.Abstractions; public interface INiftyDataService { - public Task GetCollectionAggregateAsync(Guid collectionId, CancellationToken ct = default); - Task InsertCollectionAggregateAsync(CollectionAggregate collectionAggregate, CancellationToken ct = default); + public Task GetCollectionAggregateAsync(Guid collectionId, CancellationToken ct = default); + public Task GetSaleAggregateAsync(Guid saleId, CancellationToken ct = default); + Task InsertCollectionAggregateAsync(ProjectAggregate collectionAggregate, CancellationToken ct = default); } diff --git a/Src/Abstractions/ISaleAllocationStore.cs b/Src/Abstractions/ISaleAllocationStore.cs index 0aee945..83bae49 100644 --- a/Src/Abstractions/ISaleAllocationStore.cs +++ b/Src/Abstractions/ISaleAllocationStore.cs @@ -8,7 +8,10 @@ namespace Mintsafe.Abstractions public interface ISaleAllocationStore { Task GetOrRestoreSaleContextAsync( - CollectionAggregate collectionAggregate, Guid workerId, CancellationToken ct); + ProjectAggregate collectionAggregate, Guid workerId, CancellationToken ct); + + Task GetOrRestoreSaleContextAsync( + SaleAggregate saleAggregate, Guid workerId, CancellationToken ct); Task AllocateNiftiesAsync( PurchaseAttempt request, SaleContext context, CancellationToken ct); diff --git a/Src/Abstractions/ITxBuilder.cs b/Src/Abstractions/ITxBuilder.cs index e271b25..7190faa 100644 --- a/Src/Abstractions/ITxBuilder.cs +++ b/Src/Abstractions/ITxBuilder.cs @@ -9,3 +9,10 @@ public Task BuildTxAsync( TxBuildCommand buildCommand, CancellationToken ct = default); } + +public interface IMintTransactionBuilder +{ + BuiltTransaction BuildTx( + BuildTransactionCommand buildCommand, + NetworkContext networkContext); +} diff --git a/Src/Abstractions/NiftyTypes.cs b/Src/Abstractions/NiftyTypes.cs index 5dd3d26..e62b235 100644 --- a/Src/Abstractions/NiftyTypes.cs +++ b/Src/Abstractions/NiftyTypes.cs @@ -3,7 +3,12 @@ namespace Mintsafe.Abstractions; -public record CollectionAggregate( +public record SaleAggregate( + Sale Sale, + NiftyCollection Collection, + Nifty[] Tokens); + +public record ProjectAggregate( NiftyCollection Collection, Nifty[] Tokens, Sale[] ActiveSales); @@ -12,9 +17,9 @@ public record NiftyCollection( Guid Id, string PolicyId, string Name, - string Description, + string? Description, bool IsActive, - string BrandImage, + string? BrandImage, string[] Publishers, DateTime CreatedAt, DateTime LockedAt, diff --git a/Src/DataAccess/Composers/AggregateComposer.cs b/Src/DataAccess/Composers/AggregateComposer.cs new file mode 100644 index 0000000..b0ad5b6 --- /dev/null +++ b/Src/DataAccess/Composers/AggregateComposer.cs @@ -0,0 +1,72 @@ +using Mintsafe.Abstractions; +using Mintsafe.DataAccess.Mappers; + +namespace Mintsafe.DataAccess.Composers; + +public interface IAggregateComposer +{ + ProjectAggregate Build( + Models.NiftyCollection collection, + IEnumerable nifties, + IEnumerable sales, + IEnumerable niftyFiles); + + SaleAggregate? BuildSaleAggregate( + Models.Sale sale, + Models.NiftyCollection collection, + IEnumerable nifties, + IEnumerable niftyFiles); +} + +public class AggregateComposer : IAggregateComposer +{ + public ProjectAggregate Build( + Models.NiftyCollection collection, + IEnumerable nifties, + IEnumerable sales, + IEnumerable niftyFiles) + { + var activeSales = sales.Where(IsSaleOpen).ToArray(); + var hydratedNifties = HydrateNifties(nifties, niftyFiles); + var mappedCollection = NiftyCollectionMapper.Map(collection); + var mappedSales = activeSales.Select(SaleMapper.Map).ToArray(); + + return new ProjectAggregate(mappedCollection, hydratedNifties, mappedSales); + } + + public SaleAggregate? BuildSaleAggregate( + Models.Sale sale, + Models.NiftyCollection collection, + IEnumerable nifties, + IEnumerable niftyFiles) + { + if (!IsSaleOpen(sale)) + return null; + + var mappedSale = SaleMapper.Map(sale); + var mappedCollection = NiftyCollectionMapper.Map(collection); + var hydratedNifties = HydrateNifties(nifties, niftyFiles); + + return new SaleAggregate(mappedSale, mappedCollection, hydratedNifties); + } + + private static bool IsSaleOpen(Models.Sale sale) + { + return sale.IsActive + && (sale.Start <= DateTime.UtcNow) + && (!sale.End.HasValue || (sale.End.HasValue && sale.End > DateTime.UtcNow)); + } + + private static Nifty[] HydrateNifties(IEnumerable nifties, IEnumerable allFiles) + { + var returnNifties = new List(); + foreach (var nifty in nifties) + { + var niftyFiles = allFiles.Where(x => x.NiftyId == nifty.RowKey); + var newNifty = NiftyMapper.Map(nifty, niftyFiles); + returnNifties.Add(newNifty); + } + + return returnNifties.ToArray(); + } +} diff --git a/Src/DataAccess/Composers/CollectionAggregateComposer.cs b/Src/DataAccess/Composers/CollectionAggregateComposer.cs deleted file mode 100644 index 5a0d90a..0000000 --- a/Src/DataAccess/Composers/CollectionAggregateComposer.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Mintsafe.Abstractions; -using Mintsafe.DataAccess.Mappers; - -namespace Mintsafe.DataAccess.Composers -{ - public interface ICollectionAggregateComposer - { - CollectionAggregate Build(Models.NiftyCollection collection, IEnumerable nifties, IEnumerable sales, IEnumerable niftyFiles); - } - - public class CollectionAggregateComposer : ICollectionAggregateComposer - { - public CollectionAggregate Build(Models.NiftyCollection collection, IEnumerable nifties, IEnumerable sales, IEnumerable niftyFiles) - { - var activeSales = sales.Where(IsSaleOpen).ToArray(); - var hydratedNifties = HydrateNifties(nifties, niftyFiles); - - var mappedCollection = NiftyCollectionMapper.Map(collection); - var mappedSales = activeSales.Select(SaleMapper.Map).ToArray(); - - return new CollectionAggregate(mappedCollection, hydratedNifties, mappedSales); - } - - private static bool IsSaleOpen(Models.Sale sale) - { - return sale.IsActive - && (sale.Start <= DateTime.UtcNow) - && (!sale.End.HasValue || (sale.End.HasValue && sale.End > DateTime.UtcNow)); - } - - private static Nifty[] HydrateNifties(IEnumerable nifties, IEnumerable allFiles) - { - var returnNifties = new List(); - foreach (var nifty in nifties) - { - var niftyFiles = allFiles.Where(x => x.NiftyId == nifty.RowKey); - var newNifty = NiftyMapper.Map(nifty, niftyFiles); - returnNifties.Add(newNifty); - } - - return returnNifties.ToArray(); - } - } -} diff --git a/Src/DataAccess/Mappers/NiftyCollectionMapper.cs b/Src/DataAccess/Mappers/NiftyCollectionMapper.cs index f3e94ea..b554619 100644 --- a/Src/DataAccess/Mappers/NiftyCollectionMapper.cs +++ b/Src/DataAccess/Mappers/NiftyCollectionMapper.cs @@ -28,7 +28,6 @@ public static NiftyCollection Map(Models.NiftyCollection dtoNiftyCollection) if (dtoNiftyCollection == null) throw new ArgumentNullException(nameof(dtoNiftyCollection)); if (dtoNiftyCollection.PolicyId == null) throw new ArgumentNullException(nameof(dtoNiftyCollection.PolicyId)); if (dtoNiftyCollection.Name == null) throw new ArgumentNullException(nameof(dtoNiftyCollection.Name)); - if (dtoNiftyCollection.BrandImage == null) throw new ArgumentNullException(nameof(dtoNiftyCollection.BrandImage)); return new NiftyCollection( Guid.Parse(dtoNiftyCollection.RowKey), diff --git a/Src/DataAccess/Repositories/SaleRepository.cs b/Src/DataAccess/Repositories/SaleRepository.cs index 5fcc5ee..325be54 100644 --- a/Src/DataAccess/Repositories/SaleRepository.cs +++ b/Src/DataAccess/Repositories/SaleRepository.cs @@ -8,6 +8,7 @@ namespace Mintsafe.DataAccess.Repositories { public interface ISaleRepository { + Task> GetBySaleIdAsync(Guid saleId, CancellationToken ct); Task> GetByCollectionIdAsync(Guid collectionId, CancellationToken ct); Task UpdateOneAsync(Sale sale, CancellationToken ct); Task InsertOneAsync(Sale sale, CancellationToken ct); @@ -22,7 +23,13 @@ public SaleRepository(IAzureClientFactory tableClientFactory) { _saleClient = tableClientFactory.CreateClient(Constants.TableNames.Sale); } - + + public async Task> GetBySaleIdAsync(Guid saleId, CancellationToken ct) + { + var saleQuery = _saleClient.QueryAsync(x => x.RowKey == saleId.ToString()); + return await saleQuery.GetAllAsync(ct); + } + public async Task> GetByCollectionIdAsync(Guid collectionId, CancellationToken ct) { var saleQuery = _saleClient.QueryAsync(x => x.PartitionKey == collectionId.ToString()); diff --git a/Src/DataAccess/Supporting/Constants.cs b/Src/DataAccess/Supporting/Constants.cs index 4a45efc..b681efe 100644 --- a/Src/DataAccess/Supporting/Constants.cs +++ b/Src/DataAccess/Supporting/Constants.cs @@ -1,19 +1,18 @@ -namespace Mintsafe.DataAccess.Supporting +namespace Mintsafe.DataAccess.Supporting; + +public class Constants { - public class Constants + public class TableNames { - public class TableNames - { - public const string NiftyCollection = "NiftyCollection"; - public const string NiftyFile = "NiftyFile"; - public const string Nifty = "Nifty"; - public const string Sale = "Sale"; - } + public const string NiftyCollection = "NiftyCollection"; + public const string NiftyFile = "NiftyFile"; + public const string Nifty = "Nifty"; + public const string Sale = "Sale"; + } - public class EventIds - { - public const int FailedToRetrieve = 100; - public const int FailedToInsert = 200; - } + public class EventIds + { + public const int FailedToRetrieve = 100; + public const int FailedToInsert = 200; } } diff --git a/Src/DataAccess/TableStorageDataService.cs b/Src/DataAccess/TableStorageDataService.cs index 01098c3..0e5a8c6 100644 --- a/Src/DataAccess/TableStorageDataService.cs +++ b/Src/DataAccess/TableStorageDataService.cs @@ -6,92 +6,125 @@ using Mintsafe.DataAccess.Repositories; using Mintsafe.DataAccess.Supporting; -namespace Mintsafe.DataAccess +namespace Mintsafe.DataAccess; + +public class TableStorageDataService : INiftyDataService { - public class TableStorageDataService : INiftyDataService + private readonly INiftyCollectionRepository _niftyCollectionRepository; + private readonly INiftyRepository _niftyRepository; + private readonly ISaleRepository _saleRepository; + private readonly INiftyFileRepository _niftyFileRepository; + + private readonly IAggregateComposer _aggregateComposer; + + private readonly ILogger _logger; + + public TableStorageDataService( + INiftyCollectionRepository niftyCollectionRepository, + ISaleRepository saleRepository, + INiftyRepository niftyRepository, + INiftyFileRepository niftyFileRepository, + IAggregateComposer aggregateComposer, + ILogger logger) { - private readonly INiftyCollectionRepository _niftyCollectionRepository; - private readonly INiftyRepository _niftyRepository; - private readonly ISaleRepository _saleRepository; - private readonly INiftyFileRepository _niftyFileRepository; + _niftyCollectionRepository = niftyCollectionRepository ?? throw new ArgumentNullException(nameof(niftyCollectionRepository)); + _niftyRepository = niftyRepository ?? throw new ArgumentNullException(nameof(niftyRepository)); + _saleRepository = saleRepository ?? throw new ArgumentNullException(nameof(saleRepository)); + _niftyFileRepository = niftyFileRepository ?? throw new ArgumentNullException(nameof(niftyFileRepository)); + _aggregateComposer = aggregateComposer ?? throw new ArgumentNullException(nameof(aggregateComposer)); + _logger = logger ?? throw new NullReferenceException(nameof(logger)); + } + + public async Task GetCollectionAggregateAsync(Guid collectionId, CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + + Models.NiftyCollection? niftyCollection; + IList nifties; + IEnumerable sales; + IEnumerable niftyFiles; - private readonly ICollectionAggregateComposer _collectionAggregateComposer; + try + { + var niftyCollectionTask = _niftyCollectionRepository.GetByIdAsync(collectionId, ct); + var niftyTask = _niftyRepository.GetByCollectionIdAsync(collectionId, ct); + var saleTask = _saleRepository.GetByCollectionIdAsync(collectionId, ct); + var niftyFileTask = _niftyFileRepository.GetByCollectionIdAsync(collectionId, ct); + + await Task.WhenAll(niftyCollectionTask, niftyTask, saleTask, niftyFileTask); - private readonly ILogger _logger; + niftyCollection = await niftyCollectionTask; + nifties = (await niftyTask).ToList(); + sales = await saleTask; + niftyFiles = await niftyFileTask; - public TableStorageDataService(INiftyCollectionRepository niftyCollectionRepository, ISaleRepository saleRepository, INiftyRepository niftyRepository, INiftyFileRepository niftyFileRepository, ICollectionAggregateComposer collectionAggregateComposer, ILogger logger) + _logger.LogInformation($"Finished getting all entities for collectionId: {collectionId} from table storage after {sw.ElapsedMilliseconds}ms"); + } + catch (Exception e) { - _niftyCollectionRepository = niftyCollectionRepository ?? throw new ArgumentNullException(nameof(niftyCollectionRepository)); - _niftyRepository = niftyRepository ?? throw new ArgumentNullException(nameof(niftyRepository)); - _saleRepository = saleRepository ?? throw new ArgumentNullException(nameof(saleRepository)); - _niftyFileRepository = niftyFileRepository ?? throw new ArgumentNullException(nameof(niftyFileRepository)); - _collectionAggregateComposer = collectionAggregateComposer ?? throw new ArgumentNullException(nameof(collectionAggregateComposer)); - _logger = logger ?? throw new NullReferenceException(nameof(logger)); + _logger.LogError(Constants.EventIds.FailedToRetrieve, e, $"Failed to retrieve entities from table storage for collectionId: {collectionId}"); + throw; } + // No collection exists - no easy way to represent this apart from nullable aggregate + if (niftyCollection == null) return null; + + return _aggregateComposer.Build(niftyCollection, nifties, sales, niftyFiles); + } - public async Task GetCollectionAggregateAsync(Guid collectionId, CancellationToken ct = default) + public async Task GetSaleAggregateAsync(Guid saleId, CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + Models.Sale? sale; + Models.NiftyCollection? niftyCollection; + IEnumerable nifties; + IEnumerable niftyFiles; + try { - var sw = Stopwatch.StartNew(); - - Models.NiftyCollection? niftyCollection; - IList nifties; - IEnumerable sales; - IEnumerable niftyFiles; - - try - { - var niftyCollectionTask = _niftyCollectionRepository.GetByIdAsync(collectionId, ct); - var niftyTask = _niftyRepository.GetByCollectionIdAsync(collectionId, ct); - var saleTask = _saleRepository.GetByCollectionIdAsync(collectionId, ct); - var niftyFileTask = _niftyFileRepository.GetByCollectionIdAsync(collectionId, ct); - - await Task.WhenAll(niftyCollectionTask, niftyTask, saleTask, niftyFileTask); - - niftyCollection = await niftyCollectionTask; - nifties = (await niftyTask).ToList(); - sales = await saleTask; - niftyFiles = await niftyFileTask; - - _logger.LogInformation($"Finished getting all entities for collectionId: {collectionId} from table storage after {sw.ElapsedMilliseconds}ms"); - } - catch (Exception e) - { - _logger.LogError(Constants.EventIds.FailedToRetrieve, e, $"Failed to retrieve entities from table storage for collectionId: {collectionId}"); - throw; - } - // No collection exists - no easy way to represent this apart from nullable aggregate + sale = (await _saleRepository.GetBySaleIdAsync(saleId, ct)).FirstOrDefault(); + if (sale == null) return null; + var collectionId = Guid.Parse(sale.PartitionKey); + var niftyCollectionTask = _niftyCollectionRepository.GetByIdAsync(collectionId, ct); + var niftyTask = _niftyRepository.GetByCollectionIdAsync(collectionId, ct); + var niftyFileTask = _niftyFileRepository.GetByCollectionIdAsync(collectionId, ct); + await Task.WhenAll(niftyCollectionTask, niftyTask, niftyFileTask); + niftyCollection = await niftyCollectionTask; + nifties = (await niftyTask).ToArray(); + niftyFiles = await niftyFileTask; if (niftyCollection == null) return null; - - return _collectionAggregateComposer.Build(niftyCollection, nifties, sales, niftyFiles); + _logger.LogInformation($"Finished getting all entities for collectionId: {collectionId} from table storage after {sw.ElapsedMilliseconds}ms"); } + catch (Exception e) + { + _logger.LogError(Constants.EventIds.FailedToRetrieve, e, $"Failed to retrieve entities from table storage for saleId: {saleId}"); + throw; + } + return _aggregateComposer.BuildSaleAggregate(sale, niftyCollection, nifties, niftyFiles); + } - public async Task InsertCollectionAggregateAsync(CollectionAggregate collectionAggregate, CancellationToken ct = default) + public async Task InsertCollectionAggregateAsync(ProjectAggregate collectionAggregate, CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + var collectionId = collectionAggregate.Collection.Id; + var niftyCollection = NiftyCollectionMapper.Map(collectionAggregate.Collection); + var nifties = collectionAggregate.Tokens.Select(NiftyMapper.Map); + var sales = collectionAggregate.ActiveSales.Select(SaleMapper.Map); + var files = collectionAggregate.Tokens.SelectMany(x => x.Files.Select(f => NiftyFileMapper.Map(collectionId, f))); + + try + { + var niftyCollectionTask = _niftyCollectionRepository.InsertOneAsync(niftyCollection, ct); + var niftyTask = _niftyRepository.InsertManyAsync(nifties, ct); + var saleTask = _saleRepository.InsertManyAsync(sales, ct); + var niftyFileTask = _niftyFileRepository.InsertManyAsync(files, ct); + + await Task.WhenAll(niftyCollectionTask, niftyTask, saleTask, niftyFileTask); + + _logger.LogInformation($"Inserted all entities for collectionId: {collectionId} into table storage after {sw.ElapsedMilliseconds}ms"); + } + catch (Exception e) { - var sw = Stopwatch.StartNew(); - - var collectionId = collectionAggregate.Collection.Id; - - var niftyCollection = NiftyCollectionMapper.Map(collectionAggregate.Collection); - var nifties = collectionAggregate.Tokens.Select(NiftyMapper.Map); - var sales = collectionAggregate.ActiveSales.Select(SaleMapper.Map); - var files = collectionAggregate.Tokens.SelectMany(x => x.Files.Select(f => NiftyFileMapper.Map(collectionId, f))); - - try - { - var niftyCollectionTask = _niftyCollectionRepository.InsertOneAsync(niftyCollection, ct); - var niftyTask = _niftyRepository.InsertManyAsync(nifties, ct); - var saleTask = _saleRepository.InsertManyAsync(sales, ct); - var niftyFileTask = _niftyFileRepository.InsertManyAsync(files, ct); - - await Task.WhenAll(niftyCollectionTask, niftyTask, saleTask, niftyFileTask); - - _logger.LogInformation($"Inserted all entities for collectionId: {collectionId} into table storage after {sw.ElapsedMilliseconds}ms"); - } - catch (Exception e) - { - _logger.LogError(Constants.EventIds.FailedToInsert, e, $"Failed to insert all entities for collectionId: {collectionId}"); - throw; - } + _logger.LogError(Constants.EventIds.FailedToInsert, e, $"Failed to insert all entities for collectionId: {collectionId}"); + throw; } } } diff --git a/Src/DataImporter/Program.cs b/Src/DataImporter/Program.cs index baeb708..4d6e0b4 100644 --- a/Src/DataImporter/Program.cs +++ b/Src/DataImporter/Program.cs @@ -1,5 +1,7 @@ -using System.Text.Json; +using System.Globalization; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Microsoft.Extensions.Azure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -19,11 +21,11 @@ services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddAzureClients(clientBuilder => { - var connectionString = "UseDevelopmentStorage=true"; + var connectionString = "DefaultEndpointsProtocol=https;AccountName=sstneunmsapiapivmstor;AccountKey=u6xexnihOsCJulTo3UZZqfs18GlnZ5r+CCNpaES/yLPQf3xxwJnnurOLxtOKsVNKrFgTP94NSJP++AStC5JT/Q==;EndpointSuffix=core.windows.net"; clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyCollection); clientBuilder.AddTableClient(connectionString, Constants.TableNames.Nifty); @@ -33,11 +35,11 @@ }) .Build(); -var pickupDirPath = $"C:\\ws\\temp\\hop"; +var pickupDirPath = $"C:\\ws\\temp\\tacf_portrait2"; -var collection = await LoadJsonFromFileAsync(Path.Combine(pickupDirPath, "collection.json")); -var sale = await LoadJsonFromFileAsync(Path.Combine(pickupDirPath, "sale.json")); -var nifties = await LoadDynamicJsonFromDirAsync(Path.Combine(pickupDirPath, "json")); +var collection = await LoadJsonFromFileAsync(Path.Combine(pickupDirPath, "collection_tn.json")); +var sale = await LoadJsonFromFileAsync(Path.Combine(pickupDirPath, "sale_tn.json")); +var nifties = await LoadDynamicJsonFromFileAsync(Path.Combine(pickupDirPath, "nifties.json")); BuildModelsAndInsertAsync(host.Services, collection, sale, nifties); @@ -55,49 +57,63 @@ async void BuildModelsAndInsertAsync( IServiceProvider provider = serviceScope.ServiceProvider; var dataService = provider.GetRequiredService(); - var assetPrefix = "HOP"; - var collectionId = niftyCollection.Id; var nifties = new List(); foreach (var jsonObject in niftyJson) { var niftyId = Guid.NewGuid(); - var attributesJsonArray = (JsonArray)jsonObject["attributes"]; + var attributesJsonArray = (JsonArray)jsonObject["Attributes"]; var attributesJsonObjectsArray = attributesJsonArray.Cast(); var attributes = attributesJsonObjectsArray - .Select(x => new KeyValuePair((string) x["key"], (string) x["value"])).ToArray(); - var creators = ((JsonArray)jsonObject["creators"]).Cast() + .Select(x => new KeyValuePair((string) x["Key"], (string) x["Value"])).ToArray(); + var creators = ((JsonArray)jsonObject["Creators"]).Cast() .Select(jv => (string)jv ?? string.Empty) .ToArray(); + DateTime.TryParseExact( + (string)jsonObject["CreatedAt"], + @"yyyy-MM-dd\THH:mm:ss\Z", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var dateTimeParsed); + var nifty = new Nifty( Id: niftyId, CollectionId: collectionId, IsMintable: true, - AssetName: $"{assetPrefix}{(string)jsonObject["version"]}", - Name: (string)jsonObject["name"], - Description: (string)jsonObject["description"], + AssetName: (string)jsonObject["AssetName"], + Name: (string)jsonObject["Name"], + Description: (string)jsonObject["Description"], Creators: creators, - Image: (string)jsonObject["image"], - MediaType: "image/png", + Image: (string)jsonObject["Image"], + MediaType: (string)jsonObject["MediaType"], Files: Array.Empty(), - CreatedAt: DateTimeOffset.FromUnixTimeMilliseconds((long)jsonObject["date"]).UtcDateTime, - Version: (string)jsonObject["version"], + CreatedAt: dateTimeParsed, + Version: (string)jsonObject["Version"], Attributes: attributes); nifties.Add(nifty); } - var aggregate = new CollectionAggregate(niftyCollection, nifties.ToArray(), new[] { sale }); + var aggregate = new ProjectAggregate(niftyCollection, nifties.ToArray(), new[] { sale }); + //await Task.Delay(100); await dataService.InsertCollectionAggregateAsync(aggregate, CancellationToken.None); } async Task LoadJsonFromFileAsync(string path) { var raw = await File.ReadAllTextAsync(path).ConfigureAwait(false); - return JsonSerializer.Deserialize(raw); + return JsonSerializer.Deserialize(raw, SerialiserOptions()); +} + + +async Task> LoadDynamicJsonFromFileAsync(string filePath) +{ + var raw = await File.ReadAllTextAsync(filePath); + var list = JsonSerializer.Deserialize>(raw, SerialiserOptions()); + return list.Where(x => x != null).ToList(); } async Task> LoadDynamicJsonFromDirAsync(string path) @@ -107,9 +123,17 @@ async void BuildModelsAndInsertAsync( foreach (var filePath in files) { var raw = await File.ReadAllTextAsync(filePath); - var model = JsonSerializer.Deserialize(raw); + var model = JsonSerializer.Deserialize(raw, SerialiserOptions()); list.Add(model); } return list.Where(x => x != null).ToList(); -} \ No newline at end of file +} + + +static JsonSerializerOptions SerialiserOptions() => new() +{ + //PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +}; \ No newline at end of file diff --git a/Src/Lib/CardanoSharpTxBuilder.cs b/Src/Lib/CardanoSharpTxBuilder.cs index 02917c0..f80479e 100644 --- a/Src/Lib/CardanoSharpTxBuilder.cs +++ b/Src/Lib/CardanoSharpTxBuilder.cs @@ -30,16 +30,9 @@ public CardanoSharpException( } } -public interface ITransactionBuilder +public class CardanoSharpTxBuilder : IMintTransactionBuilder { - BuiltTransaction BuildTx( - BuildTransactionCommand buildCommand, - NetworkContext networkContext); -} - -public class CardanoSharpTxBuilder : ITransactionBuilder -{ - private const ulong FeePadding = 280; + private const ulong FeePadding = 132; private readonly ILogger _logger; private readonly IInstrumentor _instrumentor; private readonly MintsafeAppSettings _settings; diff --git a/Src/Lib/ITransactionSigner.cs b/Src/Lib/ITransactionSigner.cs index b3de4ca..3376cdc 100644 --- a/Src/Lib/ITransactionSigner.cs +++ b/Src/Lib/ITransactionSigner.cs @@ -1,12 +1,6 @@ using CardanoSharp.Wallet.Extensions.Models.Transactions; -using CardanoSharp.Wallet.Models.Transactions; using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Mintsafe.Lib; diff --git a/Src/Lib/DummyMintingKeychainRetriever.cs b/Src/Lib/KeyVaultMintingKeychainRetriever.cs similarity index 97% rename from Src/Lib/DummyMintingKeychainRetriever.cs rename to Src/Lib/KeyVaultMintingKeychainRetriever.cs index ebb4ec5..3919fdf 100644 --- a/Src/Lib/DummyMintingKeychainRetriever.cs +++ b/Src/Lib/KeyVaultMintingKeychainRetriever.cs @@ -4,10 +4,7 @@ using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; diff --git a/Src/Lib/LocalNiftyDataService.cs b/Src/Lib/LocalNiftyDataService.cs index 29c2cab..cea0595 100644 --- a/Src/Lib/LocalNiftyDataService.cs +++ b/Src/Lib/LocalNiftyDataService.cs @@ -2,10 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; @@ -16,7 +13,7 @@ public class LocalNiftyDataService : INiftyDataService public const string FakeCollectionId = "4f03062a-460b-4946-a66a-be481cd8788f"; public const string FakeSaleId = "7ca72580-4285-43f4-a7bb-5a465a9bdf85"; - public Task GetCollectionAggregateAsync( + public Task GetCollectionAggregateAsync( Guid collectionId, CancellationToken ct = default) { // Retrieve {Collection * ActiveSales * MintableTokens} from db @@ -56,10 +53,15 @@ public class LocalNiftyDataService : INiftyDataService var activeSales = collection.IsActive && IsSaleOpen(sale) ? new[] { sale } : Array.Empty(); - return Task.FromResult(new CollectionAggregate(collection, tokens, ActiveSales: activeSales)); + return Task.FromResult(new ProjectAggregate(collection, tokens, ActiveSales: activeSales)); } - public Task InsertCollectionAggregateAsync(CollectionAggregate collectionAggregate, CancellationToken ct = default) + public Task InsertCollectionAggregateAsync(ProjectAggregate collectionAggregate, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task GetSaleAggregateAsync(Guid saleId, CancellationToken ct = default) { throw new NotImplementedException(); } @@ -130,96 +132,3 @@ Dictionary GetAttributesForIndex(int i) .ToArray(); } } - -public class TacfDataService : INiftyDataService -{ - public const string TicketCollectionId = "9e0d7f84-9b47-404a-b32e-c86f62d512dc"; - public const string TicketSaleId = "cb0885b1-48c4-433a-ac56-cd12fccef663"; - - public Task GetCollectionAggregateAsync(Guid collectionId, CancellationToken ct = default) - { - var fakeCollectionId = Guid.Parse(TicketCollectionId); - - var sale = new Sale( - Id: Guid.Parse(TicketSaleId), - CollectionId: fakeCollectionId, - IsActive: true, - Name: "Ticket Sale #1", - Description: "", - LovelacesPerToken: 125_000000, - Start: new DateTime(2022, 6, 1, 0, 0, 0, DateTimeKind.Utc), - End: new DateTime(2023, 7, 23, 0, 0, 0, DateTimeKind.Utc), - SaleAddress: "addr_test1vq3qme7zjxj8xsn547s7mf0j9t9x8h2x30f00vsvhag3g6snqkn9v", // addr1vy3qme7zjxj8xsn547s7mf0j9t9x8h2x30f00vsvhag3g6sggz02f - CreatorAddress: "addr_test1qpegeshnlug9g88xla67d9f6xz47z4fsx9jfyy35z04ekrzjdx9mm3ju60d74u9p86uukq9ldvjza3ycr5d9jlvqxeqsdwyruy", // ? - ProceedsAddress: "addr_test1vz93vkgv8kg5lralfmspl92c039hlu7y3vpjrccpcjg3l7qzflgnf", // addr1vx93vkgv8kg5lralfmspl92c039hlu7y3vpjrccpcjg3l7qept5uv - PostPurchaseMargin: 0.1m, - TotalReleaseQuantity: 100, - MaxAllowedPurchaseQuantity: 1); - - var tokens = Enumerable.Range(1, 100) - .Select(i => - new Nifty( - Guid.NewGuid(), - fakeCollectionId, - true, - $"TACF_GCST_C_{i}", - $"TACF Global Concert Series Ticket C{i}", - null, - new[] { "takialsop.org" }, - "data:image/svg+xml;utf8,", - "image/svg+xml", - Array.Empty(), - new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), - "1.0", - new[]{ - new KeyValuePair("Organization", "Taki Alsop Conducting Fellowship"), - new KeyValuePair("Series", "TACF Global Concert Series")})) - .ToArray(); - - var collection = new NiftyCollection( - Id: fakeCollectionId, - PolicyId: "19bb7dd38a3ef5ddbe646d36bd376ebed20ef370c4eb81d9b08e6b33",// "b1e785ba20061e1320693dce777c9939eea8cfae74a9120c844b8b1b", - Name: "TACF Global Concert Series", - Description: "125 ADA, 115 NFTs, 23th July 2023 Expiry", - IsActive: true, - Publishers: new[] { "takialsop.org", "mintsafe.io" }, - BrandImage: "", - CreatedAt: new DateTime(2022, 01, 06, 0, 0, 0, DateTimeKind.Utc), - LockedAt: new DateTime(2023, 7, 23, 0, 0, 0, DateTimeKind.Utc), - SlotExpiry: 96997186, //98504109 - Royalty: new Royalty(0.08, "addr1q9kjqwrp8fy5ea8qlac6dlecz2dc4grchm8q6rgvv4gdup9zflwyjvg8uhpyfhh46khtt90cz4wzsvqqc44cs4qmyx6qts4he6")); - - var activeSales = collection.IsActive && IsSaleOpen(sale) ? new[] { sale } : Array.Empty(); - - return Task.FromResult(new CollectionAggregate(collection, tokens, ActiveSales: activeSales)); - } - - public Task InsertCollectionAggregateAsync(CollectionAggregate collectionAggregate, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - private List LoadDynamicJsonFromDirAsync(string path) - { - var files = Directory.GetFiles(path); - var list = new List(); - foreach (var filePath in files) - { - var raw = File.ReadAllText(filePath); - var model = JsonSerializer.Deserialize(raw); - list.Add(model); - } - - return list.Where(x => x != null).ToList(); - } - - private static bool IsSaleOpen(Sale sale) - { - if (!sale.IsActive - || (sale.Start > DateTime.UtcNow) - || (sale.End.HasValue && sale.End < DateTime.UtcNow)) - return false; - - return true; - } -} diff --git a/Src/Lib/MetadataBuilder.cs b/Src/Lib/MetadataBuilder.cs index 40ff910..5c832eb 100644 --- a/Src/Lib/MetadataBuilder.cs +++ b/Src/Lib/MetadataBuilder.cs @@ -2,8 +2,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Mintsafe.Lib; diff --git a/Src/Lib/MintsafeAppSettings.cs b/Src/Lib/MintsafeAppSettings.cs index 15349be..b2fde13 100644 --- a/Src/Lib/MintsafeAppSettings.cs +++ b/Src/Lib/MintsafeAppSettings.cs @@ -11,5 +11,7 @@ public record MintsafeAppSettings public string? BlockFrostApiKey { get; init; } public string? AppInsightsInstrumentationKey { get; init; } public Guid CollectionId { get; init; } + + public Guid[] SaleIds { get; init; } public string? KeyVaultUrl { get; init; } } diff --git a/Src/Lib/NiftyDistributor.cs b/Src/Lib/NiftyDistributor.cs index 9f7254f..1d2e112 100644 --- a/Src/Lib/NiftyDistributor.cs +++ b/Src/Lib/NiftyDistributor.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; using System; -using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -258,9 +257,9 @@ public class CardanoSharpNiftyDistributor : INiftyDistributor private readonly MintsafeAppSettings _settings; private readonly ITxInfoRetriever _txRetriever; private readonly IMintingKeychainRetriever _keychainRetriever; - private readonly ITransactionBuilder _txBuilder; + private readonly IMintTransactionBuilder _txBuilder; private readonly ITxSubmitter _txSubmitter; - private readonly ISaleAllocationStore _saleContextStore; + private readonly ISaleAllocationStore _saleAllocationStore; public CardanoSharpNiftyDistributor( ILogger logger, @@ -268,7 +267,7 @@ public CardanoSharpNiftyDistributor( MintsafeAppSettings settings, ITxInfoRetriever txRetriever, IMintingKeychainRetriever keychainRetriever, - ITransactionBuilder txBuilder, + IMintTransactionBuilder txBuilder, ITxSubmitter txSubmitter, ISaleAllocationStore saleContextStore) { @@ -279,7 +278,7 @@ public CardanoSharpNiftyDistributor( _keychainRetriever = keychainRetriever; _txBuilder = txBuilder; _txSubmitter = txSubmitter; - _saleContextStore = saleContextStore; + _saleAllocationStore = saleContextStore; } public async Task DistributeNiftiesForSalePurchase( @@ -371,7 +370,7 @@ public async Task DistributeNiftiesForSalePurchase( catch (Exception ex) { _logger.LogError(EventIds.TxInfoRetrievalError, ex, $"Failed TxInfo Restrieval"); - await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); + await _saleAllocationStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); return (null, ex); } } @@ -439,13 +438,13 @@ private static long GetUtxoSlotExpiry( catch (CardanoSharpException ex) { _logger.LogError(EventIds.TxBuilderError, ex, "Failed Tx Build"); - await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); + await _saleAllocationStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); return (null, ex); } catch (Exception ex) { _logger.LogError(EventIds.TxBuilderError, ex, "Failed Tx Build"); - await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); + await _saleAllocationStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); return (null, ex); } } @@ -465,7 +464,7 @@ private static long GetUtxoSlotExpiry( catch (Exception ex) { _logger.LogError(EventIds.TxSubmissionError, ex, $"Failed Tx Submission"); - await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); + await _saleAllocationStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); return (null, ex); } } diff --git a/Src/Lib/SaleAllocationFileStore.cs b/Src/Lib/SaleAllocationFileStore.cs index 0189324..aa03968 100644 --- a/Src/Lib/SaleAllocationFileStore.cs +++ b/Src/Lib/SaleAllocationFileStore.cs @@ -30,7 +30,7 @@ public SaleAllocationFileStore( } public async Task GetOrRestoreSaleContextAsync( - CollectionAggregate collectionAggregate, Guid workerId, CancellationToken ct) + ProjectAggregate collectionAggregate, Guid workerId, CancellationToken ct) { var activeSale = collectionAggregate.ActiveSales[0]; var mintableTokens = collectionAggregate.Tokens.Where(t => t.IsMintable).ToList(); @@ -100,6 +100,77 @@ public async Task GetOrRestoreSaleContextAsync( return saleContext; } + public async Task GetOrRestoreSaleContextAsync( + SaleAggregate saleAggregate, Guid workerId, CancellationToken ct) + { + var activeSale = saleAggregate.Sale; + var mintableTokens = saleAggregate.Tokens.Where(t => t.IsMintable).ToList(); + var allocatedNfts = new List(); + var saleFolder = Path.Combine(_settings.BasePath, activeSale.Id.ToString()[..8]); + var saleUtxosFolder = Path.Combine(saleFolder, "utxos"); + var mintableNftIdsSnapshotPath = Path.Combine(saleFolder, "mintableNftIds.csv"); + var allocatedNftIdsPath = Path.Combine(saleFolder, "allocatedNftIds.csv"); + var sw = new Stopwatch(); + // Brand new sale for worker - generate fresh context and persist it + if (!Directory.Exists(saleFolder)) + { + Directory.CreateDirectory(saleFolder); + Directory.CreateDirectory(saleUtxosFolder); + sw.Start(); + await File.WriteAllLinesAsync(mintableNftIdsSnapshotPath, mintableTokens.Select(n => n.Id.ToString()), ct).ConfigureAwait(false); + await File.WriteAllTextAsync(allocatedNftIdsPath, string.Empty, ct).ConfigureAwait(false); + } + else // Restore sale context from previous execution + { + var allocatedNftIdLines = await File.ReadAllLinesAsync(allocatedNftIdsPath, ct).ConfigureAwait(false); + var allocatedNftIds = new HashSet(allocatedNftIdLines); + var revisedMintableNfts = new List(); + foreach (var nft in mintableTokens) + { + if (allocatedNftIds.Contains(nft.Id.ToString())) + allocatedNfts.Add(nft); + else + revisedMintableNfts.Add(nft); + } + mintableTokens = revisedMintableNfts; + } + var saleContext = new SaleContext( + workerId, + saleFolder, + saleUtxosFolder, + activeSale, + saleAggregate.Collection, + mintableTokens, + allocatedNfts, + new HashSet(), + new HashSet(), + new HashSet(), + new HashSet()); + + _instrumentor.TrackDependency( + EventIds.SaleContextGetOrRestoreElapsed, + sw.ElapsedMilliseconds, + DateTime.UtcNow, + nameof(SaleAllocationFileStore), + allocatedNftIdsPath, + nameof(GetOrRestoreSaleContextAsync), + isSuccessful: true, + customProperties: new Dictionary + { + { "WorkerId", saleContext.SaleWorkerId }, + { "SaleId", saleContext.Sale.Id }, + { "CollectionId", saleContext.Collection.Id }, + { "SaleContext.AllocatedTokens", saleContext.AllocatedTokens.Count }, + { "SaleContext.MintableTokens", saleContext.MintableTokens.Count }, + { "SaleContext.RefundedUtxos", saleContext.RefundedUtxos.Count }, + { "SaleContext.SuccessfulUtxos", saleContext.SuccessfulUtxos.Count }, + { "SaleContext.FailedUtxos", saleContext.FailedUtxos.Count }, + { "SaleContext.LockedUtxos", saleContext.LockedUtxos.Count }, + }); + + return saleContext; + } + public Task AllocateNiftiesAsync( PurchaseAttempt request, SaleContext context, CancellationToken ct) { diff --git a/Src/Lib/SimpleWalletService.cs b/Src/Lib/SimpleWalletService.cs index d045190..b6be4fe 100644 --- a/Src/Lib/SimpleWalletService.cs +++ b/Src/Lib/SimpleWalletService.cs @@ -37,6 +37,7 @@ public interface ISimpleWalletService public class SimpleWalletService : ISimpleWalletService { + private const ulong FeePadding = 132; private readonly ILogger _logger; private readonly IInstrumentor _instrumentor; @@ -141,7 +142,7 @@ public SimpleWalletService( witnesses.AddVKeyWitness(policyKey.GetPublicKey(false), policyKey); } var policyScriptAllBuilder = GetScriptAllBuilder(policySkeys.Select(GetPrivateKeyFromBech32SigningKey), policyExpirySlot); - witnesses.SetNativeScript(policyScriptAllBuilder); + witnesses.SetScriptAllNativeScript(policyScriptAllBuilder); } // Build Tx for fee calculation var txBuilder = TransactionBuilder.Create @@ -161,7 +162,7 @@ public SimpleWalletService( // Calculate and update change Utxo var protocolParams = protocolParamsResponse.Content.Single(); var tx = txBuilder.Build(); - var fee = tx.CalculateFee(protocolParams.MinFeeA, protocolParams.MinFeeB); + var fee = tx.CalculateFee(protocolParams.MinFeeA, protocolParams.MinFeeB) + FeePadding; txBodyBuilder.SetFee(fee); tx.TransactionBody.TransactionOutputs.Last().Value.Coin -= fee; var txBytes = tx.Serialize(); diff --git a/Src/Lib/UtxoRefunder.cs b/Src/Lib/UtxoRefunder.cs index 0925c41..753029d 100644 --- a/Src/Lib/UtxoRefunder.cs +++ b/Src/Lib/UtxoRefunder.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; using System; -using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; @@ -19,7 +18,7 @@ public class UtxoRefunder : IUtxoRefunder private readonly IMintingKeychainRetriever _keychainRetriever; private readonly IMetadataFileGenerator _metadataGenerator; private readonly ITxSubmitter _txSubmitter; - private readonly ITransactionBuilder _txBuilder; + private readonly IMintTransactionBuilder _txBuilder; public UtxoRefunder( ILogger logger, @@ -27,7 +26,7 @@ public UtxoRefunder( ITxInfoRetriever txRetriever, IMintingKeychainRetriever keychainRetriever, IMetadataFileGenerator metadataGenerator, - ITransactionBuilder txBuilder, + IMintTransactionBuilder txBuilder, ITxSubmitter txSubmitter) { _logger = logger; diff --git a/Src/SaleWorker/ConfigTypes.cs b/Src/SaleWorker/ConfigTypes.cs index 5a0a51d..3367eed 100644 --- a/Src/SaleWorker/ConfigTypes.cs +++ b/Src/SaleWorker/ConfigTypes.cs @@ -7,6 +7,7 @@ public class MintsafeWorkerConfig public string? MintBasePath { get; init; } public int? PollingIntervalSeconds { get; init; } public string? CollectionId { get; init; } + public string[]? SaleIds { get; init; } } public class CardanoNetworkConfig diff --git a/Src/SaleWorker/Program.cs b/Src/SaleWorker/Program.cs index b6a9d4a..b43c749 100644 --- a/Src/SaleWorker/Program.cs +++ b/Src/SaleWorker/Program.cs @@ -1,12 +1,19 @@ using Microsoft.ApplicationInsights.WorkerService; +using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Mintsafe.Abstractions; +using Mintsafe.DataAccess; +using Mintsafe.DataAccess.Composers; +using Mintsafe.DataAccess.Extensions; +using Mintsafe.DataAccess.Repositories; +using Mintsafe.DataAccess.Supporting; using Mintsafe.Lib; using Mintsafe.SaleWorker; using System; +using System.Linq; IHost host = Host.CreateDefaultBuilder(args) .ConfigureHostConfiguration(configHost => @@ -68,6 +75,7 @@ BasePath = mintsafeWorkerConfig.MintBasePath, PollingIntervalSeconds = mintsafeWorkerConfig.PollingIntervalSeconds.HasValue ? mintsafeWorkerConfig.PollingIntervalSeconds.Value : 10, CollectionId = Guid.Parse(mintsafeWorkerConfig.CollectionId), + SaleIds = mintsafeWorkerConfig.SaleIds?.Select(Guid.Parse).ToArray() ?? Array.Empty(), KeyVaultUrl = keychainConfig.KeyVaultUrl }; services.AddSingleton(settings); @@ -107,37 +115,35 @@ services.AddSingleton(); // Fakes - services.AddSingleton(); - services.AddSingleton(); + //services.AddSingleton(); //services.AddSingleton(); //services.AddSingleton(); //services.AddSingleton(); //services.AddSingleton(); - //// Reals + // Reals services.AddSingleton(); services.AddSingleton(); - //services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - //services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - //services.AddSingleton(); - //services.AddAzureClients(clientBuilder => - //{ - // var connectionString = hostContext.Configuration.GetSection("Storage:ConnectionString").Value; - // clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyCollection); - // clientBuilder.AddTableClient(connectionString, Constants.TableNames.Nifty); - // clientBuilder.AddTableClient(connectionString, Constants.TableNames.Sale); - // clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyFile); - //}); - //services.AddSingleton(); - //services.AddSingleton(); - //services.AddSingleton(); - //services.AddSingleton(); - //services.AddSingleton(); - //services.AddSingleton(); + services.AddSingleton(); + services.AddAzureClients(clientBuilder => + { + var connectionString = hostContext.Configuration.GetSection("Storage:ConnectionString").Value; + clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyCollection); + clientBuilder.AddTableClient(connectionString, Constants.TableNames.Nifty); + clientBuilder.AddTableClient(connectionString, Constants.TableNames.Sale); + clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyFile); + }); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); }) .Build(); diff --git a/Src/SaleWorker/Worker.cs b/Src/SaleWorker/Worker.cs index 5bc7e65..2f74f2f 100644 --- a/Src/SaleWorker/Worker.cs +++ b/Src/SaleWorker/Worker.cs @@ -3,6 +3,7 @@ using Mintsafe.Abstractions; using Mintsafe.Lib; using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -41,49 +42,94 @@ public Worker( _workerId = Guid.NewGuid(); } + //protected override async Task ExecuteAsync(CancellationToken ct) + //{ + // _logger.LogInformation(EventIds.HostedServiceStarted, $"SaleWorker({_workerId}) started for Id: {_settings.CollectionId}"); + // var collection = await _niftyDataService.GetCollectionAggregateAsync(_settings.CollectionId, ct); + // if (collection == null || collection.ActiveSales.Length == 0) + // { + // _logger.LogWarning(EventIds.DataServiceRetrievalWarning, $"Collection does not exist or there are no active sales!"); + // _hostApplicationLifetime.StopApplication(); + // return; + // } + + // // TODO: Move from CollectionId to SaleId in MintsafeAppSettings and tie Worker to a Sale + // var saleContext = await _saleContextDataStorage.GetOrRestoreSaleContextAsync(collection, _workerId, ct); + // var totalNftsInRelease = saleContext.MintableTokens.Count + saleContext.AllocatedTokens.Count; + // if (totalNftsInRelease < saleContext.Sale.TotalReleaseQuantity) + // { + // _logger.LogWarning(EventIds.HostedServiceWarning, $"{collection.Collection.Name} has {totalNftsInRelease} total tokens which is less than {saleContext.Sale.TotalReleaseQuantity} sale release quantity."); + // _hostApplicationLifetime.StopApplication(); + // return; + // } + // _logger.LogInformation(EventIds.HostedServiceInfo, $"SaleWorker({_workerId}) {collection.Collection.Name} has an active sale '{saleContext.Sale.Name}' for {saleContext.Sale.TotalReleaseQuantity} nifties (out of {totalNftsInRelease} total mintable and {saleContext.AllocatedTokens.Count} allocated) at {saleContext.Sale.SaleAddress}{Environment.NewLine}{saleContext.Sale.LovelacesPerToken} lovelaces per NFT ({saleContext.Sale.LovelacesPerToken / 1000000} ADA) and {saleContext.Sale.MaxAllowedPurchaseQuantity} max allowed"); + + // // TODO: Move away from single-threaded mutable saleContext + // var timer = new PeriodicTimer(TimeSpan.FromSeconds(_settings.PollingIntervalSeconds)); + // do + // { + // var networkContext = await _networkContextRetriever.GetNetworkContext(ct); + // var saleUtxos = await _utxoRetriever.GetUtxosAtAddressAsync(saleContext.Sale.SaleAddress, ct); + // _logger.LogDebug($"Querying SaleAddress UTxOs for sale {saleContext.Sale.Name} of {collection.Collection.Name} by {string.Join(",", collection.Collection.Publishers)}"); + // _logger.LogDebug($"Found {saleUtxos.Length} UTxOs at {saleContext.Sale.SaleAddress}"); + // foreach (var saleUtxo in saleUtxos) + // { + // if (saleContext.LockedUtxos.Contains(saleUtxo)) + // { + // _logger.LogDebug($"Utxo {saleUtxo.TxHash}[{saleUtxo.OutputIndex}]({saleUtxo.Lovelaces}) skipped (already locked)"); + // continue; + // } + // await _saleUtxoHandler.HandleAsync(saleUtxo, saleContext, networkContext, ct); + // } + // _logger.LogDebug( + // $"Successful: {saleContext.SuccessfulUtxos.Count} UTxOs | Refunded: {saleContext.RefundedUtxos.Count} UTxOs | Failed: {saleContext.FailedUtxos.Count} UTxOs | Locked: {saleContext.LockedUtxos.Count} UTxOs"); + // //_logger.LogDebug($"Allocated Tokens:\n\t\t{string.Join("\n\t\t", saleContext.AllocatedTokens.Select(t => t.AssetName))}"); + // } while (await timer.WaitForNextTickAsync(ct)); + //} + protected override async Task ExecuteAsync(CancellationToken ct) { - _logger.LogInformation(EventIds.HostedServiceStarted, $"SaleWorker({_workerId}) started for Id: {_settings.CollectionId}"); - var collection = await _niftyDataService.GetCollectionAggregateAsync(_settings.CollectionId, ct); - if (collection == null || collection.ActiveSales.Length == 0) + _logger.LogInformation(EventIds.HostedServiceStarted, $"SaleWorker({_workerId}) started for CollectionId: {_settings.CollectionId}, Ids: {string.Join(',', _settings.SaleIds)}"); + var saleAggregates = await Task.WhenAll(_settings.SaleIds.Select(saleId => _niftyDataService.GetSaleAggregateAsync(saleId, ct))); + var saleContexts = await Task.WhenAll(saleAggregates.Select(s => _saleContextDataStorage.GetOrRestoreSaleContextAsync(s, _workerId, ct))); + var activeSales = saleContexts.Where(s => (s.Sale.TotalReleaseQuantity - s.AllocatedTokens.Count) > 0).ToArray(); + if (!activeSales.Any()) { - _logger.LogWarning(EventIds.DataServiceRetrievalWarning, $"Collection does not exist or there are no active sales!"); + _logger.LogWarning(EventIds.HostedServiceWarning, $"No active sales with tokens remaining found"); _hostApplicationLifetime.StopApplication(); return; } + _logger.LogInformation(EventIds.HostedServiceInfo, $"SaleWorker({_workerId}) has an {activeSales.Length} activeSales{Environment.NewLine}{string.Join(Environment.NewLine, activeSales.Select(GetSaleInfo))}"); - // TODO: Move from CollectionId to SaleId in MintsafeAppSettings and tie Worker to a Sale - var saleContext = await _saleContextDataStorage.GetOrRestoreSaleContextAsync(collection, _workerId, ct); - var totalNftsInRelease = saleContext.MintableTokens.Count + saleContext.AllocatedTokens.Count; - if (totalNftsInRelease < saleContext.Sale.TotalReleaseQuantity) - { - _logger.LogWarning(EventIds.HostedServiceWarning, $"{collection.Collection.Name} has {totalNftsInRelease} total tokens which is less than {saleContext.Sale.TotalReleaseQuantity} sale release quantity."); - _hostApplicationLifetime.StopApplication(); - return; - } - _logger.LogInformation(EventIds.HostedServiceInfo, $"SaleWorker({_workerId}) {collection.Collection.Name} has an active sale '{saleContext.Sale.Name}' for {saleContext.Sale.TotalReleaseQuantity} nifties (out of {totalNftsInRelease} total mintable and {saleContext.AllocatedTokens.Count} allocated) at {saleContext.Sale.SaleAddress}{Environment.NewLine}{saleContext.Sale.LovelacesPerToken} lovelaces per NFT ({saleContext.Sale.LovelacesPerToken / 1000000} ADA) and {saleContext.Sale.MaxAllowedPurchaseQuantity} max allowed"); - - // TODO: Move away from single-threaded mutable saleContext var timer = new PeriodicTimer(TimeSpan.FromSeconds(_settings.PollingIntervalSeconds)); do { var networkContext = await _networkContextRetriever.GetNetworkContext(ct); - var saleUtxos = await _utxoRetriever.GetUtxosAtAddressAsync(saleContext.Sale.SaleAddress, ct); - _logger.LogDebug($"Querying SaleAddress UTxOs for sale {saleContext.Sale.Name} of {collection.Collection.Name} by {string.Join(",", collection.Collection.Publishers)}"); - _logger.LogDebug($"Found {saleUtxos.Length} UTxOs at {saleContext.Sale.SaleAddress}"); - foreach (var saleUtxo in saleUtxos) + await Task.WhenAll(activeSales.Select(s => PollSaleAddressForUtxos(s, networkContext, ct))); + } while (await timer.WaitForNextTickAsync(ct)); + } + + private async Task PollSaleAddressForUtxos(SaleContext saleContext, NetworkContext networkContext, CancellationToken ct) + { + var saleUtxos = await _utxoRetriever.GetUtxosAtAddressAsync(saleContext.Sale.SaleAddress, ct); + _logger.LogDebug($"Querying SaleAddress UTxOs for sale {saleContext.Sale.Name} of {saleContext.Collection.Name} by {string.Join(",", saleContext.Collection.Publishers)}"); + _logger.LogDebug($"Found {saleUtxos.Length} UTxOs at {saleContext.Sale.SaleAddress}"); + foreach (var saleUtxo in saleUtxos) + { + if (saleContext.LockedUtxos.Contains(saleUtxo)) { - if (saleContext.LockedUtxos.Contains(saleUtxo)) - { - _logger.LogDebug($"Utxo {saleUtxo.TxHash}[{saleUtxo.OutputIndex}]({saleUtxo.Lovelaces}) skipped (already locked)"); - continue; - } - await _saleUtxoHandler.HandleAsync(saleUtxo, saleContext, networkContext, ct); + _logger.LogDebug($"Utxo {saleUtxo.TxHash}[{saleUtxo.OutputIndex}]({saleUtxo.Lovelaces}) skipped (already locked)"); + continue; } - _logger.LogDebug( - $"Successful: {saleContext.SuccessfulUtxos.Count} UTxOs | Refunded: {saleContext.RefundedUtxos.Count} UTxOs | Failed: {saleContext.FailedUtxos.Count} UTxOs | Locked: {saleContext.LockedUtxos.Count} UTxOs"); - //_logger.LogDebug($"Allocated Tokens:\n\t\t{string.Join("\n\t\t", saleContext.AllocatedTokens.Select(t => t.AssetName))}"); - } while (await timer.WaitForNextTickAsync(ct)); + await _saleUtxoHandler.HandleAsync(saleUtxo, saleContext, networkContext, ct); + } + _logger.LogDebug( + $"Successful: {saleContext.SuccessfulUtxos.Count} UTxOs | Refunded: {saleContext.RefundedUtxos.Count} UTxOs | Failed: {saleContext.FailedUtxos.Count} UTxOs | Locked: {saleContext.LockedUtxos.Count} UTxOs"); + } + + private static string GetSaleInfo(SaleContext saleContext) + { + return $"{saleContext.Sale.TotalReleaseQuantity} NFTs ({saleContext.AllocatedTokens.Count} allocated) at {saleContext.Sale.SaleAddress} Cost {saleContext.Sale.LovelacesPerToken / 1000000} ADA and {saleContext.Sale.MaxAllowedPurchaseQuantity} max allowed"; } public override async Task StopAsync(CancellationToken stoppingToken) diff --git a/Src/SaleWorker/appsettings.Local.json b/Src/SaleWorker/appsettings.Local.json deleted file mode 100644 index 0575a1f..0000000 --- a/Src/SaleWorker/appsettings.Local.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "MintsafeWorker": { - "MintBasePath": "C:\\ws\\temp\\mintsafe", - "PollingIntervalSeconds": 20, - "CollectionId": "ce3f6cde-2efc-481e-a7d8-7f92e3e63bf9" - }, - "CardanoNetwork": { - "Network": "Testnet", - "Magic": 1097911063 - }, - "BlockfrostApi": { - "BaseUrl": "https://cardano-testnet.blockfrost.io", - "ApiKey": "testneto96qDwlg4GaoKFfmKxPlHQhSkbea80cW", - "RetryPolicy": { - "TimeoutSeconds": "5", - "RetryCount": "2" - } - }, - "ApplicationInsights": { - "Enabled": false, - "InstrumentationKey": "" - }, - "Storage": { - "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=safetneunmscd;AccountKey=FGOQi0QnA5P8J1O+vrj3fBV7q0g3L22QvmK8nna6tmCjKq4X/VCk6UHtPak3xPHrgHDBeszQ9mjFLKCV4FQdZA==;EndpointSuffix=core.windows.net" - }, - "Logging": { - "LogLevel": { - "Default": "Debug", - "Microsoft.Hosting.Lifetime": "Information", - "System.Net.Http.HttpClient": "Warning", - "Azure.Core": "Warning" - }, - "ApplicationInsights": { - "LogLevel": { - "Default": "Information", - "System.Net.Http.HttpClient": "Warning", - "Azure.Core": "Error" - } - } - } -} diff --git a/Src/SaleWorker/appsettings.json b/Src/SaleWorker/appsettings.json index be2da67..e7c6efa 100644 --- a/Src/SaleWorker/appsettings.json +++ b/Src/SaleWorker/appsettings.json @@ -1,8 +1,9 @@ { "MintsafeWorker": { "MintBasePath": "C:\\ws\\temp\\mintsafe\\", - "PollingIntervalSeconds": 10, - "CollectionId": "d5b35d3d-14cc-40ba-94f4-fe3b28bd52ae" + "PollingIntervalSeconds": 20, + "CollectionId": "", + "SaleIds": [] }, "CardanoNetwork": { "Network": "Testnet", @@ -17,7 +18,7 @@ } }, "Keychain": { - "KeyVaultUrl": "https://safe-tn-eun-ms-mint-kv.vault.azure.net/", + "KeyVaultUrl": "https://YOURKV.vault.azure.net/", "RetrievalMaxRetries": 5, "RetrievalRetryDelaySeconds": 2, "RetrievalRetryMaxDelaySeconds": 16 @@ -28,8 +29,17 @@ }, "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" + "Default": "Debug", + "Microsoft.Hosting.Lifetime": "Information", + "System.Net.Http.HttpClient": "Warning", + "Azure.Core": "Warning" + }, + "ApplicationInsights": { + "LogLevel": { + "Default": "Information", + "System.Net.Http.HttpClient": "Warning", + "Azure.Core": "Error" + } } } } diff --git a/Src/WebApi/Controllers/DataAccessTestController.cs b/Src/WebApi/Controllers/DataAccessTestController.cs index 6e406a3..39cc88f 100644 --- a/Src/WebApi/Controllers/DataAccessTestController.cs +++ b/Src/WebApi/Controllers/DataAccessTestController.cs @@ -19,7 +19,7 @@ public DataAccessTestController(INiftyDataService dataService) } [HttpGet("{collectionId}")] - public async Task Get(Guid collectionId, CancellationToken ct) + public async Task Get(Guid collectionId, CancellationToken ct) { var collectionAggregate = await _dataService.GetCollectionAggregateAsync(collectionId, ct); @@ -32,7 +32,7 @@ public async Task Post(CancellationToken ct) var collectionId = Guid.NewGuid(); var niftyCollection = new NiftyCollection(collectionId, "a", "name", "desc", true, "", new[] { "a", "b" }, - DateTime.UtcNow, DateTime.UtcNow, 5, new Royalty(5, "lol")); + DateTime.UtcNow, DateTime.UtcNow, 5, new Royalty(0.5, "lol")); var niftyId = Guid.NewGuid(); @@ -49,7 +49,7 @@ public async Task Post(CancellationToken ct) var sale = new Sale( Guid.NewGuid(), collectionId, true, "Jacob Test", string.Empty, 5, "hash", "hash2", "hash3", 0.1m, 5, 10, DateTime.UtcNow); - var aggregate = new CollectionAggregate(niftyCollection, new[] {nifty}, new []{sale}); + var aggregate = new ProjectAggregate(niftyCollection, new[] {nifty}, new []{sale}); await _dataService.InsertCollectionAggregateAsync(aggregate, ct); diff --git a/Src/WebApi/Program.cs b/Src/WebApi/Program.cs index 8618504..295dabc 100644 --- a/Src/WebApi/Program.cs +++ b/Src/WebApi/Program.cs @@ -64,7 +64,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddAzureClients(clientBuilder => { diff --git a/Tests/DataAccess.UnitTests/TableStorageDataServiceShould.cs b/Tests/DataAccess.UnitTests/TableStorageDataServiceShould.cs index 6c772bc..356f3e6 100644 --- a/Tests/DataAccess.UnitTests/TableStorageDataServiceShould.cs +++ b/Tests/DataAccess.UnitTests/TableStorageDataServiceShould.cs @@ -25,7 +25,7 @@ public class TableStorageDataServiceShould private readonly Mock _saleRepositoryMock; private readonly Mock _niftyFileRepositoryMock; - private readonly Mock _collectionAggregateComposerMock; + private readonly Mock _collectionAggregateComposerMock; private readonly Mock> _loggerMock; @@ -37,7 +37,7 @@ public TableStorageDataServiceShould() _niftyRepositoryMock = new Mock(); _saleRepositoryMock = new Mock(); _niftyFileRepositoryMock = new Mock(); - _collectionAggregateComposerMock = new Mock(); + _collectionAggregateComposerMock = new Mock(); _loggerMock = new Mock>(); @@ -67,7 +67,7 @@ public async Task Return_Correctly() _niftyFileRepositoryMock.Setup(x => x.GetByCollectionIdAsync(collectionId, It.IsAny())) .ReturnsAsync(new[] {niftyFile}); - var collectionAggregate = new Fixture().Build().Create(); + var collectionAggregate = new Fixture().Build().Create(); _collectionAggregateComposerMock.Setup(x => x.Build(niftyCollection, new[] {nifty}, new[] {sale}, new[] {niftyFile})).Returns(collectionAggregate); var result = await _tableStorageDataService.GetCollectionAggregateAsync(collectionId); @@ -90,7 +90,7 @@ public async Task Log_And_Throw_On_NiftyCollection_Exception() _niftyCollectionRepositoryMock.Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(exception); - Func> act = async () => await _tableStorageDataService.GetCollectionAggregateAsync(Guid.NewGuid()); + Func> act = async () => await _tableStorageDataService.GetCollectionAggregateAsync(Guid.NewGuid()); await act.Should().ThrowAsync(); @@ -111,7 +111,7 @@ public async Task Log_And_Throw_On_Nifty_Exception() _niftyRepositoryMock.Setup(x => x.GetByCollectionIdAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(exception); - Func> act = async () => await _tableStorageDataService.GetCollectionAggregateAsync(Guid.NewGuid()); + Func> act = async () => await _tableStorageDataService.GetCollectionAggregateAsync(Guid.NewGuid()); await act.Should().ThrowAsync(); @@ -132,7 +132,7 @@ public async Task Log_And_Throw_On_NiftyFile_Exception() _niftyFileRepositoryMock.Setup(x => x.GetByCollectionIdAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(exception); - Func> act = async () => await _tableStorageDataService.GetCollectionAggregateAsync(Guid.NewGuid()); + Func> act = async () => await _tableStorageDataService.GetCollectionAggregateAsync(Guid.NewGuid()); await act.Should().ThrowAsync(); @@ -153,7 +153,7 @@ public async Task Log_And_Throw_On_Sales_Exception() _saleRepositoryMock.Setup(x => x.GetByCollectionIdAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(exception); - Func> act = async () => await _tableStorageDataService.GetCollectionAggregateAsync(Guid.NewGuid()); + Func> act = async () => await _tableStorageDataService.GetCollectionAggregateAsync(Guid.NewGuid()); await act.Should().ThrowAsync(); diff --git a/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs index 72ea3d9..0d11b79 100644 --- a/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs +++ b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs @@ -73,14 +73,14 @@ public async Task Submit_Transaction_Successfully_When_Minting_Nft_Royalty_Asset var simpleWalletService = new SimpleWalletService(NullLogger.Instance, NullInstrumentor.Instance); var sourcePaymentAddress = "addr_test1qrlsqwhg4vay0x4yy6lw08s4qx4kcpnujcc7dz8e5lqjdnkdfj93s4scmnax7lgemz2q2ftms4zna9y9xle0c3c88f5qhvdm5m"; var sourcePaymentXsk = "addr_xsk1gr99v0ynu0wvuxwe4vu2sap3d3vxqzpupu93gvhtu869wmk8wezu2zlkssy9xj4j5u5ymf356np04f2tt77pfees3uckytdlpdhv8pwss378smdhsecy7un46q8738rhgwd0hytz4r77k6v95dmt47g0ms4ec63v"; - var royaltyRate = "0.08"; - var royaltyAddress = "addr_test1qplxcfvad2uzq2w4k99unzj6d5hmpprgrujn3l0nwsl8vh3e2mgaxpeslac7hghtxxzcwerr3wt6ly2t9hr7unkua9rskg2855"; - var policySkey = "policy_sk16zy4n87qj996t77yfzqp3hmsv3l689gm5yq939gnlxresmx8wezhuj2qzf7sz7eck62wct5vv72rf4gfa48ehgrn3j5tffqfe6zm67qsk0p44"; - var policyExpirySlot = 96997186U; + var royaltyRate = "0.10"; + var royaltyAddress = "addr_test1qrlsqwhg4vay0x4yy6lw08s4qx4kcpnujcc7dz8e5lqjdnkdfj93s4scmnax7lgemz2q2ftms4zna9y9xle0c3c88f5qhvdm5m"; + var policySkey = "policy_sk1xyz"; + var policyExpirySlot = 98504109U; var policyId = BuildScriptAllPolicy(policySkey, policyExpirySlot).GetPolicyId().ToStringHex(); - var network = Network.Testnet; + var network = Network.Mainnet; var nativeAssetsToMint = new[] { new NativeAssetValue(policyId, "", 1) }; // empty assetname is required for CIP27 - var minUtxoLovelace = TxUtils.CalculateMinUtxoLovelace(new AggregateValue(1000000, nativeAssetsToMint)); + var minUtxoLovelace = TxUtils.CalculateMinUtxoLovelace(nativeAssetsToMint); var royaltyBodyMetadata = new Dictionary { { "rate", royaltyRate }, From 647b5d18d57eee0674c5ad7389c8fbd5fcb2fabc Mon Sep 17 00:00:00 2001 From: safestak-keith Date: Tue, 21 Jun 2022 12:19:18 +1000 Subject: [PATCH 4/9] Removal of private storage details --- Src/DataImporter/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Src/DataImporter/Program.cs b/Src/DataImporter/Program.cs index 4d6e0b4..b0db53a 100644 --- a/Src/DataImporter/Program.cs +++ b/Src/DataImporter/Program.cs @@ -25,7 +25,7 @@ services.AddAzureClients(clientBuilder => { - var connectionString = "DefaultEndpointsProtocol=https;AccountName=sstneunmsapiapivmstor;AccountKey=u6xexnihOsCJulTo3UZZqfs18GlnZ5r+CCNpaES/yLPQf3xxwJnnurOLxtOKsVNKrFgTP94NSJP++AStC5JT/Q==;EndpointSuffix=core.windows.net"; + var connectionString = ""; clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyCollection); clientBuilder.AddTableClient(connectionString, Constants.TableNames.Nifty); From c7eb4375e3fc63c1fd2859961383707263a837e3 Mon Sep 17 00:00:00 2001 From: safestak-keith Date: Tue, 21 Jun 2022 12:54:29 +1000 Subject: [PATCH 5/9] Small tidyup --- Src/DataImporter/Program.cs | 4 ++-- Src/SaleWorker/Worker.cs | 45 ------------------------------------- 2 files changed, 2 insertions(+), 47 deletions(-) diff --git a/Src/DataImporter/Program.cs b/Src/DataImporter/Program.cs index b0db53a..38314ca 100644 --- a/Src/DataImporter/Program.cs +++ b/Src/DataImporter/Program.cs @@ -25,7 +25,7 @@ services.AddAzureClients(clientBuilder => { - var connectionString = ""; + var connectionString = "``"; clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyCollection); clientBuilder.AddTableClient(connectionString, Constants.TableNames.Nifty); @@ -35,7 +35,7 @@ }) .Build(); -var pickupDirPath = $"C:\\ws\\temp\\tacf_portrait2"; +var pickupDirPath = $"C:\\ws\\temp\\t"; var collection = await LoadJsonFromFileAsync(Path.Combine(pickupDirPath, "collection_tn.json")); var sale = await LoadJsonFromFileAsync(Path.Combine(pickupDirPath, "sale_tn.json")); diff --git a/Src/SaleWorker/Worker.cs b/Src/SaleWorker/Worker.cs index 2f74f2f..137fdac 100644 --- a/Src/SaleWorker/Worker.cs +++ b/Src/SaleWorker/Worker.cs @@ -42,51 +42,6 @@ public Worker( _workerId = Guid.NewGuid(); } - //protected override async Task ExecuteAsync(CancellationToken ct) - //{ - // _logger.LogInformation(EventIds.HostedServiceStarted, $"SaleWorker({_workerId}) started for Id: {_settings.CollectionId}"); - // var collection = await _niftyDataService.GetCollectionAggregateAsync(_settings.CollectionId, ct); - // if (collection == null || collection.ActiveSales.Length == 0) - // { - // _logger.LogWarning(EventIds.DataServiceRetrievalWarning, $"Collection does not exist or there are no active sales!"); - // _hostApplicationLifetime.StopApplication(); - // return; - // } - - // // TODO: Move from CollectionId to SaleId in MintsafeAppSettings and tie Worker to a Sale - // var saleContext = await _saleContextDataStorage.GetOrRestoreSaleContextAsync(collection, _workerId, ct); - // var totalNftsInRelease = saleContext.MintableTokens.Count + saleContext.AllocatedTokens.Count; - // if (totalNftsInRelease < saleContext.Sale.TotalReleaseQuantity) - // { - // _logger.LogWarning(EventIds.HostedServiceWarning, $"{collection.Collection.Name} has {totalNftsInRelease} total tokens which is less than {saleContext.Sale.TotalReleaseQuantity} sale release quantity."); - // _hostApplicationLifetime.StopApplication(); - // return; - // } - // _logger.LogInformation(EventIds.HostedServiceInfo, $"SaleWorker({_workerId}) {collection.Collection.Name} has an active sale '{saleContext.Sale.Name}' for {saleContext.Sale.TotalReleaseQuantity} nifties (out of {totalNftsInRelease} total mintable and {saleContext.AllocatedTokens.Count} allocated) at {saleContext.Sale.SaleAddress}{Environment.NewLine}{saleContext.Sale.LovelacesPerToken} lovelaces per NFT ({saleContext.Sale.LovelacesPerToken / 1000000} ADA) and {saleContext.Sale.MaxAllowedPurchaseQuantity} max allowed"); - - // // TODO: Move away from single-threaded mutable saleContext - // var timer = new PeriodicTimer(TimeSpan.FromSeconds(_settings.PollingIntervalSeconds)); - // do - // { - // var networkContext = await _networkContextRetriever.GetNetworkContext(ct); - // var saleUtxos = await _utxoRetriever.GetUtxosAtAddressAsync(saleContext.Sale.SaleAddress, ct); - // _logger.LogDebug($"Querying SaleAddress UTxOs for sale {saleContext.Sale.Name} of {collection.Collection.Name} by {string.Join(",", collection.Collection.Publishers)}"); - // _logger.LogDebug($"Found {saleUtxos.Length} UTxOs at {saleContext.Sale.SaleAddress}"); - // foreach (var saleUtxo in saleUtxos) - // { - // if (saleContext.LockedUtxos.Contains(saleUtxo)) - // { - // _logger.LogDebug($"Utxo {saleUtxo.TxHash}[{saleUtxo.OutputIndex}]({saleUtxo.Lovelaces}) skipped (already locked)"); - // continue; - // } - // await _saleUtxoHandler.HandleAsync(saleUtxo, saleContext, networkContext, ct); - // } - // _logger.LogDebug( - // $"Successful: {saleContext.SuccessfulUtxos.Count} UTxOs | Refunded: {saleContext.RefundedUtxos.Count} UTxOs | Failed: {saleContext.FailedUtxos.Count} UTxOs | Locked: {saleContext.LockedUtxos.Count} UTxOs"); - // //_logger.LogDebug($"Allocated Tokens:\n\t\t{string.Join("\n\t\t", saleContext.AllocatedTokens.Select(t => t.AssetName))}"); - // } while (await timer.WaitForNextTickAsync(ct)); - //} - protected override async Task ExecuteAsync(CancellationToken ct) { _logger.LogInformation(EventIds.HostedServiceStarted, $"SaleWorker({_workerId}) started for CollectionId: {_settings.CollectionId}, Ids: {string.Join(',', _settings.SaleIds)}"); From 58a9fb467fde1b93f291287356d88e32984ef4bd Mon Sep 17 00:00:00 2001 From: safestak-keith Date: Tue, 21 Jun 2022 21:27:58 +1000 Subject: [PATCH 6/9] Some handling of retries when Blockfrost is down --- Src/Lib/MintsafeAppSettings.cs | 2 +- Src/SaleWorker/ConfigTypes.cs | 1 + Src/SaleWorker/Program.cs | 3 ++- Src/SaleWorker/Worker.cs | 16 +++++++++++++--- Src/SaleWorker/appsettings.json | 1 + 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Src/Lib/MintsafeAppSettings.cs b/Src/Lib/MintsafeAppSettings.cs index b2fde13..94e6ca3 100644 --- a/Src/Lib/MintsafeAppSettings.cs +++ b/Src/Lib/MintsafeAppSettings.cs @@ -7,11 +7,11 @@ public record MintsafeAppSettings { public Network Network { get; init; } public int PollingIntervalSeconds { get; init; } + public int PollErrorRetryLimit { get; init; } public string? BasePath { get; init; } public string? BlockFrostApiKey { get; init; } public string? AppInsightsInstrumentationKey { get; init; } public Guid CollectionId { get; init; } - public Guid[] SaleIds { get; init; } public string? KeyVaultUrl { get; init; } } diff --git a/Src/SaleWorker/ConfigTypes.cs b/Src/SaleWorker/ConfigTypes.cs index 3367eed..5f2d6b4 100644 --- a/Src/SaleWorker/ConfigTypes.cs +++ b/Src/SaleWorker/ConfigTypes.cs @@ -6,6 +6,7 @@ public class MintsafeWorkerConfig { public string? MintBasePath { get; init; } public int? PollingIntervalSeconds { get; init; } + public int? PollErrorRetryLimit { get; init; } public string? CollectionId { get; init; } public string[]? SaleIds { get; init; } } diff --git a/Src/SaleWorker/Program.cs b/Src/SaleWorker/Program.cs index b43c749..9a8f87a 100644 --- a/Src/SaleWorker/Program.cs +++ b/Src/SaleWorker/Program.cs @@ -73,7 +73,8 @@ Network = cardanoNetworkConfig.Network == "Mainnet" ? Network.Mainnet : Network.Testnet, BlockFrostApiKey = blockfrostApiConfig.ApiKey, BasePath = mintsafeWorkerConfig.MintBasePath, - PollingIntervalSeconds = mintsafeWorkerConfig.PollingIntervalSeconds.HasValue ? mintsafeWorkerConfig.PollingIntervalSeconds.Value : 10, + PollingIntervalSeconds = mintsafeWorkerConfig.PollingIntervalSeconds.HasValue ? mintsafeWorkerConfig.PollingIntervalSeconds.Value : 20, + PollErrorRetryLimit = mintsafeWorkerConfig.PollErrorRetryLimit.HasValue ? mintsafeWorkerConfig.PollErrorRetryLimit.Value : 8, CollectionId = Guid.Parse(mintsafeWorkerConfig.CollectionId), SaleIds = mintsafeWorkerConfig.SaleIds?.Select(Guid.Parse).ToArray() ?? Array.Empty(), KeyVaultUrl = keychainConfig.KeyVaultUrl diff --git a/Src/SaleWorker/Worker.cs b/Src/SaleWorker/Worker.cs index 137fdac..47c9b98 100644 --- a/Src/SaleWorker/Worker.cs +++ b/Src/SaleWorker/Worker.cs @@ -56,12 +56,22 @@ protected override async Task ExecuteAsync(CancellationToken ct) } _logger.LogInformation(EventIds.HostedServiceInfo, $"SaleWorker({_workerId}) has an {activeSales.Length} activeSales{Environment.NewLine}{string.Join(Environment.NewLine, activeSales.Select(GetSaleInfo))}"); + var retryCount = 0; var timer = new PeriodicTimer(TimeSpan.FromSeconds(_settings.PollingIntervalSeconds)); do { - var networkContext = await _networkContextRetriever.GetNetworkContext(ct); - await Task.WhenAll(activeSales.Select(s => PollSaleAddressForUtxos(s, networkContext, ct))); - } while (await timer.WaitForNextTickAsync(ct)); + try + { + var networkContext = await _networkContextRetriever.GetNetworkContext(ct); + await Task.WhenAll(activeSales.Select(s => PollSaleAddressForUtxos(s, networkContext, ct))); + retryCount = 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception in {Worker}", nameof(Worker)); + retryCount++; + } + } while (await timer.WaitForNextTickAsync(ct) && (retryCount <= _settings.PollErrorRetryLimit)); } private async Task PollSaleAddressForUtxos(SaleContext saleContext, NetworkContext networkContext, CancellationToken ct) diff --git a/Src/SaleWorker/appsettings.json b/Src/SaleWorker/appsettings.json index e7c6efa..ad1c888 100644 --- a/Src/SaleWorker/appsettings.json +++ b/Src/SaleWorker/appsettings.json @@ -2,6 +2,7 @@ "MintsafeWorker": { "MintBasePath": "C:\\ws\\temp\\mintsafe\\", "PollingIntervalSeconds": 20, + "PollErrorRetryLimit": 8, "CollectionId": "", "SaleIds": [] }, From 0a79886f967af8c5f6d2f6a6620a2c8c2a549dae Mon Sep 17 00:00:00 2001 From: safestak-keith Date: Wed, 22 Jun 2022 23:41:22 +1000 Subject: [PATCH 7/9] Started cleaning up unused components --- .../IMintingKeychainRetriever.cs | 2 +- Src/Abstractions/ITxBuilder.cs | 7 - Src/Lib/CardanoCliTxBuilder.cs | 470 ------------------ Src/Lib/CardanoCliTxSubmitter.cs | 84 ---- Src/Lib/CardanoCliUtxoRetriever.cs | 134 ----- Src/Lib/MetadataBuilder.cs | 2 +- Src/Lib/MetadataFileGenerator.cs | 78 --- Src/Lib/MetadataJsonBuilder.cs | 290 ----------- Src/Lib/NiftyDistributor.cs | 241 +-------- Src/SaleWorker/Program.cs | 2 - Src/WebApi/Program.cs | 8 +- .../CardanoSharpDistributorShould.cs | 324 ++++++++++++ .../MetadataJsonBuilderShould.cs | 226 --------- Tests/Lib.UnitTests/NiftyDistributorShould.cs | 324 ------------ .../SimpleWalletServiceShould.cs | 2 +- 15 files changed, 329 insertions(+), 1865 deletions(-) rename Src/{Lib => Abstractions}/IMintingKeychainRetriever.cs (88%) delete mode 100644 Src/Lib/CardanoCliTxBuilder.cs delete mode 100644 Src/Lib/CardanoCliTxSubmitter.cs delete mode 100644 Src/Lib/CardanoCliUtxoRetriever.cs delete mode 100644 Src/Lib/MetadataFileGenerator.cs delete mode 100644 Src/Lib/MetadataJsonBuilder.cs create mode 100644 Tests/Lib.UnitTests/CardanoSharpDistributorShould.cs delete mode 100644 Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs delete mode 100644 Tests/Lib.UnitTests/NiftyDistributorShould.cs diff --git a/Src/Lib/IMintingKeychainRetriever.cs b/Src/Abstractions/IMintingKeychainRetriever.cs similarity index 88% rename from Src/Lib/IMintingKeychainRetriever.cs rename to Src/Abstractions/IMintingKeychainRetriever.cs index fe8780c..cb7c26e 100644 --- a/Src/Lib/IMintingKeychainRetriever.cs +++ b/Src/Abstractions/IMintingKeychainRetriever.cs @@ -2,7 +2,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Mintsafe.Lib; +namespace Mintsafe.Abstractions; public interface IMintingKeychainRetriever { diff --git a/Src/Abstractions/ITxBuilder.cs b/Src/Abstractions/ITxBuilder.cs index 7190faa..ab6a997 100644 --- a/Src/Abstractions/ITxBuilder.cs +++ b/Src/Abstractions/ITxBuilder.cs @@ -3,13 +3,6 @@ namespace Mintsafe.Abstractions; -public interface ITxBuilder -{ - public Task BuildTxAsync( - TxBuildCommand buildCommand, - CancellationToken ct = default); -} - public interface IMintTransactionBuilder { BuiltTransaction BuildTx( diff --git a/Src/Lib/CardanoCliTxBuilder.cs b/Src/Lib/CardanoCliTxBuilder.cs deleted file mode 100644 index 4b1855f..0000000 --- a/Src/Lib/CardanoCliTxBuilder.cs +++ /dev/null @@ -1,470 +0,0 @@ -using Microsoft.Extensions.Logging; -using Mintsafe.Abstractions; -using SimpleExec; -using System; -using System.Diagnostics; -using System.IO; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace Mintsafe.Lib; - -public class CardanoCliException : ApplicationException -{ - public string Network { get; } - public string Args { get; } - - public CardanoCliException( - string message, - Exception inner, - string network, - string args = "") : base(message, inner) - { - Network = network; - Args = args; - } -} - -public class CardanoCliTxBuilder : ITxBuilder -{ - private readonly ILogger _logger; - private readonly IInstrumentor _instrumentor; - private readonly MintsafeAppSettings _settings; - private readonly string _networkMagic; - - public CardanoCliTxBuilder( - ILogger logger, - IInstrumentor instrumentor, - MintsafeAppSettings settings) - { - _logger = logger; - _instrumentor = instrumentor; - _settings = settings; - _networkMagic = _settings.Network == Network.Mainnet - ? "--mainnet" - : "--testnet-magic 1097911063"; - } - - public async Task BuildTxAsync( - TxBuildCommand buildCommand, - CancellationToken ct = default) - { - var buildId = Guid.NewGuid(); - var actualTxBuildArgs = string.Empty; - var isSuccessful = false; - var sw = Stopwatch.StartNew(); - try - { - var protocolParamsOutputPath = Path.Combine(_settings.BasePath, "protocol.json"); - if (!File.Exists(protocolParamsOutputPath)) - { - await Command.ReadAsync( - "cardano-cli", - $"query protocol-parameters {_networkMagic} --out-file {protocolParamsOutputPath}", - noEcho: true, - cancellationToken: ct); - } - - var feeCalculationTxBodyOutputPath = Path.Combine(_settings.BasePath, $"fee-{buildId}.txraw"); - actualTxBuildArgs = string.Join(" ", - "transaction", "build-raw", - GetTxInArgs(buildCommand), - GetTxOutArgs(buildCommand), - GetMetadataArgs(buildCommand), - GetMintArgs(buildCommand), - GetMintingScriptFileArgs(buildCommand), - GetInvalidHereafterArgs(buildCommand), - "--fee", "0", - "--out-file", feeCalculationTxBodyOutputPath - ); - var feeTxBuildCliOutput = await Command.ReadAsync( - "cardano-cli", - actualTxBuildArgs, - noEcho: true, - cancellationToken: ct); - _logger.LogDebug($"Fee Tx built {actualTxBuildArgs}{Environment.NewLine}{feeTxBuildCliOutput}"); - - var feeCalculationArgs = string.Join(" ", - "transaction", "calculate-min-fee", - "--tx-body-file", feeCalculationTxBodyOutputPath, - "--tx-in-count", buildCommand.Inputs.Length, - "--tx-out-count", buildCommand.Outputs.Length, - "--witness-count", buildCommand.SigningKeyFiles.Length, - _networkMagic, - "--protocol-params-file", protocolParamsOutputPath - ); - var feeCalculationCliOutput = await Command.ReadAsync( - "cardano-cli", - feeCalculationArgs, - noEcho: true, - cancellationToken: ct); - var feeLovelaceQuantity = ulong.Parse(feeCalculationCliOutput.Split(' ')[0]); // Parse "199469 Lovelace" - _logger.LogDebug($"Fee Calculated {feeCalculationArgs}{Environment.NewLine}{feeCalculationCliOutput}"); - - var actualTxBodyOutputPath = Path.Combine(_settings.BasePath, $"{buildId}.txraw"); - actualTxBuildArgs = string.Join(" ", - "transaction", "build-raw", - GetTxInArgs(buildCommand), - GetTxOutArgs(buildCommand, feeLovelaceQuantity), - GetMetadataArgs(buildCommand), - GetMintArgs(buildCommand), - GetMintingScriptFileArgs(buildCommand), - GetInvalidHereafterArgs(buildCommand), - "--fee", feeLovelaceQuantity, - "--out-file", actualTxBodyOutputPath - ); - var actualTxBuildCliOutput = await Command.ReadAsync( - "cardano-cli", - actualTxBuildArgs, - noEcho: true, - cancellationToken: ct); - _logger.LogDebug($"Actual Tx built {actualTxBuildArgs}{Environment.NewLine}{actualTxBuildCliOutput}"); - - var signedTxOutputPath = Path.Combine(_settings.BasePath, $"{buildId}.txsigned"); - var txSignatureArgs = string.Join(" ", - "transaction", "sign", - GetSigningKeyFiles(buildCommand), - "--tx-body-file", actualTxBodyOutputPath, - _networkMagic, - "--out-file", signedTxOutputPath - ); - var txSignatureCliOutput = await Command.ReadAsync( - "cardano-cli", - txSignatureArgs, - noEcho: true, - cancellationToken: ct); - _logger.LogDebug($"Signed Tx built {txSignatureArgs}{Environment.NewLine}{txSignatureCliOutput}"); - - // Extract bytes from cborHex field of JSON in signed tx file - var cborJson = File.ReadAllText(signedTxOutputPath); - _logger.LogDebug(cborJson); - var doc = JsonDocument.Parse(cborJson); - var cborHex = doc.RootElement.GetProperty("cborHex").GetString(); - if (string.IsNullOrWhiteSpace(cborHex)) - { - // TODO: typed exception - throw new ApplicationException("cborHex field from generated signature is null"); - } - var signedTxCborBytes = HexStringToByteArray(cborHex); - isSuccessful = true; - return signedTxCborBytes; - } - catch (Exception ex) - { - throw new CardanoCliException($"Unhandled exception in {nameof(CardanoCliTxBuilder)}", ex, _settings.Network.ToString(), actualTxBuildArgs); - } - finally - { - TryCleanupTempFiles( - Path.Combine(_settings.BasePath, $"fee-{buildId}.txraw"), - Path.Combine(_settings.BasePath, $"{buildId}.txraw"), - Path.Combine(_settings.BasePath, $"{buildId}.txsigned"), - buildCommand.MetadataJsonPath); - _instrumentor.TrackDependency( - EventIds.TxBuilderElapsed, - sw.ElapsedMilliseconds, - DateTime.UtcNow, - nameof(CardanoCliTxBuilder), - Path.Combine(_settings.BasePath, $"{buildId}.txsigned"), - nameof(BuildTxAsync), - isSuccessful: isSuccessful); - } - } - - private static string GetTxInArgs(TxBuildCommand command) - { - var sb = new StringBuilder(); - foreach (var input in command.Inputs) - { - sb.Append($"--tx-in {input.TxHash}#{input.OutputIndex} "); - } - sb.Remove(sb.Length - 1, 1); // trim trailing space - return sb.ToString(); - } - - private static string GetTxOutArgs(TxBuildCommand command, ulong fee = 0) - { - var sb = new StringBuilder(); - foreach (var output in command.Outputs) - { - // Determine the txout that will pay for the fee (i.e. the sale proceeds address and not the buyer) - var lovelacesOut = output.Values.Lovelaces; - if (output.IsFeeDeducted) - { - lovelacesOut -= fee; - } - - sb.Append($"--tx-out \"{output.Address}+{lovelacesOut}"); - - var nativeTokens = output.Values.NativeAssets; - foreach (var value in nativeTokens) - { - sb.Append($"+{value.Quantity} {value.PolicyId}.{value.AssetName}"); - } - sb.Append("\" "); - } - sb.Remove(sb.Length - 1, 1); // trim trailing space - return sb.ToString(); - } - - private static string GetMintArgs(TxBuildCommand command) - { - if (command.Mint.Length == 0) - return string.Empty; - - var sb = new StringBuilder(); - sb.Append($"--mint \""); - foreach (var value in command.Mint) - { - sb.Append($"{value.Quantity} {value.PolicyId}.{value.AssetName}+"); - } - sb.Remove(sb.Length - 1, 1); // trim trailing + - sb.Append('"'); - return sb.ToString(); - } - - private static string GetMetadataArgs(TxBuildCommand command) - { - if (string.IsNullOrWhiteSpace(command.MetadataJsonPath)) - return string.Empty; - - return $"--metadata-json-file {command.MetadataJsonPath}"; - } - - private static string GetMintingScriptFileArgs(TxBuildCommand command) - { - if (string.IsNullOrWhiteSpace(command.MintingScriptPath)) - return string.Empty; - - return $"--minting-script-file {command.MintingScriptPath}"; - } - - private static string GetInvalidHereafterArgs(TxBuildCommand command) - { - if (command.TtlSlot <= 0) - return string.Empty; - - return $"--invalid-hereafter {command.TtlSlot}"; - } - - private static string GetSigningKeyFiles(TxBuildCommand command) - { - if (command.SigningKeyFiles.Length == 0) - return string.Empty; - - var sb = new StringBuilder(); - foreach (var skeyFile in command.SigningKeyFiles) - { - sb.Append($"--signing-key-file {skeyFile} "); - } - sb.Remove(sb.Length - 1, 1); // trim trailing space - return sb.ToString(); - } - - private static byte[] HexStringToByteArray(string hex) - { - static int GetHexVal(char hex) - { - int val = (int)hex; - return val - (val < 58 ? 48 : 87); - } - - if (hex.Length % 2 == 1) - throw new Exception("The binary key cannot have an odd number of digits"); - - byte[] arr = new byte[hex.Length >> 1]; - for (int i = 0; i < hex.Length >> 1; ++i) - { - arr[i] = (byte)((GetHexVal(hex[i << 1]) << 4) + (GetHexVal(hex[(i << 1) + 1]))); - } - - return arr; - } - - private void TryCleanupTempFiles(params string[] filePaths) - { - foreach (var filePath in filePaths) - { - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - } - } -} - -public class FakeTxBuilder : ITxBuilder -{ - private readonly ILogger _logger; - private readonly MintsafeAppSettings _settings; - private readonly string _networkMagic; - - public FakeTxBuilder( - ILogger logger, MintsafeAppSettings settings) - { - _logger = logger; - _settings = settings; - _networkMagic = _settings.Network == Network.Mainnet - ? "--mainnet" - : "--testnet-magic 1097911063"; - } - - public async Task BuildTxAsync( - TxBuildCommand buildCommand, - CancellationToken ct = default) - { - var buildId = Guid.NewGuid(); - - await Task.Delay(100, ct); - - var protocolParamsPath = Path.Combine(_settings.BasePath, "protocol.json"); - - var feeCalculationTxBodyPath = Path.Combine(_settings.BasePath, $"fee-{buildId}.txraw"); - var feeTxBuildArgs = string.Join(" ", - "transaction", "build-raw", $"{Environment.NewLine}", - GetTxInArgs(buildCommand), $"{Environment.NewLine}", - GetTxOutArgs(buildCommand), $"{Environment.NewLine}", - GetMetadataArgs(buildCommand), $"{Environment.NewLine}", - GetMintArgs(buildCommand), $"{Environment.NewLine}", - GetMintingScriptFileArgs(buildCommand), $"{Environment.NewLine}", - GetInvalidHereafterArgs(buildCommand), $"{Environment.NewLine}", - "--fee", "0", $"{Environment.NewLine}", - "--out-file", feeCalculationTxBodyPath - ); - _logger.LogDebug($"Building fee calculation tx from:{Environment.NewLine}{feeTxBuildArgs}"); - - var feeCalculationArgs = string.Join(" ", - "transaction", "calculate-min-fee", $"{Environment.NewLine}", - "--tx-body-file", feeCalculationTxBodyPath, $"{Environment.NewLine}", - "--tx-in-count", buildCommand.Inputs.Length, $"{Environment.NewLine}", - "--tx-out-count", buildCommand.Outputs.Length, $"{Environment.NewLine}", - "--witness-count", buildCommand.SigningKeyFiles.Length, $"{Environment.NewLine}", - _networkMagic, $"{Environment.NewLine}", - "--protocol-params-file", protocolParamsPath - ); - - _logger.LogDebug("Calculating fee using fee calculation tx (199469) from:"); - _logger.LogDebug(feeCalculationArgs); - var feeLovelaceQuantity = 199469UL; - - var actualTxBodyPath = Path.Combine(_settings.BasePath, $"mint-{buildId}.txraw"); - var txBuildArgs = string.Join(" ", - "transaction", "build-raw", $"{Environment.NewLine}", - GetTxInArgs(buildCommand), $"{Environment.NewLine}", - GetTxOutArgs(buildCommand, feeLovelaceQuantity), $"{Environment.NewLine}", - GetMetadataArgs(buildCommand), $"{Environment.NewLine}", - GetMintArgs(buildCommand), $"{Environment.NewLine}", - GetMintingScriptFileArgs(buildCommand), $"{Environment.NewLine}", - GetInvalidHereafterArgs(buildCommand), $"{Environment.NewLine}", - "--fee", feeLovelaceQuantity, $"{Environment.NewLine}", - "--out-file", actualTxBodyPath - ); - _logger.LogDebug($"Actual Tx built from command:{Environment.NewLine}{txBuildArgs}"); - - var signedTxOutputPath = Path.Combine(_settings.BasePath, $"{buildId}.txsigned"); - var txSignatureArgs = string.Join(" ", - "transaction", "sign", $"{Environment.NewLine}", - GetSigningKeyFiles(buildCommand), $"{Environment.NewLine}", - "--tx-body-file", actualTxBodyPath, $"{Environment.NewLine}", - _networkMagic, $"{Environment.NewLine}", - "--out-file", signedTxOutputPath - ); - _logger.LogDebug($"Signed Tx from command:{Environment.NewLine}{txSignatureArgs}"); - - return Array.Empty(); - } - - private static string GetTxInArgs(TxBuildCommand command) - { - var sb = new StringBuilder(); - foreach (var input in command.Inputs) - { - sb.Append($"--tx-in {input.TxHash}#{input.OutputIndex} "); - } - sb.Remove(sb.Length - 1, 1); // trim trailing space - return sb.ToString(); - } - - private static string GetTxOutArgs(TxBuildCommand command, ulong fee = 0) - { - var sb = new StringBuilder(); - foreach (var output in command.Outputs) - { - // Determine the txout that will pay for the fee (i.e. the sale proceeds address and not the buyer) - var lovelacesOut = output.Values.Lovelaces; - if (output.IsFeeDeducted) - { - lovelacesOut -= fee; - } - - sb.Append($"--tx-out \"{output.Address}+{lovelacesOut}"); - - var nativeTokens = output.Values.NativeAssets; - foreach (var value in nativeTokens) - { - sb.Append($"+{value.Quantity} {value.PolicyId}.{value.AssetName}"); - } - - sb.Append("\" "); - } - sb.Remove(sb.Length - 1, 1); // trim trailing space - return sb.ToString(); - } - - private static string GetMintArgs(TxBuildCommand command) - { - if (command.Mint.Length == 0) - return string.Empty; - - var sb = new StringBuilder(); - sb.Append($"--mint \""); - foreach (var value in command.Mint) - { - sb.Append($"{value.Quantity} {value.PolicyId}.{value.AssetName}+"); - } - sb.Remove(sb.Length - 1, 1); // trim trailing + - sb.Append('"'); - return sb.ToString(); - } - - private static string GetMetadataArgs(TxBuildCommand command) - { - if (string.IsNullOrWhiteSpace(command.MetadataJsonPath)) - return string.Empty; - - return $"--metadata-json-file {command.MetadataJsonPath}"; - } - - private static string GetMintingScriptFileArgs(TxBuildCommand command) - { - if (string.IsNullOrWhiteSpace(command.MintingScriptPath)) - return string.Empty; - - return $"--minting-script-file {command.MintingScriptPath}"; - } - - private static string GetInvalidHereafterArgs(TxBuildCommand command) - { - if (command.TtlSlot <= 0) - return string.Empty; - - return $"--invalid-hereafter {command.TtlSlot}"; - } - - private static string GetSigningKeyFiles(TxBuildCommand command) - { - if (command.SigningKeyFiles.Length == 0) - return string.Empty; - - var sb = new StringBuilder(); - foreach (var skeyFile in command.SigningKeyFiles) - { - sb.Append($"--signing-key-file {skeyFile} "); - } - sb.Remove(sb.Length - 1, 1); // trim trailing space - return sb.ToString(); - } - -} diff --git a/Src/Lib/CardanoCliTxSubmitter.cs b/Src/Lib/CardanoCliTxSubmitter.cs deleted file mode 100644 index 5fe344c..0000000 --- a/Src/Lib/CardanoCliTxSubmitter.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Microsoft.Extensions.Logging; -using Mintsafe.Abstractions; -using SimpleExec; -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace Mintsafe.Lib; - -public class CardanoCliTxSubmitter : ITxSubmitter -{ - private readonly ILogger _logger; - private readonly IInstrumentor _instrumentor; - private readonly MintsafeAppSettings _settings; - private readonly string _networkMagic; - - public CardanoCliTxSubmitter( - ILogger logger, - IInstrumentor instrumentor, - MintsafeAppSettings settings) - { - _logger = logger; - _instrumentor = instrumentor; - _settings = settings; - _networkMagic = _settings.Network == Network.Mainnet - ? "--mainnet" - : "--testnet-magic 1097911063"; - } - - public async Task SubmitTxAsync(byte[] txSignedBinary, CancellationToken ct = default) - { - // Build the JSON containing cborHex that the CLI expects - var txSubmissionId = Guid.NewGuid(); - var txSignedJsonPath = Path.Combine(_settings.BasePath, $"{txSubmissionId}.txsigned"); - var txSignedJson = $"{{ \"type\": \"Tx MaryEra\", \"description\": \"\", \"cborHex\": \"{Convert.ToHexString(txSignedBinary)}\"}}"; - await File.WriteAllTextAsync(txSignedJsonPath, txSignedJson, ct).ConfigureAwait(false); - - var isSuccessful = false; - var sw = Stopwatch.StartNew(); - var txHash = string.Empty; - try - { - var rawTxSubmissionResponse = await Command.ReadAsync( - "cardano-cli", string.Join(" ", - "transaction", "submit", - _networkMagic, - "--tx-file", txSignedJsonPath - ), noEcho: true, cancellationToken: ct); - - // Derive the txhash - txHash = await Command.ReadAsync( - "cardano-cli", string.Join(" ", - "transaction", "txid", - "--tx-file", txSignedJsonPath - ), noEcho: true, cancellationToken: ct); - isSuccessful = true; - - return txHash; - } - catch (Win32Exception ex) - { - throw new CardanoCliException("cardano-cli does not exist", ex, _settings.Network.ToString()); - } - catch (Exception ex) - { - throw new CardanoCliException("cardano-cli unhandled exception", ex, _settings.Network.ToString()); - } - finally - { - _logger.LogDebug($"Tx submitted after {sw.ElapsedMilliseconds}ms:{Environment.NewLine}{txHash}"); - _instrumentor.TrackDependency( - EventIds.TxSubmissionElapsed, - sw.ElapsedMilliseconds, - DateTime.UtcNow, - nameof(CardanoCliTxSubmitter), - txSignedJsonPath, - nameof(SubmitTxAsync), - isSuccessful: isSuccessful); - } - } -} diff --git a/Src/Lib/CardanoCliUtxoRetriever.cs b/Src/Lib/CardanoCliUtxoRetriever.cs deleted file mode 100644 index fc8b0ff..0000000 --- a/Src/Lib/CardanoCliUtxoRetriever.cs +++ /dev/null @@ -1,134 +0,0 @@ -using Microsoft.Extensions.Logging; -using Mintsafe.Abstractions; -using SimpleExec; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace Mintsafe.Lib; - -public class CardanoCliUtxoRetriever : IUtxoRetriever -{ - private readonly ILogger _logger; - private readonly IInstrumentor _instrumentor; - private readonly MintsafeAppSettings _settings; - private readonly string _networkMagic; - - public CardanoCliUtxoRetriever( - ILogger logger, - IInstrumentor instrumentor, - MintsafeAppSettings settings) - { - _logger = logger; - _instrumentor = instrumentor; - _settings = settings; - _networkMagic = _settings.Network == Network.Mainnet - ? "--mainnet" - : "--testnet-magic 1097911063"; - } - - public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) - { - var isSuccessful = false; - var sw = Stopwatch.StartNew(); - var rawUtxoResponse = string.Empty; - try - { - rawUtxoResponse = await Command.ReadAsync( - "cardano-cli", string.Join(" ", - "query", "utxo", - _networkMagic, - "--address", address - ), noEcho: true, cancellationToken: ct); - isSuccessful = true; - } - catch (FormatException ex) - { - throw new CardanoCliException("cardano-cli returned an invalid response", ex, _settings.Network.ToString()); - } - catch (Win32Exception ex) - { - throw new CardanoCliException("cardano-cli does not exist", ex, _settings.Network.ToString()); - } - catch (Exception ex) - { - throw new CardanoCliException("cardano-cli unhandled exception", ex, _settings.Network.ToString()); - } - finally - { - _instrumentor.TrackDependency( - EventIds.UtxoRetrievalElapsed, - sw.ElapsedMilliseconds, - DateTime.UtcNow, - nameof(CardanoCliUtxoRetriever), - address, - nameof(GetUtxosAtAddressAsync), - isSuccessful: isSuccessful); - _logger.LogDebug($"UTxOs retrieval finished after {sw.ElapsedMilliseconds}ms:{Environment.NewLine}{rawUtxoResponse}"); - } - - var lines = rawUtxoResponse.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - var utxos = new UnspentTransactionOutput[lines.Length - 2]; - var insertionIndex = 0; - foreach (var utxoLine in lines[2..]) // skip the headers - { - var contentSegments = utxoLine.Split(' ', StringSplitOptions.RemoveEmptyEntries); - var values = ParseValues(contentSegments); - - utxos[insertionIndex++] = new UnspentTransactionOutput( - TxHash: contentSegments[0], - OutputIndex: uint.Parse(contentSegments[1]), - Value: values); - - } - return utxos; - } - - private static AggregateValue ParseValues(string[] utxoLineSegments) - { - // Must always contain an ADA/lovelace UTXO value - var lovelaceValue = ulong.Parse(utxoLineSegments[2]); - - var nativeAssets = new List(); - var currentSegmentIndex = 4; // 4 comes frrom skipping [0]{txHash} [1]{txOutputIndex} [2]{txOutputLovelaceValue} [3]lovelace - while (utxoLineSegments[currentSegmentIndex] == "+" && utxoLineSegments[currentSegmentIndex + 1] != "TxOutDatumNone") - { - //_logger.LogDebug($"FOUND {utxoLineSegments[currentSegmentIndex]} AND {utxoLineSegments[currentSegmentIndex + 1]}"); - var quantity = ulong.Parse(utxoLineSegments[currentSegmentIndex + 1]); - var unit = utxoLineSegments[currentSegmentIndex + 2]; - var unitParts = unit.Split('.'); - nativeAssets.Add(new NativeAssetValue(unitParts[0], unitParts[1], quantity)); - currentSegmentIndex += 3; // skip "+ {quantity} {unit}" - } - return new AggregateValue(lovelaceValue, nativeAssets.ToArray()); - } -} - -public class FakeUtxoRetriever : IUtxoRetriever -{ - public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) - { - await Task.Delay(1000, ct).ConfigureAwait(false); - return GenerateUtxos(3, - 45_000000, - 10_000000, - 60_000000); - } - - private static UnspentTransactionOutput[] GenerateUtxos(int count, params ulong[] values) - { - if (values.Length != count) - throw new ArgumentException($"{nameof(values)} must be the same length as count", nameof(values)); - - return Enumerable.Range(0, count) - .Select(i => new UnspentTransactionOutput( - "127745e23b81a5a5e22a409ce17ae8672b234dda7be1f09fc9e3a11906bd3a11", - (uint)i, - new AggregateValue(values[i], Array.Empty()))) - .ToArray(); - } -} diff --git a/Src/Lib/MetadataBuilder.cs b/Src/Lib/MetadataBuilder.cs index 5c832eb..c5e643b 100644 --- a/Src/Lib/MetadataBuilder.cs +++ b/Src/Lib/MetadataBuilder.cs @@ -122,7 +122,7 @@ public static Dictionary> BuildMessageMetadata(s return messageMetadata; } - private static string[] SplitStringToChunks(string? value, int maxLength = MaxMetadataStringLength) + public static string[] SplitStringToChunks(string? value, int maxLength = MaxMetadataStringLength) { if (value == null) return Array.Empty(); diff --git a/Src/Lib/MetadataFileGenerator.cs b/Src/Lib/MetadataFileGenerator.cs deleted file mode 100644 index aceb248..0000000 --- a/Src/Lib/MetadataFileGenerator.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Microsoft.Extensions.Logging; -using Mintsafe.Abstractions; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace Mintsafe.Lib; - -public class MetadataFileGenerator : IMetadataFileGenerator -{ - private readonly ILogger _logger; - private readonly IInstrumentor _instrumentor; - private readonly MintsafeAppSettings _settings; - private readonly IMetadataJsonBuilder _metadataJsonBuilder; - - public MetadataFileGenerator( - ILogger logger, - IInstrumentor instrumentor, - MintsafeAppSettings settings, - IMetadataJsonBuilder metadataJsonBuilder) - { - _logger = logger; - _instrumentor = instrumentor; - _settings = settings; - _metadataJsonBuilder = metadataJsonBuilder; - } - - public async Task GenerateNftStandardMetadataJsonFile( - Nifty[] nfts, - NiftyCollection collection, - string outputPath, - CancellationToken ct = default) - { - var json = _metadataJsonBuilder.GenerateNftStandardJson(nfts, collection); - var sw = Stopwatch.StartNew(); - await File.WriteAllTextAsync(outputPath, json, ct).ConfigureAwait(false); - _instrumentor.TrackDependency( - EventIds.MetadataFileElapsed, - sw.ElapsedMilliseconds, - DateTime.UtcNow, - nameof(MetadataFileGenerator), - outputPath, - nameof(GenerateNftStandardMetadataJsonFile), - isSuccessful: true, - customProperties: new Dictionary - { - { "NftCount", nfts.Length }, - { "JsonLength", json.Length } - }); - _logger.LogDebug($"NFT Metadata JSON file generated at {outputPath} after {sw.ElapsedMilliseconds}ms"); - } - - public async Task GenerateMessageMetadataJsonFile( - string[] message, - string outputPath, - CancellationToken ct = default) - { - var json = _metadataJsonBuilder.GenerateMessageJson(message); - var sw = Stopwatch.StartNew(); - await File.WriteAllTextAsync(outputPath, json, ct).ConfigureAwait(false); - _instrumentor.TrackDependency( - EventIds.MetadataFileElapsed, - sw.ElapsedMilliseconds, - DateTime.UtcNow, - nameof(MetadataFileGenerator), - outputPath, - nameof(GenerateMessageMetadataJsonFile), - isSuccessful: true, - customProperties: new Dictionary - { - { "JsonLength", json.Length } - }); - _logger.LogDebug($"Message Metadata JSON file generated at {outputPath} after {sw.ElapsedMilliseconds}ms"); - } -} diff --git a/Src/Lib/MetadataJsonBuilder.cs b/Src/Lib/MetadataJsonBuilder.cs deleted file mode 100644 index 7290d25..0000000 --- a/Src/Lib/MetadataJsonBuilder.cs +++ /dev/null @@ -1,290 +0,0 @@ -using Microsoft.Extensions.Logging; -using Mintsafe.Abstractions; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Mintsafe.Lib -{ - #region Cardano NFT metadata types - public class CnftStandardFile - { - public string? Name { get; set; } - public string? MediaType { get; set; } - public string? Src { get; set; } - public string? Hash { get; set; } - } - - public class CnftOnChainStandardFile - { - public string? Name { get; set; } - public string? MediaType { get; set; } - public string[]? Src { get; set; } - public string? Hash { get; set; } - } - - public class CnftStandardAsset - { - public string? Name { get; set; } - public string? Description { get; set; } - public string? MediaType { get; set; } - public string? Image { get; set; } - public string[]? Creators { get; set; } - public string[]? Publishers { get; set; } - public CnftStandardFile[]? Files { get; set; } - public IEnumerable>? Attributes { get; set; } - } - - public class CnftOnChainStandardAsset - { - public string? Name { get; set; } - public string? Description { get; set; } - public string? MediaType { get; set; } - public string[]? Image { get; set; } - public string[]? Creators { get; set; } - public string[]? Publishers { get; set; } - public CnftOnChainStandardFile[]? Files { get; set; } - public IEnumerable>? Attributes { get; set; } - } - - public class CnftStandardRoyalty - { - public double Rate { get; set; } - public string[]? Addr { get; set; } - } - #endregion - - public interface IMetadataJsonBuilder - { - string GenerateMessageJson(string[] message); - string GenerateNftStandardJson(Nifty[] nfts, NiftyCollection collection); - } - - public class MetadataJsonBuilder : IMetadataJsonBuilder - { - private const string MessageStandardKey = "674"; - private const string NftStandardKey = "721"; - private const string NftRoyaltyStandardKey = "777"; - private const int MaxMetadataStringLength = 64; - - private static readonly JsonSerializerOptions SerialiserOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - private readonly ILogger _logger; - private readonly IInstrumentor _instrumentor; - private readonly MintsafeAppSettings _settings; - - public MetadataJsonBuilder( - ILogger logger, - IInstrumentor instrumentor, - MintsafeAppSettings settings) - { - _logger = logger; - _instrumentor = instrumentor; - _settings = settings; - } - - public string GenerateNftStandardJson( - Nifty[] nfts, - NiftyCollection collection) - { - var hasOnChainNifties = nfts.Any( - n => (n.Image != null && n.Image.Length > MaxMetadataStringLength) - || n.Files.Any(nf => nf.Src.Length > MaxMetadataStringLength)); - - return hasOnChainNifties - ? GetOnChainNftStandardJson(nfts, collection) - : GetOffChainNftStandardJson(nfts, collection); - } - - private string GetOffChainNftStandardJson(Nifty[] nfts, NiftyCollection collection) - { - var nftStandard = new Dictionary< - string, // 721 - Dictionary< - string, // PolicyID - Dictionary< - string, // AssetName - CnftStandardAsset>>>(); - var policyCnfts = new Dictionary< - string, // PolicyID - Dictionary< - string, // AssetName - CnftStandardAsset>>(); - - var sw = Stopwatch.StartNew(); - var nftDictionary = new Dictionary(); - foreach (var nft in nfts) - { - var nftAsset = new CnftStandardAsset - { - Name = nft.Name, - Description = nft.Description, - Image = nft.Image, - MediaType = nft.MediaType, - Creators = nft.Creators, - Publishers = collection.Publishers, - Files = nft.Files.Length == 0 ? null // don't serialise empty arrays - : nft.Files.Select( - f => new CnftStandardFile { Name = f.Name, MediaType = f.MediaType, Src = f.Src, Hash = f.FileHash }).ToArray(), - Attributes = nft.Attributes.Length == 0 ? null : nft.Attributes - }; - nftDictionary.Add(nft.AssetName, nftAsset); - } - policyCnfts.Add(collection.PolicyId, nftDictionary); - nftStandard.Add(NftStandardKey, policyCnfts); - - var json = JsonSerializer.Serialize(nftStandard, SerialiserOptions); - _logger.LogDebug($"NFT Metadata JSON (off-chain) built after {sw.ElapsedMilliseconds}ms"); - - return json; - } - - private string GetOnChainNftStandardJson( - Nifty[] nfts, - NiftyCollection collection) - { - var nftStandard = new Dictionary< - string, // 721 - Dictionary< - string, // PolicyID - Dictionary< - string, // AssetName - CnftOnChainStandardAsset>>>(); - var policyCnfts = new Dictionary< - string, // PolicyID - Dictionary< - string, // AssetName - CnftOnChainStandardAsset>>(); - - var sw = Stopwatch.StartNew(); - var nftDictionary = new Dictionary(); - foreach (var nft in nfts) - { - var nftAsset = new CnftOnChainStandardAsset - { - Name = nft.Name, - Description = nft.Description, - Image = SplitStringToChunks(nft.Image), - MediaType = nft.MediaType, - Creators = nft.Creators, - Publishers = collection.Publishers, - Files = nft.Files.Length == 0 ? null // don't serialise empty arrays - : nft.Files.Select( - f => new CnftOnChainStandardFile { Name = f.Name, MediaType = f.MediaType, Src = SplitStringToChunks(f.Src), Hash = f.FileHash }).ToArray(), - Attributes = nft.Attributes.Length == 0 ? null : nft.Attributes - }; - nftDictionary.Add(nft.AssetName, nftAsset); - } - policyCnfts.Add(collection.PolicyId, nftDictionary); - nftStandard.Add(NftStandardKey, policyCnfts); - - var json = JsonSerializer.Serialize(nftStandard, SerialiserOptions); - _logger.LogDebug($"NFT Metadata JSON (on-chain) built after {sw.ElapsedMilliseconds}ms"); - - return json; - } - - public string GenerateMessageJson(string[] message) - { - var sw = Stopwatch.StartNew(); - var metadataBody = new Dictionary< - string, // 674 - Dictionary< - string, // Msg - string[]>> - { - { - MessageStandardKey, - new Dictionary - { - { "msg", message } - } - } - }; - - var json = JsonSerializer.Serialize(metadataBody, SerialiserOptions); - _logger.LogDebug($"Message Metadata JSON built after {sw.ElapsedMilliseconds}ms"); - - return json; - } - - public string GenerateRoyaltyJson(Royalty royalty) - { - var sw = Stopwatch.StartNew(); - var metadataBody = new Dictionary< - string, // 777 - CnftStandardRoyalty> - { - { - NftRoyaltyStandardKey, - new CnftStandardRoyalty { Rate = royalty.PortionOfSale, Addr = SplitStringToChunks(royalty.Address) } - } - }; - var json = JsonSerializer.Serialize(metadataBody, SerialiserOptions); - _logger.LogDebug($"Royalty Metadata JSON built after {sw.ElapsedMilliseconds}ms"); - - return json; - } - - public static string[] SplitStringToChunks(string? value, int maxLength = MaxMetadataStringLength) - { - if (value == null) - return Array.Empty(); - if (value.Length <= maxLength) - return new[] { value }; - - var offsetLength = maxLength - 1; - var itemsLength = (value.Length + offsetLength) / maxLength; - var items = new string[itemsLength]; - for (var i = 0; i < itemsLength; i++) - { - var substringStartIndex = i * maxLength; - var substringLength = (substringStartIndex + maxLength) <= value.Length - ? maxLength - : value.Length % maxLength; // take remainder mod to prevent index-out-of-bounds - var segment = value.Substring(substringStartIndex, substringLength); - items[i] = segment; - } - return items; - } - - public static string GetBase64SvgForMessage(string message, string title = "") - { - const int MaxMessageBodyLength = 256; - const int MaxMessageLineCharLength = 32; - if (title.Length > MaxMessageLineCharLength) - throw new ArgumentException($"{nameof(title)} cannot be greater than {MaxMessageLineCharLength} characters", nameof(title)); - if (message.Length > MaxMessageBodyLength) - throw new ArgumentException($"{nameof(message)} cannot be greater than {MaxMessageBodyLength} characters", nameof(message)); - - var svgBuilder = new StringBuilder($""); - if (!string.IsNullOrWhiteSpace(title)) - { - svgBuilder.Append($"{title}"); - } - - var chunks = SplitStringToChunks(message, MaxMessageLineCharLength); - var yOffset = 35; - foreach (var chunk in chunks) - { - svgBuilder.Append($"{chunk}"); - yOffset += 15; - } - svgBuilder.Append($"reply @ mintsafe.io"); - svgBuilder.Append(""); - - var base64Svg = Convert.ToBase64String(Encoding.UTF8.GetBytes(svgBuilder.ToString())); - - return $"data:image/svg+xml;base64,{base64Svg}"; - } - } -} diff --git a/Src/Lib/NiftyDistributor.cs b/Src/Lib/NiftyDistributor.cs index 1d2e112..bd35b71 100644 --- a/Src/Lib/NiftyDistributor.cs +++ b/Src/Lib/NiftyDistributor.cs @@ -11,245 +11,6 @@ namespace Mintsafe.Lib; -public class NiftyDistributor : INiftyDistributor -{ - private readonly ILogger _logger; - private readonly IInstrumentor _instrumentor; - private readonly MintsafeAppSettings _settings; - private readonly IMetadataFileGenerator _metadataGenerator; - private readonly ITxInfoRetriever _txRetriever; - private readonly ITxBuilder _txBuilder; - private readonly ITxSubmitter _txSubmitter; - private readonly ISaleAllocationStore _saleContextStore; - - public NiftyDistributor( - ILogger logger, - IInstrumentor instrumentor, - MintsafeAppSettings settings, - IMetadataFileGenerator metadataGenerator, - ITxInfoRetriever txRetriever, - ITxBuilder txBuilder, - ITxSubmitter txSubmitter, - ISaleAllocationStore saleContextStore) - { - _logger = logger; - _instrumentor = instrumentor; - _settings = settings; - _metadataGenerator = metadataGenerator; - _txRetriever = txRetriever; - _txBuilder = txBuilder; - _txSubmitter = txSubmitter; - _saleContextStore = saleContextStore; - } - - public async Task DistributeNiftiesForSalePurchase( - Nifty[] nfts, - PurchaseAttempt purchaseAttempt, - SaleContext saleContext, - NetworkContext networkContext, - CancellationToken ct = default) - { - var swTotal = Stopwatch.StartNew(); - - // Derive buyer address after getting source UTxO details - var (address, buyerAddressException) = await TryGetBuyerAddressAsync( - nfts, purchaseAttempt, saleContext, ct).ConfigureAwait(false); - if (address == null) - { - return new NiftyDistributionResult( - NiftyDistributionOutcome.FailureTxInfo, - purchaseAttempt, - string.Empty, - Exception: buyerAddressException); - } - - var tokenMintValues = nfts.Select(n => new NativeAssetValue( - saleContext.Collection.PolicyId, Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName)), 1)).ToArray(); - var txBuildOutputs = GetTxBuildOutputs(saleContext.Sale, purchaseAttempt, address, tokenMintValues); - var policyScriptPath = Path.Combine(_settings.BasePath, $"{saleContext.Collection.PolicyId}.policy.script"); - var metadataJsonPath = Path.Combine(_settings.BasePath, $"metadata-mint-{purchaseAttempt.Utxo}.json"); - await _metadataGenerator.GenerateNftStandardMetadataJsonFile(nfts, saleContext.Collection, metadataJsonPath, ct).ConfigureAwait(false); - var slotExpiry = GetUtxoSlotExpiry(saleContext.Collection, _settings.Network); - var signingKeyFilePaths = new[] - { - Path.Combine(_settings.BasePath, $"{saleContext.Collection.PolicyId}.policy.skey"), - Path.Combine(_settings.BasePath, $"{saleContext.Sale.Id}.sale.skey") - }; - - var txBuildCommand = new TxBuildCommand( - new[] { purchaseAttempt.Utxo }, - txBuildOutputs, - tokenMintValues, - policyScriptPath, - metadataJsonPath, - slotExpiry, - signingKeyFilePaths); - - // Log tx build command - var txBuildJson = JsonSerializer.Serialize(txBuildCommand); - var utxoFolderPath = Path.Combine(saleContext.SaleUtxosPath, purchaseAttempt.Utxo.ToString()); - if (Directory.Exists(utxoFolderPath)) - { - var utxoPurchasePath = Path.Combine(utxoFolderPath, "mint_tx.json"); - await File.WriteAllTextAsync(utxoPurchasePath, JsonSerializer.Serialize(txBuildCommand), ct).ConfigureAwait(false); - } - - var (txRawBytes, txRawException) = await TryGetTxRawBytesAsync( - txBuildCommand, nfts, saleContext, ct).ConfigureAwait(false); - if (txRawBytes == null) - { - return new NiftyDistributionResult( - NiftyDistributionOutcome.FailureTxBuild, - purchaseAttempt, - string.Empty, - Exception: txRawException); - } - - var (txHash, txSubmissionException) = await TrySubmitTxAsync( - txRawBytes, nfts, saleContext, ct).ConfigureAwait(false); - if (txHash == null) - { - // TODO: Record a mint in our table storage (see NiftyTypes) - return new NiftyDistributionResult( - NiftyDistributionOutcome.FailureTxSubmit, - purchaseAttempt, - txBuildJson, - Exception: txSubmissionException); - } - - _instrumentor.TrackDependency( - EventIds.DistributorElapsed, - swTotal.ElapsedMilliseconds, - DateTime.UtcNow, - nameof(NiftyDistributor), - address, - nameof(DistributeNiftiesForSalePurchase), - data: txBuildJson, - isSuccessful: true); - - return new NiftyDistributionResult( - NiftyDistributionOutcome.Successful, - purchaseAttempt, - txBuildJson, - txHash, - address, - nfts); - } - - private async Task<(string? Address, Exception? Ex)> TryGetBuyerAddressAsync( - Nifty[] nfts, PurchaseAttempt purchaseAttempt, SaleContext saleContext, CancellationToken ct) - { - try - { - var txIo = await _txRetriever.GetTxInfoAsync(purchaseAttempt.Utxo.TxHash, ct).ConfigureAwait(false); - return (txIo.Inputs.First().Address, null); - } - catch (Exception ex) - { - _logger.LogError(EventIds.TxInfoRetrievalError, ex, $"Failed TxInfo Restrieval"); - await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); - return (null, ex); - } - } - - private async Task<(byte[]? TxRawBytes, Exception? Ex)> TryGetTxRawBytesAsync( - TxBuildCommand txBuildCommand, - Nifty[] nfts, - SaleContext saleContext, - CancellationToken ct) - { - try - { - var raw = await _txBuilder.BuildTxAsync(txBuildCommand, ct).ConfigureAwait(false); - return (raw, null); - } - catch (CardanoCliException ex) - { - _logger.LogError(EventIds.TxBuilderError, ex, $"Failed Tx Build {ex.Args}"); - await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); - return (null, ex); - } - catch (Exception ex) - { - _logger.LogError(EventIds.TxBuilderError, ex, "Failed Tx Build"); - await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); - return (null, ex); - } - } - - private async Task<(string? TxHash, Exception? Ex)> TrySubmitTxAsync( - byte[] txRawBytes, - Nifty[] nfts, - SaleContext saleContext, - CancellationToken ct) - { - try - { - var txHash = await _txSubmitter.SubmitTxAsync(txRawBytes, ct).ConfigureAwait(false); - _logger.LogDebug($"{nameof(_txSubmitter.SubmitTxAsync)} completed with txHash:{txHash}"); - return (txHash, null); - } - catch (Exception ex) - { - _logger.LogError(EventIds.TxSubmissionError, ex, $"Failed Tx Submission"); - await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); - return (null, ex); - } - } - - private static TxBuildOutput[] GetTxBuildOutputs( - Sale sale, - PurchaseAttempt purchaseAttempt, - string buyerAddress, - NativeAssetValue[] tokenMintValues) - { - // Chicken-and-egg bit to calculate the minimum output lovelace value after building the tx output back to the buyer - // Then mutating the lovelace value quantity with the calculated minLovelaceUtxo - var buyerOutputUtxoValues = new AggregateValue(0, tokenMintValues); - var minLovelaceUtxo = TxUtils.CalculateMinUtxoLovelace(buyerOutputUtxoValues); - ulong buyerLovelacesReturned = minLovelaceUtxo + purchaseAttempt.ChangeInLovelace; - buyerOutputUtxoValues.Lovelaces = buyerLovelacesReturned; - - var saleLovelaces = purchaseAttempt.Utxo.Lovelaces - buyerLovelacesReturned; - // No NFT creator address specified or we take 100% of the cut - if (string.IsNullOrWhiteSpace(sale.CreatorAddress) || sale.PostPurchaseMargin == 1) - { - return new[] { - new TxBuildOutput(buyerAddress, buyerOutputUtxoValues), - new TxBuildOutput( - sale.ProceedsAddress, - new AggregateValue(saleLovelaces, Array.Empty()), - IsFeeDeducted: true) - }; - } - - // Calculate proceeds of ADA from saleContext.Sale to creator and proceeds cut - var proceedsCutLovelaces = (ulong)(saleLovelaces * sale.PostPurchaseMargin); - var creatorCutLovelaces = saleLovelaces - proceedsCutLovelaces; - var creatorAddressUtxoValues = new AggregateValue(creatorCutLovelaces, Array.Empty()); - var proceedsAddressUtxoValues = new AggregateValue(proceedsCutLovelaces, Array.Empty()); - return new[] { - new TxBuildOutput(buyerAddress, buyerOutputUtxoValues), - new TxBuildOutput(sale.CreatorAddress, creatorAddressUtxoValues, IsFeeDeducted: true), - new TxBuildOutput(sale.ProceedsAddress, proceedsAddressUtxoValues) - }; - } - - private static long GetUtxoSlotExpiry( - NiftyCollection collection, Network network) - { - // Can override the slot expiry at the collection level - if (collection.SlotExpiry >= 0) - { - return collection.SlotExpiry; - } - // Derive from LockedAt date - return network == Network.Mainnet - ? TimeUtil.GetMainnetSlotAt(collection.LockedAt) - : TimeUtil.GetTestnetSlotAt(collection.LockedAt); - } -} - public class CardanoSharpNiftyDistributor : INiftyDistributor { private readonly ILogger _logger; @@ -345,7 +106,7 @@ public async Task DistributeNiftiesForSalePurchase( EventIds.DistributorElapsed, swTotal.ElapsedMilliseconds, DateTime.UtcNow, - nameof(NiftyDistributor), + nameof(CardanoSharpNiftyDistributor), address, nameof(DistributeNiftiesForSalePurchase), isSuccessful: true); diff --git a/Src/SaleWorker/Program.cs b/Src/SaleWorker/Program.cs index 9a8f87a..2ac8dff 100644 --- a/Src/SaleWorker/Program.cs +++ b/Src/SaleWorker/Program.cs @@ -109,8 +109,6 @@ services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Src/WebApi/Program.cs b/Src/WebApi/Program.cs index 295dabc..233ea05 100644 --- a/Src/WebApi/Program.cs +++ b/Src/WebApi/Program.cs @@ -50,9 +50,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -76,13 +74,9 @@ clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyFile); }); -//TODO -builder.Services.AddSingleton(); - // Fakes //builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Reals diff --git a/Tests/Lib.UnitTests/CardanoSharpDistributorShould.cs b/Tests/Lib.UnitTests/CardanoSharpDistributorShould.cs new file mode 100644 index 0000000..636d4e5 --- /dev/null +++ b/Tests/Lib.UnitTests/CardanoSharpDistributorShould.cs @@ -0,0 +1,324 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Mintsafe.Abstractions; +using Moq; +using System; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using static Mintsafe.Lib.UnitTests.FakeGenerator; + +namespace Mintsafe.Lib.UnitTests; + +public class NiftyDistributorShould +{ + private readonly CardanoSharpNiftyDistributor _distributor; + private readonly Mock _mockTxIoRetriever; + private readonly Mock _mockTxBuilder; + private readonly Mock _mockKeychainRetriever; + private readonly Mock _mockTxSubmitter; + private readonly Mock _mockSaleContextStore; + + public NiftyDistributorShould() + { + _mockTxIoRetriever = new Mock(); + _mockTxBuilder = new Mock(); + _mockKeychainRetriever = new Mock(); + _mockTxSubmitter = new Mock(); + _mockSaleContextStore = new Mock(); + _distributor = new CardanoSharpNiftyDistributor( + NullLogger.Instance, + NullInstrumentor.Instance, + GenerateSettings(), + _mockTxIoRetriever.Object, + _mockKeychainRetriever.Object, + _mockTxBuilder.Object, + _mockTxSubmitter.Object, + _mockSaleContextStore.Object); + } + + //[Theory] + //[InlineData(1)] + //[InlineData(10)] + //public async Task Distribute_Nifties_For_SalePurchase_Given_Active_Sale_When_Purchase_Is_Valid( + // int niftyCount) + //{ + // var purchaseAttempt = new PurchaseAttempt(Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, 10000000).First(), 3, 0); + // var allocatedNifties = GenerateTokens(niftyCount).ToArray(); + // var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; + // _mockTxIoRetriever + // .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(GenerateTxIoAggregate()); + // _mockTxBuilder + // .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(buildTxOutputBytes); + // _mockTxSubmitter + // .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) + // .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); + + // var distributionResult = await _distributor.DistributeNiftiesForSalePurchase( + // nfts: allocatedNifties, + // purchaseAttempt: purchaseAttempt, + // saleContext: GenerateSaleContext(), + // networkContext: GenerateNetworkContext()); + + // distributionResult.Outcome.Should().Be(NiftyDistributionOutcome.Successful); + // distributionResult.PurchaseAttempt.Should().Be(purchaseAttempt); + // distributionResult.NiftiesDistributed.Should().BeEquivalentTo(allocatedNifties); + // distributionResult.MintTxHash.Should().Be("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); + // _mockTxBuilder + // .Verify( + // t => t.BuildTxAsync( + // It.Is(b => b.Mint.Length == niftyCount), + // It.IsAny()), + // Times.Once, + // "Output should have buyer address and correct values"); + //} + + //[Theory] + //[InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0, 10000000)] + //[InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 2, 35000000)] + //public async Task Build_Correct_Tx_Input_For_Buyer_SalePurchase_Given_Active_Sale_When_Purchase_Is_Valid( + // string purchaseTxHash, uint purchaseOutputIndex, ulong purchaseUtxoLovelaceValue) + //{ + // var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; + // _mockTxIoRetriever + // .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(GenerateTxIoAggregate()); + // _mockTxBuilder + // .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(buildTxOutputBytes); + // _mockTxSubmitter + // .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) + // .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); + // var purchaseUtxo = new UnspentTransactionOutput( + // purchaseTxHash, + // purchaseOutputIndex, + // new AggregateValue(purchaseUtxoLovelaceValue, Array.Empty())); + + // var txHash = await _distributor.DistributeNiftiesForSalePurchase( + // nfts: GenerateTokens(3).ToArray(), + // purchaseAttempt: new PurchaseAttempt(Guid.NewGuid(), Guid.NewGuid(), purchaseUtxo, 3, 0), + // saleContext: GenerateSaleContext(), + // networkContext: GenerateNetworkContext()); + + // _mockTxBuilder + // .Verify( + // t => t.BuildTxAsync(It.Is(b => b.Inputs.First() == purchaseUtxo && b.Inputs.Length == 1), It.IsAny()), + // Times.Once, + // "Input should be the purchase UTxO"); + //} + + //[Theory] + //[InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0, 1, "0ac248e17f0fc35be4d2a7d186a84cdcda5b88d7ad2799ebe98a98b2")] + //[InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 1000000, 3, "629718e24d22e0c02c2efd27290e1a58ebc2972635a7c523aee2d8fc")] + //public async Task Build_Correct_Tx_Output_For_Buyer_SalePurchase_Given_Active_Sale_When_Purchase_Is_Valid( + // string buyerAddress, ulong changeInLovelace, int niftyCount, string policyId) + //{ + // var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; + // _mockTxIoRetriever + // .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(GenerateTxIoAggregate(inputAddress: buyerAddress)); + // _mockTxBuilder + // .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(buildTxOutputBytes); + // _mockTxSubmitter + // .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) + // .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); + // var nifties = GenerateTokens(niftyCount).ToArray(); + + // var txHash = await _distributor.DistributeNiftiesForSalePurchase( + // nfts: nifties, + // purchaseAttempt: new PurchaseAttempt( + // Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, 10000000).First(), niftyCount, changeInLovelace), + // saleContext: GenerateSaleContext(collection: GenerateCollection(policyId: policyId)), + // networkContext: GenerateNetworkContext()); + + // _mockTxBuilder + // .Verify( + // t => t.BuildTxAsync( + // It.Is(b => IsBuyerOutputCorrect(b, buyerAddress, changeInLovelace, nifties, policyId)), + // It.IsAny()), + // Times.Once, + // "Output should have buyer address and correct values"); + //} + + //[Theory] + //[InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0.1, "addr_test1qrup2zgu69knkts7m3y3ghhwdzmgaus5u3s28vcsdegajr9wv6zerpdre7qdyvf68dcjyslazq0tfj5rq80v02tm5mysd3xc2u", 10000000, 0, 1)] + //[InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 0.078, "addr_test1qp06h7um737tlp2s5um8fvwef5rmx6jh7auchrcwgct3w43w7gtapvad5hzgkvr3ksnzpu6a2ejaew5ypeurygknqs5qhjuvf6", 36000000, 2000000, 3)] + //[InlineData("addr_test1vrfxxeuzqfuknfz4hu0ym4fe4l3axvqd7t5agd6pfzml59q30qc4x", 0.0088, "addr_test1vrldgv89yh0edkuwrvkkhc3yx4npfccdvtz7dfkn85a78rsu9nkm4", 50500000, 2500000, 5)] + //public async Task Build_Correct_Tx_Output_For_Creator_Address_Given_Active_Sale_When_Purchase_Is_Valid( + // string creatorAddress, double margin, string buyerAddress, ulong purchaseLovelaces, ulong changeInLovelace, int niftyCount) + //{ + // var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; + // var txInfo = GenerateTxIoAggregate(inputAddress: buyerAddress); + // _mockTxIoRetriever + // .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(txInfo); + // _mockTxBuilder + // .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(buildTxOutputBytes); + // _mockTxSubmitter + // .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) + // .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); + // var nifties = GenerateTokens(niftyCount).ToArray(); + + // var txHash = await _distributor.DistributeNiftiesForSalePurchase( + // nfts: nifties, + // purchaseAttempt: new PurchaseAttempt( + // Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, purchaseLovelaces).First(), niftyCount, changeInLovelace), + // saleContext: GenerateSaleContext( + // sale: GenerateSale(creatorAddress: creatorAddress, postPurchaseMargin: (decimal)margin)), + // networkContext: GenerateNetworkContext()); + + // _mockTxBuilder + // .Verify( + // t => t.BuildTxAsync( + // It.Is( + // b => IsCreatorOutputCorrect(b, creatorAddress, margin, buyerAddress, purchaseLovelaces, changeInLovelace)), + // It.IsAny()), + // Times.Once, + // "Output should have proceeds address and correct lovelace value"); + //} + + //[Theory] + //[InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0.1, "addr_test1qrup2zgu69knkts7m3y3ghhwdzmgaus5u3s28vcsdegajr9wv6zerpdre7qdyvf68dcjyslazq0tfj5rq80v02tm5mysd3xc2u", 10000000, 0, 1)] + //[InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 0.078, "addr_test1qp06h7um737tlp2s5um8fvwef5rmx6jh7auchrcwgct3w43w7gtapvad5hzgkvr3ksnzpu6a2ejaew5ypeurygknqs5qhjuvf6", 36000000, 2000000, 3)] + //[InlineData("addr_test1vrfxxeuzqfuknfz4hu0ym4fe4l3axvqd7t5agd6pfzml59q30qc4x", 0.0088, "addr_test1vrldgv89yh0edkuwrvkkhc3yx4npfccdvtz7dfkn85a78rsu9nkm4", 50500000, 2500000, 5)] + //public async Task Build_Correct_Tx_Output_For_Proceeds_Address_Given_Active_Sale_When_Purchase_Is_Valid( + // string proceedsAddress, double margin, string buyerAddress, ulong purchaseLovelaces, ulong changeInLovelace, int niftyCount) + //{ + // var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; + // var txInfo = GenerateTxIoAggregate(inputAddress: buyerAddress); + // _mockTxIoRetriever + // .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(txInfo); + // _mockTxBuilder + // .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(buildTxOutputBytes); + // _mockTxSubmitter + // .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) + // .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); + // var allocatedNifties = GenerateTokens(niftyCount).ToArray(); + // var sale = GenerateSale(proceedsAddress: proceedsAddress, postPurchaseMargin: (decimal)margin); + + // var txHash = await _distributor.DistributeNiftiesForSalePurchase( + // nfts: allocatedNifties, + // purchaseAttempt: new PurchaseAttempt( + // Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, purchaseLovelaces).First(), niftyCount, changeInLovelace), + // saleContext: GenerateSaleContext(sale: sale), + // networkContext: GenerateNetworkContext()); + + // _mockTxBuilder + // .Verify( + // t => t.BuildTxAsync( + // It.Is( + // b => IsProceedsOutputCorrect(b, proceedsAddress, margin, buyerAddress, purchaseLovelaces, changeInLovelace)), + // It.IsAny()), + // Times.Once, + // "Output should have proceeds address and correct lovelace value"); + //} + + //[Theory] + //[InlineData(1, "0ac248e17f0fc35be4d2a7d186a84cdcda5b88d7ad2799ebe98a98b2")] + //[InlineData(3, "629718e24d22e0c02c2efd27290e1a58ebc2972635a7c523aee2d")] + //public async Task Build_Correct_Tx_Mint_For_Nifities_Given_Active_Sale_When_Purchase_Is_Valid( + // int niftyCount, string policyId) + //{ + // var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; + // _mockTxIoRetriever + // .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(GenerateTxIoAggregate()); + // _mockTxBuilder + // .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) + // .ReturnsAsync(buildTxOutputBytes); + // _mockTxSubmitter + // .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) + // .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); + // var nifties = GenerateTokens(niftyCount).ToArray(); + + // var txHash = await _distributor.DistributeNiftiesForSalePurchase( + // nfts: nifties, + // purchaseAttempt: new PurchaseAttempt( + // Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, 1000000).First(), 3, 0), + // saleContext: GenerateSaleContext(collection: GenerateCollection(policyId: policyId)), + // networkContext: GenerateNetworkContext()); + + // _mockTxBuilder + // .Verify( + // t => t.BuildTxAsync( + // It.Is(b => IsMintCorrect(b, nifties, policyId)), It.IsAny()), + // Times.Once, + // "Should have correctly mapped mint parameters for nifties"); + //} + + //private static bool IsBuyerOutputCorrect( + // TxBuildCommand buildCommand, + // string buyerAddress, + // ulong changeInLovelace, + // Nifty[] nifties, + // string policyId) + //{ + // var buyerOutput = buildCommand.Outputs.First(output => output.Address == buyerAddress); + // var buyerOutputLovelace = buyerOutput.Values.Lovelaces; + + // var minUtxoLovelaceQuantity = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); + // var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName))}").ToArray(); + // var allSingleNiftyOutputs = buyerOutput.Values.NativeAssets + // .All(v => expectedNiftyAssetNames.Contains($"{v.PolicyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(v.AssetName))}") && v.Quantity == 1); + + // return (buyerOutputLovelace == changeInLovelace + minUtxoLovelaceQuantity) && allSingleNiftyOutputs; + //} + + //private static bool IsProceedsOutputCorrect( + // TxBuildCommand buildCommand, + // string proceedsAddress, + // double margin, + // string buyerAddress, + // ulong purchaseLovelaces, + // ulong changeInLovelace) + //{ + // var buyerOutput = buildCommand.Outputs.First(output => output.Address == buyerAddress); + // var proceedsOutput = buildCommand.Outputs.First(output => output.Address == proceedsAddress); + // var proceedsOutputLovelaces = proceedsOutput.Values.Lovelaces; + // var minUtxoLovelaces = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); + + // var saleLovelaces = purchaseLovelaces - changeInLovelace - minUtxoLovelaces; + // var proceedsCutLovelaces = (ulong)(saleLovelaces * margin); + + // return proceedsOutputLovelaces == proceedsCutLovelaces; + //} + + //private static bool IsCreatorOutputCorrect( + // TxBuildCommand buildCommand, + // string creatorAddress, + // double margin, + // string buyerAddress, + // ulong purchaseLovelaces, + // ulong changeInLovelace) + //{ + // var buyerOutput = buildCommand.Outputs.First(output => output.Address == buyerAddress); + // var creatorOutput = buildCommand.Outputs.First(output => output.Address == creatorAddress); + // var creatorOutputLovelaces = creatorOutput.Values.Lovelaces; + // var minUtxoLovelaces = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); + + // var saleLovelaces = purchaseLovelaces - changeInLovelace - minUtxoLovelaces; + // var proceedsCutLovelaces = (ulong)(saleLovelaces * margin); + + // return creatorOutputLovelaces == saleLovelaces - proceedsCutLovelaces; + //} + + //private static bool IsMintCorrect( + // TxBuildCommand buildCommand, + // Nifty[] nifties, + // string policyId) + //{ + // var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName))}").ToArray(); + // var allSingleNiftyMints = buildCommand.Mint + // .All(v => expectedNiftyAssetNames.Contains($"{v.PolicyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(v.AssetName))}") && v.Quantity == 1); + + // return allSingleNiftyMints; + //} +} diff --git a/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs b/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs deleted file mode 100644 index 73523f3..0000000 --- a/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs +++ /dev/null @@ -1,226 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using Xunit; -using static Mintsafe.Lib.UnitTests.FakeGenerator; - -namespace Mintsafe.Lib.UnitTests; - -public class MetadataJsonBuilderShould -{ - private MetadataJsonBuilder _metadataJsonBuilder; - - private static readonly JsonSerializerOptions SerialiserOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - - public MetadataJsonBuilderShould() - { - _metadataJsonBuilder = new MetadataJsonBuilder( - NullLogger.Instance, - NullInstrumentor.Instance, - GenerateSettings()); - } - - [Theory] - [InlineData(1)] - [InlineData(2)] - [InlineData(15)] - public void Generate_The_Right_Json_With_Correct_Token_Metadata(int nftCount) - { - var collection = GenerateCollection(); - var tokens = GenerateTokens(nftCount).ToArray(); - - var json = _metadataJsonBuilder.GenerateNftStandardJson( - tokens, collection); - - var deserialised = JsonSerializer.Deserialize>>>(json, SerialiserOptions); - - deserialised.Should().NotBeNull(); -#pragma warning disable CS8602 // Dereference of a possibly null reference. - var policyAssets = deserialised["721"][collection.PolicyId]; -#pragma warning restore CS8602 // Dereference of a possibly null reference. - - policyAssets.Keys.Count.Should().Be(nftCount); - foreach (var token in tokens) - { - var asset = policyAssets[token.AssetName]; - asset.Should().NotBeNull(); - asset.Name.Should().Be(token.Name); - asset.Description.Should().Be(token.Description); - asset.Creators.Should().BeEquivalentTo(token.Creators); - asset.Publishers.Should().BeEquivalentTo(collection.Publishers); - asset.Image.Should().Be(token.Image); - asset.MediaType.Should().Be(token.MediaType); - foreach (var file in token.Files) - { - if (asset.Files == null) - throw new Exception("Files cannot be null"); - var assetFile = asset.Files.First(f => f.Name == file.Name); - assetFile.Name.Should().Be(file.Name); - assetFile.Src.Should().Be(file.Src); - assetFile.MediaType.Should().Be(file.MediaType); - assetFile.Hash.Should().Be(file.FileHash); - } - } - } - - [Theory] - [InlineData(1, "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG", null)] - [InlineData(2, null, "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG")] - [InlineData(4, "", "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG")] - [InlineData(15, "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG", "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG")] - public void Generate_The_Right_Json_With_Correct_Token_OnChain_Metadata(int nftCount, string onChainImage, string onChainFile) - { - var collection = GenerateCollection(); - var tokens = GenerateOnChainTokens(nftCount, onChainImage, onChainFile).ToArray(); - - var json = _metadataJsonBuilder.GenerateNftStandardJson( - tokens, collection); - - var deserialised = JsonSerializer.Deserialize>>>(json, SerialiserOptions); - - deserialised.Should().NotBeNull(); -#pragma warning disable CS8602 // Dereference of a possibly null reference. - var policyAssets = deserialised["721"][collection.PolicyId]; -#pragma warning restore CS8602 // Dereference of a possibly null reference. - - policyAssets.Keys.Count.Should().Be(nftCount); - foreach (var token in tokens) - { - var asset = policyAssets[token.AssetName]; - asset.Should().NotBeNull(); - asset.Name.Should().Be(token.Name); - asset.Description.Should().Be(token.Description); - asset.Creators.Should().BeEquivalentTo(token.Creators); - asset.Publishers.Should().BeEquivalentTo(collection.Publishers); - if (!string.IsNullOrEmpty(token.Image)) - { - asset.Image.Should().BeEquivalentTo(MetadataJsonBuilder.SplitStringToChunks(token.Image)); - } - asset.MediaType.Should().Be(token.MediaType); - foreach (var file in token.Files) - { - if (asset.Files == null) - throw new Exception("Files cannot be null"); - var assetFile = asset.Files.First(f => f.Name == file.Name); - assetFile.Name.Should().Be(file.Name); - assetFile.Src.Should().BeEquivalentTo(MetadataJsonBuilder.SplitStringToChunks(file.Src)); - assetFile.MediaType.Should().Be(file.MediaType); - assetFile.Hash.Should().Be(file.FileHash); - } - } - } - -// [Fact] -// public void Generate_The_Right_Json_With_Correct_Token_OnChain_Metadata() -// { -// var collection = GenerateCollection("9dadb20a-4996-446e-a655-bc6668cfa635", policyId: "d92b380b5413b76202056eea98b6bf579d52a54a44688c1f7f97b823"); -// var collectionId = Guid.Parse("9dadb20a-4996-446e-a655-bc6668cfa635"); -// var createdAt = DateTime.UtcNow; - -// var nft1 = new Nifty( -// Id: Guid.Parse("519a0911-e4c4-455e-aea6-48fd24fd766a"), CollectionId: collectionId, IsMintable: true, -// AssetName: "ticket_chicago_01", Name: "Chicago Concert Ticket #01", Description: "Ticket for Chicago Concert #01", Creators: new[] { "Taki Alsop Conducting Fellowship" }, -// Image: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMjAwMTA5MDQvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvVFIvMjAwMS9SRUMtU1ZHLTIwMDEwOTA0L0RURC9zdmcxMC5kdGQiPjxzdmcgdmVyc2lvbj0iMS4wIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MjVwdCIgaGVpZ2h0PSIzMDNwdCIgdmlld0JveD0iMCAwIDQyNSAzMDMiICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBtZWV0IiBzdHlsZT0nYmFja2dyb3VuZDojZmZmJz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLDMwMykgc2NhbGUoMC4wNSwtMC4wNSkiIGZpbGw9IiNkMTIwM2QiPjxwYXRoIGQ9Ik0xNTExIDU5ODEgYy0zOCAtNzIgLTQwIC0yNTYgLTUgLTM5MSAyMSAtNzcgMjggLTQ4MiAzMiAtMTc5MCA1Ci0xNTAxIDEyIC0xNzU4IDUwIC0xNzE5IDQgNSAxNCA3NzkgMjIgMTcxOSAxMSAxMjQ5IDIyIDE3NDIgNDAgMTgzMCA1OSAyNzkKLTQ0IDUzNiAtMTM5IDM1MXoiLz48cGF0aCBkPSJNMTI1IDUxMTEgYy0xMzMgLTE1MSAtMTc0IC00OTEgLTgzIC02OTEgMjAwIC00NDUgODQ4IC00NTQgMTA4MiAtMTYKMTA5IDIwNSA0OSA2MDQgLTExNCA3NTYgbC01MyA1MCAtMzkgLTQ3IC0zOCAtNDggNjkgLTc3IGMyOTggLTMyOSAxOSAtODgzCi00MTcgLTgyOCAtNDA4IDUxIC01ODYgNTUzIC0yOTggODQxIDU5IDU5IDYwIDYyIDI1IDEwNSAtNDYgNTcgLTQzIDU4IC0xMzQKLTQ1eiIvPjxwYXRoIGQ9Ik0yMDU4IDQ4OTUgYy0xMzAgLTg3IC0xNDcgLTE1NCAtMTU0IC02MTAgLTkgLTU1MCAzOSAtNjk4IDI0MCAtNzQ0CjIyMSAtNDkgMzc2IDEwOSAzNzYgMzg0IGwwIDk1IC04OCAwIC04OSAwIDEgLTExMSBjMiAtMTc0IC0xNDcgLTI2NyAtMjMzCi0xNDUgLTM5IDU3IC00NiA4NTAgLTggOTIyIDgwIDE1MyAyMzMgNzkgMjUxIC0xMjEgbDEyIC0xMjUgNzkgMCA4MCAwIC05IDE0OApjLTE5IDI5NiAtMjQ2IDQ0OCAtNDU4IDMwN3oiLz48cGF0aCBkPSJNMjc4MCA0OTA0IGMtMTQzIC03NSAtMTY1IC0xNjIgLTE2NSAtNjYxIDAgLTM5NyA0IC00NDUgNDEgLTUyOSAxMDUKLTIzNyA0MzggLTIzNyA1MzcgLTEgNTIgMTI1IDM4IDk1OCAtMTggMTA2NiAtNzYgMTQ5IC0yNDYgMjAzIC0zOTUgMTI1eiBtMjMyCi0xOTQgbDUwIC01MCAtNiAtNDQyIGMtNyAtNDg2IC0xMCAtNDk4IC0xMzcgLTQ5OCAtMTE5IDAgLTEyOSAzOSAtMTI5IDUyNCAwCjQzOCAwIDQ0MCA0NyA0NzggNjQgNTEgMTE1IDQ4IDE3NSAtMTJ6Ii8+PHBhdGggZD0iTTMzNDAgNDI0MCBsMCAtNzAwIDkzIDAgOTIgMCAtOSA0MjEgYy01IDIzMSAtMyA0MTMgNCA0MDUgNyAtOSA2MQotMTUxIDExOSAtMzE2IDE5MyAtNTQ5IDE3MyAtNTEwIDI1NyAtNTEwIGw3NCAwIDAgNzAwIDAgNzAwIC04NSAwIC04NSAwIC0xCi00MjUgMCAtNDI1IC0xMjcgMzUwIGMtNjkgMTkzIC0xMzggMzg0IC0xNTMgNDI1IC0yNSA2OSAtMzMgNzUgLTEwMyA3NSBsLTc2CjAgMCAtNzAweiIvPjxwYXRoIGQ9Ik00MDk1IDQyMzcgbDQgLTcwNyAxODQgNSBjMzYwIDEwIDM4NyA1OSAzODcgNzA1IDAgNjM1IC0yNyA2ODQgLTM4Ngo2OTcgbC0xOTQgNiA1IC03MDZ6IG0zNTAgNDk1IGM1NyAtNDMgODAgLTIzMyA3MiAtNTg0IC0xMCAtMzg1IC0yMyAtNDIwIC0xNjYKLTQzMSBsLTkxIC03IDAgNTI1IDAgNTI1IDc1IC0xIGM0MSAwIDkxIC0xMiAxMTAgLTI3eiIvPjxwYXRoIGQ9Ik00NzgwIDQzNzUgYzAgLTc0MyAzMiAtODM1IDI5MCAtODM1IDI1NyAwIDI5MCA5OCAyOTAgODQ5IGwwIDU1MQotODUgMCAtODUgMCAwIC01NjUgYzAgLTYzNiAtNCAtNjU1IC0xMjIgLTY1NSAtMTIzIDAgLTEzNCA1NyAtMTI1IDY4NSBsOCA1MzUKLTg2IDAgLTg1IDAgMCAtNTY1eiIvPjxwYXRoIGQ9Ik01NjUwIDQ5MjEgYy0xNjUgLTY3IC0xOTUgLTE4NCAtMTg3IC03MzMgOCAtNDg3IDE5IC01MzMgMTQzIC02MDkKMjI4IC0xMzkgNDc0IDUxIDQ3NCAzNjUgbDAgNzYgLTkwIDAgLTkwIDAgMCAtOTUgYy0xIC0xOTkgLTE2NCAtMjg2IC0yNDMKLTEyOSAtNDYgOTIgLTUzIDc2NiAtOSA4NzIgNzcgMTg1IDI1MiA5NSAyNTIgLTEyOSBsMCAtOTkgOTMgMCA5MiAwIC0xMCAxMjgKYy0yMyAyODMgLTIxMiA0NDAgLTQyNSAzNTN6Ii8+PHBhdGggZD0iTTYwODAgNDg1MCBsMCAtOTAgMTUxIDAgMTUxIDAgLTYgLTYyMCAtNiAtNjIwIDg1IDAgODUgMCAwIDYyMCAwCjYyMCAxNDAgMCAxNDAgMCAwIDkwIDAgOTAgLTM3MCAwIC0zNzAgMCAwIC05MHoiLz48cGF0aCBkPSJNNjkwMCA0MjMwIGwwIC03MTAgOTAgMCA5MCAwIDAgNzEwIDAgNzEwIC05MCAwIC05MCAwIDAgLTcxMHoiLz48cGF0aCBkPSJNNzE2MCA0MjQwIGwwIC03MDAgOTMgMCA5MiAwIC05IDQxOSBjLTkgMzkxIDAgNDgwIDMzIDM0NyAxMiAtNDUKMjE1IC02MTQgMjYxIC03MzEgMTAgLTI0IDM2IC0zNSA4NyAtMzUgbDczIDAgMCA3MDAgMCA3MDAgLTg1IDAgLTg1IDAgLTEKLTQyNSAtMSAtNDI1IC04NyAyNDAgYy00OCAxMzIgLTExNyAzMjMgLTE1NCA0MjUgbC02NiAxODUgLTc2IDAgLTc1IDAgMCAtNzAweiIvPjxwYXRoIGQ9Ik04MDQxIDQ5MDUgYy0xNTYgLTgwIC0xODggLTIxNCAtMTc4IC03NDUgMTAgLTUwMiA3MCAtNjIzIDMwOCAtNjIzCjIxNiAwIDI5NCAxMTQgMzA2IDQ0NyBsOCAyMzYgLTE4OCAwIC0xODcgMCAtMSAtOTAgMCAtOTAgMTA4IDAgMTA5IDAgLTEzCi0xMjMgYy0yMCAtMTg2IC0xMzAgLTI2MCAtMjMzIC0xNTcgLTQwIDQwIC02MyA3ODMgLTI4IDkxMyA1MSAxODggMjY4IDY1IDI2OAotMTUyIGwwIC04MSA4MyAwIDgyIDAgLTkgMTQ4IGMtMTkgMjg4IC0yMTMgNDMwIC00MzUgMzE3eiIvPjxnIGZpbGw9IiM4ODgiPjxwYXRoIGQ9Ik0zNjMxIDE3NjEgYy03IC0xMSAtNCAtMjEgOCAtMjEgMjcgMCAyNyAtNzEgMCAtMTIyIC00MSAtNzYgLTExOQotNTA0IC0xMTkgLTY1MCAwIC0xNTEgLTggLTE2NCAtOTAgLTE0OSAtMTEgMiAtMTAgLTcgMyAtMjAgNjMgLTYzIDEwMSAtNCAxMTMKMTc2IDEyIDE4NiA3NCA0OTkgMTI2IDY0MCAzMSA4NSAtMyAyMDggLTQxIDE0NnoiLz48cGF0aCBkPSJNMTMzMSAxNjQ5IGMzOCAtMzAgMzggLTMxIC02IC0xOSAtMjI2IDYyIC01NDAgLTYxOSAtMzQyIC03NDIgMTMgLTgKMTIgNSAtMyAzMyAtMTA4IDIwMSAxMjUgNjc5IDMzMSA2NzkgMzggMCA2OSA3IDY5IDE2IDAgMjIgLTQ0IDY0IC02OCA2NCAtMTEKMCAtMiAtMTQgMTkgLTMxeiIvPjxwYXRoIGQ9Ik04MTIgMTU5MiBjNjMgLTE1NyAtODIgLTM5MCAtMzYxIC01ODIgLTYxIC00MiAtMTExIC04MCAtMTExIC04MyAwCi0zMiA4MSAxNiAyMzEgMTQwIDI2MCAyMTMgMzU2IDQyOSAyNDkgNTU2IGwtNDAgNDcgMzIgLTc4eiIvPjxwYXRoIGQ9Ik01NjcgMTYwNCBjLTEwNCAtNTYgLTE4OCAtMTQyIC0yMDAgLTIwNiAtOCAtNDAgLTIyIC01NyAtNDAgLTUwIC0xNQo2IC0yNyAyIC0yNyAtOSAwIC0xMCAxOCAtMTkgNDAgLTE5IDI1IDAgNDAgMTMgNDAgMzYgMCA4NiAxODQgMjQ0IDI4NCAyNDQgMzQKMCA1MyA4IDQ2IDIwIC0xOCAyOSAtNzEgMjMgLTE0MyAtMTZ6Ii8+PHBhdGggZD0iTTIwNjQgMTU4MSBjLTMgLTExMCAtMTM5IC0zMDEgLTIxNCAtMzAxIC0yMSAwIDg4IDE4MiAxNDYgMjQ1IDM3IDM5CjQzIDU1IDIyIDU1IC01MiAwIC0yMTMgLTI1OSAtMTg4IC0zMDEgNyAtMTAgNCAtMTkgLTcgLTE5IC0yMCAwIC0xMDkgLTI2MAotOTQgLTI3NiAxMSAtMTEgNzAgMTI4IDg1IDIwMCA2IDMwIDI4IDYwIDQ4IDY3IDEwNiAzMyAyMzggMjEwIDIzOCAzMTcgMCA3MAotMzQgODMgLTM2IDEzeiIvPjxwYXRoIGQ9Ik0xMjY4IDE1NTggYy00NCAtMTggLTM4IC0yMiAzNyAtMjcgMzAgLTEgNTUgNSA1NSAxNCAwIDM5IDM2IC04IDQ2Ci02MCA1NCAtMjc5IC0yNTcgLTcyMSAtNDU2IC02NDUgLTc2IDI5IC04NiAxMSAtMTEgLTIwIDE0MyAtNTkgMzA3IDU4IDQyMAozMDEgMTMzIDI4NiA4NyA1MDcgLTkxIDQzN3oiLz48cGF0aCBkPSJNMzQwMCAxNDI3IGMtNTUgLTgzIC0xMDAgLTE2MSAtMTAwIC0xNzEgMCAtMjIgMTE3IC0xOTYgMTMyIC0xOTYgMTMKMCAtNiAzNSAtNjcgMTE2IGwtNDkgNjcgMTEyIDE2OCBjNjIgOTMgMTAzIDE2OSA5MiAxNjkgLTEyIDAgLTY2IC02OSAtMTIwCi0xNTN6Ii8+PHBhdGggZD0iTTUyMDAgMTUwNCBjMCAtMjEgLTE5IC02MiAtNDEgLTkxIC0zMSAtMzkgLTM1IC01MyAtMTQgLTUzIDQ0IDAgMTA4CjE0OSA3NCAxNzEgLTEwIDYgLTE5IC02IC0xOSAtMjd6Ii8+PHBhdGggZD0iTTE2NDYgMTQyNCBjLTU1IC0xMTggLTc0IC0xODQgLTUyIC0xODQgOSAxIDM3IDUyIDYyIDExNSAyNSA2MyA1MQoxMjYgNTcgMTQwIDYgMTQgNCAyNSAtNiAyNCAtOSAwIC0zNyAtNDMgLTYxIC05NXoiLz48cGF0aCBkPSJNMjg2OSAxMzExIGMtNTAgLTUwIC02NyAtMTU3IC0yOSAtMTgxIDEyIC03IDE1IDExIDggNDcgLTI2IDEzMCAxMzgKMTg2IDMwMiAxMDIgNzkgLTQwIDgwIC00MCAzMyAwIC0xMDAgODYgLTI0NSAxMDEgLTMxNCAzMnoiLz48cGF0aCBkPSJNNDQ1MyAxMTYzIGMtNyAtNDQgLTE5IC05NiAtMjYgLTExNiAtOSAtMjYgLTYgLTMxIDggLTE3IDMxIDMwIDY3CjE3NiA0NyAxOTYgLTkgOSAtMjIgLTE5IC0yOSAtNjN6Ii8+PHBhdGggZD0iTTY3NTggMTIzNSBjLTMgLTExIC0xNSAtOTEgLTI5IC0xOTUgLTE4IC0xMzQgMCAtMTI4IDI4IDEwIDExIDU1IDI2CjEyMCAzNCAxNDUgMTAgMzQgLTIyIDczIC0zMyA0MHoiLz48cGF0aCBkPSJNMTU4MyAxMjAyIGMtNzIgLTQ1IC0xMjkgLTQyMCAtNzQgLTQ5MyAyNiAtMzUgMjYgLTMwIDcgNDYgLTE3IDcwCi0xMyAxMTMgMjIgMjQ1IDIzIDg5IDQyIDE2NSA0MiAxNzEgMCA1IDIyIDkgNDkgOSAyNyAwIDU0IDkgNjEgMjAgMTUgMjQgLTY5CjI2IC0xMDcgMnoiLz48cGF0aCBkPSJNMzgyMyAxMTQzIGMtMjUgLTgzIC0zMCAtMTM3IC0xMiAtMTE4IDIyIDIxIDY3IDE5NSA1MSAxOTUgLTkgMCAtMjYKLTM1IC0zOSAtNzd6Ii8+PHBhdGggZD0iTTgyNDAgMTE3NiBjMCAtMjUgLTIzIC0xMTggLTUxIC0yMDYgLTE1OCAtNTAwIC0zNDQgLTc1NyAtNTUxIC03NTkKLTQzIC0xIC03OCAtOCAtNzggLTE2IDAgLTgwIDI1NSAyMCAzNTggMTQwIDE2MiAxOTAgNDEyIDgzMiAzNDAgODc2IC0xMCA2Ci0xOCAtOSAtMTggLTM1eiIvPjxwYXRoIGQ9Ik0zMTUwIDEwNjYgYy0xODMgLTI0NCAtMzkxIC0zMzYgLTU0MCAtMjM4IC0yNyAxOCAtNTAgMjQgLTUwIDEyIDAKLTExIDEwIC0yMCAyMSAtMjAgMTIgMCAxNyAtNyAxMiAtMTYgLTI3IC00MyAyNTcgLTQxIDM0MCAyIDgyIDQzIDM0NyAzMzYgMzQ3CjM4NCAwIDM0IC00MiAtNyAtMTMwIC0xMjR6Ii8+PHBhdGggZD0iTTUwMzUgMTE3OSBjMTcgLTIzIDE0IC00OSAtMTQgLTExMiAtMzYgLTgwIC03NSAtMjI3IC01NiAtMjA3IDU2IDU2CjExNyAyODcgODMgMzE3IC0yNyAyNSAtMzAgMjUgLTEzIDJ6Ii8+PHBhdGggZD0iTTUzODcgMTE4NCBjNiAtOCAtMyAtNjkgLTE4IC0xMzUgLTMyIC0xMzYgLTM3IC0yMDYgLTEyIC0xNzggMTUgMTcKNjMgMjUwIDYzIDMwNiAwIDEzIC0xMCAyMyAtMjEgMjMgLTEyIDAgLTE3IC03IC0xMiAtMTZ6Ii8+PHBhdGggZD0iTTU1NzcgMTE0MSBjLTIgLTMzIC00IC03NiAtNSAtOTUgLTYgLTEwOCAtMjY1IC0yNjggLTMxMCAtMTkxIC0xOQozMyAtMjEgMzMgLTIxIDEgLTIgLTEwMCAxNDYgLTcxIDI1MSA0OCA1NSA2MiA2NSA2NiAxMDEgNDIgMjMgLTE1IDU2IC0yMSA3NAotMTQgNTMgMjAgMzggNDMgLTIwIDMyIGwtNTMgLTEwIDExIDExMyBjNyA2MiA0IDExOCAtNiAxMjQgLTExIDYgLTIwIC0xNiAtMjIKLTUweiIvPjxwYXRoIGQ9Ik0zMDIwIDExNTEgYzAgLTY5IC0xNjAgLTk1IC0yMjQgLTM3IC0zNCAzMSAtMzcgMzEgLTI1IDEgMTggLTQ4IDEzMQotODEgMTk1IC01NyA2NSAyNSAxMjQgMTIyIDc0IDEyMiAtMTEgMCAtMjAgLTEzIC0yMCAtMjl6Ii8+PHBhdGggZD0iTTU5NjUgMTE2MCBjMTcgLTUyIC00MyAtMTM5IC0xMTUgLTE2OSAtNjkgLTI5IC05NSAtOTMgLTUwIC0xMjEgMTEKLTcgMjAgNSAyMCAyNSAwIDIyIDMyIDU5IDc1IDg3IDg2IDU1IDEyOCAxNDQgODYgMTgzIC0yMSAyMCAtMjQgMTkgLTE2IC01eiIvPjxwYXRoIGQ9Ik03OTgwIDExNTYgYzAgLTE3IC0xOCAtNzEgLTQwIC0xMTkgLTIxIC00OCAtMzkgLTEwMCAtMzggLTExNyAwIC0xNgoyOCAyOCA2MCA5OCA0MSA4OCA1MyAxMzUgMzkgMTQ5IC0xNSAxNSAtMjEgMTEgLTIxIC0xMXoiLz48cGF0aCBkPSJNMjAyMyAxMTMwIGMwIC0yMiAtNSAtOTQgLTEyIC0xNjAgbC0xMSAtMTIwIDI5IDEyOCBjMTkgODEgMjMgMTQwCjExIDE2MCAtMTUgMjcgLTE4IDI2IC0xNyAtOHoiLz48cGF0aCBkPSJNNDA0NCAxMDMyIGMtMyAtNzEgMSAtMTMzIDEwIC0xMzggOSAtNiAxOSA1MiAyMiAxMjggMyA3NiAtMiAxMzgKLTEwIDEzOCAtOSAwIC0xOSAtNTggLTIyIC0xMjh6Ii8+PHBhdGggZD0iTTQ2NzMgMTA0MCBjLTcgLTcwIC01IC0xMzYgNSAtMTQ2IDExIC0xMSAyMSAyNyAyNSA5NSAxMCAxNjIgLTE0CjIwMyAtMzAgNTF6Ii8+PHBhdGggZD0iTTcwNTggMTEzMCBjLTg3IC00NyAtMjE4IC0yNTAgLTE2MiAtMjUwIDExIDAgMjUgMjIgMzIgNTAgMjAgNzkgMTQ0CjE5MCAyMTMgMTkwIDMyIDAgNTkgOSA1OSAyMCAwIDI5IC04MSAyMyAtMTQyIC0xMHoiLz48cGF0aCBkPSJNNzIyOSAxMTIzIGMxMiAtMzkgMCAtNTAgLTQ5IC00NyAtNSAwIC0xMSAtMjMgLTExIC01MyAtMSAtMjkgLTkKLTc4IC0xOCAtMTA4IC05IC0zNCAtOCAtNTUgNSAtNTUgMTkgMCA1NCAxMjMgNTAgMTc1IC0xIDE0IDExIDI1IDI2IDI1IDMzIDAKMzggNzIgNiA5MSAtMTUgMTAgLTE4IDAgLTkgLTI4eiIvPjxwYXRoIGQ9Ik01ODQ5IDEwODcgYy0yNyAtMjkgLTQ5IC02MyAtNDggLTc1IDAgLTEyIDE5IDAgNDEgMjcgMjIgMjYgNTYgNjAKNzQgNzQgMjMgMTcgMjUgMjYgOCAyNiAtMTQgMSAtNDggLTIzIC03NSAtNTJ6Ii8+PHBhdGggZD0iTTE4NjUgMTA2NSBjLTM0IC0zMCAtOTUgLTEwMCAtMTM2IC0xNTUgLTQwIC01NSAtODggLTEwNiAtMTA2IC0xMTQKLTI2IC0xMCAtMjQgLTEzIDEwIC0xNSAyNyAwIDYyIDI2IDk4IDc0IDMwIDQxIDkxIDExMyAxMzYgMTYwIDkyIDk2IDkxIDEzMQotMiA1MHoiLz48cGF0aCBkPSJNMjY2MCAxMTAwIGMwIC0xMSAtMTMgLTIwIC0zMCAtMjAgLTQ0IDAgLTU4IC04OCAtMjMgLTE1MyBsMzAgLTU3Ci0xMSA4MyBjLTkgNzAgLTQgODcgMzIgMTA2IDQzIDIzIDU3IDYxIDIyIDYxIC0xMSAwIC0yMCAtOSAtMjAgLTIweiIvPjxwYXRoIGQ9Ik0zODMwIDk5OCBjLTUwIC02NyAtOTAgLTEzNSAtOTAgLTE1MCAwIC0xNyAtMjIgLTI4IC01NSAtMjkgLTQ1IDAKLTQ5IC00IC0yMiAtMjAgNDkgLTI4IDg2IC02IDEyNSA3NSAxOSAzOSA2NiAxMTEgMTA1IDE1OSA0MCA0OCA2MiA4NyA0OSA4NwotMTIgMCAtNjMgLTU1IC0xMTIgLTEyMnoiLz48cGF0aCBkPSJNNDQ3MyAxMDI3IGMtMzkgLTUxIC04MyAtMTE4IC05NyAtMTUwIC0yMCAtNDQgLTQwIC01NyAtODYgLTU4IC01NgotMSAtNTcgLTMgLTE1IC0yMCA2NSAtMjYgOTggLTYgMTQ0IDg4IDIzIDQ2IDY4IDExMyAxMDEgMTUxIDY1IDczIDY5IDgyIDQzCjgyIC0xMCAwIC01MCAtNDIgLTkwIC05M3oiLz48cGF0aCBkPSJNNTIwMiAxMDI1IGMtMTA3IC0xMjcgLTE5NiAtMTkyIC0yNjUgLTE5MiAtMzAgMCAtNTEgLTcgLTQ1IC0xNiAzNgotNTggMTg5IDE2IDI4OSAxNDEgMzUgNDQgNzUgNzUgODcgNjggMTIgLTcgMTUgLTUgNyA0IC04IDkgLTQgMjkgOCA0NCAxMiAxNQoxOCAzMiAxMiAzOCAtNiA2IC00OCAtMzMgLTkzIC04N3oiLz48cGF0aCBkPSJNNjMxMyAxMDYzIGMtNyAtMzUgLTE4IC04OSAtMjUgLTEyMSAtNyAtMzYgLTMgLTYzIDEzIC03MyAxNiAtOSAyMAotNyAxMiA3IC04IDEyIC0xIDYwIDE2IDEwNiAxNiA0OSAyMiA5OCAxMyAxMTQgLTEyIDIyIC0yMCAxMyAtMjkgLTMzeiIvPjxwYXRoIGQ9Ik02NTcwIDEwNDAgYy00MyAtNDQgLTcxIC04NSAtNjQgLTkzIDggLTggMTYgLTEwIDE5IC01IDMgNSAzNiA0MiA3NAo4MyA5MyAxMDEgNjggMTE0IC0yOSAxNXoiLz48cGF0aCBkPSJNNzU4MyAxMDA3IGMtMjEgLTg4IC0yMSAtMTE3IC0yIC0xMzQgMTkgLTE3IDIyIC0xNyAxMiAxIC04IDEzIC0xCjYyIDE2IDEwOCAxNiA0NyAyMiA5NyAxNCAxMTEgLTggMTYgLTI1IC0xOSAtNDAgLTg2eiIvPjxwYXRoIGQ9Ik0zNjU5IDEwMjggYy01NCAtNTcgLTYxIC03MCAtMzAgLTY1IDIxIDQgNTYgMzYgNzcgNzIgNDkgODQgMzggODIKLTQ3IC03eiIvPjxwYXRoIGQ9Ik00MjYyIDEwMDUgYy0xMTcgLTEyNiAtMjA0IC0xODkgLTI0NyAtMTc4IC0xOSA1IC0zNSAxIC0zNSAtOSAwIC0yNwo3NSAtMjIgMTM3IDEwIDg4IDQ2IDI4NiAyNzIgMjM4IDI3MiAtMyAwIC00NSAtNDMgLTkzIC05NXoiLz48cGF0aCBkPSJNNjA2MiA5NzkgYy0xMjUgLTExNyAtMjQ5IC0xNzYgLTMxNSAtMTUxIC0xNiA3IC0yNCAzIC0xNyAtOCAxOCAtMzAKMTMyIC0yNCAxOTcgMTAgNzIgMzggMjkzIDIyOCAyOTMgMjUyIDAgMzUgLTMyIDE1IC0xNTggLTEwM3oiLz48cGF0aCBkPSJNNzQzNCAxMDYwIGMtNzkgLTExMyAtMjYwIC0yNDQgLTMyMiAtMjMzIC01NSAxMCAtNTcgOCAtMTggLTExIDI0Ci0xMSA2NCAtMTYgOTAgLTkgNDkgMTIgMzE2IDI0NSAzMTYgMjc1IDAgMzIgLTM3IDE5IC02NiAtMjJ6Ii8+PHBhdGggZD0iTTc3MzggOTQ4IGMtOTUgLTk0IC0xMjQgLTExMiAtMTg4IC0xMTQgLTYzIC0yIC02OSAtNSAtMzQgLTIxIDc4Ci0zNCAzNDQgMTQ0IDM0NCAyMzAgMCAyNyAtNCAyMyAtMTIyIC05NXoiLz48cGF0aCBkPSJNNDgzMSA5NDQgYy03MSAtODMgLTE5MyAtMTQ3IC0yMDYgLTEwOSAtNSAxNCAtMTcgMjAgLTI3IDE0IC0zMyAtMjEKMyAtNDkgNjEgLTQ5IDYyIDAgMjU0IDE1NSAyMzkgMTkyIC00IDExIC0zNCAtMTAgLTY3IC00OHoiLz48cGF0aCBkPSJNMjExMSA5MDggYy02MSAtNTQgLTEyMCAtODggLTE1MCAtODkgLTQ1IDAgLTQ3IC0zIC0xNSAtMjEgNDEgLTI0CjEzMCAyMyAyMjMgMTIwIDkwIDk1IDUzIDg4IC01OCAtMTB6Ii8+PHBhdGggZD0iTTcwMzAgOTM5IGMtNTcgLTYyIC0xNjcgLTEyMyAtMTkzIC0xMDcgLTE0IDkgLTE4IDYgLTkgLTkgOCAtMTMgMzEKLTIzIDUxIC0yMyA0MCAwIDIxOCAxNDkgMTk5IDE2OCAtNiA3IC0yOCAtNiAtNDggLTI5eiIvPjxwYXRoIGQ9Ik04MDEwIDkwOSBjLTQ0IC0zOCAtMTA1IC03NSAtMTM1IC04MiAtMzAgLTcgLTUwIC0xOSAtNDQgLTI5IDIzIC0zNwoyMTEgNzEgMjUwIDE0MyAyOCA1MyAyMSA1MCAtNzEgLTMyeiIvPjxwYXRoIGQ9Ik02NjkxIDg3NiBjLTg1IC01NiAtMTAyIC02MCAtMTU1IC00MSAtNTEgMTkgLTU2IDE5IC0zNSAtNiAzOCAtNDYKMTUwIC0zNiAyMTAgMjAgMjkgMjcgNjMgNTMgNzYgNTcgMTMgNSAxOCAxNCAxMSAyMSAtNiA3IC01NCAtMTYgLTEwNyAtNTF6Ii8+PHBhdGggZD0iTTcyMSA4NTYgYy00OCAtNjAgLTQ5IC02MSAtMTgwIC00NiAtNzIgOSAtMTkxIDE3IC0yNjUgMTkgLTE0MCAzCi0xODUgMjAgLTEzNSA1MiAxOCAxMSAyMCAxOSA1IDE5IC00NCAwIC01MyAtNDYgLTE0IC03NSAyNSAtMTkgNjUgLTI1IDEyMQotMTcgNDYgNiAxMzMgLTIgMTk2IC0xOSAxOTAgLTQ5IDM1MSAxIDM1MSAxMDkgMCAzNyAtMjggMjIgLTc5IC00MnoiLz48cGF0aCBkPSJNNjM3NSA4NzQgYy0zNSAtMjYgLTgzIC00MSAtMTMwIC00MCAtNTcgMSAtNjUgLTMgLTM1IC0xNSA3MSAtMjkKMTI3IC0yMCAxODQgMjkgNzAgNjEgNTUgODIgLTE5IDI2eiIvPjxwYXRoIGQ9Ik0xNDc4IDY3NCBjMjcgLTMwIDY1IC01NCA4NSAtNTQgNDEgMCA0OSAzMCAxNCA1MiAtMTQgOSAtMTggNSAtOSAtOQoyMyAtMzcgLTE4IC0yNyAtODAgMjEgbC01OCA0NCA0OCAtNTR6Ii8+PC9nPjwvZz48L3N2Zz4=", -// MediaType: "image/svg+xml", Files: Array.Empty(), CreatedAt: createdAt, -// Version: "1.0", -// Attributes: new[] -// { -// new KeyValuePair("Location", "Chicago"), -// new KeyValuePair("Seat", "A39"), -// }); - -// var nft2 = new Nifty( -// Id: Guid.Parse("10cf1b4e-d457-4707-8ad9-7e8ce97e22da"), CollectionId: collectionId, IsMintable: true, -// AssetName: "ticket_madrid_01", Name: "Madrid Concert Ticket #01", Description: "Ticket for Madrid Concert #01", Creators: new[] { "Taki Alsop Conducting Fellowship" }, -// Image: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMjAwMTA5MDQvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvVFIvMjAwMS9SRUMtU1ZHLTIwMDEwOTA0L0RURC9zdmcxMC5kdGQiPjxzdmcgdmVyc2lvbj0iMS4wIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MjVwdCIgaGVpZ2h0PSIzMDNwdCIgdmlld0JveD0iMCAwIDQyNSAzMDMiICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBtZWV0IiBzdHlsZT0nYmFja2dyb3VuZDojZmZmJz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLDMwMykgc2NhbGUoMC4wNSwtMC4wNSkiIGZpbGw9IiMzODc5RDMiPjxwYXRoIGQ9Ik0xNTExIDU5ODEgYy0zOCAtNzIgLTQwIC0yNTYgLTUgLTM5MSAyMSAtNzcgMjggLTQ4MiAzMiAtMTc5MCA1Ci0xNTAxIDEyIC0xNzU4IDUwIC0xNzE5IDQgNSAxNCA3NzkgMjIgMTcxOSAxMSAxMjQ5IDIyIDE3NDIgNDAgMTgzMCA1OSAyNzkKLTQ0IDUzNiAtMTM5IDM1MXoiLz48cGF0aCBkPSJNMTI1IDUxMTEgYy0xMzMgLTE1MSAtMTc0IC00OTEgLTgzIC02OTEgMjAwIC00NDUgODQ4IC00NTQgMTA4MiAtMTYKMTA5IDIwNSA0OSA2MDQgLTExNCA3NTYgbC01MyA1MCAtMzkgLTQ3IC0zOCAtNDggNjkgLTc3IGMyOTggLTMyOSAxOSAtODgzCi00MTcgLTgyOCAtNDA4IDUxIC01ODYgNTUzIC0yOTggODQxIDU5IDU5IDYwIDYyIDI1IDEwNSAtNDYgNTcgLTQzIDU4IC0xMzQKLTQ1eiIvPjxwYXRoIGQ9Ik0yMDU4IDQ4OTUgYy0xMzAgLTg3IC0xNDcgLTE1NCAtMTU0IC02MTAgLTkgLTU1MCAzOSAtNjk4IDI0MCAtNzQ0CjIyMSAtNDkgMzc2IDEwOSAzNzYgMzg0IGwwIDk1IC04OCAwIC04OSAwIDEgLTExMSBjMiAtMTc0IC0xNDcgLTI2NyAtMjMzCi0xNDUgLTM5IDU3IC00NiA4NTAgLTggOTIyIDgwIDE1MyAyMzMgNzkgMjUxIC0xMjEgbDEyIC0xMjUgNzkgMCA4MCAwIC05IDE0OApjLTE5IDI5NiAtMjQ2IDQ0OCAtNDU4IDMwN3oiLz48cGF0aCBkPSJNMjc4MCA0OTA0IGMtMTQzIC03NSAtMTY1IC0xNjIgLTE2NSAtNjYxIDAgLTM5NyA0IC00NDUgNDEgLTUyOSAxMDUKLTIzNyA0MzggLTIzNyA1MzcgLTEgNTIgMTI1IDM4IDk1OCAtMTggMTA2NiAtNzYgMTQ5IC0yNDYgMjAzIC0zOTUgMTI1eiBtMjMyCi0xOTQgbDUwIC01MCAtNiAtNDQyIGMtNyAtNDg2IC0xMCAtNDk4IC0xMzcgLTQ5OCAtMTE5IDAgLTEyOSAzOSAtMTI5IDUyNCAwCjQzOCAwIDQ0MCA0NyA0NzggNjQgNTEgMTE1IDQ4IDE3NSAtMTJ6Ii8+PHBhdGggZD0iTTMzNDAgNDI0MCBsMCAtNzAwIDkzIDAgOTIgMCAtOSA0MjEgYy01IDIzMSAtMyA0MTMgNCA0MDUgNyAtOSA2MQotMTUxIDExOSAtMzE2IDE5MyAtNTQ5IDE3MyAtNTEwIDI1NyAtNTEwIGw3NCAwIDAgNzAwIDAgNzAwIC04NSAwIC04NSAwIC0xCi00MjUgMCAtNDI1IC0xMjcgMzUwIGMtNjkgMTkzIC0xMzggMzg0IC0xNTMgNDI1IC0yNSA2OSAtMzMgNzUgLTEwMyA3NSBsLTc2CjAgMCAtNzAweiIvPjxwYXRoIGQ9Ik00MDk1IDQyMzcgbDQgLTcwNyAxODQgNSBjMzYwIDEwIDM4NyA1OSAzODcgNzA1IDAgNjM1IC0yNyA2ODQgLTM4Ngo2OTcgbC0xOTQgNiA1IC03MDZ6IG0zNTAgNDk1IGM1NyAtNDMgODAgLTIzMyA3MiAtNTg0IC0xMCAtMzg1IC0yMyAtNDIwIC0xNjYKLTQzMSBsLTkxIC03IDAgNTI1IDAgNTI1IDc1IC0xIGM0MSAwIDkxIC0xMiAxMTAgLTI3eiIvPjxwYXRoIGQ9Ik00NzgwIDQzNzUgYzAgLTc0MyAzMiAtODM1IDI5MCAtODM1IDI1NyAwIDI5MCA5OCAyOTAgODQ5IGwwIDU1MQotODUgMCAtODUgMCAwIC01NjUgYzAgLTYzNiAtNCAtNjU1IC0xMjIgLTY1NSAtMTIzIDAgLTEzNCA1NyAtMTI1IDY4NSBsOCA1MzUKLTg2IDAgLTg1IDAgMCAtNTY1eiIvPjxwYXRoIGQ9Ik01NjUwIDQ5MjEgYy0xNjUgLTY3IC0xOTUgLTE4NCAtMTg3IC03MzMgOCAtNDg3IDE5IC01MzMgMTQzIC02MDkKMjI4IC0xMzkgNDc0IDUxIDQ3NCAzNjUgbDAgNzYgLTkwIDAgLTkwIDAgMCAtOTUgYy0xIC0xOTkgLTE2NCAtMjg2IC0yNDMKLTEyOSAtNDYgOTIgLTUzIDc2NiAtOSA4NzIgNzcgMTg1IDI1MiA5NSAyNTIgLTEyOSBsMCAtOTkgOTMgMCA5MiAwIC0xMCAxMjgKYy0yMyAyODMgLTIxMiA0NDAgLTQyNSAzNTN6Ii8+PHBhdGggZD0iTTYwODAgNDg1MCBsMCAtOTAgMTUxIDAgMTUxIDAgLTYgLTYyMCAtNiAtNjIwIDg1IDAgODUgMCAwIDYyMCAwCjYyMCAxNDAgMCAxNDAgMCAwIDkwIDAgOTAgLTM3MCAwIC0zNzAgMCAwIC05MHoiLz48cGF0aCBkPSJNNjkwMCA0MjMwIGwwIC03MTAgOTAgMCA5MCAwIDAgNzEwIDAgNzEwIC05MCAwIC05MCAwIDAgLTcxMHoiLz48cGF0aCBkPSJNNzE2MCA0MjQwIGwwIC03MDAgOTMgMCA5MiAwIC05IDQxOSBjLTkgMzkxIDAgNDgwIDMzIDM0NyAxMiAtNDUKMjE1IC02MTQgMjYxIC03MzEgMTAgLTI0IDM2IC0zNSA4NyAtMzUgbDczIDAgMCA3MDAgMCA3MDAgLTg1IDAgLTg1IDAgLTEKLTQyNSAtMSAtNDI1IC04NyAyNDAgYy00OCAxMzIgLTExNyAzMjMgLTE1NCA0MjUgbC02NiAxODUgLTc2IDAgLTc1IDAgMCAtNzAweiIvPjxwYXRoIGQ9Ik04MDQxIDQ5MDUgYy0xNTYgLTgwIC0xODggLTIxNCAtMTc4IC03NDUgMTAgLTUwMiA3MCAtNjIzIDMwOCAtNjIzCjIxNiAwIDI5NCAxMTQgMzA2IDQ0NyBsOCAyMzYgLTE4OCAwIC0xODcgMCAtMSAtOTAgMCAtOTAgMTA4IDAgMTA5IDAgLTEzCi0xMjMgYy0yMCAtMTg2IC0xMzAgLTI2MCAtMjMzIC0xNTcgLTQwIDQwIC02MyA3ODMgLTI4IDkxMyA1MSAxODggMjY4IDY1IDI2OAotMTUyIGwwIC04MSA4MyAwIDgyIDAgLTkgMTQ4IGMtMTkgMjg4IC0yMTMgNDMwIC00MzUgMzE3eiIvPjxnIGZpbGw9IiM4ODgiPjxwYXRoIGQ9Ik0zNjMxIDE3NjEgYy03IC0xMSAtNCAtMjEgOCAtMjEgMjcgMCAyNyAtNzEgMCAtMTIyIC00MSAtNzYgLTExOQotNTA0IC0xMTkgLTY1MCAwIC0xNTEgLTggLTE2NCAtOTAgLTE0OSAtMTEgMiAtMTAgLTcgMyAtMjAgNjMgLTYzIDEwMSAtNCAxMTMKMTc2IDEyIDE4NiA3NCA0OTkgMTI2IDY0MCAzMSA4NSAtMyAyMDggLTQxIDE0NnoiLz48cGF0aCBkPSJNMTMzMSAxNjQ5IGMzOCAtMzAgMzggLTMxIC02IC0xOSAtMjI2IDYyIC01NDAgLTYxOSAtMzQyIC03NDIgMTMgLTgKMTIgNSAtMyAzMyAtMTA4IDIwMSAxMjUgNjc5IDMzMSA2NzkgMzggMCA2OSA3IDY5IDE2IDAgMjIgLTQ0IDY0IC02OCA2NCAtMTEKMCAtMiAtMTQgMTkgLTMxeiIvPjxwYXRoIGQ9Ik04MTIgMTU5MiBjNjMgLTE1NyAtODIgLTM5MCAtMzYxIC01ODIgLTYxIC00MiAtMTExIC04MCAtMTExIC04MyAwCi0zMiA4MSAxNiAyMzEgMTQwIDI2MCAyMTMgMzU2IDQyOSAyNDkgNTU2IGwtNDAgNDcgMzIgLTc4eiIvPjxwYXRoIGQ9Ik01NjcgMTYwNCBjLTEwNCAtNTYgLTE4OCAtMTQyIC0yMDAgLTIwNiAtOCAtNDAgLTIyIC01NyAtNDAgLTUwIC0xNQo2IC0yNyAyIC0yNyAtOSAwIC0xMCAxOCAtMTkgNDAgLTE5IDI1IDAgNDAgMTMgNDAgMzYgMCA4NiAxODQgMjQ0IDI4NCAyNDQgMzQKMCA1MyA4IDQ2IDIwIC0xOCAyOSAtNzEgMjMgLTE0MyAtMTZ6Ii8+PHBhdGggZD0iTTIwNjQgMTU4MSBjLTMgLTExMCAtMTM5IC0zMDEgLTIxNCAtMzAxIC0yMSAwIDg4IDE4MiAxNDYgMjQ1IDM3IDM5CjQzIDU1IDIyIDU1IC01MiAwIC0yMTMgLTI1OSAtMTg4IC0zMDEgNyAtMTAgNCAtMTkgLTcgLTE5IC0yMCAwIC0xMDkgLTI2MAotOTQgLTI3NiAxMSAtMTEgNzAgMTI4IDg1IDIwMCA2IDMwIDI4IDYwIDQ4IDY3IDEwNiAzMyAyMzggMjEwIDIzOCAzMTcgMCA3MAotMzQgODMgLTM2IDEzeiIvPjxwYXRoIGQ9Ik0xMjY4IDE1NTggYy00NCAtMTggLTM4IC0yMiAzNyAtMjcgMzAgLTEgNTUgNSA1NSAxNCAwIDM5IDM2IC04IDQ2Ci02MCA1NCAtMjc5IC0yNTcgLTcyMSAtNDU2IC02NDUgLTc2IDI5IC04NiAxMSAtMTEgLTIwIDE0MyAtNTkgMzA3IDU4IDQyMAozMDEgMTMzIDI4NiA4NyA1MDcgLTkxIDQzN3oiLz48cGF0aCBkPSJNMzQwMCAxNDI3IGMtNTUgLTgzIC0xMDAgLTE2MSAtMTAwIC0xNzEgMCAtMjIgMTE3IC0xOTYgMTMyIC0xOTYgMTMKMCAtNiAzNSAtNjcgMTE2IGwtNDkgNjcgMTEyIDE2OCBjNjIgOTMgMTAzIDE2OSA5MiAxNjkgLTEyIDAgLTY2IC02OSAtMTIwCi0xNTN6Ii8+PHBhdGggZD0iTTUyMDAgMTUwNCBjMCAtMjEgLTE5IC02MiAtNDEgLTkxIC0zMSAtMzkgLTM1IC01MyAtMTQgLTUzIDQ0IDAgMTA4CjE0OSA3NCAxNzEgLTEwIDYgLTE5IC02IC0xOSAtMjd6Ii8+PHBhdGggZD0iTTE2NDYgMTQyNCBjLTU1IC0xMTggLTc0IC0xODQgLTUyIC0xODQgOSAxIDM3IDUyIDYyIDExNSAyNSA2MyA1MQoxMjYgNTcgMTQwIDYgMTQgNCAyNSAtNiAyNCAtOSAwIC0zNyAtNDMgLTYxIC05NXoiLz48cGF0aCBkPSJNMjg2OSAxMzExIGMtNTAgLTUwIC02NyAtMTU3IC0yOSAtMTgxIDEyIC03IDE1IDExIDggNDcgLTI2IDEzMCAxMzgKMTg2IDMwMiAxMDIgNzkgLTQwIDgwIC00MCAzMyAwIC0xMDAgODYgLTI0NSAxMDEgLTMxNCAzMnoiLz48cGF0aCBkPSJNNDQ1MyAxMTYzIGMtNyAtNDQgLTE5IC05NiAtMjYgLTExNiAtOSAtMjYgLTYgLTMxIDggLTE3IDMxIDMwIDY3CjE3NiA0NyAxOTYgLTkgOSAtMjIgLTE5IC0yOSAtNjN6Ii8+PHBhdGggZD0iTTY3NTggMTIzNSBjLTMgLTExIC0xNSAtOTEgLTI5IC0xOTUgLTE4IC0xMzQgMCAtMTI4IDI4IDEwIDExIDU1IDI2CjEyMCAzNCAxNDUgMTAgMzQgLTIyIDczIC0zMyA0MHoiLz48cGF0aCBkPSJNMTU4MyAxMjAyIGMtNzIgLTQ1IC0xMjkgLTQyMCAtNzQgLTQ5MyAyNiAtMzUgMjYgLTMwIDcgNDYgLTE3IDcwCi0xMyAxMTMgMjIgMjQ1IDIzIDg5IDQyIDE2NSA0MiAxNzEgMCA1IDIyIDkgNDkgOSAyNyAwIDU0IDkgNjEgMjAgMTUgMjQgLTY5CjI2IC0xMDcgMnoiLz48cGF0aCBkPSJNMzgyMyAxMTQzIGMtMjUgLTgzIC0zMCAtMTM3IC0xMiAtMTE4IDIyIDIxIDY3IDE5NSA1MSAxOTUgLTkgMCAtMjYKLTM1IC0zOSAtNzd6Ii8+PHBhdGggZD0iTTgyNDAgMTE3NiBjMCAtMjUgLTIzIC0xMTggLTUxIC0yMDYgLTE1OCAtNTAwIC0zNDQgLTc1NyAtNTUxIC03NTkKLTQzIC0xIC03OCAtOCAtNzggLTE2IDAgLTgwIDI1NSAyMCAzNTggMTQwIDE2MiAxOTAgNDEyIDgzMiAzNDAgODc2IC0xMCA2Ci0xOCAtOSAtMTggLTM1eiIvPjxwYXRoIGQ9Ik0zMTUwIDEwNjYgYy0xODMgLTI0NCAtMzkxIC0zMzYgLTU0MCAtMjM4IC0yNyAxOCAtNTAgMjQgLTUwIDEyIDAKLTExIDEwIC0yMCAyMSAtMjAgMTIgMCAxNyAtNyAxMiAtMTYgLTI3IC00MyAyNTcgLTQxIDM0MCAyIDgyIDQzIDM0NyAzMzYgMzQ3CjM4NCAwIDM0IC00MiAtNyAtMTMwIC0xMjR6Ii8+PHBhdGggZD0iTTUwMzUgMTE3OSBjMTcgLTIzIDE0IC00OSAtMTQgLTExMiAtMzYgLTgwIC03NSAtMjI3IC01NiAtMjA3IDU2IDU2CjExNyAyODcgODMgMzE3IC0yNyAyNSAtMzAgMjUgLTEzIDJ6Ii8+PHBhdGggZD0iTTUzODcgMTE4NCBjNiAtOCAtMyAtNjkgLTE4IC0xMzUgLTMyIC0xMzYgLTM3IC0yMDYgLTEyIC0xNzggMTUgMTcKNjMgMjUwIDYzIDMwNiAwIDEzIC0xMCAyMyAtMjEgMjMgLTEyIDAgLTE3IC03IC0xMiAtMTZ6Ii8+PHBhdGggZD0iTTU1NzcgMTE0MSBjLTIgLTMzIC00IC03NiAtNSAtOTUgLTYgLTEwOCAtMjY1IC0yNjggLTMxMCAtMTkxIC0xOQozMyAtMjEgMzMgLTIxIDEgLTIgLTEwMCAxNDYgLTcxIDI1MSA0OCA1NSA2MiA2NSA2NiAxMDEgNDIgMjMgLTE1IDU2IC0yMSA3NAotMTQgNTMgMjAgMzggNDMgLTIwIDMyIGwtNTMgLTEwIDExIDExMyBjNyA2MiA0IDExOCAtNiAxMjQgLTExIDYgLTIwIC0xNiAtMjIKLTUweiIvPjxwYXRoIGQ9Ik0zMDIwIDExNTEgYzAgLTY5IC0xNjAgLTk1IC0yMjQgLTM3IC0zNCAzMSAtMzcgMzEgLTI1IDEgMTggLTQ4IDEzMQotODEgMTk1IC01NyA2NSAyNSAxMjQgMTIyIDc0IDEyMiAtMTEgMCAtMjAgLTEzIC0yMCAtMjl6Ii8+PHBhdGggZD0iTTU5NjUgMTE2MCBjMTcgLTUyIC00MyAtMTM5IC0xMTUgLTE2OSAtNjkgLTI5IC05NSAtOTMgLTUwIC0xMjEgMTEKLTcgMjAgNSAyMCAyNSAwIDIyIDMyIDU5IDc1IDg3IDg2IDU1IDEyOCAxNDQgODYgMTgzIC0yMSAyMCAtMjQgMTkgLTE2IC01eiIvPjxwYXRoIGQ9Ik03OTgwIDExNTYgYzAgLTE3IC0xOCAtNzEgLTQwIC0xMTkgLTIxIC00OCAtMzkgLTEwMCAtMzggLTExNyAwIC0xNgoyOCAyOCA2MCA5OCA0MSA4OCA1MyAxMzUgMzkgMTQ5IC0xNSAxNSAtMjEgMTEgLTIxIC0xMXoiLz48cGF0aCBkPSJNMjAyMyAxMTMwIGMwIC0yMiAtNSAtOTQgLTEyIC0xNjAgbC0xMSAtMTIwIDI5IDEyOCBjMTkgODEgMjMgMTQwCjExIDE2MCAtMTUgMjcgLTE4IDI2IC0xNyAtOHoiLz48cGF0aCBkPSJNNDA0NCAxMDMyIGMtMyAtNzEgMSAtMTMzIDEwIC0xMzggOSAtNiAxOSA1MiAyMiAxMjggMyA3NiAtMiAxMzgKLTEwIDEzOCAtOSAwIC0xOSAtNTggLTIyIC0xMjh6Ii8+PHBhdGggZD0iTTQ2NzMgMTA0MCBjLTcgLTcwIC01IC0xMzYgNSAtMTQ2IDExIC0xMSAyMSAyNyAyNSA5NSAxMCAxNjIgLTE0CjIwMyAtMzAgNTF6Ii8+PHBhdGggZD0iTTcwNTggMTEzMCBjLTg3IC00NyAtMjE4IC0yNTAgLTE2MiAtMjUwIDExIDAgMjUgMjIgMzIgNTAgMjAgNzkgMTQ0CjE5MCAyMTMgMTkwIDMyIDAgNTkgOSA1OSAyMCAwIDI5IC04MSAyMyAtMTQyIC0xMHoiLz48cGF0aCBkPSJNNzIyOSAxMTIzIGMxMiAtMzkgMCAtNTAgLTQ5IC00NyAtNSAwIC0xMSAtMjMgLTExIC01MyAtMSAtMjkgLTkKLTc4IC0xOCAtMTA4IC05IC0zNCAtOCAtNTUgNSAtNTUgMTkgMCA1NCAxMjMgNTAgMTc1IC0xIDE0IDExIDI1IDI2IDI1IDMzIDAKMzggNzIgNiA5MSAtMTUgMTAgLTE4IDAgLTkgLTI4eiIvPjxwYXRoIGQ9Ik01ODQ5IDEwODcgYy0yNyAtMjkgLTQ5IC02MyAtNDggLTc1IDAgLTEyIDE5IDAgNDEgMjcgMjIgMjYgNTYgNjAKNzQgNzQgMjMgMTcgMjUgMjYgOCAyNiAtMTQgMSAtNDggLTIzIC03NSAtNTJ6Ii8+PHBhdGggZD0iTTE4NjUgMTA2NSBjLTM0IC0zMCAtOTUgLTEwMCAtMTM2IC0xNTUgLTQwIC01NSAtODggLTEwNiAtMTA2IC0xMTQKLTI2IC0xMCAtMjQgLTEzIDEwIC0xNSAyNyAwIDYyIDI2IDk4IDc0IDMwIDQxIDkxIDExMyAxMzYgMTYwIDkyIDk2IDkxIDEzMQotMiA1MHoiLz48cGF0aCBkPSJNMjY2MCAxMTAwIGMwIC0xMSAtMTMgLTIwIC0zMCAtMjAgLTQ0IDAgLTU4IC04OCAtMjMgLTE1MyBsMzAgLTU3Ci0xMSA4MyBjLTkgNzAgLTQgODcgMzIgMTA2IDQzIDIzIDU3IDYxIDIyIDYxIC0xMSAwIC0yMCAtOSAtMjAgLTIweiIvPjxwYXRoIGQ9Ik0zODMwIDk5OCBjLTUwIC02NyAtOTAgLTEzNSAtOTAgLTE1MCAwIC0xNyAtMjIgLTI4IC01NSAtMjkgLTQ1IDAKLTQ5IC00IC0yMiAtMjAgNDkgLTI4IDg2IC02IDEyNSA3NSAxOSAzOSA2NiAxMTEgMTA1IDE1OSA0MCA0OCA2MiA4NyA0OSA4NwotMTIgMCAtNjMgLTU1IC0xMTIgLTEyMnoiLz48cGF0aCBkPSJNNDQ3MyAxMDI3IGMtMzkgLTUxIC04MyAtMTE4IC05NyAtMTUwIC0yMCAtNDQgLTQwIC01NyAtODYgLTU4IC01NgotMSAtNTcgLTMgLTE1IC0yMCA2NSAtMjYgOTggLTYgMTQ0IDg4IDIzIDQ2IDY4IDExMyAxMDEgMTUxIDY1IDczIDY5IDgyIDQzCjgyIC0xMCAwIC01MCAtNDIgLTkwIC05M3oiLz48cGF0aCBkPSJNNTIwMiAxMDI1IGMtMTA3IC0xMjcgLTE5NiAtMTkyIC0yNjUgLTE5MiAtMzAgMCAtNTEgLTcgLTQ1IC0xNiAzNgotNTggMTg5IDE2IDI4OSAxNDEgMzUgNDQgNzUgNzUgODcgNjggMTIgLTcgMTUgLTUgNyA0IC04IDkgLTQgMjkgOCA0NCAxMiAxNQoxOCAzMiAxMiAzOCAtNiA2IC00OCAtMzMgLTkzIC04N3oiLz48cGF0aCBkPSJNNjMxMyAxMDYzIGMtNyAtMzUgLTE4IC04OSAtMjUgLTEyMSAtNyAtMzYgLTMgLTYzIDEzIC03MyAxNiAtOSAyMAotNyAxMiA3IC04IDEyIC0xIDYwIDE2IDEwNiAxNiA0OSAyMiA5OCAxMyAxMTQgLTEyIDIyIC0yMCAxMyAtMjkgLTMzeiIvPjxwYXRoIGQ9Ik02NTcwIDEwNDAgYy00MyAtNDQgLTcxIC04NSAtNjQgLTkzIDggLTggMTYgLTEwIDE5IC01IDMgNSAzNiA0MiA3NAo4MyA5MyAxMDEgNjggMTE0IC0yOSAxNXoiLz48cGF0aCBkPSJNNzU4MyAxMDA3IGMtMjEgLTg4IC0yMSAtMTE3IC0yIC0xMzQgMTkgLTE3IDIyIC0xNyAxMiAxIC04IDEzIC0xCjYyIDE2IDEwOCAxNiA0NyAyMiA5NyAxNCAxMTEgLTggMTYgLTI1IC0xOSAtNDAgLTg2eiIvPjxwYXRoIGQ9Ik0zNjU5IDEwMjggYy01NCAtNTcgLTYxIC03MCAtMzAgLTY1IDIxIDQgNTYgMzYgNzcgNzIgNDkgODQgMzggODIKLTQ3IC03eiIvPjxwYXRoIGQ9Ik00MjYyIDEwMDUgYy0xMTcgLTEyNiAtMjA0IC0xODkgLTI0NyAtMTc4IC0xOSA1IC0zNSAxIC0zNSAtOSAwIC0yNwo3NSAtMjIgMTM3IDEwIDg4IDQ2IDI4NiAyNzIgMjM4IDI3MiAtMyAwIC00NSAtNDMgLTkzIC05NXoiLz48cGF0aCBkPSJNNjA2MiA5NzkgYy0xMjUgLTExNyAtMjQ5IC0xNzYgLTMxNSAtMTUxIC0xNiA3IC0yNCAzIC0xNyAtOCAxOCAtMzAKMTMyIC0yNCAxOTcgMTAgNzIgMzggMjkzIDIyOCAyOTMgMjUyIDAgMzUgLTMyIDE1IC0xNTggLTEwM3oiLz48cGF0aCBkPSJNNzQzNCAxMDYwIGMtNzkgLTExMyAtMjYwIC0yNDQgLTMyMiAtMjMzIC01NSAxMCAtNTcgOCAtMTggLTExIDI0Ci0xMSA2NCAtMTYgOTAgLTkgNDkgMTIgMzE2IDI0NSAzMTYgMjc1IDAgMzIgLTM3IDE5IC02NiAtMjJ6Ii8+PHBhdGggZD0iTTc3MzggOTQ4IGMtOTUgLTk0IC0xMjQgLTExMiAtMTg4IC0xMTQgLTYzIC0yIC02OSAtNSAtMzQgLTIxIDc4Ci0zNCAzNDQgMTQ0IDM0NCAyMzAgMCAyNyAtNCAyMyAtMTIyIC05NXoiLz48cGF0aCBkPSJNNDgzMSA5NDQgYy03MSAtODMgLTE5MyAtMTQ3IC0yMDYgLTEwOSAtNSAxNCAtMTcgMjAgLTI3IDE0IC0zMyAtMjEKMyAtNDkgNjEgLTQ5IDYyIDAgMjU0IDE1NSAyMzkgMTkyIC00IDExIC0zNCAtMTAgLTY3IC00OHoiLz48cGF0aCBkPSJNMjExMSA5MDggYy02MSAtNTQgLTEyMCAtODggLTE1MCAtODkgLTQ1IDAgLTQ3IC0zIC0xNSAtMjEgNDEgLTI0CjEzMCAyMyAyMjMgMTIwIDkwIDk1IDUzIDg4IC01OCAtMTB6Ii8+PHBhdGggZD0iTTcwMzAgOTM5IGMtNTcgLTYyIC0xNjcgLTEyMyAtMTkzIC0xMDcgLTE0IDkgLTE4IDYgLTkgLTkgOCAtMTMgMzEKLTIzIDUxIC0yMyA0MCAwIDIxOCAxNDkgMTk5IDE2OCAtNiA3IC0yOCAtNiAtNDggLTI5eiIvPjxwYXRoIGQ9Ik04MDEwIDkwOSBjLTQ0IC0zOCAtMTA1IC03NSAtMTM1IC04MiAtMzAgLTcgLTUwIC0xOSAtNDQgLTI5IDIzIC0zNwoyMTEgNzEgMjUwIDE0MyAyOCA1MyAyMSA1MCAtNzEgLTMyeiIvPjxwYXRoIGQ9Ik02NjkxIDg3NiBjLTg1IC01NiAtMTAyIC02MCAtMTU1IC00MSAtNTEgMTkgLTU2IDE5IC0zNSAtNiAzOCAtNDYKMTUwIC0zNiAyMTAgMjAgMjkgMjcgNjMgNTMgNzYgNTcgMTMgNSAxOCAxNCAxMSAyMSAtNiA3IC01NCAtMTYgLTEwNyAtNTF6Ii8+PHBhdGggZD0iTTcyMSA4NTYgYy00OCAtNjAgLTQ5IC02MSAtMTgwIC00NiAtNzIgOSAtMTkxIDE3IC0yNjUgMTkgLTE0MCAzCi0xODUgMjAgLTEzNSA1MiAxOCAxMSAyMCAxOSA1IDE5IC00NCAwIC01MyAtNDYgLTE0IC03NSAyNSAtMTkgNjUgLTI1IDEyMQotMTcgNDYgNiAxMzMgLTIgMTk2IC0xOSAxOTAgLTQ5IDM1MSAxIDM1MSAxMDkgMCAzNyAtMjggMjIgLTc5IC00MnoiLz48cGF0aCBkPSJNNjM3NSA4NzQgYy0zNSAtMjYgLTgzIC00MSAtMTMwIC00MCAtNTcgMSAtNjUgLTMgLTM1IC0xNSA3MSAtMjkKMTI3IC0yMCAxODQgMjkgNzAgNjEgNTUgODIgLTE5IDI2eiIvPjxwYXRoIGQ9Ik0xNDc4IDY3NCBjMjcgLTMwIDY1IC01NCA4NSAtNTQgNDEgMCA0OSAzMCAxNCA1MiAtMTQgOSAtMTggNSAtOSAtOQoyMyAtMzcgLTE4IC0yNyAtODAgMjEgbC01OCA0NCA0OCAtNTR6Ii8+PC9nPjwvZz48L3N2Zz4=", -// MediaType: "image/svg+xml", Files: Array.Empty(), CreatedAt: createdAt, -// Version: "1.0", -// Attributes: new[] -// { -// new KeyValuePair("Location", "Madrid"), -// new KeyValuePair("Seat", "B10"), -// }); - -// var tokens = new[] { nft1, nft2 }; -// var json = _metadataJsonBuilder.GenerateNftStandardJson(tokens, collection); - -// //File.WriteAllText(@"C:\ws\temp\nft-metadata.json", json); -// var deserialised = JsonSerializer.Deserialize>>>(json, SerialiserOptions); - -// deserialised.Should().NotBeNull(); -//#pragma warning disable CS8602 // Dereference of a possibly null reference. -// var policyAssets = deserialised["721"][collection.PolicyId]; -//#pragma warning restore CS8602 // Dereference of a possibly null reference. - -// policyAssets.Keys.Count.Should().Be(2); -// foreach (var token in tokens) -// { -// var asset = policyAssets[token.AssetName]; -// asset.Should().NotBeNull(); -// asset.Name.Should().Be(token.Name); -// asset.Description.Should().Be(token.Description); -// asset.Creators.Should().BeEquivalentTo(token.Creators); -// asset.Publishers.Should().BeEquivalentTo(collection.Publishers); -// asset.Image.Should().BeEquivalentTo(new[] { token.Image }); -// asset.MediaType.Should().Be(token.MediaType); -// foreach (var file in token.Files) -// { -// if (asset.Files == null) -// throw new Exception("Files cannot be null"); -// var assetFile = asset.Files.First(f => f.Name == file.Name); -// assetFile.Name.Should().Be(file.Name); -// assetFile.Src.Should().BeEquivalentTo(new[] { file.Url }); -// assetFile.MediaType.Should().Be(file.MediaType); -// assetFile.Hash.Should().Be(file.FileHash); -// } -// } -// } - - [Theory] - [InlineData("https://mintsafe.io/project1/1.png", 1)] - [InlineData("ipfs://QmZxFT9cswMB2MWCnjKMkLGQMoUy3A6WvKjNh16ht5S55m", 1)] - [InlineData("data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodH", 1)] - [InlineData("data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaG", 2)] - [InlineData("data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjQyNi4wMDAwMDBwdCIgaGVpZ2h0PSI0MDQuMDAwMDAwcHQiIHZpZXdCb3g9IjAgMCA0MjYuMDAwMDAwIDQwNC4wMDAwMDAiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiNmZmYiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIG1lZXQiPg0KDQogICAgPGcgdHJhbnNmb3JtPSJ", 6)] - public void Split_Large_Strings(string value, int expectedItems) - { - var items = MetadataJsonBuilder.SplitStringToChunks(value); - var itemsTotalLength = items.Sum(s => s.Length); - items.Should().HaveCount(expectedItems); - itemsTotalLength.Should().Be(value.Length); - } - - //[Fact] - //public void Create_Svg() - //{ - // var dataUriSvgBase64 = MetadataJsonBuilder.GetBase64SvgForMessage("Hey there! I would like to buy your ClayNation NFT 🙏 Please send me a message at $keefie", "Sick ClayNation 🤘"); - - // var output = $""; - - // output.Should().NotBeNull(); - //} -} diff --git a/Tests/Lib.UnitTests/NiftyDistributorShould.cs b/Tests/Lib.UnitTests/NiftyDistributorShould.cs deleted file mode 100644 index 0e21bbc..0000000 --- a/Tests/Lib.UnitTests/NiftyDistributorShould.cs +++ /dev/null @@ -1,324 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Mintsafe.Abstractions; -using Moq; -using System; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Xunit; -using static Mintsafe.Lib.UnitTests.FakeGenerator; - -namespace Mintsafe.Lib.UnitTests; - -public class NiftyDistributorShould -{ - private readonly NiftyDistributor _distributor; - private readonly Mock _mockMetadataGenerator; - private readonly Mock _mockTxIoRetriever; - private readonly Mock _mockTxBuilder; - private readonly Mock _mockTxSubmitter; - private readonly Mock _mockSaleContextStore; - - public NiftyDistributorShould() - { - _mockMetadataGenerator = new Mock(); - _mockTxIoRetriever = new Mock(); - _mockTxBuilder = new Mock(); - _mockTxSubmitter = new Mock(); - _mockSaleContextStore = new Mock(); - _distributor = new NiftyDistributor( - NullLogger.Instance, - NullInstrumentor.Instance, - GenerateSettings(), - _mockMetadataGenerator.Object, - _mockTxIoRetriever.Object, - _mockTxBuilder.Object, - _mockTxSubmitter.Object, - _mockSaleContextStore.Object); - } - - [Theory] - [InlineData(1)] - [InlineData(10)] - public async Task Distribute_Nifties_For_SalePurchase_Given_Active_Sale_When_Purchase_Is_Valid( - int niftyCount) - { - var purchaseAttempt = new PurchaseAttempt(Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, 10000000).First(), 3, 0); - var allocatedNifties = GenerateTokens(niftyCount).ToArray(); - var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; - _mockTxIoRetriever - .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(GenerateTxIoAggregate()); - _mockTxBuilder - .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(buildTxOutputBytes); - _mockTxSubmitter - .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) - .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); - - var distributionResult = await _distributor.DistributeNiftiesForSalePurchase( - nfts: allocatedNifties, - purchaseAttempt: purchaseAttempt, - saleContext: GenerateSaleContext(), - networkContext: GenerateNetworkContext()); - - distributionResult.Outcome.Should().Be(NiftyDistributionOutcome.Successful); - distributionResult.PurchaseAttempt.Should().Be(purchaseAttempt); - distributionResult.NiftiesDistributed.Should().BeEquivalentTo(allocatedNifties); - distributionResult.MintTxHash.Should().Be("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); - _mockTxBuilder - .Verify( - t => t.BuildTxAsync( - It.Is(b => b.Mint.Length == niftyCount), - It.IsAny()), - Times.Once, - "Output should have buyer address and correct values"); - } - - [Theory] - [InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0, 10000000)] - [InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 2, 35000000)] - public async Task Build_Correct_Tx_Input_For_Buyer_SalePurchase_Given_Active_Sale_When_Purchase_Is_Valid( - string purchaseTxHash, uint purchaseOutputIndex, ulong purchaseUtxoLovelaceValue) - { - var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; - _mockTxIoRetriever - .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(GenerateTxIoAggregate()); - _mockTxBuilder - .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(buildTxOutputBytes); - _mockTxSubmitter - .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) - .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); - var purchaseUtxo = new UnspentTransactionOutput( - purchaseTxHash, - purchaseOutputIndex, - new AggregateValue(purchaseUtxoLovelaceValue, Array.Empty())); - - var txHash = await _distributor.DistributeNiftiesForSalePurchase( - nfts: GenerateTokens(3).ToArray(), - purchaseAttempt: new PurchaseAttempt(Guid.NewGuid(), Guid.NewGuid(), purchaseUtxo, 3, 0), - saleContext: GenerateSaleContext(), - networkContext: GenerateNetworkContext()); - - _mockTxBuilder - .Verify( - t => t.BuildTxAsync(It.Is(b => b.Inputs.First() == purchaseUtxo && b.Inputs.Length == 1), It.IsAny()), - Times.Once, - "Input should be the purchase UTxO"); - } - - //[Theory] - //[InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0, 1, "0ac248e17f0fc35be4d2a7d186a84cdcda5b88d7ad2799ebe98a98b2")] - //[InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 1000000, 3, "629718e24d22e0c02c2efd27290e1a58ebc2972635a7c523aee2d8fc")] - //public async Task Build_Correct_Tx_Output_For_Buyer_SalePurchase_Given_Active_Sale_When_Purchase_Is_Valid( - // string buyerAddress, ulong changeInLovelace, int niftyCount, string policyId) - //{ - // var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; - // _mockTxIoRetriever - // .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) - // .ReturnsAsync(GenerateTxIoAggregate(inputAddress: buyerAddress)); - // _mockTxBuilder - // .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) - // .ReturnsAsync(buildTxOutputBytes); - // _mockTxSubmitter - // .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) - // .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); - // var nifties = GenerateTokens(niftyCount).ToArray(); - - // var txHash = await _distributor.DistributeNiftiesForSalePurchase( - // nfts: nifties, - // purchaseAttempt: new PurchaseAttempt( - // Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, 10000000).First(), niftyCount, changeInLovelace), - // saleContext: GenerateSaleContext(collection: GenerateCollection(policyId: policyId)), - // networkContext: GenerateNetworkContext()); - - // _mockTxBuilder - // .Verify( - // t => t.BuildTxAsync( - // It.Is(b => IsBuyerOutputCorrect(b, buyerAddress, changeInLovelace, nifties, policyId)), - // It.IsAny()), - // Times.Once, - // "Output should have buyer address and correct values"); - //} - - [Theory] - [InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0.1, "addr_test1qrup2zgu69knkts7m3y3ghhwdzmgaus5u3s28vcsdegajr9wv6zerpdre7qdyvf68dcjyslazq0tfj5rq80v02tm5mysd3xc2u", 10000000, 0, 1)] - [InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 0.078, "addr_test1qp06h7um737tlp2s5um8fvwef5rmx6jh7auchrcwgct3w43w7gtapvad5hzgkvr3ksnzpu6a2ejaew5ypeurygknqs5qhjuvf6", 36000000, 2000000, 3)] - [InlineData("addr_test1vrfxxeuzqfuknfz4hu0ym4fe4l3axvqd7t5agd6pfzml59q30qc4x", 0.0088, "addr_test1vrldgv89yh0edkuwrvkkhc3yx4npfccdvtz7dfkn85a78rsu9nkm4", 50500000, 2500000, 5)] - public async Task Build_Correct_Tx_Output_For_Creator_Address_Given_Active_Sale_When_Purchase_Is_Valid( - string creatorAddress, double margin, string buyerAddress, ulong purchaseLovelaces, ulong changeInLovelace, int niftyCount) - { - var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; - var txInfo = GenerateTxIoAggregate(inputAddress: buyerAddress); - _mockTxIoRetriever - .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(txInfo); - _mockTxBuilder - .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(buildTxOutputBytes); - _mockTxSubmitter - .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) - .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); - var nifties = GenerateTokens(niftyCount).ToArray(); - - var txHash = await _distributor.DistributeNiftiesForSalePurchase( - nfts: nifties, - purchaseAttempt: new PurchaseAttempt( - Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, purchaseLovelaces).First(), niftyCount, changeInLovelace), - saleContext: GenerateSaleContext( - sale: GenerateSale(creatorAddress: creatorAddress, postPurchaseMargin: (decimal)margin)), - networkContext: GenerateNetworkContext()); - - _mockTxBuilder - .Verify( - t => t.BuildTxAsync( - It.Is( - b => IsCreatorOutputCorrect(b, creatorAddress, margin, buyerAddress, purchaseLovelaces, changeInLovelace)), - It.IsAny()), - Times.Once, - "Output should have proceeds address and correct lovelace value"); - } - - [Theory] - [InlineData("addr_test1vpjvftua27afux73wpjz8089d2fsdu097apcuhdewyxmfssj0dlty", 0.1, "addr_test1qrup2zgu69knkts7m3y3ghhwdzmgaus5u3s28vcsdegajr9wv6zerpdre7qdyvf68dcjyslazq0tfj5rq80v02tm5mysd3xc2u", 10000000, 0, 1)] - [InlineData("addr_test1vzze0x09pe5v80sxtzz06uvt7gdmdpp9z4m5xndacy4044g8err8c", 0.078,"addr_test1qp06h7um737tlp2s5um8fvwef5rmx6jh7auchrcwgct3w43w7gtapvad5hzgkvr3ksnzpu6a2ejaew5ypeurygknqs5qhjuvf6", 36000000, 2000000, 3)] - [InlineData("addr_test1vrfxxeuzqfuknfz4hu0ym4fe4l3axvqd7t5agd6pfzml59q30qc4x", 0.0088, "addr_test1vrldgv89yh0edkuwrvkkhc3yx4npfccdvtz7dfkn85a78rsu9nkm4", 50500000, 2500000, 5)] - public async Task Build_Correct_Tx_Output_For_Proceeds_Address_Given_Active_Sale_When_Purchase_Is_Valid( - string proceedsAddress, double margin, string buyerAddress, ulong purchaseLovelaces, ulong changeInLovelace, int niftyCount) - { - var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; - var txInfo = GenerateTxIoAggregate(inputAddress: buyerAddress); - _mockTxIoRetriever - .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(txInfo); - _mockTxBuilder - .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(buildTxOutputBytes); - _mockTxSubmitter - .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) - .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); - var allocatedNifties = GenerateTokens(niftyCount).ToArray(); - var sale = GenerateSale(proceedsAddress: proceedsAddress, postPurchaseMargin: (decimal)margin); - - var txHash = await _distributor.DistributeNiftiesForSalePurchase( - nfts: allocatedNifties, - purchaseAttempt: new PurchaseAttempt( - Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, purchaseLovelaces).First(), niftyCount, changeInLovelace), - saleContext: GenerateSaleContext(sale: sale), - networkContext: GenerateNetworkContext()); - - _mockTxBuilder - .Verify( - t => t.BuildTxAsync( - It.Is( - b => IsProceedsOutputCorrect(b, proceedsAddress, margin, buyerAddress, purchaseLovelaces, changeInLovelace)), - It.IsAny()), - Times.Once, - "Output should have proceeds address and correct lovelace value"); - } - - //[Theory] - //[InlineData(1, "0ac248e17f0fc35be4d2a7d186a84cdcda5b88d7ad2799ebe98a98b2")] - //[InlineData(3, "629718e24d22e0c02c2efd27290e1a58ebc2972635a7c523aee2d")] - //public async Task Build_Correct_Tx_Mint_For_Nifities_Given_Active_Sale_When_Purchase_Is_Valid( - // int niftyCount, string policyId) - //{ - // var buildTxOutputBytes = new byte[] { 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }; - // _mockTxIoRetriever - // .Setup(t => t.GetTxInfoAsync(It.IsAny(), It.IsAny())) - // .ReturnsAsync(GenerateTxIoAggregate()); - // _mockTxBuilder - // .Setup(t => t.BuildTxAsync(It.IsAny(), It.IsAny())) - // .ReturnsAsync(buildTxOutputBytes); - // _mockTxSubmitter - // .Setup(t => t.SubmitTxAsync(It.Is(b => b == buildTxOutputBytes), It.IsAny())) - // .ReturnsAsync("01daae688d236601109d9fc1bc11d7380a7617e6835eddca6527738963a87279"); - // var nifties = GenerateTokens(niftyCount).ToArray(); - - // var txHash = await _distributor.DistributeNiftiesForSalePurchase( - // nfts: nifties, - // purchaseAttempt: new PurchaseAttempt( - // Guid.NewGuid(), Guid.NewGuid(), GenerateUtxos(1, 1000000).First(), 3, 0), - // saleContext: GenerateSaleContext(collection: GenerateCollection(policyId: policyId)), - // networkContext: GenerateNetworkContext()); - - // _mockTxBuilder - // .Verify( - // t => t.BuildTxAsync( - // It.Is(b => IsMintCorrect(b, nifties, policyId)), It.IsAny()), - // Times.Once, - // "Should have correctly mapped mint parameters for nifties"); - //} - - private static bool IsBuyerOutputCorrect( - TxBuildCommand buildCommand, - string buyerAddress, - ulong changeInLovelace, - Nifty[] nifties, - string policyId) - { - var buyerOutput = buildCommand.Outputs.First(output => output.Address == buyerAddress); - var buyerOutputLovelace = buyerOutput.Values.Lovelaces; - - var minUtxoLovelaceQuantity = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); - var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName))}").ToArray(); - var allSingleNiftyOutputs = buyerOutput.Values.NativeAssets - .All(v => expectedNiftyAssetNames.Contains($"{v.PolicyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(v.AssetName))}") && v.Quantity == 1); - - return (buyerOutputLovelace == changeInLovelace + minUtxoLovelaceQuantity) && allSingleNiftyOutputs; - } - - private static bool IsProceedsOutputCorrect( - TxBuildCommand buildCommand, - string proceedsAddress, - double margin, - string buyerAddress, - ulong purchaseLovelaces, - ulong changeInLovelace) - { - var buyerOutput = buildCommand.Outputs.First(output => output.Address == buyerAddress); - var proceedsOutput = buildCommand.Outputs.First(output => output.Address == proceedsAddress); - var proceedsOutputLovelaces = proceedsOutput.Values.Lovelaces; - var minUtxoLovelaces = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); - - var saleLovelaces = purchaseLovelaces - changeInLovelace - minUtxoLovelaces; - var proceedsCutLovelaces = (ulong)(saleLovelaces * margin); - - return proceedsOutputLovelaces == proceedsCutLovelaces; - } - - private static bool IsCreatorOutputCorrect( - TxBuildCommand buildCommand, - string creatorAddress, - double margin, - string buyerAddress, - ulong purchaseLovelaces, - ulong changeInLovelace) - { - var buyerOutput = buildCommand.Outputs.First(output => output.Address == buyerAddress); - var creatorOutput = buildCommand.Outputs.First(output => output.Address == creatorAddress); - var creatorOutputLovelaces = creatorOutput.Values.Lovelaces; - var minUtxoLovelaces = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); - - var saleLovelaces = purchaseLovelaces - changeInLovelace - minUtxoLovelaces; - var proceedsCutLovelaces = (ulong)(saleLovelaces * margin); - - return creatorOutputLovelaces == saleLovelaces - proceedsCutLovelaces; - } - - private static bool IsMintCorrect( - TxBuildCommand buildCommand, - Nifty[] nifties, - string policyId) - { - var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(n.AssetName))}").ToArray(); - var allSingleNiftyMints = buildCommand.Mint - .All(v => expectedNiftyAssetNames.Contains($"{v.PolicyId}.{Convert.ToHexString(Encoding.UTF8.GetBytes(v.AssetName))}") && v.Quantity == 1); - - return allSingleNiftyMints; - } -} diff --git a/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs index 0d11b79..1592cb2 100644 --- a/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs +++ b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs @@ -84,7 +84,7 @@ public async Task Submit_Transaction_Successfully_When_Minting_Nft_Royalty_Asset var royaltyBodyMetadata = new Dictionary { { "rate", royaltyRate }, - { "addr", royaltyAddress.Length > 64 ? MetadataJsonBuilder.SplitStringToChunks(royaltyAddress) : royaltyAddress } + { "addr", royaltyAddress.Length > 64 ? MetadataBuilder.SplitStringToChunks(royaltyAddress) : royaltyAddress } }; var royaltyMetadata = new Dictionary> { { NftRoyaltyMetadataStandardKey, royaltyBodyMetadata } }; From 0e9ef57710e56784678666d402ed139dbcdfc550 Mon Sep 17 00:00:00 2001 From: safestak-keith Date: Thu, 23 Jun 2022 01:02:09 +1000 Subject: [PATCH 8/9] Removed unused package reference --- Src/Lib/Mintsafe.Lib.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/Src/Lib/Mintsafe.Lib.csproj b/Src/Lib/Mintsafe.Lib.csproj index c5002e4..1bbdf68 100644 --- a/Src/Lib/Mintsafe.Lib.csproj +++ b/Src/Lib/Mintsafe.Lib.csproj @@ -12,7 +12,6 @@ - From 8ee74028395e6fcb4d8b73e66c357d2631fd569f Mon Sep 17 00:00:00 2001 From: safestak-keith Date: Thu, 13 Oct 2022 19:25:20 +1100 Subject: [PATCH 9/9] More refactoring --- Src/Abstractions/CardanoTypes.cs | 47 +++-- Src/Abstractions/IBlockfrostClient.cs | 8 +- Src/Abstractions/ITxBuilder.cs | 4 + Src/Lib/BlockfrostClient.cs | 12 +- Src/Lib/BlockfrostUtxoRetriever.cs | 2 +- Src/Lib/CardanoSharpTxBuilder.cs | 133 ++++++++++++++- Src/Lib/GenericMintingService.cs | 160 ++++++++++++++++++ Src/Lib/Mintsafe.Lib.csproj | 2 +- Src/Lib/NetworkContextBuilder.cs | 25 +++ Src/Lib/NiftyDistributor.cs | 14 +- Src/Lib/SimpleWalletService.cs | 125 +------------- ...nsactionSigner.cs => TransactionSigner.cs} | 0 Src/Lib/TxUtils.cs | 52 +++--- Src/Lib/UtxoRefunder.cs | 2 +- Tests/Lib.UnitTests/FakeGenerator.cs | 2 +- .../GenericMintingServiceShould.cs | 44 +++++ Tests/Lib.UnitTests/NiftyAllocatorShould.cs | 4 +- .../PurchaseAttemptGeneratorShould.cs | 16 +- .../SimpleWalletServiceShould.cs | 4 +- Tests/Lib.UnitTests/TxUtilsShould.cs | 8 +- 20 files changed, 462 insertions(+), 202 deletions(-) create mode 100644 Src/Lib/GenericMintingService.cs create mode 100644 Src/Lib/NetworkContextBuilder.cs rename Src/Lib/{ITransactionSigner.cs => TransactionSigner.cs} (100%) create mode 100644 Tests/Lib.UnitTests/GenericMintingServiceShould.cs diff --git a/Src/Abstractions/CardanoTypes.cs b/Src/Abstractions/CardanoTypes.cs index df00930..76b3ece 100644 --- a/Src/Abstractions/CardanoTypes.cs +++ b/Src/Abstractions/CardanoTypes.cs @@ -24,11 +24,11 @@ public record Utxo(string TxHash, int OutputIndex, Value[] Values) // TODO: Ideal types public record struct NativeAssetValue(string PolicyId, string AssetName, ulong Quantity); -public record struct AggregateValue(ulong Lovelaces, NativeAssetValue[] NativeAssets); +public record struct Balance(ulong Lovelaces, NativeAssetValue[] NativeAssets); -public record struct PendingTransactionOutput(string Address, AggregateValue Value); +public record struct PendingTransactionOutput(string Address, Balance Value); -public record UnspentTransactionOutput(string TxHash, uint OutputIndex, AggregateValue Value) +public record UnspentTransactionOutput(string TxHash, uint OutputIndex, Balance Value) { public override int GetHashCode() => ToString().GetHashCode(); public override string ToString() => $"{TxHash}_{OutputIndex}"; @@ -37,7 +37,7 @@ bool IEquatable.Equals(UnspentTransactionOutput? other public ulong Lovelaces => Value.Lovelaces; } public record TransactionSummary(string TxHash, TransactionIo[] Inputs, TransactionIo[] Outputs); -public record TransactionIo(string Address, int OutputIndex, AggregateValue Values); +public record TransactionIo(string Address, int OutputIndex, Balance Values); public record BasicMintingPolicy(string[] PolicySigningKeysAll, uint ExpirySlot); public record Mint(BasicMintingPolicy BasicMintingPolicy, NativeAssetValue[] NativeAssetsToMint); @@ -53,25 +53,38 @@ public record BuildTransactionCommand( string[] PaymentSigningKeys, Network Network, uint TtlTipOffsetSlots = 5400); -public record BuiltTransaction(string TxHash, byte[] Bytes); -// End TODO + +public record RewardsWithdrawal(string StakeAddress, uint RewardLovelaces); + +public record BuildTxCommand( + UnspentTransactionOutput[] Inputs, + PendingTransactionOutput[] Outputs, + Network Network, + NativeAssetValue[]? Mint = null, + Dictionary>? Metadata = null, + RewardsWithdrawal[]? RewardsWithdrawals = null, + SimpleScript[]? SimpleScripts = null, + string[]? SigningKeys = null, + uint TtlTipOffsetSlots = 7200); + +public record BuiltTransaction(string TxHash, byte[] CborBytes); public record TxIo(string Address, uint OutputIndex, Value[] Values); public record TxInfo(string TxHash, TxIo[] Inputs, TxIo[] Outputs); -public record TxBuildOutput(string Address, AggregateValue Values, bool IsFeeDeducted = false); +public record TxBuildOutput(string Address, Balance Values, bool IsFeeDeducted = false); -public record TxBuildCommand( - UnspentTransactionOutput[] Inputs, - TxBuildOutput[] Outputs, - NativeAssetValue[] Mint, - string MintingScriptPath, - string MetadataJsonPath, - long TtlSlot, - string[] SigningKeyFiles); - -public interface INativeScript { } +public enum NativeScriptType { PubKeyHash = 0, All, Any, AtLeast, InvalidBefore, InvalidAfter } +public record SimpleScript( + NativeScriptType Type, + uint? AtLeast = null, + SimpleScript[]? Scripts = null, + string? PubKeyHash = null, + uint? InvalidBefore = null, + uint? InvalidAfter = null); + +//public interface INativeScript { } //public class ScriptPubKey : INativeScript //{ // public byte[] KeyHash { get; init; } diff --git a/Src/Abstractions/IBlockfrostClient.cs b/Src/Abstractions/IBlockfrostClient.cs index 33ae35a..2320701 100644 --- a/Src/Abstractions/IBlockfrostClient.cs +++ b/Src/Abstractions/IBlockfrostClient.cs @@ -6,8 +6,8 @@ namespace Mintsafe.Abstractions; public interface IBlockfrostClient { - Task GetLatestBlockAsync(CancellationToken ct = default); - Task GetLatestProtocolParameters(CancellationToken ct = default); + Task GetLatestBlockAsync(CancellationToken ct = default); + Task GetLatestProtocolParameters(CancellationToken ct = default); Task GetTransactionAsync(string txHash, CancellationToken ct = default); Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default); Task SubmitTransactionAsync(byte[] txSignedBinary, CancellationToken ct = default); @@ -29,7 +29,7 @@ public BlockfrostResponseException( } } -public class LatestBlock +public class BlockfrostLatestBlock { public uint? Epoch { get; init; } public uint? Slot { get; init; } @@ -37,7 +37,7 @@ public class LatestBlock public string? Hash { get; init; } } -public class ProtocolParameters +public class BlockfrostProtocolParameters { public uint? Protocol_major_ver { get; init; } public uint? Protocol_minor_ver { get; init; } diff --git a/Src/Abstractions/ITxBuilder.cs b/Src/Abstractions/ITxBuilder.cs index ab6a997..14616ec 100644 --- a/Src/Abstractions/ITxBuilder.cs +++ b/Src/Abstractions/ITxBuilder.cs @@ -8,4 +8,8 @@ public interface IMintTransactionBuilder BuiltTransaction BuildTx( BuildTransactionCommand buildCommand, NetworkContext networkContext); + + BuiltTransaction BuildTx( + BuildTxCommand buildCommand, + NetworkContext networkContext); } diff --git a/Src/Lib/BlockfrostClient.cs b/Src/Lib/BlockfrostClient.cs index 465a5fb..0635494 100644 --- a/Src/Lib/BlockfrostClient.cs +++ b/Src/Lib/BlockfrostClient.cs @@ -175,12 +175,12 @@ public async Task SubmitTransactionAsync(byte[] txSignedBinary, Cancella } } - public async Task GetLatestBlockAsync(CancellationToken ct = default) + public async Task GetLatestBlockAsync(CancellationToken ct = default) { var relativePath = $"api/v0/blocks/latest"; var isSuccessful = false; - LatestBlock? bfResponse = null; + BlockfrostLatestBlock? bfResponse = null; var responseCode = 0; var sw = Stopwatch.StartNew(); try @@ -192,7 +192,7 @@ public async Task GetLatestBlockAsync(CancellationToken ct = defaul throw new BlockfrostResponseException($"Unsuccessful Blockfrost response:{responseBody}", (int)response.StatusCode, responseBody); } _logger.LogDebug($"{nameof(BlockfrostClient)}.{nameof(GetLatestBlockAsync)} from {relativePath} reponse: {responseCode}"); - bfResponse = await response.Content.ReadFromJsonAsync(SerialiserOptions, ct).ConfigureAwait(false); + bfResponse = await response.Content.ReadFromJsonAsync(SerialiserOptions, ct).ConfigureAwait(false); if (bfResponse == null) { var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); @@ -217,11 +217,11 @@ public async Task GetLatestBlockAsync(CancellationToken ct = defaul } } - public async Task GetLatestProtocolParameters(CancellationToken ct = default) + public async Task GetLatestProtocolParameters(CancellationToken ct = default) { var relativePath = $"api/v0/epochs/latest/parameters"; var isSuccessful = false; - ProtocolParameters? bfResponse = null; + BlockfrostProtocolParameters? bfResponse = null; var responseCode = 0; var sw = Stopwatch.StartNew(); try @@ -233,7 +233,7 @@ public async Task GetLatestProtocolParameters(CancellationTo throw new BlockfrostResponseException($"Unsuccessful Blockfrost response:{responseBody}", (int)response.StatusCode, responseBody); } _logger.LogDebug($"{nameof(BlockfrostClient)}.{nameof(GetLatestProtocolParameters)} from {relativePath} reponse: {responseCode}"); - bfResponse = await response.Content.ReadFromJsonAsync(SerialiserOptions, ct).ConfigureAwait(false); + bfResponse = await response.Content.ReadFromJsonAsync(SerialiserOptions, ct).ConfigureAwait(false); if (bfResponse == null) { var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); diff --git a/Src/Lib/BlockfrostUtxoRetriever.cs b/Src/Lib/BlockfrostUtxoRetriever.cs index 3b36e35..162895a 100644 --- a/Src/Lib/BlockfrostUtxoRetriever.cs +++ b/Src/Lib/BlockfrostUtxoRetriever.cs @@ -63,7 +63,7 @@ private static UnspentTransactionOutput MapBlockFrostUtxoToUtxo(BlockFrostAddres var assetName = val.Unit[56..]; nativeAssets[index++] = new NativeAssetValue(policyId, assetName, ulong.Parse(val.Quantity)); } - var aggValue = new AggregateValue(lovelaces, nativeAssets); + var aggValue = new Balance(lovelaces, nativeAssets); return new UnspentTransactionOutput( bfUtxo.Tx_hash, diff --git a/Src/Lib/CardanoSharpTxBuilder.cs b/Src/Lib/CardanoSharpTxBuilder.cs index f80479e..e5a0ddc 100644 --- a/Src/Lib/CardanoSharpTxBuilder.cs +++ b/Src/Lib/CardanoSharpTxBuilder.cs @@ -62,7 +62,7 @@ public BuiltTransaction BuildTx( var txOutputs = buildCommand.Outputs; var consolidatedOutputValue = txOutputs.Select(txOut => txOut.Value).Sum(); var valueDifference = consolidatedInputValue.Subtract(consolidatedOutputValue); - if (valueDifference.Lovelaces != 0 && !valueDifference.NativeAssets.All(na => na.Quantity == 0)) + if (!valueDifference.IsZero()) { throw new InputOutputValueMismatchException( "Input/Output value mismatch", buildCommand.Inputs, buildCommand.Outputs); @@ -139,6 +139,7 @@ public BuiltTransaction BuildTx( var txHash = HashUtility.Blake2b256(tx.TransactionBody.Serialize(auxDataBuilder.Build())).ToStringHex(); _logger.LogInformation("Built mint tx {elapnsed}ms", sw.ElapsedMilliseconds); var cborHex = txBytes.ToStringHex(); + isSuccessful = true; return new BuiltTransaction(txHash, txBytes); } catch (Exception ex) @@ -162,7 +163,113 @@ public BuiltTransaction BuildTx( } } - private static AggregateValue BuildConsolidatedTxInputValue( + public BuiltTransaction BuildTx(BuildTxCommand txCommand, NetworkContext networkContext) + { + var isSuccessful = false; + // Prevent null propagation for optional properties of array type + var mint = txCommand.Mint ?? Array.Empty(); + var signingKeys = txCommand.SigningKeys ?? Array.Empty(); + var simpleScripts = txCommand.SimpleScripts ?? Array.Empty(); + var rewardsWithdrawals = txCommand.RewardsWithdrawals ?? Array.Empty(); + var sw = Stopwatch.StartNew(); + try + { + var consolidatedInputValue = BuildConsolidatedTxInputValue(txCommand.Inputs, mint); + var consolidatedOutputValue = txCommand.Outputs.Select(txOut => txOut.Value).Sum(); + var valueDifference = consolidatedInputValue.Subtract(consolidatedOutputValue); + if (!valueDifference.IsZero()) + { + throw new InputOutputValueMismatchException( + "Input/Output value mismatch", txCommand.Inputs, txCommand.Outputs); + } + // Start building transaction body using CardanoSharp + var txBodyBuilder = TransactionBodyBuilder.Create + .SetFee(networkContext.ProtocolParams.MinFeeB) + .SetTtl(networkContext.LatestSlot + txCommand.TtlTipOffsetSlots); + // Inputs + foreach (var txInput in txCommand.Inputs) + { + txBodyBuilder.AddInput(txInput.TxHash, txInput.OutputIndex); + } + // Outputs + foreach (var txOutput in txCommand.Outputs) + { + var tokenBundleBuilder = (txOutput.Value.NativeAssets.Length > 0) + ? GetTokenBundleBuilderFromNativeAssets(txOutput.Value.NativeAssets) + : null; + txBodyBuilder.AddOutput(new Address(txOutput.Address), txOutput.Value.Lovelaces, tokenBundleBuilder); + } + // Mint + if (mint.Length > 0) + { + // Build Cardano Native Assets from TestResults + var freshMintTokenBundleBuilder = TokenBundleBuilder.Create; + foreach (var newAssetMint in mint) + { + freshMintTokenBundleBuilder = freshMintTokenBundleBuilder + .AddToken(newAssetMint.PolicyId.HexToByteArray(), newAssetMint.AssetName.HexToByteArray(), 1); + } + txBodyBuilder.SetMint(freshMintTokenBundleBuilder); + } + // Witnesses + var witnesses = TransactionWitnessSetBuilder.Create; + foreach (var signingKey in signingKeys) + { + var paymentSkey = TxUtils.GetPrivateKeyFromBech32SigningKey(signingKey); + witnesses.AddVKeyWitness(paymentSkey.GetPublicKey(false), paymentSkey); + } + // Simple Scripts + foreach (var simpleScript in simpleScripts) + { + witnesses.AddNativeScript(GetScriptBuilderForSimpleScript(simpleScript)); + } + + // Build Tx for fee calculation + var txBuilder = TransactionBuilder.Create + .SetBody(txBodyBuilder) + .SetWitnesses(witnesses); + // Metadata + IAuxiliaryDataBuilder? auxDataBuilder = null; + if (txCommand.Metadata is not null && txCommand.Metadata.Keys.Any()) + { + auxDataBuilder = AuxiliaryDataBuilder.Create; + foreach (var key in txCommand.Metadata.Keys) + { + auxDataBuilder = auxDataBuilder.AddMetadata(key, txCommand.Metadata[key]); + } + txBuilder = txBuilder.SetAuxData(auxDataBuilder); + } + _logger.LogInformation("Build Metadata {txMetadata}", auxDataBuilder); + // Calculate and update change Utxo + var tx = txBuilder.Build(); + var fee = tx.CalculateFee(networkContext.ProtocolParams.MinFeeA, networkContext.ProtocolParams.MinFeeB); + txBodyBuilder.SetFee(fee); + tx.TransactionBody.TransactionOutputs.Last().Value.Coin -= fee; + var txBytes = tx.Serialize(); + var txHash = HashUtility.Blake2b256(tx.TransactionBody.Serialize(auxDataBuilder?.Build())).ToStringHex(); + _logger.LogInformation("Built mint tx {elapnsed}ms", sw.ElapsedMilliseconds); + var cborHex = txBytes.ToStringHex(); + isSuccessful = true; + return new BuiltTransaction(txHash, txBytes); + } + catch + { + throw; + } + finally + { + _instrumentor.TrackDependency( + EventIds.TxBuilderElapsed, + sw.ElapsedMilliseconds, + DateTime.UtcNow, + nameof(CardanoSharpTxBuilder), + nameof(CardanoSharp), + nameof(BuildTx), + isSuccessful: isSuccessful); + } + } + + private static Balance BuildConsolidatedTxInputValue( UnspentTransactionOutput[] sourceAddressUtxos, NativeAssetValue[]? nativeAssetsToMint) { @@ -170,7 +277,7 @@ private static AggregateValue BuildConsolidatedTxInputValue( { return sourceAddressUtxos .Select(utxo => utxo.Value) - .Concat(new[] { new AggregateValue(0, nativeAssetsToMint) }) + .Concat(new[] { new Balance(0, nativeAssetsToMint) }) .Sum(); } return sourceAddressUtxos.Select(utxo => utxo.Value).Sum(); @@ -210,4 +317,24 @@ private static IScriptAllBuilder GetScriptAllBuilder( } return scriptAllBuilder; } + + + private static INativeScriptBuilder GetScriptBuilderForSimpleScript(SimpleScript simpleScript) => + simpleScript.Type switch + { + NativeScriptType.PubKeyHash => NativeScriptBuilder.Create.SetKeyHash( + simpleScript.PubKeyHash.HexToByteArray()), + NativeScriptType.InvalidBefore => NativeScriptBuilder.Create.SetInvalidBefore( + simpleScript.InvalidBefore ?? 0), + NativeScriptType.InvalidAfter => NativeScriptBuilder.Create.SetInvalidAfter( + simpleScript.InvalidAfter ?? 0), + NativeScriptType.Any => NativeScriptBuilder.Create.SetScriptAny( + simpleScript.Scripts?.Select(GetScriptBuilderForSimpleScript)?.ToArray() ?? Array.Empty()), + NativeScriptType.All => NativeScriptBuilder.Create.SetScriptAll( + simpleScript.Scripts?.Select(GetScriptBuilderForSimpleScript)?.ToArray() ?? Array.Empty()), + NativeScriptType.AtLeast => NativeScriptBuilder.Create.SetScriptNofK( + simpleScript.AtLeast ?? 0, + simpleScript.Scripts?.Select(GetScriptBuilderForSimpleScript)?.ToArray() ?? Array.Empty()), + _ => throw new NotImplementedException() + }; } diff --git a/Src/Lib/GenericMintingService.cs b/Src/Lib/GenericMintingService.cs new file mode 100644 index 0000000..57a1696 --- /dev/null +++ b/Src/Lib/GenericMintingService.cs @@ -0,0 +1,160 @@ +using CardanoSharp.Koios.Sdk; +using CardanoSharp.Koios.Sdk.Contracts; +using Microsoft.Extensions.Logging; +using Mintsafe.Abstractions; +using Refit; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Mintsafe.Lib; + +public class GenericMintingService +{ + private readonly ILogger _logger; + private readonly IInstrumentor _instrumentor; + private readonly IMintTransactionBuilder _txBuilder; + private readonly ITxSubmitter _txSubmitter; + + public GenericMintingService( + ILogger logger, + IInstrumentor instrumentor, + IMintTransactionBuilder txBuilder, + ITxSubmitter txSubmitter) + { + _logger = logger; + _instrumentor = instrumentor; + _txBuilder = txBuilder; + _txSubmitter = txSubmitter; + } + + public async Task MintNativeAssets( + string from, + string to, + Network network, + Balance? balanceToSend = null, + bool sendAll = false, + Dictionary>? nativeAssets = null, + string? fromSigningKey = null, + CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + (var epochClient, var networkClient, var addressClient, var txClient) = GetKoiosClients(network); + var tip = (await networkClient.GetChainTip()).Content?.FirstOrDefault(); + if (tip is null) return null; + var protocolParams = (await epochClient.GetProtocolParameters(tip.Epoch.ToString())).Content?.FirstOrDefault(); + if (protocolParams is null) return null; + var sourceAddressInfo = (await addressClient.GetAddressInformation(from)).Content?.FirstOrDefault(); + if (sourceAddressInfo is null) return null; + if (sourceAddressInfo.UtxoSets is null || !sourceAddressInfo.UtxoSets.Any()) return null; + var sourceAddressUtxos = BuildSourceAddressUtxos(sourceAddressInfo.UtxoSets); + var consolidatedInputValue = BuildConsolidatedTxInputValue(sourceAddressUtxos); + var outputBalance = sendAll ? consolidatedInputValue : balanceToSend ?? throw new Exception("Must have a balance to send or send-all"); + var changeBalance = consolidatedInputValue.Subtract(outputBalance); + var txOutput = sendAll || changeBalance.IsZero() + ? new[] { new PendingTransactionOutput(to, outputBalance) } + : new[] { + new PendingTransactionOutput(to, outputBalance), + new PendingTransactionOutput(to, changeBalance) + }; + + _logger.LogInformation( + "Queried Koios {elapsedMs}ms - Epoch: {Epoch}, AbsSlot: {AbsSlot}", + sw.ElapsedMilliseconds, tip.Epoch, tip.AbsSlot); + + var txCommand = new BuildTxCommand( + Inputs: sourceAddressUtxos, + Outputs: txOutput, + Mint: null, + Metadata: MetadataBuilder.BuildMessageMetadata("data:image/svg+xml;utf8,"), + Network: network, + SigningKeys: fromSigningKey is null ? null : new[] { fromSigningKey }); + + var builtTx = _txBuilder.BuildTx(txCommand, NetworkContextBuilder.Build(tip, protocolParams)); + + // Sign Tx? + + // Submit Tx + try + { + //sw.Restart(); + //using var stream = new MemoryStream(builtTx.CborBytes); + //var txSubmissionResponse = await txClient.Submit(stream).ConfigureAwait(false); + //if (!txSubmissionResponse.IsSuccessStatusCode) + //{ + // if (txSubmissionResponse.Error is null || string.IsNullOrWhiteSpace(txSubmissionResponse.Error.Content)) + // return CommandResult.FailureBackend($"Koios backend response was unsuccessful"); + // return CommandResult.FailureBackend(txSubmissionResponse.Error.Content); + //} + //if (txSubmissionResponse.Content is null) + //{ + // return CommandResult.FailureBackend("Koios transaction submission response did not return a valid transaction ID"); + //} + //var txId = txSubmissionResponse.Content.TrimStart('"').TrimEnd('"'); + //var txHash = HashUtility.Blake2b256(tx.TransactionBody.Serialize(auxDataBuilder?.Build())).ToStringHex(); + //var result = txId == txHash ? txId : $"Submission response tx-id: {txId} does not match expected: {txHash}"; + return builtTx; + } + catch (ApiException ex) + { + _logger.LogError(ex, "Failed tx submission {error}", ex.Content); + return null; + } + } + + private static ( + IEpochClient epochClient, + INetworkClient networkClient, + IAddressClient addressClient, + ITransactionClient transactionClient + ) GetKoiosClients(Network network) => + (GetBackendClient(network), + GetBackendClient(network), + GetBackendClient(network), + GetBackendClient(network)); + + public static T GetBackendClient(Network networkType) => + RestService.For(GetBaseUrlForNetwork(networkType)); + + private static string GetBaseUrlForNetwork(Network networkType) => networkType switch + { + Network.Mainnet => "https://api.koios.rest/api/v0", + Network.Testnet => "https://testnet.koios.rest/api/v0", + _ => throw new ArgumentException($"{nameof(networkType)} {networkType} is invalid", nameof(networkType)) + }; + + private static UnspentTransactionOutput[] BuildSourceAddressUtxos(IEnumerable addressUtxoSet) + { + return addressUtxoSet + .Select(utxo => new UnspentTransactionOutput( + utxo.TxHash ?? "n/a", + utxo.TxIndex, + new Balance( + ulong.Parse(utxo.Value ?? "0"), + utxo.AssetList.Select( + a => new NativeAssetValue( + a.PolicyId ?? "n/a", + a.AssetName ?? "n/a", + ulong.Parse(a.Quantity ?? "0"))) + .ToArray()))) + .ToArray(); + } + + private static Balance BuildConsolidatedTxInputValue( + UnspentTransactionOutput[] sourceAddressUtxos, + NativeAssetValue[]? nativeAssetsToMint = null) + { + if (nativeAssetsToMint is not null && nativeAssetsToMint.Length > 0) + { + return sourceAddressUtxos + .Select(utxo => utxo.Value) + .Concat(new[] { new Balance(0, nativeAssetsToMint) }) + .Sum(); + } + return sourceAddressUtxos.Select(utxo => utxo.Value).Sum(); + } +} diff --git a/Src/Lib/Mintsafe.Lib.csproj b/Src/Lib/Mintsafe.Lib.csproj index 1bbdf68..8c4ea57 100644 --- a/Src/Lib/Mintsafe.Lib.csproj +++ b/Src/Lib/Mintsafe.Lib.csproj @@ -9,7 +9,7 @@ - + diff --git a/Src/Lib/NetworkContextBuilder.cs b/Src/Lib/NetworkContextBuilder.cs new file mode 100644 index 0000000..3491223 --- /dev/null +++ b/Src/Lib/NetworkContextBuilder.cs @@ -0,0 +1,25 @@ +using CardanoSharp.Koios.Sdk.Contracts; +using Mintsafe.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mintsafe.Lib; + +public static class NetworkContextBuilder +{ + public static NetworkContext Build(BlockSummary latestBlock, ProtocolParameters protocolParams) + { + return new NetworkContext( + (uint)latestBlock.AbsSlot, + new ProtocolParams( + MajorVersion: protocolParams.ProtocolMajor, + MinorVersion: protocolParams.ProtocolMinor, + MinFeeA: protocolParams.MinFeeA, + MinFeeB: protocolParams.MinFeeB, + CoinsPerUtxoWord: protocolParams.CoinsPerUtxoWord)); + } + +} diff --git a/Src/Lib/NiftyDistributor.cs b/Src/Lib/NiftyDistributor.cs index bd35b71..6999c5b 100644 --- a/Src/Lib/NiftyDistributor.cs +++ b/Src/Lib/NiftyDistributor.cs @@ -87,14 +87,14 @@ public async Task DistributeNiftiesForSalePurchase( } var (txHash, txSubmissionException) = await TrySubmitTxAsync( - tx.Bytes, nfts, saleContext, ct).ConfigureAwait(false); + tx.CborBytes, nfts, saleContext, ct).ConfigureAwait(false); if (txHash == null) { // TODO: Record a mint in our table storage (see NiftyTypes) return new NiftyDistributionResult( NiftyDistributionOutcome.FailureTxSubmit, purchaseAttempt, - Convert.ToHexString(tx.Bytes), + Convert.ToHexString(tx.CborBytes), Exception: txSubmissionException); } if (txHash != tx.TxHash) @@ -114,7 +114,7 @@ public async Task DistributeNiftiesForSalePurchase( return new NiftyDistributionResult( NiftyDistributionOutcome.Successful, purchaseAttempt, - Convert.ToHexString(tx.Bytes), + Convert.ToHexString(tx.CborBytes), MintTxHash: tx.TxHash, BuyerAddress: address, NiftiesDistributed: nfts); @@ -144,7 +144,7 @@ private static PendingTransactionOutput[] GetTxBuildOutputs( { var minLovelaceUtxo = TxUtils.CalculateMinUtxoLovelace(tokenMintValues); ulong buyerLovelacesReturned = minLovelaceUtxo + purchaseAttempt.ChangeInLovelace; - var buyerOutputUtxoValues = new AggregateValue(buyerLovelacesReturned, tokenMintValues); + var buyerOutputUtxoValues = new Balance(buyerLovelacesReturned, tokenMintValues); var saleLovelaces = purchaseAttempt.Utxo.Lovelaces - buyerLovelacesReturned; // No NFT creator address specified or we take 100% of the cut @@ -154,15 +154,15 @@ private static PendingTransactionOutput[] GetTxBuildOutputs( new PendingTransactionOutput(buyerAddress, buyerOutputUtxoValues), new PendingTransactionOutput( sale.ProceedsAddress, - new AggregateValue(saleLovelaces, Array.Empty())) + new Balance(saleLovelaces, Array.Empty())) }; } // Calculate proceeds of ADA from saleContext.Sale to creator and proceeds cut var proceedsCutLovelaces = (ulong)(saleLovelaces * sale.PostPurchaseMargin); var creatorCutLovelaces = saleLovelaces - proceedsCutLovelaces; - var creatorAddressUtxoValues = new AggregateValue(creatorCutLovelaces, Array.Empty()); - var proceedsAddressUtxoValues = new AggregateValue(proceedsCutLovelaces, Array.Empty()); + var creatorAddressUtxoValues = new Balance(creatorCutLovelaces, Array.Empty()); + var proceedsAddressUtxoValues = new Balance(proceedsCutLovelaces, Array.Empty()); return new[] { new PendingTransactionOutput(buyerAddress, buyerOutputUtxoValues), new PendingTransactionOutput(sale.CreatorAddress, creatorAddressUtxoValues), diff --git a/Src/Lib/SimpleWalletService.cs b/Src/Lib/SimpleWalletService.cs index b6be4fe..6dd4abf 100644 --- a/Src/Lib/SimpleWalletService.cs +++ b/Src/Lib/SimpleWalletService.cs @@ -196,132 +196,13 @@ public SimpleWalletService( } } - public async Task SubmitTransactionAsync( - BuildTransactionCommand txCommand, CancellationToken ct = default) - { - var sw = Stopwatch.StartNew(); - (var epochClient, var networkClient, var addressClient, var txClient) = GetKoiosClients(txCommand.Network); - var tip = (await networkClient.GetChainTip()).Content.First(); - var protocolParams = (await epochClient.GetProtocolParameters(tip.Epoch.ToString())).Content.First(); - _logger.LogInformation( - "Queried Koios {elapsedMs}ms - Epoch: {Epoch}, AbsSlot: {AbsSlot}", - sw.ElapsedMilliseconds, tip.Epoch, tip.AbsSlot); - - // Inputs TODO: Coin selection? - var txInputs = txCommand.Inputs; - var consolidatedInputValue = BuildConsolidatedTxInputValue( - txInputs, txCommand.Mint.SelectMany(m => m.NativeAssetsToMint).ToArray()); - // Outputs - var txOutputs = txCommand.Outputs; - var consolidatedOutputValue = txOutputs.Select(txOut => txOut.Value).Sum(); - var valueDifference = consolidatedInputValue.Subtract(consolidatedOutputValue); - if (valueDifference.Lovelaces != 0 && !valueDifference.NativeAssets.All(na => na.Quantity == 0)) - { - throw new InputOutputValueMismatchException( - "Input/Output value mismatch", txCommand.Inputs, txCommand.Outputs); - } - - // Start building transaction body using CardanoSharp - var txBodyBuilder = TransactionBodyBuilder.Create - .SetFee(0) - .SetTtl((uint)tip.AbsSlot + txCommand.TtlTipOffsetSlots); - // TxInputs - foreach (var txInput in txInputs) - { - txBodyBuilder.AddInput(txInput.TxHash, txInput.OutputIndex); - } - // TxOutputs - foreach (var txOutput in txOutputs) - { - var tokenBundleBuilder = (txOutput.Value.NativeAssets.Length > 0) - ? GetTokenBundleBuilderFromNativeAssets(txOutput.Value.NativeAssets) - : null; - txBodyBuilder.AddOutput(new Address(txOutput.Address), txOutput.Value.Lovelaces, tokenBundleBuilder); - } - // TxMint - if (txCommand.Mint.Length > 0) - { - // Build Cardano Native Assets from TestResults - var freshMintTokenBundleBuilder = TokenBundleBuilder.Create; - foreach (var newAssetMint in txCommand.Mint.SelectMany(m => m.NativeAssetsToMint)) - { - freshMintTokenBundleBuilder = freshMintTokenBundleBuilder - .AddToken(newAssetMint.PolicyId.HexToByteArray(), newAssetMint.AssetName.HexToByteArray(), 1); - } - txBodyBuilder.SetMint(freshMintTokenBundleBuilder); - } - // TxWitnesses - var witnesses = TransactionWitnessSetBuilder.Create; - foreach (var signingKey in txCommand.PaymentSigningKeys) - { - var paymentSkey = GetPrivateKeyFromBech32SigningKey(signingKey); - witnesses.AddVKeyWitness(paymentSkey.GetPublicKey(false), paymentSkey); - } - foreach (var policy in txCommand.Mint.Select(m => m.BasicMintingPolicy)) - { - var policyKey = GetPrivateKeyFromBech32SigningKey(policy.PolicySigningKeysAll.First()); - witnesses.AddVKeyWitness(policyKey.GetPublicKey(false), policyKey); - var policyScriptAllBuilder = GetScriptAllBuilder( - policy.PolicySigningKeysAll.Select(GetPrivateKeyFromBech32SigningKey), - policy.ExpirySlot); - witnesses.SetNativeScript(policyScriptAllBuilder); - } - - // Build Tx for fee calculation - var txBuilder = TransactionBuilder.Create - .SetBody(txBodyBuilder) - .SetWitnesses(witnesses); - // Metadata - var auxDataBuilder = AuxiliaryDataBuilder.Create; - foreach (var key in txCommand.Metadata.Keys) - { - auxDataBuilder = auxDataBuilder.AddMetadata(key, txCommand.Metadata[key]); - } - txBuilder = txBuilder.SetAuxData(auxDataBuilder); - _logger.LogInformation("Build Metadata {txMetadata}", auxDataBuilder); - // Calculate and update change Utxo - var tx = txBuilder.Build(); - var fee = tx.CalculateFee(protocolParams.MinFeeA, protocolParams.MinFeeB); - txBodyBuilder.SetFee(fee); - tx.TransactionBody.TransactionOutputs.Last().Value.Coin -= fee; - var txBytes = tx.Serialize(); - var txHash = HashUtility.Blake2b256(tx.TransactionBody.Serialize(auxDataBuilder.Build())).ToStringHex(); - _logger.LogInformation("Built mint tx {elapnsed}ms", sw.ElapsedMilliseconds); - - // Submit Tx - try - { - sw.Restart(); - //using var stream = new MemoryStream(txBytes); - //var response = await txClient.Submit(stream).ConfigureAwait(false); - //if (!response.IsSuccessStatusCode || response.Content == null) - //{ - // _logger.LogWarning("Failed tx submission status={statusCode}, error={errorContent}", response.Error.StatusCode, response.Error.Content); - // return null; - //} - //var txId = response.Content.TrimStart('"').TrimEnd('"'); - //if (txId != txHash) - //{ - // _logger.LogWarning("TxId {txId} from txClient.Submit is different to calculated TxHash {txHash}", txId, txHash); - //} - //_logger.LogInformation("Submitted mint tx {elapnsed}ms TxId: {txId} ({txBytesLength}bytes)", sw.ElapsedMilliseconds, txId, txBytes.Length); - //return txId; - return txHash; - } - catch (ApiException ex) - { - _logger.LogError(ex, "Failed tx submission {error}", ex.Content); - return null; - } - } - private static UnspentTransactionOutput[] BuildSourceAddressUtxos(AddressInformation[] addrInfoResponse) { return addrInfoResponse.Single().UtxoSets .Select(utxo => new UnspentTransactionOutput( utxo.TxHash, utxo.TxIndex, - new AggregateValue( + new Balance( ulong.Parse(utxo.Value), utxo.AssetList.Select( a => new NativeAssetValue( @@ -332,7 +213,7 @@ private static UnspentTransactionOutput[] BuildSourceAddressUtxos(AddressInforma .ToArray(); } - private static AggregateValue BuildConsolidatedTxInputValue( + private static Balance BuildConsolidatedTxInputValue( UnspentTransactionOutput[] sourceAddressUtxos, NativeAssetValue[]? nativeAssetsToMint) { @@ -340,7 +221,7 @@ private static AggregateValue BuildConsolidatedTxInputValue( { return sourceAddressUtxos .Select(utxo => utxo.Value) - .Concat(new[] { new AggregateValue(0, nativeAssetsToMint) }) + .Concat(new[] { new Balance(0, nativeAssetsToMint) }) .Sum(); } return sourceAddressUtxos.Select(utxo => utxo.Value).Sum(); diff --git a/Src/Lib/ITransactionSigner.cs b/Src/Lib/TransactionSigner.cs similarity index 100% rename from Src/Lib/ITransactionSigner.cs rename to Src/Lib/TransactionSigner.cs diff --git a/Src/Lib/TxUtils.cs b/Src/Lib/TxUtils.cs index a5ceb08..ce01b7f 100644 --- a/Src/Lib/TxUtils.cs +++ b/Src/Lib/TxUtils.cs @@ -9,27 +9,12 @@ namespace Mintsafe.Lib; public static class TxUtils { - public static Value[] SubtractValues( - this Value[] lhsValues, Value[] rhsValues) + public static bool IsZero(this Balance value) { - static Value SubtractSingleValue(Value lhsValue, Value rhsValue) - { - return rhsValue == default - ? lhsValue - : new Value(lhsValue.Unit, lhsValue.Quantity - rhsValue.Quantity); - }; - - if (rhsValues.Length == 0) - return lhsValues; - - var diff = lhsValues - .Select(lv => SubtractSingleValue(lv, rhsValues.FirstOrDefault(rv => rv.Unit == lv.Unit))) - .ToArray(); - - return diff; + return value.Lovelaces == 0 && value.NativeAssets.Length == 0; } - public static AggregateValue Sum(this IEnumerable values) + public static Balance Sum(this IEnumerable values) { var lovelaces = 0UL; var nativeAssets = new Dictionary<(string PolicyId, string AssetNameHex), ulong>(); @@ -46,12 +31,12 @@ public static AggregateValue Sum(this IEnumerable values) nativeAssets.Add((nativeAsset.PolicyId, nativeAsset.AssetName), nativeAsset.Quantity); } } - return new AggregateValue( + return new Balance( lovelaces, nativeAssets.Select(nav => new NativeAssetValue(nav.Key.PolicyId, nav.Key.AssetNameHex, nav.Value)).ToArray()); } - public static AggregateValue Subtract(this AggregateValue lhsValue, AggregateValue rhsValue) + public static Balance Subtract(this Balance lhsValue, Balance rhsValue) { static NativeAssetValue SubtractSingleValue(NativeAssetValue lhsValue, NativeAssetValue rhsValue) { @@ -61,7 +46,7 @@ static NativeAssetValue SubtractSingleValue(NativeAssetValue lhsValue, NativeAss }; if (rhsValue.NativeAssets.Length == 0) - return new AggregateValue(lhsValue.Lovelaces - rhsValue.Lovelaces, lhsValue.NativeAssets); + return new Balance(lhsValue.Lovelaces - rhsValue.Lovelaces, lhsValue.NativeAssets); var missingLhsValues = rhsValue.NativeAssets .Where(rna => !lhsValue.NativeAssets @@ -77,7 +62,28 @@ static NativeAssetValue SubtractSingleValue(NativeAssetValue lhsValue, NativeAss rv => rv.PolicyId == lv.PolicyId && rv.AssetName == lv.AssetName))) .Where(na => na.Quantity != 0) .ToArray(); - return new AggregateValue(lhsValue.Lovelaces - rhsValue.Lovelaces, nativeAssets); + return new Balance(lhsValue.Lovelaces - rhsValue.Lovelaces, nativeAssets); + } + + [Obsolete("Deprecated by Balance based Subtract")] + public static Value[] SubtractValues( + this Value[] lhsValues, Value[] rhsValues) + { + static Value SubtractSingleValue(Value lhsValue, Value rhsValue) + { + return rhsValue == default + ? lhsValue + : new Value(lhsValue.Unit, lhsValue.Quantity - rhsValue.Quantity); + }; + + if (rhsValues.Length == 0) + return lhsValues; + + var diff = lhsValues + .Select(lv => SubtractSingleValue(lv, rhsValues.FirstOrDefault(rv => rv.Unit == lv.Unit))) + .ToArray(); + + return diff; } public static ulong CalculateMinUtxoLovelace( @@ -125,7 +131,7 @@ public static ulong CalculateMinUtxoLovelace( } public static ulong CalculateMinUtxoLovelace( - AggregateValue txOutBundle, + Balance txOutBundle, int lovelacePerUtxoWord = 34482, // utxoCostPerWord in protocol params (could change in the future) int policyIdSizeBytes = 28, // 224 bit policyID (won't in forseeable future) bool hasDataHash = false) // for UTxOs with smart contract datum diff --git a/Src/Lib/UtxoRefunder.cs b/Src/Lib/UtxoRefunder.cs index 753029d..3bc34a7 100644 --- a/Src/Lib/UtxoRefunder.cs +++ b/Src/Lib/UtxoRefunder.cs @@ -71,7 +71,7 @@ public async Task ProcessRefundForUtxo( _logger.LogDebug($"{nameof(_txBuilder.BuildTx)} completed after {sw.ElapsedMilliseconds}ms"); sw.Restart(); - var txHash = await _txSubmitter.SubmitTxAsync(tx.Bytes, ct).ConfigureAwait(false); + var txHash = await _txSubmitter.SubmitTxAsync(tx.CborBytes, ct).ConfigureAwait(false); _logger.LogDebug($"{nameof(_txSubmitter.SubmitTxAsync)} completed after {sw.ElapsedMilliseconds}ms"); _logger.LogDebug($"TxID:{txHash} Successfully refunded {utxo.Lovelaces} to {buyerAddress}"); diff --git a/Tests/Lib.UnitTests/FakeGenerator.cs b/Tests/Lib.UnitTests/FakeGenerator.cs index 3afd9a8..bfe29b8 100644 --- a/Tests/Lib.UnitTests/FakeGenerator.cs +++ b/Tests/Lib.UnitTests/FakeGenerator.cs @@ -90,7 +90,7 @@ public static UnspentTransactionOutput[] GenerateUtxos(int count, params ulong[] .Select(i => new UnspentTransactionOutput( "127745e23b81a5a5e22a409ce17ae8672b234dda7be1f09fc9e3a11906bd3a11", (uint)i, - new AggregateValue(values[i], Array.Empty()))) + new Balance(values[i], Array.Empty()))) .ToArray(); } diff --git a/Tests/Lib.UnitTests/GenericMintingServiceShould.cs b/Tests/Lib.UnitTests/GenericMintingServiceShould.cs new file mode 100644 index 0000000..a6a2a8d --- /dev/null +++ b/Tests/Lib.UnitTests/GenericMintingServiceShould.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Mintsafe.Abstractions; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; +using static Mintsafe.Lib.UnitTests.FakeGenerator; + +namespace Mintsafe.Lib.UnitTests; + +public class GenericMintingServiceShould +{ + //[Fact(Skip = "Live integration test - can fail if run concurrently with other tests")] + [Fact] + public async Task Submit_Mint_Transaction_Successfully_When_Consolidating_Own_Address_Utxos() + { + var mockTxSubmitter = new Mock(); + var simpleWalletService = new GenericMintingService( + NullLogger.Instance, + NullInstrumentor.Instance, + new CardanoSharpTxBuilder( + NullLogger.Instance, + NullInstrumentor.Instance, + GenerateSettings()), + mockTxSubmitter.Object); + + var fromAddress = "addr_test1qq5zuhh9685fup86syuzmu3e6eengzv8t46mfqxg086cvqz8fquadv00d7t7a88rlf6z2knwfesls5f2cndan7runlcsad62ju"; + string toAddress = "addr_test1qpvttg5263dnutj749k5dcr35yk5mr94fxx0q2zs2xeuxq5hvcrpf2ezgxucdwcjytcrww34j5y609ss4sfpptg3uvpsxmcdtf"; + uint lovelaces = 51455855; + var skey = "addr_xsk1fzw9r482t0ekua7rcqewg3k8ju5d9run4juuehm2p24jtuzz4dg4wpeulnqhualvtx9lyy7u0h9pdjvmyhxdhzsyy49szs6y8c9zwfp0eqyrqyl290e6dr0q3fvngmsjn4aask9jjr6q34juh25hczw3euust0dw"; + var network = Network.Testnet; + + var tx = await simpleWalletService.MintNativeAssets( + from: fromAddress, + to: toAddress, + network: network, + balanceToSend: new Balance(lovelaces, Array.Empty()), + fromSigningKey: skey); + + } +} diff --git a/Tests/Lib.UnitTests/NiftyAllocatorShould.cs b/Tests/Lib.UnitTests/NiftyAllocatorShould.cs index 50d3b98..0b9b176 100644 --- a/Tests/Lib.UnitTests/NiftyAllocatorShould.cs +++ b/Tests/Lib.UnitTests/NiftyAllocatorShould.cs @@ -78,7 +78,7 @@ public async Task Throws_CannotAllocateMoreThanSaleReleaseException_When_Request var request = new PurchaseAttempt( Guid.NewGuid(), Guid.NewGuid(), - new UnspentTransactionOutput("", 0, new AggregateValue(1000000, Array.Empty())), + new UnspentTransactionOutput("", 0, new Balance(1000000, Array.Empty())), requestedQuantity, 0); @@ -110,7 +110,7 @@ public async Task Throws_ArgumentException_When_Requesting_Zero_Or_Negative_Toke var request = new PurchaseAttempt( Guid.NewGuid(), Guid.NewGuid(), - new UnspentTransactionOutput("", 0, new AggregateValue(1000000, Array.Empty())), + new UnspentTransactionOutput("", 0, new Balance(1000000, Array.Empty())), requestedQuantity, 0); diff --git a/Tests/Lib.UnitTests/PurchaseAttemptGeneratorShould.cs b/Tests/Lib.UnitTests/PurchaseAttemptGeneratorShould.cs index 9424cc6..5e38f81 100644 --- a/Tests/Lib.UnitTests/PurchaseAttemptGeneratorShould.cs +++ b/Tests/Lib.UnitTests/PurchaseAttemptGeneratorShould.cs @@ -20,7 +20,7 @@ public void Correctly_Calculate_Quantity( new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new AggregateValue(utxoValueLovelace, Array.Empty())), + new Balance(utxoValueLovelace, Array.Empty())), sale); salePurchase.NiftyQuantityRequested.Should().Be(expectedQuantity); @@ -39,7 +39,7 @@ public void Correctly_Calculate_Change( new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new AggregateValue(utxoValueLovelace, Array.Empty())), + new Balance(utxoValueLovelace, Array.Empty())), sale); salePurchase.ChangeInLovelace.Should().Be(expectedChange); @@ -61,7 +61,7 @@ public void Correctly_Maps_Values_When_Sale_Is_Active_And_Within_Start_End_Dates new UnspentTransactionOutput( txHash, 0, - new AggregateValue(10000000, Array.Empty())), + new Balance(10000000, Array.Empty())), sale); salePurchase.Utxo.TxHash.Should().Be(txHash); @@ -82,7 +82,7 @@ public void Throws_InsufficientPaymentException_When_Utxo_Value_Is_Less_Than_Lov new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new AggregateValue(utxoValueLovelace, Array.Empty())), + new Balance(utxoValueLovelace, Array.Empty())), sale); }; @@ -100,7 +100,7 @@ public void Throws_SaleInactiveException_When_Sale_Is_Inactive() new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new AggregateValue(100000000, Array.Empty())), + new Balance(100000000, Array.Empty())), sale); }; @@ -121,7 +121,7 @@ public void Throws_MaxAllowedPurchaseQuantityExceededException_When_Quantity_Exc new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new AggregateValue(utxoValueLovelace, Array.Empty())), + new Balance(utxoValueLovelace, Array.Empty())), sale); }; @@ -142,7 +142,7 @@ public void Throws_SalePeriodOutOfRangeException_When_Sale_Has_Not_Started( new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new AggregateValue(100000000, Array.Empty())), + new Balance(100000000, Array.Empty())), sale); }; @@ -163,7 +163,7 @@ public void Throws_SalePeriodOutOfRangeException_When_Sale_Has_Already_Ended( new UnspentTransactionOutput( "95c248e17f0fc35be4d2a7d186a84cdcda5b99d7ad2799ebe98a9865", 0, - new AggregateValue(100000000, Array.Empty())), + new Balance(100000000, Array.Empty())), sale); }; diff --git a/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs index 1592cb2..14839d4 100644 --- a/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs +++ b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs @@ -51,7 +51,7 @@ public async Task Submit_Transaction_Successfully_When_Making_Simple_Ada_Payment var network = Network.Testnet; // Not used now var destinationPaymentAddress = "addr_test1qplxcfvad2uzq2w4k99unzj6d5hmpprgrujn3l0nwsl8vh3e2mgaxpeslac7hghtxxzcwerr3wt6ly2t9hr7unkua9rskg2855"; - var destinationOutputValue = new AggregateValue(8888888, Array.Empty()); + var destinationOutputValue = new Balance(8888888, Array.Empty()); var messageBodyMetadata = new Dictionary { { "msg", new[] { "mintsafe.io test", DateTime.UtcNow.ToString("o") } } }; var messageMetadata = new Dictionary> @@ -93,7 +93,7 @@ public async Task Submit_Transaction_Successfully_When_Minting_Nft_Royalty_Asset sourcePaymentAddress, sourcePaymentXsk, network, - outputs: new[] { new PendingTransactionOutput(sourcePaymentAddress, new AggregateValue(minUtxoLovelace, nativeAssetsToMint)) }, + outputs: new[] { new PendingTransactionOutput(sourcePaymentAddress, new Balance(minUtxoLovelace, nativeAssetsToMint)) }, nativeAssetsToMint: nativeAssetsToMint, metadata: royaltyMetadata, policySkeys: new[] { policySkey }, diff --git a/Tests/Lib.UnitTests/TxUtilsShould.cs b/Tests/Lib.UnitTests/TxUtilsShould.cs index 96286f1..558878d 100644 --- a/Tests/Lib.UnitTests/TxUtilsShould.cs +++ b/Tests/Lib.UnitTests/TxUtilsShould.cs @@ -236,7 +236,7 @@ public void DeriveMinUtxoLovelace_For_Token_Bundle_When_Output_Has_Two_Assets_Un values.Add(new Value("e8a447d4e19016ca2aa74d20b4c4de87adb1f21dfb5493bf2d7281a6.COND1", 20)); values.Add(new Value("e8a447d4e19016ca2aa74d20b4c4de87adb1f21dfb5493bf2d7281a6.COND2", 20)); - var bundle = new AggregateValue(100_000000, new[] + var bundle = new Balance(100_000000, new[] { new NativeAssetValue("e8a447d4e19016ca2aa74d20b4c4de87adb1f21dfb5493bf2d7281a6", "434f4e4431", 20), new NativeAssetValue("e8a447d4e19016ca2aa74d20b4c4de87adb1f21dfb5493bf2d7281a6", "434f4e4432", 20), @@ -256,7 +256,7 @@ public void DeriveMinUtxoLovelace_For_Token_Bundle_When_Output_Has_Two_Assets_Un public void Consolidate_Output_Values_Calculating_Lovelaces_Correctly_When_No_Native_Assets_Exist(params ulong[] lovelaceValues) { var outputValues = lovelaceValues - .Select(lv => new AggregateValue(lv, Array.Empty())).ToArray(); + .Select(lv => new Balance(lv, Array.Empty())).ToArray(); var foldedOutputValue = outputValues.Sum(); @@ -280,7 +280,7 @@ public void Consolidate_Output_Values_Combining_Native_Assets_Correctly_When_Out { var outputValues = outputJson .Select(json => JsonSerializer.Deserialize(json)) - .Select(ov => new AggregateValue(ov.Lovelaces, ov.NativeAssets.Select(na => new NativeAssetValue(na.PolicyId, na.AssetNameHex, na.Quantity)).ToArray())).ToArray(); + .Select(ov => new Balance(ov.Lovelaces, ov.NativeAssets.Select(na => new NativeAssetValue(na.PolicyId, na.AssetNameHex, na.Quantity)).ToArray())).ToArray(); var actualOutputValue = outputValues.Sum(); @@ -308,7 +308,7 @@ public void Consolidate_Output_Values_Combining_Native_Assets_Correctly_When_Out { var outputValues = outputValuesJson .Select(json => JsonSerializer.Deserialize(json)) - .Select(ov => new AggregateValue(ov.Lovelaces, ov.NativeAssets.Select(na => new NativeAssetValue(na.PolicyId, na.AssetNameHex, na.Quantity)).ToArray())).ToArray(); + .Select(ov => new Balance(ov.Lovelaces, ov.NativeAssets.Select(na => new NativeAssetValue(na.PolicyId, na.AssetNameHex, na.Quantity)).ToArray())).ToArray(); var actualOutputValue = outputValues.Sum();