diff --git a/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs b/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs index 9cc7e9e..07a3ec4 100644 --- a/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs +++ b/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs @@ -3,6 +3,7 @@ using Trakx.Common.ApiClient; using Trakx.Common.Configuration; using Trakx.Common.DateAndTime; +using Trakx.Fireblocks.ApiClient.TravelRule; using Trakx.Fireblocks.ApiClient.Utils; namespace Trakx.Fireblocks.ApiClient; @@ -30,6 +31,8 @@ public static IServiceCollection AddFireblocksClient(this IServiceCollection ser services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddApiClientsOfBaseTypeWithHttpClient( new ApiClientWithHttpClientRetryOptions diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs new file mode 100644 index 0000000..b5a7e73 --- /dev/null +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs @@ -0,0 +1,150 @@ +namespace Trakx.Fireblocks.ApiClient.TravelRule; + +/// +internal class BinanceTravelRuleService( + IExchange_accountsClient exchangeAccountsClient, + ITransactionsClient transactionsClient, + IRsaEncryptionService encryptionService) : IBinanceTravelRuleService +{ + private string? _cachedPublicKeyPem; + + /// + public async Task BuildWithdrawalExtraParametersAsync(CancellationToken cancellationToken = default) + { + return await BuildExtraParametersAsync( + partyKey: "beneficiary", + transactionDirection: "withdraw", + vaspSection: new Dictionary + { + ["originatingVASP"] = new Dictionary { ["vaspCountry"] = "FR" } + }, + cancellationToken); + } + + /// + public async Task BuildDepositExtraParametersAsync(CancellationToken cancellationToken = default) + { + return await BuildExtraParametersAsync( + partyKey: "originator", + transactionDirection: "deposit", + vaspSection: new Dictionary + { + ["originatingVASP"] = new Dictionary { ["vaspName"] = "Binance" } + }, + cancellationToken); + } + + private async Task BuildExtraParametersAsync( + string partyKey, + string transactionDirection, + Dictionary vaspSection, + CancellationToken cancellationToken) + { + var pem = await GetPublicKeyAsync(cancellationToken); + + var data = new Dictionary + { + [partyKey] = new Dictionary + { + ["participantRelationshipType"] = encryptionService.Encrypt("FirstParty", pem), + ["entityType"] = encryptionService.Encrypt("Business", pem) + }, + ["transactionData"] = new Dictionary + { + [transactionDirection] = new Dictionary + { + ["isAddressVerified"] = encryptionService.Encrypt(true, pem) + } + } + }; + + foreach (var (key, value) in vaspSection) + { + var plainValues = (Dictionary)value; + var encrypted = new Dictionary(); + foreach (var (fieldKey, fieldValue) in plainValues) + encrypted[fieldKey] = encryptionService.Encrypt((string)fieldValue, pem); + data[key] = encrypted; + } + + var piiData = new Dictionary + { + ["type"] = "exchange-service-travel-rule", + ["typeVersion"] = "1.0.0", + ["data"] = data + }; + + var extraParameters = new ExtraParameters(); + extraParameters.AdditionalProperties["piiData"] = piiData; + return extraParameters; + } + + /// + public async Task WithdrawFromBinanceAsync( + string exchangeAccountId, string vaultAccountId, string assetId, string amount, + string? note = null, string? customerRefId = null, CancellationToken cancellationToken = default) + { + var extraParameters = await BuildWithdrawalExtraParametersAsync(cancellationToken); + + var request = new TransactionRequest + { + Operation = TransactionOperation.TRANSFER, + AssetId = assetId, + Amount = amount, + Note = note, + CustomerRefId = customerRefId, + Source = new TransferPeerPath + { + Type = TransferPeerPathType.EXCHANGE_ACCOUNT, + Id = exchangeAccountId, + }, + Destination = new DestinationTransferPeerPath + { + Type = TransferPeerPathType.VAULT_ACCOUNT, + Id = vaultAccountId, + }, + ExtraParameters = extraParameters, + }; + + var response = await transactionsClient.CreateTransactionAsync(body: request, cancellationToken: cancellationToken); + return response.Content; + } + + /// + public async Task DepositToBinanceAsync( + string exchangeAccountId, string vaultAccountId, string assetId, string amount, + string? note = null, string? customerRefId = null, CancellationToken cancellationToken = default) + { + var extraParameters = await BuildDepositExtraParametersAsync(cancellationToken); + + var request = new TransactionRequest + { + Operation = TransactionOperation.TRANSFER, + AssetId = assetId, + Amount = amount, + Note = note, + CustomerRefId = customerRefId, + Source = new TransferPeerPath + { + Type = TransferPeerPathType.VAULT_ACCOUNT, + Id = vaultAccountId, + }, + Destination = new DestinationTransferPeerPath + { + Type = TransferPeerPathType.EXCHANGE_ACCOUNT, + Id = exchangeAccountId, + }, + ExtraParameters = extraParameters, + }; + + var response = await transactionsClient.CreateTransactionAsync(body: request, cancellationToken: cancellationToken); + return response.Content; + } + + private async Task GetPublicKeyAsync(CancellationToken cancellationToken) + { + return _cachedPublicKeyPem ??= (await exchangeAccountsClient + .GetExchangeAccountsCredentialsPublicKeyAsync(cancellationToken)) + .Content.PublicKey; + } +} diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs new file mode 100644 index 0000000..37ea43f --- /dev/null +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs @@ -0,0 +1,32 @@ +namespace Trakx.Fireblocks.ApiClient.TravelRule; + +/// +/// Service for building encrypted PII data required by Binance travel rule compliance. +/// See: https://developers.fireblocks.com/docs/a-developers-guide-to-constructing-encrypted-pii-messages-for-binance-via-fireblocks +/// +public interface IBinanceTravelRuleService +{ + /// + /// Builds the travel rule extra parameters required for Binance withdrawals (exchange to custodian). + /// + Task BuildWithdrawalExtraParametersAsync(CancellationToken cancellationToken = default); + + /// + /// Builds the travel rule extra parameters required for Binance deposits (custodian to exchange). + /// + Task BuildDepositExtraParametersAsync(CancellationToken cancellationToken = default); + + /// + /// Withdraws from Binance exchange account to a vault account, with travel rule compliance. + /// + Task WithdrawFromBinanceAsync( + string exchangeAccountId, string vaultAccountId, string assetId, string amount, + string? note = null, string? customerRefId = null, CancellationToken cancellationToken = default); + + /// + /// Deposits from a vault account to Binance exchange account, with travel rule compliance. + /// + Task DepositToBinanceAsync( + string exchangeAccountId, string vaultAccountId, string assetId, string amount, + string? note = null, string? customerRefId = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs new file mode 100644 index 0000000..3a86a0f --- /dev/null +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs @@ -0,0 +1,24 @@ +namespace Trakx.Fireblocks.ApiClient.TravelRule; + +/// +/// Service for encrypting PII data using RSA-OAEP with SHA-256. +/// +public interface IRsaEncryptionService +{ + /// + /// Encrypts a string value using RSA-OAEP with SHA-256. + /// + /// The plain text value to encrypt. + /// The RSA public key in PEM format. + /// Base64-encoded encrypted value. + string Encrypt(string plainText, string publicKeyPem); + + /// + /// Encrypts a boolean value using RSA-OAEP with SHA-256. + /// The boolean is converted to lowercase JSON representation ("true" or "false") before encryption. + /// + /// The boolean value to encrypt. + /// The RSA public key in PEM format. + /// Base64-encoded encrypted value. + string Encrypt(bool plainValue, string publicKeyPem); +} diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs new file mode 100644 index 0000000..fa0dce9 --- /dev/null +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Trakx.Fireblocks.ApiClient.TravelRule; + +/// +internal class RsaEncryptionService : IRsaEncryptionService +{ + /// + public string Encrypt(string plainText, string publicKeyPem) + { + ArgumentException.ThrowIfNullOrEmpty(plainText); + ArgumentException.ThrowIfNullOrEmpty(publicKeyPem); + + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + + var plainBytes = Encoding.UTF8.GetBytes(plainText); + var encryptedBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256); + + return Convert.ToBase64String(encryptedBytes); + } + + /// + public string Encrypt(bool plainValue, string publicKeyPem) + { + ArgumentException.ThrowIfNullOrEmpty(publicKeyPem); + + var boolBytes = BitConverter.GetBytes(plainValue); + + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + + var encryptedBytes = rsa.Encrypt(boolBytes, RSAEncryptionPadding.OaepSHA256); + + return Convert.ToBase64String(encryptedBytes); + } +} diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs index 9d02afe..12966e5 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs @@ -32,10 +32,10 @@ public class FireblocksApiFixture : IDisposable public FireblocksApiFixture() { var apiConfiguration = AwsConfigurationHelper.GetConfigurationFromAws() - with - { - BaseUrl = new Uri("https://api.fireblocks.io/v1") - }; + with + { + BaseUrl = new Uri("https://api.fireblocks.io/v1") + }; var serviceCollection = new ServiceCollection(); diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/BinanceTravelRuleTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/BinanceTravelRuleTests.cs new file mode 100644 index 0000000..153f5ba --- /dev/null +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/BinanceTravelRuleTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Trakx.Fireblocks.ApiClient.TravelRule; + +namespace Trakx.Fireblocks.ApiClient.Tests.Integration; + +public partial class TransactionsClientTests +{ + [Fact(Skip = "I hope we will never have to test this again 🙈")] + public async Task WithdrawFromBinance_should_transfer_JUP_to_vault() + { + var binanceTravelRuleService = _serviceProvider.GetRequiredService(); + var transactionsClient = _serviceProvider.GetRequiredService(); + + var extraParameters = await binanceTravelRuleService.BuildWithdrawalExtraParametersAsync(); + + var request = new TransactionRequest + { + Operation = TransactionOperation.TRANSFER, + AssetId = "JUP_SOL", + Amount = "10", + Note = "test travel rule compliance", + CustomerRefId = Guid.NewGuid().ToString(), + Source = new TransferPeerPath + { + Type = TransferPeerPathType.EXCHANGE_ACCOUNT, + Id = "7b29fd51-6098-4c45-8471-4195cdcbdd70", + }, + Destination = new DestinationTransferPeerPath + { + Type = TransferPeerPathType.VAULT_ACCOUNT, + Id = "0", + }, + ExtraParameters = extraParameters, + }; + + var serialized = Newtonsoft.Json.JsonConvert.SerializeObject(request, Newtonsoft.Json.Formatting.Indented); + _logger.Information("TransactionRequest payload:\n{Payload}", serialized); + + var response = await transactionsClient.CreateTransactionAsync(body: request); + + response.Content.Should().NotBeNull(); + response.Content.Id.Should().NotBeNullOrEmpty(); + + _logger.Information("Withdrawal transaction created: Id={Id}, Status={Status}", + response.Content.Id, response.Content.Status); + } + + [Fact(Skip = "I hope we will never have to test this again 🙈")] + public async Task DepositToBinance_should_transfer_JUP_from_vault() + { + var binanceTravelRuleService = _serviceProvider.GetRequiredService(); + var transactionsClient = _serviceProvider.GetRequiredService(); + + var extraParameters = await binanceTravelRuleService.BuildDepositExtraParametersAsync(); + + var request = new TransactionRequest + { + Operation = TransactionOperation.TRANSFER, + AssetId = "JUP_SOL", + Amount = "10", + Note = "test travel rule compliance", + CustomerRefId = Guid.NewGuid().ToString(), + Source = new TransferPeerPath + { + Type = TransferPeerPathType.VAULT_ACCOUNT, + Id = "0", + }, + Destination = new DestinationTransferPeerPath + { + Type = TransferPeerPathType.EXCHANGE_ACCOUNT, + Id = "7b29fd51-6098-4c45-8471-4195cdcbdd70", + }, + ExtraParameters = extraParameters, + }; + + var serialized = Newtonsoft.Json.JsonConvert.SerializeObject(request, Newtonsoft.Json.Formatting.Indented); + _logger.Information("TransactionRequest payload:\n{Payload}", serialized); + + var response = await transactionsClient.CreateTransactionAsync(body: request); + + response.Content.Should().NotBeNull(); + response.Content.Id.Should().NotBeNullOrEmpty(); + + _logger.Information("Deposit transaction created: Id={Id}, Status={Status}", + response.Content.Id, response.Content.Status); + } +} \ No newline at end of file diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs index 041d71b..f90b21f 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs @@ -1,5 +1,7 @@ +using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Trakx.Fireblocks.ApiClient.Tests.Integration.Base; +using Trakx.Fireblocks.ApiClient.TravelRule; namespace Trakx.Fireblocks.ApiClient.Tests.Integration; diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/TransactionsClientTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/TransactionsClientTests.cs index 7c28b4d..d77bb79 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/TransactionsClientTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/TransactionsClientTests.cs @@ -4,7 +4,7 @@ namespace Trakx.Fireblocks.ApiClient.Tests.Integration; -public class TransactionsClientTests : FireblocksClientTestsBase +public partial class TransactionsClientTests : FireblocksClientTestsBase { private readonly ITransactionsClient _transactionsClient; diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/VaultClientTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/VaultClientTests.cs index c041991..7edbecd 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/VaultClientTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/VaultClientTests.cs @@ -1,9 +1,10 @@ +using System.Text.RegularExpressions; using Microsoft.Extensions.DependencyInjection; using Trakx.Fireblocks.ApiClient.Tests.Integration.Base; namespace Trakx.Fireblocks.ApiClient.Tests.Integration; -public class VaultClientTests : FireblocksClientTestsBase +public partial class VaultClientTests : FireblocksClientTestsBase { private readonly IVaultsClient _vaultClient; @@ -23,6 +24,36 @@ public async Task GetVaultAccountsAsync_should_return_all_vault_accounts() accounts.Should().Contain(x => x.Assets.Any(x => x.Id == "BTC_TEST")); } + [Fact] + public async Task List_vault_accounts_with_details() + { + var userIdPattern = UserIdVaultNameRegex(); + string? after = null; + + do + { + var response = await _vaultClient.GetPagedVaultAccountsAsync(after: after, limit: 200); + var page = response.Content; + + foreach (var vault in page.Accounts) + { + if (userIdPattern.IsMatch(vault.Name ?? "")) continue; + + _logger.Information("Vault: Id={Id}, Name={Name}, AutoFuel={AutoFuel}, Hidden={Hidden}", + vault.Id, vault.Name, vault.AutoFuel, vault.HiddenOnUI); + + if (vault.Assets is not { Count: > 0 }) continue; + foreach (var asset in vault.Assets) + { + _logger.Information(" Asset: Id={AssetId}, Total={Total}, Available={Available}, Pending={Pending}, Frozen={Frozen}", + asset.Id, asset.Total, asset.Available, asset.Pending, asset.Frozen); + } + } + + after = page.Paging?.After; + } while (!string.IsNullOrEmpty(after)); + } + [Fact] public async Task GetVaultAccountsAsync_is_case_insensitive() { @@ -32,4 +63,7 @@ public async Task GetVaultAccountsAsync_is_case_insensitive() accounts.Should().NotBeNullOrEmpty(); accounts.Should().Contain(x => x.Name == "Exchange Warm Wallet"); } -} \ No newline at end of file + + [GeneratedRegex(@"^user__ID[0-9A-Fa-f]{10}$")] + private static partial Regex UserIdVaultNameRegex(); +} diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs index decf46b..86f8fdc 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs @@ -2,6 +2,7 @@ using Trakx.Common.DateAndTime; using Trakx.Common.Testing.Configuration; using Trakx.Common.Testing.Extensions; +using Trakx.Fireblocks.ApiClient.TravelRule; using Trakx.Fireblocks.ApiClient.Utils; namespace Trakx.Fireblocks.ApiClient.Tests.Unit; @@ -26,6 +27,8 @@ public void Should_be_able_to_resolve_common_dependencies() _serviceProvider.CheckServiceRegistration(); _serviceProvider.CheckServiceRegistration(); _serviceProvider.CheckServiceRegistration(); + _serviceProvider.CheckServiceRegistration(); + _serviceProvider.CheckServiceRegistration(); } [Fact] diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs new file mode 100644 index 0000000..555f792 --- /dev/null +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs @@ -0,0 +1,163 @@ +using System.Security.Cryptography; +using System.Text; +using Trakx.Common.ApiClient; +using Trakx.Fireblocks.ApiClient.TravelRule; + +namespace Trakx.Fireblocks.ApiClient.Tests.Unit.TravelRule; + +public class BinanceTravelRuleServiceTests : IDisposable +{ + private readonly BinanceTravelRuleService _sut; + private readonly ITransactionsClient _transactionsClient; + private readonly RSA _rsa; + + public BinanceTravelRuleServiceTests() + { + _rsa = RSA.Create(2048); + var publicKeyPem = _rsa.ExportSubjectPublicKeyInfoPem(); + + var exchangeAccountsClient = Substitute.For(); + exchangeAccountsClient.GetExchangeAccountsCredentialsPublicKeyAsync(Arg.Any()) + .Returns(new Response( + 200, + new Dictionary>(), + new ExchangeCredentialsPublicKeyResponse { PublicKey = publicKeyPem })); + + _transactionsClient = Substitute.For(); + _transactionsClient.CreateTransactionAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => new Response( + 200, + new Dictionary>(), + new CreateTransactionResponse { Id = "tx-123", Status = "SUBMITTED" })); + + var encryptionService = new RsaEncryptionService(); + _sut = new BinanceTravelRuleService(exchangeAccountsClient, _transactionsClient, encryptionService); + } + + [Fact] + public async Task BuildWithdrawalExtraParametersAsync_should_return_valid_pii_data_structure() + { + var result = await _sut.BuildWithdrawalExtraParametersAsync(); + + result.Should().NotBeNull(); + result.AdditionalProperties.Should().ContainKey("piiData"); + + var piiData = result.AdditionalProperties["piiData"] as Dictionary; + piiData.Should().NotBeNull(); + piiData!["type"].Should().Be("exchange-service-travel-rule"); + piiData["typeVersion"].Should().Be("1.0.0"); + + var data = piiData["data"] as Dictionary; + data.Should().NotBeNull(); + + var beneficiary = data!["beneficiary"] as Dictionary; + beneficiary.Should().NotBeNull(); + DecryptValue(beneficiary!["participantRelationshipType"]).Should().Be("FirstParty"); + DecryptValue(beneficiary["entityType"]).Should().Be("Business"); + + var transactionData = data["transactionData"] as Dictionary; + var withdraw = (transactionData!["withdraw"] as Dictionary)!; + DecryptBool(withdraw["isAddressVerified"]).Should().BeTrue(); + + //explicitly diverge from Fireblocks documentation, the doc is wrong. + data.Should().NotContainKey("beneficiaryVASP"); + + var originatingVasp = data["originatingVASP"] as Dictionary; + DecryptValue(originatingVasp!["vaspCountry"]).Should().Be("FR"); + } + + [Fact] + public async Task BuildDepositExtraParametersAsync_should_return_valid_pii_data_structure() + { + var result = await _sut.BuildDepositExtraParametersAsync(); + + result.Should().NotBeNull(); + result.AdditionalProperties.Should().ContainKey("piiData"); + + var piiData = result.AdditionalProperties["piiData"] as Dictionary; + piiData.Should().NotBeNull(); + piiData!["type"].Should().Be("exchange-service-travel-rule"); + piiData["typeVersion"].Should().Be("1.0.0"); + + var data = piiData["data"] as Dictionary; + data.Should().NotBeNull(); + + var originator = data!["originator"] as Dictionary; + originator.Should().NotBeNull(); + DecryptValue(originator!["participantRelationshipType"]).Should().Be("FirstParty"); + DecryptValue(originator["entityType"]).Should().Be("Business"); + + var originatingVasp = data["originatingVASP"] as Dictionary; + DecryptValue(originatingVasp!["vaspName"]).Should().Be("Binance"); + + var transactionData = data["transactionData"] as Dictionary; + var deposit = (transactionData!["deposit"] as Dictionary)!; + DecryptBool(deposit["isAddressVerified"]).Should().BeTrue(); + + //explicitly diverge from Fireblocks documentation, the doc is wrong. + data.Should().NotContainKey("beneficiaryVASP"); + } + + [Fact] + public async Task WithdrawFromBinanceAsync_should_create_transaction_with_correct_peer_paths() + { + var result = await _sut.WithdrawFromBinanceAsync("exchange-1", "vault-2", "BTC", "0.5", "test note"); + + result.Id.Should().Be("tx-123"); + + await _transactionsClient.Received(1).CreateTransactionAsync( + Arg.Any(), + Arg.Any(), + Arg.Is(r => + r.Source.Type == TransferPeerPathType.EXCHANGE_ACCOUNT && + r.Source.Id == "exchange-1" && + r.Destination.Type == TransferPeerPathType.VAULT_ACCOUNT && + r.Destination.Id == "vault-2" && + r.AssetId == "BTC" && + r.Amount == "0.5" && + r.Note == "test note" && + r.ExtraParameters != null), + Arg.Any()); + } + + [Fact] + public async Task DepositToBinanceAsync_should_create_transaction_with_correct_peer_paths() + { + var result = await _sut.DepositToBinanceAsync("exchange-1", "vault-2", "ETH", "1.0"); + + result.Id.Should().Be("tx-123"); + + await _transactionsClient.Received(1).CreateTransactionAsync( + Arg.Any(), + Arg.Any(), + Arg.Is(r => + r.Source.Type == TransferPeerPathType.VAULT_ACCOUNT && + r.Source.Id == "vault-2" && + r.Destination.Type == TransferPeerPathType.EXCHANGE_ACCOUNT && + r.Destination.Id == "exchange-1" && + r.AssetId == "ETH" && + r.Amount == "1.0" && + r.ExtraParameters != null), + Arg.Any()); + } + + private string DecryptValue(object encryptedBase64) + { + var encryptedBytes = Convert.FromBase64String((string)encryptedBase64); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + return Encoding.UTF8.GetString(decryptedBytes); + } + + private bool DecryptBool(object encryptedBase64) + { + var encryptedBytes = Convert.FromBase64String((string)encryptedBase64); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + return BitConverter.ToBoolean(decryptedBytes); + } + + public void Dispose() + { + _rsa.Dispose(); + } +} \ No newline at end of file diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs new file mode 100644 index 0000000..17c45f7 --- /dev/null +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs @@ -0,0 +1,74 @@ +using System.Security.Cryptography; +using System.Text; +using Trakx.Fireblocks.ApiClient.TravelRule; + +namespace Trakx.Fireblocks.ApiClient.Tests.Unit.TravelRule; + +public class RsaEncryptionServiceTests : IDisposable +{ + private readonly RsaEncryptionService _sut; + private readonly RSA _rsa; + private readonly string _publicKeyPem; + + public RsaEncryptionServiceTests() + { + _sut = new RsaEncryptionService(); + _rsa = RSA.Create(2048); + _publicKeyPem = _rsa.ExportSubjectPublicKeyInfoPem(); + } + + [Fact] + public void Encrypt_string_should_produce_base64_that_can_be_decrypted() + { + var plainText = "Hello World"; + + var encrypted = _sut.Encrypt(plainText, _publicKeyPem); + + encrypted.Should().NotBeNullOrEmpty(); + var encryptedBytes = Convert.FromBase64String(encrypted); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + var decrypted = Encoding.UTF8.GetString(decryptedBytes); + decrypted.Should().Be(plainText); + } + + [Fact] + public void Encrypt_bool_true_should_encrypt_as_raw_byte() + { + var encrypted = _sut.Encrypt(true, _publicKeyPem); + + encrypted.Should().NotBeNullOrEmpty(); + var encryptedBytes = Convert.FromBase64String(encrypted); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + BitConverter.ToBoolean(decryptedBytes).Should().BeTrue(); + } + + [Fact] + public void Encrypt_bool_false_should_encrypt_as_raw_byte() + { + var encrypted = _sut.Encrypt(false, _publicKeyPem); + + encrypted.Should().NotBeNullOrEmpty(); + var encryptedBytes = Convert.FromBase64String(encrypted); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + BitConverter.ToBoolean(decryptedBytes).Should().BeFalse(); + } + + [Fact] + public void Encrypt_should_throw_when_plainText_is_null_or_empty() + { + var action = () => _sut.Encrypt(string.Empty, _publicKeyPem); + action.Should().Throw(); + } + + [Fact] + public void Encrypt_should_throw_when_publicKeyPem_is_null_or_empty() + { + var action = () => _sut.Encrypt("test", string.Empty); + action.Should().Throw(); + } + + public void Dispose() + { + _rsa.Dispose(); + } +}