diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs index 54b13dc..345a4be 100644 --- a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs @@ -5,9 +5,11 @@ namespace KeetaNet.Anchor; /// /// An asset-movement anchor client bound to a signer and a metadata root. /// Discovery, request signing, retries, and the account-status blocker fold all -/// run inside the wasm core. The client is thread-safe: operations serialize -/// onto the runtime's dispatcher, and every networked method honors its -/// before dispatch and during host HTTP and sleeps. +/// run inside the wasm core. Discovery returns +/// handles carrying every per-provider operation. The client is thread-safe: +/// operations serialize onto the runtime's dispatcher, and every networked +/// method honors its before dispatch and during +/// host HTTP and sleeps. /// public sealed class AssetMovementClient : WasmObject { @@ -32,14 +34,14 @@ internal static AssetMovementClient WithAccount(WasmRuntime runtime, string node public async Task> GetProviders(CancellationToken cancellationToken = default) { byte[] payload = await Runtime.AssetProviders(Handle, cancellationToken).ConfigureAwait(false); - return KeetaJson.ReadList(payload); + return BindAll(KeetaJson.ReadList(payload)); } /// The provider with , or null when none advertises it. public async Task GetProviderById(string id, CancellationToken cancellationToken = default) { byte[] payload = await Runtime.AssetProviderById(Handle, id, cancellationToken).ConfigureAwait(false); - return ParseOptionalProvider(payload); + return BindOptional(payload); } /// The provider signed by , or null when absent. @@ -52,7 +54,7 @@ public async Task> GetProviders(CancellationToken c public async Task GetProviderByAccount(string account, CancellationToken cancellationToken = default) { byte[] payload = await Runtime.AssetProviderByAccount(Handle, account, cancellationToken).ConfigureAwait(false); - return ParseOptionalProvider(payload); + return BindOptional(payload); } /// @@ -68,80 +70,11 @@ public async Task> GetProvidersForTransfer( .AssetProvidersForTransfer(Handle, searchJson, cancellationToken) .ConfigureAwait(false); - return KeetaJson.ReadList(payload); + return BindAll(KeetaJson.ReadList(payload)); } - /// - /// Whether advertises the - /// endpoint (e.g. initiateTransfer, - /// createPersistentForwarding). - /// - public bool IsOperationSupported(AssetProvider provider, string operation) => provider.Operations.ContainsKey(operation); - - /// - /// The provider's advertised legal disclaimers, or null when its metadata - /// carries none. Malformed entries are skipped. - /// - public IReadOnlyList? GetLegalDisclaimers(AssetProvider provider) - { - if (provider.Legal is not { } legal - || legal.ValueKind != JsonValueKind.Object - || !legal.TryGetProperty("disclaimers", out JsonElement entries) - || entries.ValueKind != JsonValueKind.Array) - { - return null; - } - - var disclaimers = new List(); - using JsonElement.ArrayEnumerator enumerated = entries.EnumerateArray(); - foreach (JsonElement entry in enumerated) - { - if (TryDeserialize(entry, out AssetDisclaimer? disclaimer)) - { - disclaimers.Add(disclaimer!); - } - } - - return disclaimers; - } - - /// - /// The provider's identifying details published under - /// legal.anchorDetails, or null when its metadata carries none. A - /// malformed description is dropped while the name and logo are kept. - /// - public AssetAnchorDetails? GetProviderAnchorDetails(AssetProvider provider) - { - if (provider.Legal is not { } legal - || legal.ValueKind != JsonValueKind.Object - || !legal.TryGetProperty("anchorDetails", out JsonElement details) - || details.ValueKind != JsonValueKind.Object) - { - return null; - } - - string? name = ReadOptionalString(details, "name"); - string? logo = ReadOptionalString(details, "logo"); - - AssetRenderableContent? description = null; - if (details.TryGetProperty("description", out JsonElement rawDescription)) - { - TryDeserialize(rawDescription, out description); - } - - return new AssetAnchorDetails(name, description, logo); - } - - /// The member's string value, or null when absent or not a string. - private static string? ReadOptionalString(JsonElement element, string name) - { - if (!element.TryGetProperty(name, out JsonElement found) || found.ValueKind != JsonValueKind.String) - { - return null; - } - - return found.GetString(); - } + /// Bind a stored metadata snapshot back to this client as an operable handle. + public AssetProvider Provider(AssetProviderInfo info) => new(this, info); /// /// The legal disclaimers advertised by the provider with @@ -153,98 +86,47 @@ public async Task> GetProvidersForTransfer( CancellationToken cancellationToken = default) { AssetProvider? provider = await GetProviderById(id, cancellationToken).ConfigureAwait(false); - if (provider is null) - { - return null; - } - - return GetLegalDisclaimers(provider); + return provider?.GetLegalDisclaimers(); } - /// - /// The provider's display metadata for (an external - /// chain asset id) at (a canonical location - /// string), or null when the provider advertises none or the entry does not - /// parse. - /// - public AssetTokenMetadata? GetAssetMetadataForLocation(AssetProvider provider, string location, string asset) - { - if (provider.LocationMetadata is not { } metadata || metadata.ValueKind != JsonValueKind.Object) - { - return null; - } - - if (!metadata.TryGetProperty(location, out JsonElement forLocation) - || forLocation.ValueKind != JsonValueKind.Object - || !forLocation.TryGetProperty("assets", out JsonElement assets) - || assets.ValueKind != JsonValueKind.Object) - { - return null; - } - - if (!assets.TryGetProperty(asset, out JsonElement found) - || !TryDeserialize(found, out AssetTokenMetadata? parsed)) - { - return null; - } - - return parsed; - } - - /// Deserialize one metadata entry, treating malformed JSON as absent. - private static bool TryDeserialize(JsonElement element, out T? value) - where T : class - { - try - { - value = element.Deserialize(KeetaJson.Options); - } - catch (JsonException) - { - value = null; - } - - return value is not null; - } - - /// Simulate a transfer, returning a fluent handle over its instruction choices. - public async Task SimulateTransfer( + /// Simulate a transfer for . + internal async Task SimulateTransfer( AssetProvider provider, AssetTransferRequest request, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { - var transport = await ReadOperationAsync(Runtime.AssetSimulateTransfer, provider, request, cancellationToken).ConfigureAwait(false); - return new AssetSimulatedTransfer(this, provider, request, transport.InstructionChoices); + var transport = await ReadOperationAsync(Runtime.AssetSimulateTransfer, provider.Info, request, cancellationToken).ConfigureAwait(false); + return new AssetSimulatedTransfer(provider, request, transport.InstructionChoices); } - /// Initiate a transfer, returning a fluent handle. The request's recipient is required. - public async Task InitiateTransfer( + /// Initiate a transfer for . + internal async Task InitiateTransfer( AssetProvider provider, AssetTransferRequest request, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { - var transport = await ReadOperationAsync(Runtime.AssetInitiateTransfer, provider, request, cancellationToken).ConfigureAwait(false); - return new AssetTransfer(this, provider, transport.Id, transport.InstructionChoices); + var transport = await ReadOperationAsync(Runtime.AssetInitiateTransfer, provider.Info, request, cancellationToken).ConfigureAwait(false); + return new AssetTransfer(provider, transport.Id, transport.InstructionChoices); } /// Execute a pull instruction for a transfer. - public Task ExecuteTransfer( - AssetProvider provider, + internal Task ExecuteTransfer( + AssetProviderInfo provider, AssetExecuteRequest request, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => ReadOperationAsync(Runtime.AssetExecuteTransfer, provider, request, cancellationToken); /// Read the status of transfer . - public Task GetTransferStatus( - AssetProvider provider, + internal Task GetTransferStatus( + AssetProviderInfo provider, string id, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => ReadOperationForIdAsync(Runtime.AssetTransferStatus, provider, id, cancellationToken); - /// Read whether the signer's account is ready to use this provider. - public async Task GetAccountStatus( - AssetProvider provider, - CancellationToken cancellationToken = default) + /// Read whether the signer's account is ready to use . + internal async Task GetAccountStatus( + AssetProviderInfo provider, + CancellationToken cancellationToken) { string providerJson = Serialize(provider); byte[] payload = await Runtime.AssetAccountStatus(Handle, providerJson, cancellationToken).ConfigureAwait(false); @@ -253,82 +135,78 @@ public async Task GetAccountStatus( } /// Open a persistent-forwarding template session. - public Task InitiatePersistentForwardingTemplate( - AssetProvider provider, + internal Task InitiatePersistentForwardingTemplate( + AssetProviderInfo provider, AssetInitiateTemplateRequest request, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => ReadOperationAsync(Runtime.AssetInitiatePersistentForwardingTemplate, provider, request, cancellationToken); /// Create a persistent-forwarding template. - public Task CreatePersistentForwardingTemplate( - AssetProvider provider, + internal Task CreatePersistentForwardingTemplate( + AssetProviderInfo provider, AssetCreateTemplateRequest request, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => ReadOperationAsync(Runtime.AssetCreatePersistentForwardingTemplate, provider, request, cancellationToken); /// List persistent-forwarding templates. - public Task ListForwardingAddressTemplates( - AssetProvider provider, + internal Task ListForwardingAddressTemplates( + AssetProviderInfo provider, AssetListTemplatesRequest request, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => ReadOperationAsync(Runtime.AssetListForwardingAddressTemplates, provider, request, cancellationToken); /// Create a persistent-forwarding address, returning its (obfuscated) details. - public Task CreatePersistentForwardingAddress( - AssetProvider provider, + internal Task CreatePersistentForwardingAddress( + AssetProviderInfo provider, AssetCreateAddressRequest request, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => ReadOperationAsync(Runtime.AssetCreatePersistentForwardingAddress, provider, request, cancellationToken); /// List persistent-forwarding addresses. - public Task ListForwardingAddresses( - AssetProvider provider, + internal Task ListForwardingAddresses( + AssetProviderInfo provider, AssetListAddressesRequest request, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => ReadOperationAsync(Runtime.AssetListForwardingAddresses, provider, request, cancellationToken); /// Deactivate a persistent-forwarding template by id. - public Task DeactivatePersistentForwardingTemplate( - AssetProvider provider, + internal Task DeactivatePersistentForwardingTemplate( + AssetProviderInfo provider, string id, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => RunOperationForIdAsync(Runtime.AssetDeactivatePersistentForwardingTemplate, provider, id, cancellationToken); /// Deactivate a persistent-forwarding address by id. - public Task DeactivatePersistentForwardingAddress( - AssetProvider provider, + internal Task DeactivatePersistentForwardingAddress( + AssetProviderInfo provider, string id, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => RunOperationForIdAsync(Runtime.AssetDeactivatePersistentForwardingAddress, provider, id, cancellationToken); /// List asset-movement transactions. - public Task ListTransactions( - AssetProvider provider, + internal Task ListTransactions( + AssetProviderInfo provider, AssetListTransactionsRequest request, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => ReadOperationAsync(Runtime.AssetListTransactions, provider, request, cancellationToken); - /// - /// Share KYC attributes with the provider, returning the provider's outcome unchanged. - /// A pending outcome carries the promise URL the caller must poll. Use - /// to poll it automatically. - /// - public Task ShareKycAttributes( - AssetProvider provider, + /// Share KYC attributes with , returning its outcome unchanged. + internal Task ShareKycAttributes( + AssetProviderInfo provider, AssetShareKycRequest request, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken) => ReadOperationAsync(Runtime.AssetShareKycAttributes, provider, request, cancellationToken); /// /// Share KYC attributes and, when the outcome is pending with a promise URL, /// poll that URL inside the core until it resolves. /// - public async Task ShareKycAttributesAndWait( - AssetProvider provider, + internal async Task ShareKycAttributesAndWait( + AssetProviderInfo provider, AssetShareKycRequest request, - TimeSpan? pollInterval = null, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default) + TimeSpan? pollInterval, + TimeSpan? timeout, + CancellationToken cancellationToken) { string providerJson = Serialize(provider); string requestJson = Serialize(request); @@ -344,7 +222,7 @@ public async Task ShareKycAttributesAndWait( /// Drive a provider operation whose crosses as JSON. private async Task ReadOperationAsync( Func> operation, - AssetProvider provider, + AssetProviderInfo provider, object request, CancellationToken cancellationToken) { @@ -358,7 +236,7 @@ private async Task ReadOperationAsync( /// Drive a provider operation keyed by a raw . private async Task ReadOperationForIdAsync( Func> operation, - AssetProvider provider, + AssetProviderInfo provider, string id, CancellationToken cancellationToken) { @@ -371,7 +249,7 @@ private async Task ReadOperationForIdAsync( /// Drive a provider operation keyed by a raw , discarding the response. private async Task RunOperationForIdAsync( Func> operation, - AssetProvider provider, + AssetProviderInfo provider, string id, CancellationToken cancellationToken) { @@ -396,7 +274,12 @@ private static T Read(byte[] payload) => JsonSerializer.Deserialize(payload, KeetaJson.Options) ?? throw new KeetaException("DECODE", $"could not decode a {typeof(T).Name} from the asset-movement response"); - private static AssetProvider? ParseOptionalProvider(byte[] payload) + /// Bind every discovered snapshot to this client. + private AssetProvider[] BindAll(IReadOnlyList infos) => + infos.Select(Provider).ToArray(); + + /// Bind an optional discovery payload, mapping JSON null to no provider. + private AssetProvider? BindOptional(byte[] payload) { using var document = JsonDocument.Parse(payload); JsonElement root = document.RootElement; @@ -405,7 +288,13 @@ private static T Read(byte[] payload) => return null; } - return root.Deserialize(KeetaJson.Options); + AssetProviderInfo? info = root.Deserialize(KeetaJson.Options); + if (info is null) + { + return null; + } + + return Provider(info); } private protected override void Release(WasmRuntime runtime, int handle) => runtime.AssetFree(handle); diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs index c467e15..1902a6b 100644 --- a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs @@ -23,18 +23,138 @@ public enum AssetEndpointAuth public sealed record AssetEndpoint(string Url, AssetEndpointAuth Auth); /// -/// An asset-movement provider discovered from on-chain service metadata. The +/// An asset-movement provider's advertised metadata, discovered from on-chain +/// service metadata (the reference AssetMovementProviderInfo). The /// polymorphic , , and /// members are carried as raw JSON so the value round-trips -/// unchanged when handed back to an operation. +/// unchanged when handed back to an operation. Operations live on the +/// handle bound through +/// . /// -public sealed record AssetProvider( +public sealed record AssetProviderInfo( string Id, IReadOnlyDictionary Operations, IReadOnlyList? SupportedAssets = null, JsonElement? LocationMetadata = null, JsonElement? Legal = null, - string? Account = null); + string? Account = null) +{ + /// + /// Whether this provider advertises the + /// endpoint (e.g. initiateTransfer, createPersistentForwarding). + /// + public bool IsOperationSupported(string operation) => Operations.ContainsKey(operation); + + /// + /// The advertised legal disclaimers, or null when the metadata carries + /// none. Malformed entries are skipped. + /// + public IReadOnlyList? GetLegalDisclaimers() + { + if (Legal is not { } legal + || legal.ValueKind != JsonValueKind.Object + || !legal.TryGetProperty("disclaimers", out JsonElement entries) + || entries.ValueKind != JsonValueKind.Array) + { + return null; + } + + var disclaimers = new List(); + using JsonElement.ArrayEnumerator enumerated = entries.EnumerateArray(); + foreach (JsonElement entry in enumerated) + { + if (TryDeserialize(entry, out AssetDisclaimer? disclaimer)) + { + disclaimers.Add(disclaimer!); + } + } + + return disclaimers; + } + + /// + /// The identifying details published under legal.anchorDetails, or + /// null when the metadata carries none. A malformed description is dropped + /// while the name and logo are kept. + /// + public AssetAnchorDetails? GetAnchorDetails() + { + if (Legal is not { } legal + || legal.ValueKind != JsonValueKind.Object + || !legal.TryGetProperty("anchorDetails", out JsonElement details) + || details.ValueKind != JsonValueKind.Object) + { + return null; + } + + string? name = ReadOptionalString(details, "name"); + string? logo = ReadOptionalString(details, "logo"); + + AssetRenderableContent? description = null; + if (details.TryGetProperty("description", out JsonElement rawDescription)) + { + TryDeserialize(rawDescription, out description); + } + + return new AssetAnchorDetails(name, description, logo); + } + + /// + /// The display metadata for (an external chain + /// asset id) at (a canonical location string), + /// or null when the provider advertises none or the entry does not parse. + /// + public AssetTokenMetadata? GetAssetMetadataForLocation(string location, string asset) + { + if (LocationMetadata is not { } metadata || metadata.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!metadata.TryGetProperty(location, out JsonElement forLocation) + || forLocation.ValueKind != JsonValueKind.Object + || !forLocation.TryGetProperty("assets", out JsonElement assets) + || assets.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!assets.TryGetProperty(asset, out JsonElement found) + || !TryDeserialize(found, out AssetTokenMetadata? parsed)) + { + return null; + } + + return parsed; + } + + /// Deserialize one metadata entry, treating malformed JSON as absent. + private static bool TryDeserialize(JsonElement element, out T? value) + where T : class + { + try + { + value = element.Deserialize(KeetaJson.Options); + } + catch (JsonException) + { + value = null; + } + + return value is not null; + } + + /// The member's string value, or null when absent or not a string. + private static string? ReadOptionalString(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out JsonElement found) || found.ValueKind != JsonValueKind.String) + { + return null; + } + + return found.GetString(); + } +} /// Pagination bounds shared by the list operations. public sealed record AssetPagination(uint? Limit = null, uint? Offset = null); diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetProvider.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetProvider.cs new file mode 100644 index 0000000..747d5fa --- /dev/null +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetProvider.cs @@ -0,0 +1,130 @@ +namespace KeetaNet.Anchor; + +/// +/// One asset-movement provider bound to its discovering client (the reference +/// provider handle): a metadata snapshot in plus every +/// per-provider operation, signed and retried by the client it came from. +/// Obtained from the discovery methods or +/// re-bound from a stored snapshot with +/// . +/// +public sealed class AssetProvider +{ + private readonly AssetMovementClient _client; + + internal AssetProvider(AssetMovementClient client, AssetProviderInfo info) + { + _client = client; + Info = info; + } + + /// The provider's advertised metadata snapshot. + public AssetProviderInfo Info { get; } + + /// The provider's id. + public string Id => Info.Id; + + /// + public bool IsOperationSupported(string operation) => Info.IsOperationSupported(operation); + + /// + public IReadOnlyList? GetLegalDisclaimers() => Info.GetLegalDisclaimers(); + + /// + public AssetAnchorDetails? GetAnchorDetails() => Info.GetAnchorDetails(); + + /// + public AssetTokenMetadata? GetAssetMetadataForLocation(string location, string asset) => + Info.GetAssetMetadataForLocation(location, asset); + + /// Simulate a transfer, returning a fluent handle over its instruction choices. + public Task SimulateTransfer( + AssetTransferRequest request, + CancellationToken cancellationToken = default) => + _client.SimulateTransfer(this, request, cancellationToken); + + /// Initiate a transfer, returning a fluent handle. The request's recipient is required. + public Task InitiateTransfer( + AssetTransferRequest request, + CancellationToken cancellationToken = default) => + _client.InitiateTransfer(this, request, cancellationToken); + + /// Execute a pull instruction for a transfer. + public Task ExecuteTransfer( + AssetExecuteRequest request, + CancellationToken cancellationToken = default) => + _client.ExecuteTransfer(Info, request, cancellationToken); + + /// Read the status of transfer . + public Task GetTransferStatus(string id, CancellationToken cancellationToken = default) => + _client.GetTransferStatus(Info, id, cancellationToken); + + /// Read whether the signer's account is ready to use this provider. + public Task GetAccountStatus(CancellationToken cancellationToken = default) => + _client.GetAccountStatus(Info, cancellationToken); + + /// Open a persistent-forwarding template session. + public Task InitiatePersistentForwardingTemplate( + AssetInitiateTemplateRequest request, + CancellationToken cancellationToken = default) => + _client.InitiatePersistentForwardingTemplate(Info, request, cancellationToken); + + /// Create a persistent-forwarding template. + public Task CreatePersistentForwardingTemplate( + AssetCreateTemplateRequest request, + CancellationToken cancellationToken = default) => + _client.CreatePersistentForwardingTemplate(Info, request, cancellationToken); + + /// List persistent-forwarding templates. + public Task ListForwardingAddressTemplates( + AssetListTemplatesRequest request, + CancellationToken cancellationToken = default) => + _client.ListForwardingAddressTemplates(Info, request, cancellationToken); + + /// Create a persistent-forwarding address, returning its (obfuscated) details. + public Task CreatePersistentForwardingAddress( + AssetCreateAddressRequest request, + CancellationToken cancellationToken = default) => + _client.CreatePersistentForwardingAddress(Info, request, cancellationToken); + + /// List persistent-forwarding addresses. + public Task ListForwardingAddresses( + AssetListAddressesRequest request, + CancellationToken cancellationToken = default) => + _client.ListForwardingAddresses(Info, request, cancellationToken); + + /// Deactivate a persistent-forwarding template by id. + public Task DeactivatePersistentForwardingTemplate(string id, CancellationToken cancellationToken = default) => + _client.DeactivatePersistentForwardingTemplate(Info, id, cancellationToken); + + /// Deactivate a persistent-forwarding address by id. + public Task DeactivatePersistentForwardingAddress(string id, CancellationToken cancellationToken = default) => + _client.DeactivatePersistentForwardingAddress(Info, id, cancellationToken); + + /// List asset-movement transactions. + public Task ListTransactions( + AssetListTransactionsRequest request, + CancellationToken cancellationToken = default) => + _client.ListTransactions(Info, request, cancellationToken); + + /// + /// Share KYC attributes with the provider, returning the provider's outcome unchanged. + /// A pending outcome carries the promise URL the caller must poll. Use + /// to poll it automatically. + /// + public Task ShareKycAttributes( + AssetShareKycRequest request, + CancellationToken cancellationToken = default) => + _client.ShareKycAttributes(Info, request, cancellationToken); + + /// + /// Share KYC attributes and, when the outcome is pending with a promise URL, + /// poll that URL inside the core until it resolves. + /// + public Task ShareKycAttributesAndWait( + AssetShareKycRequest request, + TimeSpan? pollInterval = null, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) => + _client.ShareKycAttributesAndWait(Info, request, pollInterval, timeout, cancellationToken); +} diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetTransfer.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetTransfer.cs index 18e8c2d..c2d1fdc 100644 --- a/src/KeetaNet.Anchor/Services/AssetMovement/AssetTransfer.cs +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetTransfer.cs @@ -9,17 +9,14 @@ namespace KeetaNet.Anchor; /// public sealed class AssetSimulatedTransfer { - private readonly AssetMovementClient _client; private readonly AssetProvider _provider; private readonly AssetTransferRequest _request; internal AssetSimulatedTransfer( - AssetMovementClient client, AssetProvider provider, AssetTransferRequest request, IReadOnlyList instructionChoices) { - _client = client; _provider = provider; _request = request; InstructionChoices = instructionChoices; @@ -45,7 +42,7 @@ public Task CreateTransfer( }; AssetTransferRequest request = _request with { To = to }; - return _client.InitiateTransfer(_provider, request, cancellationToken); + return _provider.InitiateTransfer(request, cancellationToken); } } @@ -56,16 +53,13 @@ public Task CreateTransfer( /// public sealed class AssetTransfer { - private readonly AssetMovementClient _client; private readonly AssetProvider _provider; internal AssetTransfer( - AssetMovementClient client, AssetProvider provider, string id, IReadOnlyList instructionChoices) { - _client = client; _provider = provider; Id = id; InstructionChoices = instructionChoices; @@ -79,7 +73,7 @@ internal AssetTransfer( /// Read this transfer's current status. public Task GetTransferStatus(CancellationToken cancellationToken = default) => - _client.GetTransferStatus(_provider, Id, cancellationToken); + _provider.GetTransferStatus(Id, cancellationToken); /// Execute a fiat pull for this transfer. public Task ExecuteTransfer( @@ -87,6 +81,6 @@ public Task ExecuteTransfer( CancellationToken cancellationToken = default) { var request = new AssetExecuteRequest(Id, instruction); - return _client.ExecuteTransfer(_provider, request, cancellationToken); + return _provider.ExecuteTransfer(request, cancellationToken); } } diff --git a/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs b/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs index 27e21d3..7752853 100644 --- a/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs +++ b/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs @@ -4,10 +4,11 @@ namespace KeetaNet.Anchor; /// /// A KYC anchor client bound to a signer and a metadata root. Discovery, request -/// signing, retries, and polling all run inside the wasm core. The client is -/// thread-safe: operations serialize onto the runtime's dispatcher, and every -/// networked method honors its before dispatch -/// and during host HTTP and sleeps. +/// signing, retries, and polling all run inside the wasm core. Discovery returns +/// handles carrying the verification operations. The +/// client is thread-safe: operations serialize onto the runtime's dispatcher, +/// and every networked method honors its before +/// dispatch and during host HTTP and sleeps. /// public sealed class KycClient : WasmObject { @@ -33,10 +34,8 @@ public async Task> GetProviders( IEnumerable countries, CancellationToken cancellationToken = default) { - string countriesJson = SerializeCountries(countries); - - byte[] payload = await Runtime.KycProviders(Handle, countriesJson, cancellationToken).ConfigureAwait(false); - return KeetaJson.ReadList(payload); + IReadOnlyList infos = await GetProviderInfos(countries, cancellationToken).ConfigureAwait(false); + return infos.Select(Provider).ToArray(); } /// @@ -45,20 +44,23 @@ public async Task> GetProviders( /// public async Task GetSupportedCountries(CancellationToken cancellationToken = default) { - IReadOnlyList providers = await GetProviders(Array.Empty(), cancellationToken).ConfigureAwait(false); - return SupportedCountries.FromProviders(providers); + IReadOnlyList infos = await GetProviderInfos(Array.Empty(), cancellationToken).ConfigureAwait(false); + return SupportedCountries.FromProviders(infos); } + /// Bind a stored metadata snapshot back to this client as an operable handle. + public KycProvider Provider(KycProviderInfo info) => new(this, info); + /// /// Start a verification with for /// , optionally redirecting the user to /// when the flow ends. /// - public async Task StartVerification( - KycProvider provider, + internal async Task StartVerification( + KycProviderInfo provider, IEnumerable countries, - string? redirect = null, - CancellationToken cancellationToken = default) + string? redirect, + CancellationToken cancellationToken) { string providerJson = JsonSerializer.Serialize(provider, KeetaJson.Options); string countriesJson = SerializeCountries(countries); @@ -71,10 +73,10 @@ public async Task StartVerification( } /// Fetch the certificates issued for verification . - public async Task GetCertificates( - KycProvider provider, + internal async Task GetCertificates( + KycProviderInfo provider, string id, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { string providerJson = JsonSerializer.Serialize(provider, KeetaJson.Options); byte[] payload = await Runtime @@ -85,15 +87,14 @@ public async Task GetCertificates( } /// Parse 's advertised issuer CA certificate. - /// Use it as a trusted root when verifying an issued . - public Crypto.Certificate GetCA(KycProvider provider) => + internal Crypto.Certificate GetCA(KycProviderInfo provider) => Runtime.Certificates.Parse(provider.Ca); /// Read the status of verification . - public async Task GetVerificationStatus( - KycProvider provider, + internal async Task GetVerificationStatus( + KycProviderInfo provider, string id, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { string providerJson = JsonSerializer.Serialize(provider, KeetaJson.Options); byte[] payload = await Runtime @@ -103,6 +104,17 @@ public async Task GetVerificationStatus( return ParseOutcome(payload, "status", ready => new StatusOutcome(ready, null), retry => new StatusOutcome(null, retry)); } + /// The raw discovery payload decoded to metadata snapshots. + private async Task> GetProviderInfos( + IEnumerable countries, + CancellationToken cancellationToken) + { + string countriesJson = SerializeCountries(countries); + + byte[] payload = await Runtime.KycProviders(Handle, countriesJson, cancellationToken).ConfigureAwait(false); + return KeetaJson.ReadList(payload); + } + private static string SerializeCountries(IEnumerable countries) => JsonSerializer.Serialize(countries.ToArray(), KeetaJson.Options); diff --git a/src/KeetaNet.Anchor/Services/Kyc/KycModels.cs b/src/KeetaNet.Anchor/Services/Kyc/KycModels.cs index 645a787..5bc640e 100644 --- a/src/KeetaNet.Anchor/Services/Kyc/KycModels.cs +++ b/src/KeetaNet.Anchor/Services/Kyc/KycModels.cs @@ -10,9 +10,14 @@ public sealed record KycOperations( string? CheckLocality, string? GetEstimate); -/// A KYC provider discovered from on-chain service metadata. +/// +/// A KYC provider's advertised metadata, discovered from on-chain service +/// metadata (the reference KycProviderInfo). Operations live on the +/// handle bound through +/// . +/// /// is null for a worldwide provider. -public sealed record KycProvider( +public sealed record KycProviderInfo( string Id, string Ca, KycOperations Operations, @@ -27,10 +32,10 @@ public sealed record KycProvider( public sealed record SupportedCountries(bool Worldwide, IReadOnlyList Countries) { /// Fold discovered into their aggregate coverage. - public static SupportedCountries FromProviders(IEnumerable providers) + public static SupportedCountries FromProviders(IEnumerable providers) { var countries = new List(); - foreach (KycProvider provider in providers) + foreach (KycProviderInfo provider in providers) { if (provider.CountryCodes is null) { diff --git a/src/KeetaNet.Anchor/Services/Kyc/KycProvider.cs b/src/KeetaNet.Anchor/Services/Kyc/KycProvider.cs new file mode 100644 index 0000000..1b7d266 --- /dev/null +++ b/src/KeetaNet.Anchor/Services/Kyc/KycProvider.cs @@ -0,0 +1,47 @@ +namespace KeetaNet.Anchor; + +/// +/// One KYC provider bound to its discovering client (the reference provider +/// handle): a metadata snapshot in plus the verification +/// operations, signed and retried by the client it came from. Obtained from +/// or re-bound from a stored snapshot +/// with . +/// +public sealed class KycProvider +{ + private readonly KycClient _client; + + internal KycProvider(KycClient client, KycProviderInfo info) + { + _client = client; + Info = info; + } + + /// The provider's advertised metadata snapshot. + public KycProviderInfo Info { get; } + + /// The provider's id. + public string Id => Info.Id; + + /// + /// Start a verification for , optionally + /// redirecting the user to when the flow ends. + /// + public Task StartVerification( + IEnumerable countries, + string? redirect = null, + CancellationToken cancellationToken = default) => + _client.StartVerification(Info, countries, redirect, cancellationToken); + + /// Fetch the certificates issued for verification . + public Task GetCertificates(string id, CancellationToken cancellationToken = default) => + _client.GetCertificates(Info, id, cancellationToken); + + /// Read the status of verification . + public Task GetVerificationStatus(string id, CancellationToken cancellationToken = default) => + _client.GetVerificationStatus(Info, id, cancellationToken); + + /// Parse this provider's advertised issuer CA certificate. + /// Use it as a trusted root when verifying an issued . + public Crypto.Certificate GetCA() => _client.GetCA(Info); +} diff --git a/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs index 0f66a7c..533c198 100644 --- a/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs @@ -27,7 +27,7 @@ public async Task DiscoveryReadsThePublishedProvider() IReadOnlyList providers = await client.GetProviders(cancellationToken); AssetProvider provider = Assert.Single(providers); Assert.Equal(anchor.ProviderId, provider.Id); - Assert.True(client.IsOperationSupported(provider, "simulateTransfer")); + Assert.True(provider.IsOperationSupported("simulateTransfer")); // The Account overload resolves the public-key string itself, so one // call covers both lookup forms. @@ -54,10 +54,10 @@ public async Task TransfersRunEndToEndAgainstTheLiveAnchor() (AssetMovementClient client, AssetAnchor anchor, CancellationToken cancellationToken) = session; AssetProvider provider = await session.DiscoveredProviderAsync(); - AssetAccountStatus status = await client.GetAccountStatus(provider, cancellationToken); + AssetAccountStatus status = await provider.GetAccountStatus(cancellationToken); Assert.False(status.ActionRequired); - AssetSimulatedTransfer simulated = await client.SimulateTransfer(provider, PushTransfer(anchor, anchor.SendToAddress), cancellationToken); + AssetSimulatedTransfer simulated = await provider.SimulateTransfer(PushTransfer(anchor, anchor.SendToAddress), cancellationToken); JsonElement simulatedInstruction = Assert.Single(simulated.InstructionChoices); Assert.Equal("KEETA_SEND", simulatedInstruction.GetProperty("type").GetString()); @@ -75,20 +75,20 @@ public async Task TransfersRunEndToEndAgainstTheLiveAnchor() $"123:{anchor.Signer}", Assert.Single(redirected.InstructionChoices).GetProperty("external").GetString()); - AssetTransfer transfer = await client.InitiateTransfer(provider, PushTransfer(anchor, anchor.SendToAddress), cancellationToken); + AssetTransfer transfer = await provider.InitiateTransfer(PushTransfer(anchor, anchor.SendToAddress), cancellationToken); Assert.Equal("123", transfer.Id); Assert.Equal( anchor.SendToAddress, transfer.InstructionChoices[0].GetProperty("sendToAddress").GetString()); await Assert.ThrowsAsync( - () => client.InitiateTransfer(provider, PushTransfer(anchor, recipient: null), cancellationToken)); + () => provider.InitiateTransfer(PushTransfer(anchor, recipient: null), cancellationToken)); AssetTransferStatus completed = await transfer.GetTransferStatus(cancellationToken); Assert.Equal("123", completed.Transaction.GetProperty("id").GetString()); Assert.Equal("COMPLETED", completed.Transaction.GetProperty("status").GetString()); - AssetTransfer pull = await client.InitiateTransfer(provider, PullTransfer(anchor), cancellationToken); + AssetTransfer pull = await provider.InitiateTransfer(PullTransfer(anchor), cancellationToken); JsonElement pullInstruction = Assert.Single(pull.InstructionChoices); Assert.Equal("ACH_DEBIT", pullInstruction.GetProperty("type").GetString()); @@ -106,7 +106,7 @@ public async Task AccountStatusServesTypedBlockersForABlockedCaller() (AssetMovementClient client, AssetAnchor anchor, CancellationToken cancellationToken) = session; AssetProvider provider = await session.DiscoveredProviderAsync(); - AssetAccountStatus status = await client.GetAccountStatus(provider, cancellationToken); + AssetAccountStatus status = await provider.GetAccountStatus(cancellationToken); Assert.True(status.ActionRequired); Assert.Equal(2, status.Blockers!.Count); @@ -136,7 +136,7 @@ public async Task PublishedLegalAndTokenMetadataRoundTrip() (AssetMovementClient client, AssetAnchor anchor, CancellationToken cancellationToken) = session; AssetProvider provider = await session.DiscoveredProviderAsync(); - IReadOnlyList? disclaimers = client.GetLegalDisclaimers(provider); + IReadOnlyList? disclaimers = provider.GetLegalDisclaimers(); Assert.NotNull(disclaimers); AssetDisclaimer disclaimer = Assert.Single(disclaimers!); Assert.Equal(AssetDisclaimerPurpose.General, disclaimer.Purpose); @@ -147,7 +147,7 @@ public async Task PublishedLegalAndTokenMetadataRoundTrip() IReadOnlyList? byId = await client.GetProviderLegalDisclaimersById(anchor.ProviderId, cancellationToken); Assert.Equal(disclaimers, byId); - AssetTokenMetadata? metadata = client.GetAssetMetadataForLocation(provider, EvmLocation, EvmAsset); + AssetTokenMetadata? metadata = provider.GetAssetMetadataForLocation(EvmLocation, EvmAsset); Assert.NotNull(metadata); Assert.Equal(18u, metadata!.DecimalPlaces); Assert.Equal("Test Token", metadata.DisplayName); @@ -156,10 +156,10 @@ public async Task PublishedLegalAndTokenMetadataRoundTrip() // An asset the anchor publishes no display metadata for reports absent, // not an error. - Assert.Null(client.GetAssetMetadataForLocation(provider, EvmLocation, "evm:0xdeadbeef")); + Assert.Null(provider.GetAssetMetadataForLocation(EvmLocation, "evm:0xdeadbeef")); // The identifying details under legal.anchorDetails decode typed. - AssetAnchorDetails? details = client.GetProviderAnchorDetails(provider); + AssetAnchorDetails? details = provider.GetAnchorDetails(); Assert.NotNull(details); Assert.Equal("Test Anchor", details!.Name); Assert.NotNull(details.Description); @@ -177,13 +177,12 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor() (AssetMovementClient client, AssetAnchor anchor, CancellationToken cancellationToken) = session; AssetProvider provider = await session.DiscoveredProviderAsync(); - AssetTemplateSession templateSession = await client.InitiatePersistentForwardingTemplate( - provider, new AssetInitiateTemplateRequest(anchor.Asset, EvmLocation), cancellationToken); + AssetTemplateSession templateSession = await provider.InitiatePersistentForwardingTemplate( + new AssetInitiateTemplateRequest(anchor.Asset, EvmLocation), cancellationToken); Assert.Equal("test-session-id", templateSession.Id); Assert.Equal("link-sandbox-test-token", templateSession.Data.GetProperty("plaidLinkToken").GetString()); - AssetForwardingTemplate template = await client.CreatePersistentForwardingTemplate( - provider, + AssetForwardingTemplate template = await provider.CreatePersistentForwardingTemplate( new AssetCreateTemplateRequest(Asset: anchor.Asset, Location: EvmLocation, Address: anchor.SendToAddress), cancellationToken); Assert.Equal("template-id", template.Id); @@ -194,19 +193,17 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor() plaidPublicToken = "public-sandbox-token", plaidAccountId = "account-1", }; - AssetForwardingTemplate completed = await client.CreatePersistentForwardingTemplate( - provider, new AssetCreateTemplateRequest(Id: templateSession.Id, Data: completionData), cancellationToken); + AssetForwardingTemplate completed = await provider.CreatePersistentForwardingTemplate( + new AssetCreateTemplateRequest(Id: templateSession.Id, Data: completionData), cancellationToken); Assert.Equal("template-id", completed.Id); - AssetTemplatePage templates = await client.ListForwardingAddressTemplates( - provider, + AssetTemplatePage templates = await provider.ListForwardingAddressTemplates( new AssetListTemplatesRequest(new[] { anchor.Asset }, new[] { EvmLocation }), cancellationToken); Assert.Single(templates.Templates); Assert.Equal("1", templates.Total); - AssetForwardingAddress created = await client.CreatePersistentForwardingAddress( - provider, + AssetForwardingAddress created = await provider.CreatePersistentForwardingAddress( new AssetCreateAddressRequest( EvmLocation, anchor.Asset, @@ -225,14 +222,12 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor() Assert.Equal(50d, lineItem.BasisPoints); Assert.Equal(AssetContentType.Markdown, lineItem.Details!.Type); - AssetForwardingAddress fromTemplate = await client.CreatePersistentForwardingAddress( - provider, + AssetForwardingAddress fromTemplate = await provider.CreatePersistentForwardingAddress( new AssetCreateAddressRequest(EvmLocation, anchor.Asset, PersistentAddressTemplateId: template.Id), cancellationToken); Assert.Equal(anchor.SendToAddress, fromTemplate.Address.GetString()); - AssetAddressPage addresses = await client.ListForwardingAddresses( - provider, + AssetAddressPage addresses = await provider.ListForwardingAddresses( new AssetListAddressesRequest( new[] { new AssetAddressFilter(SourceLocation: EvmLocation, Asset: anchor.Asset) }, new AssetPagination(10, 0)), @@ -251,16 +246,14 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor() // A conversion-pair filter crosses the wire in the reference `{ from, // to }` form and passes the live anchor's request validation. - AssetAddressPage paired = await client.ListForwardingAddresses( - provider, + AssetAddressPage paired = await provider.ListForwardingAddresses( new AssetListAddressesRequest( new[] { new AssetAddressFilter(SourceLocation: EvmLocation, Asset: AssetOrPair.Pair(anchor.Asset, "USD")) }, new AssetPagination(10, 0)), cancellationToken); Assert.Single(paired.Addresses); - AssetTransactionPage transactions = await client.ListTransactions( - provider, + AssetTransactionPage transactions = await provider.ListTransactions( new AssetListTransactionsRequest( new[] { new AssetPersistentAddressFilter(EvmLocation, anchor.SendToAddress) }, new AssetEndpointFilter(EvmLocation, anchor.SendToAddress, anchor.Asset), @@ -269,20 +262,21 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor() JsonElement transaction = Assert.Single(transactions.Transactions); Assert.Equal("123", transaction.GetProperty("id").GetString()); - await client.DeactivatePersistentForwardingTemplate(provider, template.Id, cancellationToken); - await client.DeactivatePersistentForwardingAddress(provider, template.Id, cancellationToken); + await provider.DeactivatePersistentForwardingTemplate(template.Id, cancellationToken); + await provider.DeactivatePersistentForwardingAddress(template.Id, cancellationToken); await Assert.ThrowsAsync( - () => client.DeactivatePersistentForwardingTemplate(provider, "does-not-exist", cancellationToken)); + () => provider.DeactivatePersistentForwardingTemplate("does-not-exist", cancellationToken)); // An operation the provider does not advertise must surface a typed - // error before any request leaves the client. - Dictionary narrowedOperations = provider.Operations + // error before any request leaves the client. A stored snapshot + // re-binds through the client's Provider factory. + Dictionary narrowedOperations = provider.Info.Operations .Where(operation => operation.Key != "listTransactions") .ToDictionary(operation => operation.Key, operation => operation.Value); - AssetProvider narrowed = provider with { Operations = narrowedOperations }; + AssetProvider narrowed = client.Provider(provider.Info with { Operations = narrowedOperations }); await Assert.ThrowsAsync( - () => client.ListTransactions(narrowed, new AssetListTransactionsRequest(), cancellationToken)); + () => narrowed.ListTransactions(new AssetListTransactionsRequest(), cancellationToken)); session.Shutdown(); } @@ -294,18 +288,17 @@ public async Task ShareKycSettlesAndPollsAgainstTheLiveAnchor() (AssetMovementClient client, _, CancellationToken cancellationToken) = session; AssetProvider provider = await session.DiscoveredProviderAsync(); - AssetShareKycOutcome settled = await client.ShareKycAttributes( - provider, new AssetShareKycRequest("exported-attributes"), cancellationToken); + AssetShareKycOutcome settled = await provider.ShareKycAttributes( + new AssetShareKycRequest("exported-attributes"), cancellationToken); Assert.False(settled.IsPending); - AssetShareKycOutcome withoutPolling = await client.ShareKycAttributesAndWait( - provider, new AssetShareKycRequest("exported-attributes"), cancellationToken: cancellationToken); + AssetShareKycOutcome withoutPolling = await provider.ShareKycAttributesAndWait( + new AssetShareKycRequest("exported-attributes"), cancellationToken: cancellationToken); Assert.False(withoutPolling.IsPending); // The promise route reports pending (202 + Retry-After) for the first // two polls and settles on the third. - AssetShareKycOutcome polled = await client.ShareKycAttributesAndWait( - provider, + AssetShareKycOutcome polled = await provider.ShareKycAttributesAndWait( new AssetShareKycRequest("promise-flow"), pollInterval: TimeSpan.FromMilliseconds(1), timeout: TimeSpan.FromMinutes(1), @@ -313,8 +306,7 @@ public async Task ShareKycSettlesAndPollsAgainstTheLiveAnchor() Assert.False(polled.IsPending); await Assert.ThrowsAsync( - () => client.ShareKycAttributesAndWait( - provider, + () => provider.ShareKycAttributesAndWait( new AssetShareKycRequest("promise-stall"), pollInterval: TimeSpan.FromSeconds(1), timeout: TimeSpan.FromMilliseconds(500), @@ -332,7 +324,7 @@ public async Task ARefusedShareSurfacesTheTypedKycBlocker() // The anchor refuses the magic attributes with a 403 blocker envelope KeetaBlockerException refusal = await Assert.ThrowsAsync( - () => client.ShareKycAttributes(provider, new AssetShareKycRequest("blocked"), cancellationToken)); + () => provider.ShareKycAttributes(new AssetShareKycRequest("blocked"), cancellationToken)); Assert.Equal("KEETA_ANCHOR_ASSET_MOVEMENT_KYC_SHARE_NEEDED", refusal.Code); var share = Assert.IsType(refusal.Blocker); diff --git a/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs index 6d9b98f..f206629 100644 --- a/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs @@ -38,10 +38,10 @@ public async Task VerificationPathRunsAgainstTheLiveAnchor(string algorithm) Assert.False(supported.Worldwide); Assert.Equal(Countries, supported.Countries); - using CryptoCertificate ca = client.GetCA(provider); + using CryptoCertificate ca = provider.GetCA(); Assert.NotEmpty(ca.SubjectPublicKey); - VerificationOutcome created = await client.StartVerification(provider, Countries, cancellationToken: cancellationToken); + VerificationOutcome created = await provider.StartVerification(Countries, cancellationToken: cancellationToken); Assert.NotNull(created.Ready); Verification verification = created.Ready!; Assert.NotEmpty(verification.Id); @@ -50,20 +50,20 @@ public async Task VerificationPathRunsAgainstTheLiveAnchor(string algorithm) // A redirect URL rides the signed create body. The server must accept // the extra field and still assign a verification. - VerificationOutcome redirected = await client.StartVerification(provider, Countries, "https://example.test/done", cancellationToken); + VerificationOutcome redirected = await provider.StartVerification(Countries, "https://example.test/done", cancellationToken); Assert.NotNull(redirected.Ready); Assert.NotEmpty(redirected.Ready!.Id); - StatusOutcome status = await client.GetVerificationStatus(provider, verification.Id, cancellationToken); + StatusOutcome status = await provider.GetVerificationStatus(verification.Id, cancellationToken); Assert.NotNull(status.Ready); Assert.Equal("pending", status.Ready!.Status); Assert.True(status.Ready.RequiresManualVerification); - CertificatesOutcome pending = await client.GetCertificates(provider, "pending", cancellationToken); + CertificatesOutcome pending = await provider.GetCertificates("pending", cancellationToken); Assert.Null(pending.Ready); Assert.NotNull(pending.RetryAfterMs); - CertificatesOutcome ready = await client.GetCertificates(provider, "ready", cancellationToken); + CertificatesOutcome ready = await provider.GetCertificates("ready", cancellationToken); Assert.NotNull(ready.Ready); Assert.NotEmpty(ready.Ready!.Results); @@ -71,7 +71,7 @@ public async Task VerificationPathRunsAgainstTheLiveAnchor(string algorithm) // `[leaf, ca]` chain over the same signed-URL certificate path. IssuedLeaf issued = IssuedLeaf.Issue(harness); - CertificatesOutcome chain = await client.GetCertificates(provider, issued.VerificationId, cancellationToken); + CertificatesOutcome chain = await provider.GetCertificates(issued.VerificationId, cancellationToken); Assert.NotNull(chain.Ready); Assert.Equal(2, chain.Ready!.Results.Count); diff --git a/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs b/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs index 364a3dd..4439f19 100644 --- a/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs +++ b/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs @@ -78,12 +78,8 @@ public void AnUnknownBlockerTypeRefusesToDecode() [Fact] public void LegalDisclaimersDecodeAndSkipMalformedEntries() { - using var runtime = WasmRuntime.Load(); - using Account account = runtime.Accounts.FromSeed(TestSeeds.Subject, 0, TestSeeds.DefaultAlgorithm); - using AssetMovementClient client = runtime.CreateAssetMovementClient(TestSeeds.NonRoutableAnchor, account.PublicKeyString, account); - // One well-formed markdown disclaimer and one with an unknown purpose - AssetProvider provider = Provider(legal: """ + AssetProviderInfo provider = Provider(legal: """ { "disclaimers": [ { "purpose": "general", "content": { "type": "markdown", "content": "# Terms" } }, @@ -92,7 +88,7 @@ public void LegalDisclaimersDecodeAndSkipMalformedEntries() } """); - IReadOnlyList? disclaimers = client.GetLegalDisclaimers(provider); + IReadOnlyList? disclaimers = provider.GetLegalDisclaimers(); Assert.NotNull(disclaimers); AssetDisclaimer disclaimer = Assert.Single(disclaimers!); Assert.Equal(AssetDisclaimerPurpose.General, disclaimer.Purpose); @@ -100,19 +96,15 @@ public void LegalDisclaimersDecodeAndSkipMalformedEntries() Assert.Equal("# Terms", disclaimer.Content.Content); // A provider without legal metadata reports none, not an empty list. - Assert.Null(client.GetLegalDisclaimers(Provider(legal: null))); + Assert.Null(Provider(legal: null).GetLegalDisclaimers()); } [Fact] public void TokenMetadataDecodesNumberAndStringDecimalPlaces() { - using var runtime = WasmRuntime.Load(); - using Account account = runtime.Accounts.FromSeed(TestSeeds.Subject, 0, TestSeeds.DefaultAlgorithm); - using AssetMovementClient client = runtime.CreateAssetMovementClient(TestSeeds.NonRoutableAnchor, account.PublicKeyString, account); - // The reference TokenMetadataJSON publishes decimalPlaces as a number // or a numeric string; both must decode, and garbage must read absent. - AssetProvider provider = Provider(locationMetadata: """ + AssetProviderInfo provider = Provider(locationMetadata: """ { "chain:evm:100": { "assets": { @@ -124,21 +116,21 @@ public void TokenMetadataDecodesNumberAndStringDecimalPlaces() } """); - AssetTokenMetadata? full = client.GetAssetMetadataForLocation(provider, "chain:evm:100", "text-places"); + AssetTokenMetadata? full = provider.GetAssetMetadataForLocation("chain:evm:100", "text-places"); Assert.NotNull(full); Assert.Equal(18u, full!.DecimalPlaces); Assert.Equal("https://logo.test/t.png", full.LogoUri); Assert.Equal("Token", full.DisplayName); Assert.Equal("$TOK", full.Ticker); - AssetTokenMetadata? bare = client.GetAssetMetadataForLocation(provider, "chain:evm:100", "numeric-places"); + AssetTokenMetadata? bare = provider.GetAssetMetadataForLocation("chain:evm:100", "numeric-places"); Assert.NotNull(bare); Assert.Equal(6u, bare!.DecimalPlaces); Assert.Null(bare.LogoUri); - Assert.Null(client.GetAssetMetadataForLocation(provider, "chain:evm:100", "garbage-places")); - Assert.Null(client.GetAssetMetadataForLocation(provider, "chain:evm:100", "absent-asset")); - Assert.Null(client.GetAssetMetadataForLocation(provider, "chain:solana:1", "text-places")); + Assert.Null(provider.GetAssetMetadataForLocation("chain:evm:100", "garbage-places")); + Assert.Null(provider.GetAssetMetadataForLocation("chain:evm:100", "absent-asset")); + Assert.Null(provider.GetAssetMetadataForLocation("chain:solana:1", "text-places")); } [Fact] @@ -216,11 +208,7 @@ public void LocatedAssetsRoundTripTheirCanonicalTransportForms(string id, string [Fact] public void AnchorDetailsDecodeAndDropAMalformedDescription() { - using var runtime = WasmRuntime.Load(); - using Account account = runtime.Accounts.FromSeed(TestSeeds.Subject, 0, TestSeeds.DefaultAlgorithm); - using AssetMovementClient client = runtime.CreateAssetMovementClient(TestSeeds.NonRoutableAnchor, account.PublicKeyString, account); - - AssetProvider provider = Provider(legal: """ + AssetProviderInfo provider = Provider(legal: """ { "anchorDetails": { "name": "Anchor Under Test", @@ -230,7 +218,7 @@ public void AnchorDetailsDecodeAndDropAMalformedDescription() } """); - AssetAnchorDetails? details = client.GetProviderAnchorDetails(provider); + AssetAnchorDetails? details = provider.GetAnchorDetails(); Assert.NotNull(details); Assert.Equal("Anchor Under Test", details!.Name); Assert.Equal(AssetContentType.Plaintext, details.Description!.Type); @@ -238,22 +226,22 @@ public void AnchorDetailsDecodeAndDropAMalformedDescription() Assert.Equal("https://logo.test/a.svg", details.Logo); // A malformed description drops while the identifying fields survive. - AssetProvider malformed = Provider(legal: """ + AssetProviderInfo malformed = Provider(legal: """ { "anchorDetails": { "name": "Partial", "description": { "type": "unknown-kind", "content": 5 } } } """); - AssetAnchorDetails? partial = client.GetProviderAnchorDetails(malformed); + AssetAnchorDetails? partial = malformed.GetAnchorDetails(); Assert.NotNull(partial); Assert.Equal("Partial", partial!.Name); Assert.Null(partial.Description); Assert.Null(partial.Logo); // Legal metadata without anchor details reports none. - Assert.Null(client.GetProviderAnchorDetails(Provider(legal: """{ "disclaimers": [] }"""))); - Assert.Null(client.GetProviderAnchorDetails(Provider(legal: null))); + Assert.Null(Provider(legal: """{ "disclaimers": [] }""").GetAnchorDetails()); + Assert.Null(Provider(legal: null).GetAnchorDetails()); } - /// A minimal provider carrying only the polymorphic metadata under test. - private static AssetProvider Provider(string? legal = null, string? locationMetadata = null) + /// A minimal provider snapshot carrying only the polymorphic metadata under test. + private static AssetProviderInfo Provider(string? legal = null, string? locationMetadata = null) { JsonElement? legalElement = null; if (legal is not null) @@ -267,7 +255,7 @@ private static AssetProvider Provider(string? legal = null, string? locationMeta locationElement = JsonSerializer.Deserialize(locationMetadata); } - return new AssetProvider( + return new AssetProviderInfo( "provider-under-test", new Dictionary(), LocationMetadata: locationElement, diff --git a/tests/KeetaNet.Anchor.Tests/KycModelTests.cs b/tests/KeetaNet.Anchor.Tests/KycModelTests.cs index 998f509..068a546 100644 --- a/tests/KeetaNet.Anchor.Tests/KycModelTests.cs +++ b/tests/KeetaNet.Anchor.Tests/KycModelTests.cs @@ -37,13 +37,13 @@ public void AWorldwideProviderFoldsToWorldwide() [Fact] public void NoProvidersFoldToAnEmptyUnion() { - SupportedCountries folded = SupportedCountries.FromProviders(Array.Empty()); + SupportedCountries folded = SupportedCountries.FromProviders(Array.Empty()); Assert.False(folded.Worldwide); Assert.Empty(folded.Countries); } - /// A provider advertising , or worldwide when null. - private static KycProvider Provider(string id, string[]? countryCodes) => + /// A provider snapshot advertising , or worldwide when null. + private static KycProviderInfo Provider(string id, string[]? countryCodes) => new(id, "ca-pem", new KycOperations(null, null, null, null, null), countryCodes); }