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/.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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ba2ab0a Binary files /dev/null and b/CONTRIBUTING.md differ 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 8d53940..ca3bd68 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,13 @@ 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 +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 @@ -83,6 +92,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..76b3ece 100644 --- a/Src/Abstractions/CardanoTypes.cs +++ b/Src/Abstractions/CardanoTypes.cs @@ -1,34 +1,112 @@ 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}"; + 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 +public record struct NativeAssetValue(string PolicyId, string AssetName, ulong Quantity); + +public record struct Balance(ulong Lovelaces, NativeAssetValue[] NativeAssets); + +public record struct PendingTransactionOutput(string Address, Balance Value); + +public record UnspentTransactionOutput(string TxHash, uint OutputIndex, Balance 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; } +public record TransactionSummary(string TxHash, TransactionIo[] Inputs, TransactionIo[] Outputs); +public record TransactionIo(string Address, int OutputIndex, Balance Values); -public record TxIo(string Address, int OutputIndex, Value[] 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 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, Value[] Values, bool IsFeeDeducted = false); +public record TxBuildOutput(string Address, Balance Values, bool IsFeeDeducted = false); + +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 record TxBuildCommand( - Utxo[] Inputs, - TxBuildOutput[] Outputs, - Value[] 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..2320701 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 BlockfrostLatestBlock +{ + public uint? Epoch { get; init; } + public uint? Slot { get; init; } + public uint? Height { get; init; } + public string? Hash { get; init; } +} + +public class BlockfrostProtocolParameters +{ + 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/IMintingKeychainRetriever.cs b/Src/Abstractions/IMintingKeychainRetriever.cs new file mode 100644 index 0000000..cb7c26e --- /dev/null +++ b/Src/Abstractions/IMintingKeychainRetriever.cs @@ -0,0 +1,12 @@ +using Mintsafe.Abstractions; +using System.Threading; +using System.Threading.Tasks; + +namespace Mintsafe.Abstractions; + +public interface IMintingKeychainRetriever +{ + Task GetMintingKeyChainAsync( + SaleContext saleContext, + CancellationToken ct = default); +} \ No newline at end of file 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/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/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/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..14616ec 100644 --- a/Src/Abstractions/ITxBuilder.cs +++ b/Src/Abstractions/ITxBuilder.cs @@ -3,9 +3,13 @@ namespace Mintsafe.Abstractions; -public interface ITxBuilder +public interface IMintTransactionBuilder { - public Task BuildTxAsync( - TxBuildCommand buildCommand, - CancellationToken ct = default); + BuiltTransaction BuildTx( + BuildTransactionCommand buildCommand, + NetworkContext networkContext); + + BuiltTransaction BuildTx( + BuildTxCommand buildCommand, + NetworkContext networkContext); } 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 7c85d8c..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,13 +17,14 @@ 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, - long SlotExpiry); + long SlotExpiry, + Royalty Royalty); public record Nifty( Guid Id, @@ -26,14 +32,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( @@ -41,7 +46,7 @@ public record NiftyFile( Guid NiftyId, string Name, string MediaType, - string Url, + string Src, string FileHash = ""); public record Royalty( @@ -54,7 +59,7 @@ public record Sale( bool IsActive, string Name, string Description, - long LovelacesPerToken, + ulong LovelacesPerToken, string SaleAddress, string CreatorAddress, string ProceedsAddress, @@ -73,18 +78,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 +112,7 @@ public record NiftyDistributionResult( Nifty[]? NiftiesDistributed = null, Exception? Exception = null); -public record Mint( +public record MintRecord( Guid PurchaseAttemptId, Guid SaleId, string SaleAddress, @@ -118,3 +127,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/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 23b6f98..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 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 f8650d6..b554619 100644 --- a/Src/DataAccess/Mappers/NiftyCollectionMapper.cs +++ b/Src/DataAccess/Mappers/NiftyCollectionMapper.cs @@ -17,12 +17,18 @@ 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)); + return new NiftyCollection( Guid.Parse(dtoNiftyCollection.RowKey), dtoNiftyCollection.PolicyId, @@ -30,10 +36,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..de24aac 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.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.FileHash + niftyFileDto.Src, + niftyFileDto.FileHash ?? string.Empty ); } @@ -25,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/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/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/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..a9b48e6 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? Src { 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..3e3380f 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 long LovelacesPerToken { get; set; } - public string SaleAddress { get; set; } - public string CreatorAddress { get; set; } - public string ProceedsAddress { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public ulong LovelacesPerToken { 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/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/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/DataImporter/Program.cs b/Src/DataImporter/Program.cs index cebc9b8..38314ca 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 = "``"; 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\\t"; -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); @@ -49,68 +51,89 @@ async void BuildModelsAndInsertAsync( IServiceProvider services, NiftyCollection niftyCollection, Sale sale, - IList niftyJson) + IList niftyJson) { using IServiceScope serviceScope = services.CreateScope(); 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(jv => (string)jv) + .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, - Royalty: new Royalty(0, ""), - 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) +async Task LoadJsonFromFileAsync(string path) { - var raw = await File.ReadAllTextAsync(path); - return JsonSerializer.Deserialize(raw); + var raw = await File.ReadAllTextAsync(path).ConfigureAwait(false); + return JsonSerializer.Deserialize(raw, SerialiserOptions()); } -async Task> LoadDynamicJsonFromDirAsync(string path) + +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) { var files = Directory.GetFiles(path); - var list = new List(); + var list = new List(); 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/BlockfrostClient.cs b/Src/Lib/BlockfrostClient.cs index 0cb1023..0635494 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; + BlockfrostLatestBlock? 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; + BlockfrostProtocolParameters? 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/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..d304ab7 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; } @@ -16,7 +16,6 @@ public BlockfrostTxSubmitter(BlockfrostClient 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 263ff7d..162895a 100644 --- a/Src/Lib/BlockfrostUtxoRetriever.cs +++ b/Src/Lib/BlockfrostUtxoRetriever.cs @@ -10,18 +10,17 @@ 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; } - public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) + public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) { var bfResult = Array.Empty(); try @@ -33,31 +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); - - return new Value( - bfVal.Unit ?? throw new BlockfrostResponseException("Blockfrost response has null amount.unit", 0), - 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 Balance(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 deleted file mode 100644 index 9106e57..0000000 --- a/Src/Lib/CardanoCliTxBuilder.cs +++ /dev/null @@ -1,471 +0,0 @@ -using Microsoft.Extensions.Logging; -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; -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 = long.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, long 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; - if (output.IsFeeDeducted) - { - lovelacesOut -= fee; - } - - sb.Append($"--tx-out \"{output.Address}+{lovelacesOut}"); - - var nativeTokens = output.Values.Where(o => o.Unit != Assets.LovelaceUnit).ToArray(); - foreach (var value in nativeTokens) - { - sb.Append($"+{value.Quantity} {value.Unit}"); - } - 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.Unit}+"); - } - 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 = 199469; - - 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, long 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; - if (output.IsFeeDeducted) - { - lovelacesOut -= fee; - } - - sb.Append($"--tx-out \"{output.Address}+{lovelacesOut}"); - - var nativeTokens = output.Values.Where(o => o.Unit != Assets.LovelaceUnit).ToArray(); - foreach (var value in nativeTokens) - { - sb.Append($"+{value.Quantity} {value.Unit}"); - } - 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.Unit}+"); - } - 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 f936918..0000000 --- a/Src/Lib/CardanoCliUtxoRetriever.cs +++ /dev/null @@ -1,132 +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 Utxo[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(); - - utxos[insertionIndex++] = new Utxo( - TxHash: contentSegments[0], - OutputIndex: int.Parse(contentSegments[1]), - Values: values); - - } - return utxos; - } - - private static IEnumerable 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 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 unit = utxoLineSegments[currentSegmentIndex + 2]; - yield return new Value(unit, quantity); - currentSegmentIndex += 3; // skip "+ {quantity} {unit}" - } - } -} - -public class FakeUtxoRetriever : IUtxoRetriever -{ - public async Task GetUtxosAtAddressAsync(string address, CancellationToken ct = default) - { - await Task.Delay(1000, ct); - return GenerateUtxos(3, - 15000000, - 10000000, - 30000000); - } - - private static Utxo[] GenerateUtxos(int count, params long[] 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( - "127745e23b81a5a5e22a409ce17ae8672b234dda7be1f09fc9e3a11906bd3a11", - i, - new[] { new Value(Assets.LovelaceUnit, values[i]) })) - .ToArray(); - } -} diff --git a/Src/Lib/CardanoSharpTxBuilder.cs b/Src/Lib/CardanoSharpTxBuilder.cs new file mode 100644 index 0000000..e5a0ddc --- /dev/null +++ b/Src/Lib/CardanoSharpTxBuilder.cs @@ -0,0 +1,340 @@ +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 class CardanoSharpTxBuilder : IMintTransactionBuilder +{ + private const ulong FeePadding = 132; + 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.IsZero()) + { + 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(); + isSuccessful = true; + 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); + } + } + + 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) + { + if (nativeAssetsToMint != null && nativeAssetsToMint.Length > 0) + { + return sourceAddressUtxos + .Select(utxo => utxo.Value) + .Concat(new[] { new Balance(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; + } + + + 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/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/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/KeyVaultMintingKeychainRetriever.cs b/Src/Lib/KeyVaultMintingKeychainRetriever.cs new file mode 100644 index 0000000..3919fdf --- /dev/null +++ b/Src/Lib/KeyVaultMintingKeychainRetriever.cs @@ -0,0 +1,71 @@ +using Azure.Core; +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Logging; +using Mintsafe.Abstractions; +using System; +using System.Diagnostics; +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/LocalNiftyDataService.cs b/Src/Lib/LocalNiftyDataService.cs index 1fa11e9..cea0595 100644 --- a/Src/Lib/LocalNiftyDataService.cs +++ b/Src/Lib/LocalNiftyDataService.cs @@ -13,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 @@ -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,23 +40,28 @@ 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(); - 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(); } @@ -79,8 +85,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 +126,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/MetadataBuilder.cs b/Src/Lib/MetadataBuilder.cs new file mode 100644 index 0000000..c5e643b --- /dev/null +++ b/Src/Lib/MetadataBuilder.cs @@ -0,0 +1,146 @@ +using Mintsafe.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; + +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; + } + + 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; + } +} 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 62f22c9..0000000 --- a/Src/Lib/MetadataJsonBuilder.cs +++ /dev/null @@ -1,140 +0,0 @@ -using Microsoft.Extensions.Logging; -using Mintsafe.Abstractions; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -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 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 CnftStandardRoyalty - { - public double Pct { 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 static readonly JsonSerializerOptions SerialiserOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - 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 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.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 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; - } - } -} diff --git a/Src/Lib/Mintsafe.Lib.csproj b/Src/Lib/Mintsafe.Lib.csproj index c69b400..8c4ea57 100644 --- a/Src/Lib/Mintsafe.Lib.csproj +++ b/Src/Lib/Mintsafe.Lib.csproj @@ -7,12 +7,16 @@ - - + + + + + + diff --git a/Src/Lib/MintsafeAppSettings.cs b/Src/Lib/MintsafeAppSettings.cs index 13493ba..94e6ca3 100644 --- a/Src/Lib/MintsafeAppSettings.cs +++ b/Src/Lib/MintsafeAppSettings.cs @@ -1,16 +1,17 @@ -using System; +using Mintsafe.Abstractions; +using System; namespace Mintsafe.Lib; -public enum Network { Mainnet, Testnet } - 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/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/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 b91c9ee..6999c5b 100644 --- a/Src/Lib/NiftyDistributor.cs +++ b/Src/Lib/NiftyDistributor.cs @@ -4,47 +4,49 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace Mintsafe.Lib; -public class NiftyDistributor : INiftyDistributor +public class CardanoSharpNiftyDistributor : INiftyDistributor { - private readonly ILogger _logger; + 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 IMintingKeychainRetriever _keychainRetriever; + private readonly IMintTransactionBuilder _txBuilder; private readonly ITxSubmitter _txSubmitter; - private readonly ISaleAllocationStore _saleContextStore; + private readonly ISaleAllocationStore _saleAllocationStore; - public NiftyDistributor( - ILogger logger, + public CardanoSharpNiftyDistributor( + ILogger logger, IInstrumentor instrumentor, MintsafeAppSettings settings, - IMetadataFileGenerator metadataGenerator, ITxInfoRetriever txRetriever, - ITxBuilder txBuilder, + IMintingKeychainRetriever keychainRetriever, + IMintTransactionBuilder txBuilder, ITxSubmitter txSubmitter, ISaleAllocationStore saleContextStore) { _logger = logger; _instrumentor = instrumentor; _settings = settings; - _metadataGenerator = metadataGenerator; _txRetriever = txRetriever; + _keychainRetriever = keychainRetriever; _txBuilder = txBuilder; _txSubmitter = txSubmitter; - _saleContextStore = saleContextStore; + _saleAllocationStore = saleContextStore; } public async Task DistributeNiftiesForSalePurchase( Nifty[] nfts, PurchaseAttempt purchaseAttempt, SaleContext saleContext, + NetworkContext networkContext, CancellationToken ct = default) { var swTotal = Stopwatch.StartNew(); @@ -53,7 +55,7 @@ public async Task DistributeNiftiesForSalePurchase( var (address, buyerAddressException) = await TryGetBuyerAddressAsync( nfts, purchaseAttempt, saleContext, ct).ConfigureAwait(false); if (address == null) - { + { return new NiftyDistributionResult( NiftyDistributionOutcome.FailureTxInfo, purchaseAttempt, @@ -61,76 +63,61 @@ public async Task DistributeNiftiesForSalePurchase( Exception: buyerAddressException); } - var tokenMintValues = nfts.Select(n => new Value($"{saleContext.Collection.PolicyId}.{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) + // 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, - string.Empty, + null, Exception: txRawException); } var (txHash, txSubmissionException) = await TrySubmitTxAsync( - txRawBytes, 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, - txBuildJson, + Convert.ToHexString(tx.CborBytes), 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), + nameof(CardanoSharpNiftyDistributor), address, nameof(DistributeNiftiesForSalePurchase), - data: txBuildJson, isSuccessful: true); return new NiftyDistributionResult( - NiftyDistributionOutcome.Successful, + NiftyDistributionOutcome.Successful, purchaseAttempt, - txBuildJson, - txHash, - address, - nfts); + Convert.ToHexString(tx.CborBytes), + MintTxHash: tx.TxHash, + BuyerAddress: address, + NiftiesDistributed: nfts); } private async Task<(string? Address, Exception? Ex)> TryGetBuyerAddressAsync( @@ -144,32 +131,81 @@ 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); } } - private async Task<(byte[]? TxRawBytes, Exception? Ex)> TryGetTxRawBytesAsync( - TxBuildCommand txBuildCommand, - Nifty[] nfts, - SaleContext saleContext, + 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 Balance(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 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 Balance(creatorCutLovelaces, Array.Empty()); + var proceedsAddressUtxoValues = new Balance(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 = await _txBuilder.BuildTxAsync(txBuildCommand, ct).ConfigureAwait(false); + var raw = _txBuilder.BuildTx(txBuildCommand, networkContext); return (raw, null); } - catch (CardanoCliException ex) + catch (CardanoSharpException ex) { - _logger.LogError(EventIds.TxBuilderError, ex, $"Failed Tx Build {ex.Args}"); - await _saleContextStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); + _logger.LogError(EventIds.TxBuilderError, ex, "Failed Tx Build"); + 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); } } @@ -184,77 +220,13 @@ public async Task DistributeNiftiesForSalePurchase( { var txHash = await _txSubmitter.SubmitTxAsync(txRawBytes, ct).ConfigureAwait(false); _logger.LogDebug($"{nameof(_txSubmitter.SubmitTxAsync)} completed with txHash:{txHash}"); - return (txHash, null); + 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); + await _saleAllocationStore.ReleaseAllocationAsync(nfts, saleContext, ct).ConfigureAwait(false); return (null, ex); } } - - 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) - { - // 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 minLovelaceUtxo = TxUtils.CalculateMinUtxoLovelace(buyerOutputUtxoValues); - long buyerLovelacesReturned = minLovelaceUtxo + purchaseAttempt.ChangeInLovelace; - buyerOutputUtxoValues[0].Quantity = 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[] { new Value(Assets.LovelaceUnit, saleLovelaces) }, - IsFeeDeducted: true) - }; - } - - // Calculate proceeds of ADA from saleContext.Sale to creator and proceeds cut - var proceedsCutLovelaces = (int)(saleLovelaces * sale.PostPurchaseMargin); - var creatorCutLovelaces = saleLovelaces - proceedsCutLovelaces; - var creatorAddressUtxoValues = new[] { new Value(Assets.LovelaceUnit, creatorCutLovelaces) }; - var proceedsAddressUtxoValues = new[] { new Value(Assets.LovelaceUnit, proceedsCutLovelaces) }; - 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); - } -} +} \ 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..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(); @@ -71,10 +71,81 @@ 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, + 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 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, diff --git a/Src/Lib/SimpleWalletService.cs b/Src/Lib/SimpleWalletService.cs new file mode 100644 index 0000000..6dd4abf --- /dev/null +++ b/Src/Lib/SimpleWalletService.cs @@ -0,0 +1,436 @@ +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.TransactionBuilding; +using CardanoSharp.Wallet.Utilities; +using Microsoft.Extensions.Logging; +using Mintsafe.Abstractions; +using Refit; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +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 const ulong FeePadding = 132; + 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.SetScriptAllNativeScript(policyScriptAllBuilder); + } + // Build Tx for fee calculation + var txBuilder = TransactionBuilder.Create + .SetBody(txBodyBuilder) + .SetWitnesses(witnesses); + // Metadata + var auxDataBuilder = AuxiliaryDataBuilder.Create; + if (metadata != null && metadata.Any()) + { + foreach (var key in metadata.Keys) + { + auxDataBuilder = auxDataBuilder.AddMetadata(key, metadata[key]); + } + 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) + 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); + + // 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 Balance( + ulong.Parse(utxo.Value), + utxo.AssetList.Select( + a => new NativeAssetValue( + a.PolicyId, + a.AssetName, + ulong.Parse(a.Quantity))) + .ToArray()))) + .ToArray(); + } + + private static Balance BuildConsolidatedTxInputValue( + UnspentTransactionOutput[] sourceAddressUtxos, + NativeAssetValue[]? nativeAssetsToMint) + { + if (nativeAssetsToMint != null && nativeAssetsToMint.Length > 0) + { + return sourceAddressUtxos + .Select(utxo => utxo.Value) + .Concat(new[] { new Balance(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/TransactionSigner.cs b/Src/Lib/TransactionSigner.cs new file mode 100644 index 0000000..3376cdc --- /dev/null +++ b/Src/Lib/TransactionSigner.cs @@ -0,0 +1,36 @@ +using CardanoSharp.Wallet.Extensions.Models.Transactions; +using Microsoft.Extensions.Logging; +using Mintsafe.Abstractions; + +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/TxUtils.cs b/Src/Lib/TxUtils.cs index d4b7805..ce01b7f 100644 --- a/Src/Lib/TxUtils.cs +++ b/Src/Lib/TxUtils.cs @@ -1,4 +1,7 @@ -using Mintsafe.Abstractions; +using CardanoSharp.Wallet.Encoding; +using CardanoSharp.Wallet.Models.Keys; +using Mintsafe.Abstractions; +using System; using System.Collections.Generic; using System.Linq; @@ -6,6 +9,63 @@ namespace Mintsafe.Lib; public static class TxUtils { + public static bool IsZero(this Balance value) + { + return value.Lovelaces == 0 && value.NativeAssets.Length == 0; + } + + public static Balance 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 Balance( + lovelaces, + nativeAssets.Select(nav => new NativeAssetValue(nav.Key.PolicyId, nav.Key.AssetNameHex, nav.Value)).ToArray()); + } + + public static Balance Subtract(this Balance lhsValue, Balance 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 Balance(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 Balance(lhsValue.Lovelaces - rhsValue.Lovelaces, nativeAssets); + } + + [Obsolete("Deprecated by Balance based Subtract")] public static Value[] SubtractValues( this Value[] lhsValues, Value[] rhsValues) { @@ -26,9 +86,9 @@ static Value SubtractSingleValue(Value lhsValue, Value rhsValue) return diff; } - public static long CalculateMinUtxoLovelace( - Value[] outputValues, - int lovelacePerUtxoWord = 34482, + public static ulong CalculateMinUtxoLovelace( + Value[] outputValues, + int lovelacePerUtxoWord = 34482, int policyIdBytes = 28, bool hasDataHash = false) { @@ -37,7 +97,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; @@ -45,7 +105,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(); @@ -59,14 +119,107 @@ 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( + 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 + { + // 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; + } + + 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..3bc34a7 100644 --- a/Src/Lib/UtxoRefunder.cs +++ b/Src/Lib/UtxoRefunder.cs @@ -2,7 +2,6 @@ using Mintsafe.Abstractions; using System; using System.Diagnostics; -using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,31 +15,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 IMintTransactionBuilder _txBuilder; public UtxoRefunder( ILogger logger, MintsafeAppSettings settings, ITxInfoRetriever txRetriever, + IMintingKeychainRetriever keychainRetriever, IMetadataFileGenerator metadataGenerator, - ITxBuilder txBuilder, + IMintTransactionBuilder 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 +57,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.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/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/ConfigTypes.cs b/Src/SaleWorker/ConfigTypes.cs index 8c4eac8..5f2d6b4 100644 --- a/Src/SaleWorker/ConfigTypes.cs +++ b/Src/SaleWorker/ConfigTypes.cs @@ -6,7 +6,9 @@ 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; } } public class CardanoNetworkConfig @@ -21,6 +23,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 feeb024..2ac8dff 100644 --- a/Src/SaleWorker/Program.cs +++ b/Src/SaleWorker/Program.cs @@ -13,6 +13,7 @@ using Mintsafe.Lib; using Mintsafe.SaleWorker; using System; +using System.Linq; IHost host = Host.CreateDefaultBuilder(args) .ConfigureHostConfiguration(configHost => @@ -54,7 +55,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") @@ -66,8 +73,11 @@ Network = cardanoNetworkConfig.Network == "Mainnet" ? Network.Mainnet : Network.Testnet, BlockFrostApiKey = blockfrostApiConfig.ApiKey, BasePath = mintsafeWorkerConfig.MintBasePath, - PollingIntervalSeconds = mintsafeWorkerConfig.PollingIntervalSeconds.HasValue ? mintsafeWorkerConfig.PollingIntervalSeconds.Value : 10, - CollectionId = Guid.Parse(mintsafeWorkerConfig.CollectionId) + 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 }; services.AddSingleton(settings); @@ -99,9 +109,7 @@ services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -112,11 +120,13 @@ //services.AddSingleton(); //services.AddSingleton(); - //// Reals - //services.AddSingleton(); + // Reals + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddAzureClients(clientBuilder => @@ -128,7 +138,7 @@ clientBuilder.AddTableClient(connectionString, Constants.TableNames.NiftyFile); }); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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..47c9b98 100644 --- a/Src/SaleWorker/Worker.cs +++ b/Src/SaleWorker/Worker.cs @@ -3,9 +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 +13,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 +24,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(); @@ -47,46 +44,57 @@ public Worker( 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 retryCount = 0; var timer = new PeriodicTimer(TimeSpan.FromSeconds(_settings.PollingIntervalSeconds)); do { - 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) + try { - if (saleContext.LockedUtxos.Contains(saleUtxo)) - { - _logger.LogDebug($"Utxo {saleUtxo.TxHash}[{saleUtxo.OutputIndex}]({saleUtxo.Lovelaces}) skipped (already locked)"); - continue; - } - await _saleUtxoHandler.HandleAsync(saleUtxo, saleContext, ct); + var networkContext = await _networkContextRetriever.GetNetworkContext(ct); + await Task.WhenAll(activeSales.Select(s => PollSaleAddressForUtxos(s, networkContext, ct))); + retryCount = 0; } - _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)); + 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) + { + 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)) + { + _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"); + } + + 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 e5e2d9a..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": "9ca55025-e271-4eb1-860c-ea9e3de84978" - }, - "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 22f3559..ad1c888 100644 --- a/Src/SaleWorker/appsettings.json +++ b/Src/SaleWorker/appsettings.json @@ -1,8 +1,10 @@ { "MintsafeWorker": { "MintBasePath": "C:\\ws\\temp\\mintsafe\\", - "PollingIntervalSeconds": 10, - "CollectionId": "d5b35d3d-14cc-40ba-94f4-fe3b28bd52ae" + "PollingIntervalSeconds": 20, + "PollErrorRetryLimit": 8, + "CollectionId": "", + "SaleIds": [] }, "CardanoNetwork": { "Network": "Testnet", @@ -16,14 +18,29 @@ "RetryCount": "2" } }, + "Keychain": { + "KeyVaultUrl": "https://YOURKV.vault.azure.net/", + "RetrievalMaxRetries": 5, + "RetrievalRetryDelaySeconds": 2, + "RetrievalRetryMaxDelaySeconds": 16 + }, "ApplicationInsights": { "Enabled": false, "InstrumentationKey": "" }, "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/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 521a9fa..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*@ @@ -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..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; @@ -26,6 +27,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..696f768 --- /dev/null +++ b/Src/WasmApp/Services/SimplePaymentService.cs @@ -0,0 +1,34 @@ +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/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/DataAccessTestController.cs b/Src/WebApi/Controllers/DataAccessTestController.cs index f3d3822..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,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(0.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"), @@ -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/Controllers/SimplePaymentController.cs b/Src/WebApi/Controllers/SimplePaymentController.cs new file mode 100644 index 0000000..59bcb9c --- /dev/null +++ b/Src/WebApi/Controllers/SimplePaymentController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Mintsafe.Lib; + +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..233ea05 100644 --- a/Src/WebApi/Program.cs +++ b/Src/WebApi/Program.cs @@ -50,12 +50,10 @@ 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(); +builder.Services.AddSingleton(); // Data Access builder.Services.AddSingleton(); @@ -64,7 +62,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddAzureClients(clientBuilder => { @@ -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/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs b/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs index 4fa694b..8dc169f 100644 --- a/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs +++ b/Tests/DataAccess.UnitTests/Mappers/NiftyCollectionMapperShould.cs @@ -1,7 +1,7 @@ using System; using FluentAssertions; +using Mintsafe.Abstractions; using Mintsafe.DataAccess.Mappers; -using Mintsafe.DataAccess.Models; using Xunit; namespace Mintsafe.DataAccess.UnitTests.Mappers @@ -14,7 +14,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 +26,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 +44,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 +55,7 @@ public void Map_Model_Correctly() var rowKey = Guid.NewGuid(); var niftyCollection = new Abstractions.NiftyCollection( - rowKey, + rowKey, "3", "Name", "Description", @@ -60,8 +64,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 +81,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/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 f4fa2cf..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; @@ -33,8 +32,6 @@ public void Map_Dto_Correctly() MediaType = "jpeg", CreatedAt = now, Version = "Version", - RoyaltyPortion = 1.0, - RoyaltyAddress = "RoyaltyAddress", Attributes = new List>() { new("key", "value") } }; @@ -47,7 +44,7 @@ public void Map_Dto_Correctly() NiftyId = niftyId.ToString(), Name = "Name", MediaType = "jpeg", - Url = "test.com", + Src = "test.com", FileHash = "hash" } }; @@ -66,8 +63,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") }); @@ -76,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"); } @@ -86,7 +81,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 +104,6 @@ public void Map_Model_Correctly() ) }, now, - new Royalty(1.0, "RoyaltyAddress"), "Version", new KeyValuePair[] { new("key", "value") } ); @@ -128,8 +122,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..e193947 100644 --- a/Tests/DataAccess.UnitTests/Mintsafe.DataAccess.UnitTests.csproj +++ b/Tests/DataAccess.UnitTests/Mintsafe.DataAccess.UnitTests.csproj @@ -9,16 +9,16 @@ - - - - + + + + - + 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/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/BlockfrostUtxoRetrieverShould.cs b/Tests/Lib.UnitTests/BlockfrostUtxoRetrieverShould.cs new file mode 100644 index 0000000..8a94199 --- /dev/null +++ b/Tests/Lib.UnitTests/BlockfrostUtxoRetrieverShould.cs @@ -0,0 +1,63 @@ +using System.Threading.Tasks; +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", 0U, "540f107c7a3df20d2111a41c3bc407cce3e63c10c8dd673d51a02c22434f4e4431", "1", "2172289", + "540f107c7a3df20d2111a41c3bc407cce3e63c10c8dd673d51a02c22", "434f4e4431", 1, 2172289)] + public async Task Should_Map_Utxo_Values_Correctly( + 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[] { + 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.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/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/FakeGenerator.cs b/Tests/Lib.UnitTests/FakeGenerator.cs index d221f34..bfe29b8 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; @@ -29,11 +30,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,22 +53,44 @@ public static List GenerateTokens(int mintableTokenCount) "image/png", Array.Empty(), DateTime.UtcNow, - new Royalty(0, string.Empty), "1.0", Array.Empty>())) .ToList(); } - public static Utxo[] GenerateUtxos(int count, params long[] values) + 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(); + } + + 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 Balance(values[i], Array.Empty()))) .ToArray(); } @@ -75,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, @@ -111,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/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/MetadataJsonBuilderShould.cs b/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs deleted file mode 100644 index 741b9ce..0000000 --- a/Tests/Lib.UnitTests/MetadataJsonBuilderShould.cs +++ /dev/null @@ -1,77 +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.Url); - 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 5061be5..b57cc3c 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/NiftyAllocatorShould.cs b/Tests/Lib.UnitTests/NiftyAllocatorShould.cs index 6e6e95f..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 Utxo("", 0, new[] { new Value(Assets.LovelaceUnit, 1000000) }), + 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 Utxo("", 0, new[] { new Value(Assets.LovelaceUnit, 1000000) }), + new UnspentTransactionOutput("", 0, new Balance(1000000, Array.Empty())), requestedQuantity, 0); diff --git a/Tests/Lib.UnitTests/NiftyDistributorShould.cs b/Tests/Lib.UnitTests/NiftyDistributorShould.cs deleted file mode 100644 index eb4f6c2..0000000 --- a/Tests/Lib.UnitTests/NiftyDistributorShould.cs +++ /dev/null @@ -1,318 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Mintsafe.Abstractions; -using Moq; -using System; -using System.Linq; -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()); - - 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, int purchaseOutputIndex, long 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 Utxo( - purchaseTxHash, - purchaseOutputIndex, - new[] { new Value(Assets.LovelaceUnit, purchaseUtxoLovelaceValue) }); - - var txHash = await _distributor.DistributeNiftiesForSalePurchase( - nfts: GenerateTokens(3).ToArray(), - purchaseAttempt: new PurchaseAttempt(Guid.NewGuid(), Guid.NewGuid(), purchaseUtxo, 3, 0), - saleContext: GenerateSaleContext()); - - _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, 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, "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) - { - 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))); - - _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, long purchaseLovelaces, int 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)); - - _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))); - - _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, - 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 minUtxoLovelaceQuantity = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); - var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{n.AssetName}").ToArray(); - var allSingleNiftyOutputs = buyerOutput.Values - .Where(v => v.Unit != Assets.LovelaceUnit) - .All(v => expectedNiftyAssetNames.Contains(v.Unit) && v.Quantity == 1); - - return (buyerOutputLovelace == changeInLovelace + minUtxoLovelaceQuantity) && allSingleNiftyOutputs; - } - - private static bool IsProceedsOutputCorrect( - TxBuildCommand buildCommand, - string proceedsAddress, - double margin, - string buyerAddress, - long purchaseLovelaces, - long 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 minUtxoLovelaces = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); - - var saleLovelaces = purchaseLovelaces - changeInLovelace - minUtxoLovelaces; - var proceedsCutLovelaces = (int)(saleLovelaces * margin); - - return proceedsOutputLovelaces == proceedsCutLovelaces; - } - - private static bool IsCreatorOutputCorrect( - TxBuildCommand buildCommand, - string creatorAddress, - double margin, - string buyerAddress, - long purchaseLovelaces, - long 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 minUtxoLovelaces = TxUtils.CalculateMinUtxoLovelace(buyerOutput.Values); - - var saleLovelaces = purchaseLovelaces - changeInLovelace - minUtxoLovelaces; - var proceedsCutLovelaces = (int)(saleLovelaces * margin); - - return creatorOutputLovelaces == saleLovelaces - proceedsCutLovelaces; - } - - private static bool IsMintCorrect( - TxBuildCommand buildCommand, - Nifty[] nifties, - string policyId) - { - var expectedNiftyAssetNames = nifties.Select(n => $"{policyId}.{n.AssetName}").ToArray(); - var allSingleNiftyMints = buildCommand.Mint - .All(v => expectedNiftyAssetNames.Contains(v.Unit) && v.Quantity == 1); - - return allSingleNiftyMints; - } -} diff --git a/Tests/Lib.UnitTests/PurchaseAttemptGeneratorShould.cs b/Tests/Lib.UnitTests/PurchaseAttemptGeneratorShould.cs index 3cb1686..5e38f81 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 Balance(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 Balance(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 Balance(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 Balance(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 Balance(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 Balance(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 Balance(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 Balance(100000000, Array.Empty())), sale); }; diff --git a/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs new file mode 100644 index 0000000..14839d4 --- /dev/null +++ b/Tests/Lib.UnitTests/SimpleWalletServiceShould.cs @@ -0,0 +1,138 @@ +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.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(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); + 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(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); + var sourcePaymentAddress = "addr_test1qrlsqwhg4vay0x4yy6lw08s4qx4kcpnujcc7dz8e5lqjdnkdfj93s4scmnax7lgemz2q2ftms4zna9y9xle0c3c88f5qhvdm5m"; + var sourcePaymentXsk = "addr_xsk1gr99v0ynu0wvuxwe4vu2sap3d3vxqzpupu93gvhtu869wmk8wezu2zlkssy9xj4j5u5ymf356np04f2tt77pfees3uckytdlpdhv8pwss378smdhsecy7un46q8738rhgwd0hytz4r77k6v95dmt47g0ms4ec63v"; + var network = Network.Testnet; + // Not used now + var destinationPaymentAddress = "addr_test1qplxcfvad2uzq2w4k99unzj6d5hmpprgrujn3l0nwsl8vh3e2mgaxpeslac7hghtxxzcwerr3wt6ly2t9hr7unkua9rskg2855"; + var destinationOutputValue = new Balance(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(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); + var sourcePaymentAddress = "addr_test1qrlsqwhg4vay0x4yy6lw08s4qx4kcpnujcc7dz8e5lqjdnkdfj93s4scmnax7lgemz2q2ftms4zna9y9xle0c3c88f5qhvdm5m"; + var sourcePaymentXsk = "addr_xsk1gr99v0ynu0wvuxwe4vu2sap3d3vxqzpupu93gvhtu869wmk8wezu2zlkssy9xj4j5u5ymf356np04f2tt77pfees3uckytdlpdhv8pwss378smdhsecy7un46q8738rhgwd0hytz4r77k6v95dmt47g0ms4ec63v"; + 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.Mainnet; + var nativeAssetsToMint = new[] { new NativeAssetValue(policyId, "", 1) }; // empty assetname is required for CIP27 + var minUtxoLovelace = TxUtils.CalculateMinUtxoLovelace(nativeAssetsToMint); + var royaltyBodyMetadata = new Dictionary + { + { "rate", royaltyRate }, + { "addr", royaltyAddress.Length > 64 ? MetadataBuilder.SplitStringToChunks(royaltyAddress) : royaltyAddress } + }; + var royaltyMetadata = new Dictionary> + { { NftRoyaltyMetadataStandardKey, royaltyBodyMetadata } }; + + var txId = await simpleWalletService.SubmitTransactionAsync( + sourcePaymentAddress, + sourcePaymentXsk, + network, + outputs: new[] { new PendingTransactionOutput(sourcePaymentAddress, new Balance(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..5c2ba71 100644 --- a/Tests/Lib.UnitTests/TimeUtilShould.cs +++ b/Tests/Lib.UnitTests/TimeUtilShould.cs @@ -10,7 +10,8 @@ public class TimeUtilShould [Theory] [InlineData(2021, 10, 28, 14, 0, 4, 41060390)] [InlineData(2022, 1, 28, 19, 0, 0, 49027186)] - public void Return_Correct_Testnet_Slot( + [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) { @@ -24,6 +25,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) { @@ -37,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) @@ -51,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 e98e686..558878d 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( + ulong 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( + ulong lovelaceQuantityLhs, ulong lovelaceQuantityRhs, ulong 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( + ulong ftQuantityLhs, ulong ftQuantityRhs, ulong 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( + ulong lovelaceValue, ulong 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( + ulong 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( + ulong 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( + 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))); + + 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 Balance(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 Balance(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 Balance(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 Balance(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