From d099a29552745a1725bd06d90de0037d2a308d32 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Wed, 29 Jul 2026 11:10:41 -0700 Subject: [PATCH 1/2] feat(node): ledger reads and builder publishing --- .../Interop/WasmRuntime.Surface.cs | 24 +- .../Services/Node/KeetaClient.cs | 361 +++++++++++++++++- .../Services/Node/KeetaNetwork.cs | 58 +++ .../Services/Node/NodeModels.cs | 71 ++++ .../Services/Node/TransmitOptions.cs | 6 + .../Services/Node/UserClient.cs | 132 ++++++- .../KeetaNet.Anchor.E2eTests/NodeFlowTests.cs | 136 +++++++ tests/KeetaNet.Anchor.Tests/NetworkTests.cs | 41 ++ 8 files changed, 812 insertions(+), 17 deletions(-) create mode 100644 src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs create mode 100644 tests/KeetaNet.Anchor.Tests/NetworkTests.cs diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs index fa38d6e..9bbb8ce 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs @@ -54,12 +54,20 @@ public AssetMovementClient CreateAssetMovementClient(string nodeUrl, string root public KeetaClient CreateKeetaClient(string nodeUrl, HttpClient? httpClient = null, long? network = null) => new(this, nodeUrl, httpClient, network); + /// + /// Create the base client for a well-known , + /// the reference fromNetwork: its first representative's endpoint + /// and its network id, so the write path is enabled. + /// + public KeetaClient CreateKeetaClient(KeetaNetwork network, HttpClient? httpClient = null) => + new(this, network.RepresentativeApiUrl(), httpClient, network.Id()); + /// /// Create a client bound to (null for a /// read-only client), operating as when given /// and as the signer itself otherwise. Both accounts are borrowed, not - /// disposed. See for the remaining - /// parameters. + /// disposed. See + /// for the remaining parameters. /// public UserClient CreateUserClient( string nodeUrl, @@ -68,4 +76,16 @@ public UserClient CreateUserClient( long? network = null, Account? account = null) => new(this, nodeUrl, httpClient, network, signer, account); + + /// + /// Create a signer-bound client for a well-known + /// , the reference UserClient.fromNetwork. + /// See the URL overload for the remaining parameters. + /// + public UserClient CreateUserClient( + KeetaNetwork network, + Account? signer, + HttpClient? httpClient = null, + Account? account = null) => + new(this, network.RepresentativeApiUrl(), httpClient, network.Id(), signer, account); } diff --git a/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs b/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs index 66804c5..a75f1f6 100644 --- a/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/KeetaClient.cs @@ -1,11 +1,15 @@ +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Numerics; using System.Text.Json; using KeetaNet.Anchor.Generated.Node; +using GeneratedBlock = KeetaNet.Anchor.Generated.Node.Block; using GeneratedCertificate = KeetaNet.Anchor.Generated.Node.Certificate; +using GeneratedHistoryEntry = KeetaNet.Anchor.Generated.Node.HistoryEntry; using GeneratedRepresentative = KeetaNet.Anchor.Generated.Node.Representative; +using GeneratedVote = KeetaNet.Anchor.Generated.Node.Vote; namespace KeetaNet.Anchor; @@ -186,6 +190,213 @@ public async Task GetAccountBalance( return OptionalHexAmount(response.Balance) ?? BigInteger.Zero; } + /// The head block of 's chain, or null for a never-used account. + public async Task GetHeadBlock( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + GetAccountHeadResponse response = await Attempt(() => _api.GetAccountHeadAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + return DecodeBlock(response.Block); + } + + /// The next pending (unreceived) block for , if any. + public async Task GetPendingBlock( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + GetPendingBlockResponse response = await Attempt(() => _api.GetPendingBlockAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + return DecodeBlock(response.Block); + } + + /// The block identified by on the given , if present. + public async Task GetBlock( + Crypto.BlockHash blockHash, + LedgerSide? side = null, + CancellationToken cancellationToken = default) + { + Side2? generated = side switch + { + LedgerSide.Main => Side2.Main, + LedgerSide.Side => Side2.Side, + _ => null, + }; + + GetBlockResponse response = await Attempt(() => _api.GetBlockAsync(blockHash.ToString(), generated, cancellationToken)).ConfigureAwait(false); + return DecodeBlock(response.Block); + } + + /// The block following , if one exists. + public async Task GetSuccessorBlock( + Crypto.BlockHash blockHash, + CancellationToken cancellationToken = default) + { + GetSuccessorBlockResponse response = await Attempt(() => _api.GetSuccessorBlockAsync(blockHash.ToString(), cancellationToken)).ConfigureAwait(false); + return DecodeBlock(response.SuccessorBlock); + } + + /// + /// The block produced by for the idempotent + /// , if any, searching the given + /// (the main ledger when omitted). + /// + public async Task GetBlockFromIdempotent( + Crypto.Account account, + string key, + LedgerSide? side = null, + CancellationToken cancellationToken = default) + { + Side3? generated = side switch + { + LedgerSide.Main => Side3.Main, + LedgerSide.Side => Side3.Side, + _ => null, + }; + + GetBlockFromIdempotentResponse response = await Attempt(() => _api.GetBlockFromIdempotentAsync(account.PublicKeyString, key, generated, cancellationToken)).ConfigureAwait(false); + return DecodeBlock(response.Block); + } + + /// + /// The verified votes the node holds for on + /// , or null when it holds none. The caller owns + /// the votes and must dispose them. + /// + public async Task?> GetBlockVotes( + Crypto.BlockHash blockHash, + LedgerSide side = LedgerSide.Main, + CancellationToken cancellationToken = default) + { + Side generated = side == LedgerSide.Side ? Side.Side : Side.Main; + GetBlockVotesResponse response = await Attempt(() => _api.GetBlockVotesAsync(blockHash.ToString(), generated, cancellationToken)).ConfigureAwait(false); + if (response.Votes is null) + { + return null; + } + + var votes = new List(response.Votes.Count); + try + { + foreach (GeneratedVote vote in response.Votes) + { + votes.Add(DecodeVote(vote.Binary)); + } + } + catch + { + foreach (Crypto.Vote vote in votes) + { + vote.Dispose(); + } + + throw; + } + + return votes; + } + + /// + /// A single page of 's block chain (most recent + /// first), bounded by , with the cursor for the + /// next page. The caller owns the blocks and must dispose them. + /// + public async Task GetAccountChain( + Crypto.Account account, + ChainQuery? query = null, + CancellationToken cancellationToken = default) + { + ChainQuery bounds = query ?? new ChainQuery(); + GetAccountChainResponse response = await Attempt(() => _api.GetAccountChainAsync( + account.PublicKeyString, + bounds.Start?.ToString(), + bounds.End?.ToString(), + bounds.Limit, + cancellationToken)).ConfigureAwait(false); + + ICollection items = response.Blocks ?? Array.Empty(); + var blocks = new List(items.Count); + try + { + foreach (GetAccountChainResponseBlocksItem item in items) + { + if (DecodeBlock(item.Block) is { } block) + { + blocks.Add(block); + } + } + } + catch + { + foreach (Crypto.Block block in blocks) + { + block.Dispose(); + } + + throw; + } + + return new ChainPage(blocks, OptionalBlockHash(response.NextKey)); + } + + /// + /// A single page of 's committed staple history, + /// bounded by , with the cursor for the next page. + /// + public async Task GetAccountHistory( + Crypto.Account account, + HistoryQuery? query = null, + CancellationToken cancellationToken = default) + { + HistoryQuery bounds = query ?? new HistoryQuery(); + GetAccountHistoryResponse response = await Attempt(() => _api.GetAccountHistoryAsync( + account.PublicKeyString, + bounds.Start?.ToString(), + bounds.Limit, + cancellationToken)).ConfigureAwait(false); + + return DecodeHistoryPage(response.History, response.NextKey); + } + + /// + /// A single page of the node's global staple history, bounded by + /// , with the cursor for the next page. + /// + public async Task GetGlobalHistory( + HistoryQuery? query = null, + CancellationToken cancellationToken = default) + { + HistoryQuery bounds = query ?? new HistoryQuery(); + GetGlobalHistoryResponse response = await Attempt(() => _api.GetGlobalHistoryAsync( + bounds.Start?.ToString(), + bounds.Limit, + cancellationToken)).ConfigureAwait(false); + + return DecodeHistoryPage(response.History, response.NextKey); + } + + /// + /// ACL entries where is the principal. The + /// caller owns the returned accounts and permission sets. + /// + public async Task> GetAclsByPrincipal( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + ListAclsByPrincipalResponse response = await Attempt(() => _api.ListAclsByPrincipalAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + return DecodeAcls(response.Permissions); + } + + /// + /// ACL entries granted to as an entity. The + /// caller owns the returned accounts and permission sets. + /// + public async Task> GetAclsByEntity( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + ListAclsByEntityResponse response = await Attempt(() => _api.ListAclsByEntityAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false); + return DecodeAcls(response.Permissions); + } + /// /// A builder pre-set with the reference block version, the bound network, /// as originator, @@ -225,7 +436,7 @@ public async Task Transmit( { TransmitOptions resolved = options ?? new TransmitOptions(); List encoded = blocks.Select(EncodeBlock).ToList(); - string temporary = await RequestVote(encoded, priorVote: null, cancellationToken).ConfigureAwait(false); + string temporary = await RequestVote(encoded, priorVote: null, resolved.Quote, cancellationToken).ConfigureAwait(false); Crypto.Block? feeBlock = null; try @@ -245,7 +456,7 @@ public async Task Transmit( encoded.Add(EncodeBlock(feeBlock)); } - string permanent = await RequestVote(encoded, temporary, cancellationToken).ConfigureAwait(false); + string permanent = await RequestVote(encoded, temporary, quote: null, cancellationToken).ConfigureAwait(false); return await PublishStaple(all, permanent, cancellationToken).ConfigureAwait(false); } finally @@ -425,14 +636,37 @@ public void Dispose() /// A block's transport bytes in the base64 form the vote endpoint carries. private static string EncodeBlock(Crypto.Block block) => Convert.ToBase64String(block.ToBytes()); + /// + /// Request a non-binding vote quote for , locking + /// in the fee the node would charge. Attach it to a transmit through + /// . + /// + public async Task GetVoteQuote( + IReadOnlyList blocks, + CancellationToken cancellationToken = default) + { + var body = new Body2 { Blocks = blocks.Select(EncodeBlock).ToList() }; + CreateVoteQuoteResponse response = await Attempt(() => _api.CreateVoteQuoteAsync(body, cancellationToken)).ConfigureAwait(false); + + string? quote = response.Quote?.Binary; + if (string.IsNullOrEmpty(quote)) + { + throw new KeetaException("VOTE_DECLINED", "the node returned no vote quote"); + } + + return Convert.FromBase64String(quote); + } + /// /// Request one vote over . Round one leaves - /// null so the body omits votes entirely. - /// Round two attaches the temporary vote so the representative escalates it. + /// null so the body omits votes entirely, + /// and may attach a pre-fetched . Round two attaches + /// the temporary vote so the representative escalates it. /// private async Task RequestVote( IReadOnlyList blocksBase64, string? priorVote, + byte[]? quote, CancellationToken cancellationToken) { var body = new Body { Blocks = blocksBase64.ToList() }; @@ -441,6 +675,11 @@ private async Task RequestVote( body.Votes = new List { priorVote }; } + if (quote is not null) + { + body.Quote = Convert.ToBase64String(quote); + } + CreateVoteResponse response = await Attempt(() => _api.CreateVoteAsync(body, cancellationToken)).ConfigureAwait(false); string? vote = response.Vote?.Binary; if (string.IsNullOrEmpty(vote)) @@ -579,7 +818,9 @@ private bool RecordChainsToRoot( /// /// Run one generated transport call, projecting its failure to a - /// with the stable NODE_STATUS code. + /// . A node error envelope surfaces its own + /// code (for example LEDGER_SUCCESSOR_VOTE_EXISTS); anything else + /// collapses to the stable NODE_STATUS code. /// private static async Task Attempt(Func> operation) { @@ -587,6 +828,10 @@ private static async Task Attempt(Func> operation) { return await operation().ConfigureAwait(false); } + catch (NodeApiException error) when (!string.IsNullOrEmpty(error.Result?.Code)) + { + throw new KeetaException(error.Result.Code, error.Result.Message ?? "the node rejected the request", error); + } catch (NodeApiException error) { throw new KeetaException("NODE_STATUS", $"node request failed with status {error.StatusCode}", error); @@ -597,6 +842,112 @@ private static async Task Attempt(Func> operation) private static Certificate DecodeCertificate(GeneratedCertificate record) => new(record.Certificate1, record.Intermediates?.ToArray() ?? Array.Empty()); + /// + /// Materialize a transport block (base64 $binary) inside the core. + /// An absent block field is the node's "none" shape. + /// + private Crypto.Block? DecodeBlock(GeneratedBlock? block) + { + if (string.IsNullOrEmpty(block?.Binary)) + { + return null; + } + + string hex = Convert.ToHexString(Convert.FromBase64String(block.Binary)); + return _runtime.Blocks.ParseHex(hex); + } + + /// Map generated history entries and the paging cursor to the typed page. + private static HistoryPage DecodeHistoryPage(ICollection? history, string? nextKey) + { + ICollection items = history ?? Array.Empty(); + var entries = new List(items.Count); + foreach (GeneratedHistoryEntry item in items) + { + string? binary = item.VoteStaple?.Binary; + if (string.IsNullOrEmpty(binary)) + { + continue; + } + + DateTimeOffset? timestamp = null; + if (!string.IsNullOrEmpty(item.Timestamp)) + { + timestamp = DateTimeOffset.Parse(item.Timestamp, CultureInfo.InvariantCulture); + } + + entries.Add(new NodeHistoryEntry(Convert.FromBase64String(binary), OptionalBlockHash(item.Id), timestamp)); + } + + return new HistoryPage(entries, OptionalBlockHash(nextKey)); + } + + /// + /// Map generated ACL rows to typed entries: each principal by its declared + /// type, the entity/target accounts, and the [base, external] + /// permission bitmaps decoded through the core. + /// + [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP001:Dispose created", + Justification = "Ownership of the permission set transfers to the returned Acl entry; the caller disposes it with the entry's accounts, as with every model carrying live handles.")] + private Acl[] DecodeAcls(ICollection? rows) => + (rows ?? Array.Empty()) + .Select(row => + { + Crypto.Permissions granted = _runtime.Blocks.PermissionsFromBitmaps( + row.Permissions?.FirstOrDefault() ?? "0x0", + row.Permissions?.Skip(1).FirstOrDefault() ?? "0x0"); + + return new Acl( + DecodeAclPrincipal(row.PrincipalType, row.Principal), + OptionalAccount(row.Entity), + OptionalAccount(row.Target), + granted); + }) + .ToArray(); + + /// + /// Decode an ACL principal from its wire shape: an account address string + /// when the type is ACCOUNT, or an object carrying the issuing + /// certificate hash and its anchor account when CERTIFICATE. + /// + private AclPrincipal? DecodeAclPrincipal(ACLRowPrincipalType kind, object? principal) + { + if (principal is not JsonElement value) + { + return null; + } + + if (kind == ACLRowPrincipalType.CERTIFICATE) + { + string? hash = value.GetProperty("certificate").GetString(); + string? anchor = value.GetProperty("certificateAccount").GetString(); + if (hash is null || anchor is null) + { + throw new KeetaException("ACL_PRINCIPAL", "a certificate principal requires 'certificate' and 'certificateAccount'"); + } + + return new AclCertificatePrincipal( + Crypto.CertificateHash.Parse(hash), + _runtime.Accounts.FromPublicKeyString(anchor)); + } + + string? address = value.GetString(); + if (address is null) + { + throw new KeetaException("ACL_PRINCIPAL", "an account principal must be an address string"); + } + + return new AclAccountPrincipal(_runtime.Accounts.FromPublicKeyString(address)); + } + + /// Parse an optional account address field, null when absent. + private Crypto.Account? OptionalAccount(string? address) => + string.IsNullOrEmpty(address) ? null : _runtime.Accounts.FromPublicKeyString(address); + + /// Parse an optional hex hash field, null when absent. + private static Crypto.BlockHash? OptionalBlockHash(string? hex) => + string.IsNullOrEmpty(hex) ? null : Crypto.BlockHash.Parse(hex); + /// /// Map one account's generated state fields to the typed /// , shared by the single and batch reads. diff --git a/src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs b/src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs new file mode 100644 index 0000000..5d8c491 --- /dev/null +++ b/src/KeetaNet.Anchor/Services/Node/KeetaNetwork.cs @@ -0,0 +1,58 @@ +namespace KeetaNet.Anchor; + +/// +/// A well-known KeetaNet network, the port of the reference network registry. +/// Feeds the FromNetwork-style client factories with the network id and +/// its first representative's API endpoint. +/// +public enum KeetaNetwork +{ + /// The production network. + Main, + /// The staging network. + Staging, + /// The public test network. + Test, + /// The development network (deterministic, seed-derived accounts). + Dev, +} + +/// The reference registry values for each . +public static class KeetaNetworkExtensions +{ + /// The network identifier stamped onto blocks for this network. + public static long Id(this KeetaNetwork network) => + network switch + { + KeetaNetwork.Main => 0x5382, + KeetaNetwork.Staging => 0x0053_8201, + KeetaNetwork.Test => 0x5445_5354, + _ => 0x0044_4556, + }; + + /// The lowercase alias used in URLs and string parsing. + public static string Alias(this KeetaNetwork network) => + network switch + { + KeetaNetwork.Main => "main", + KeetaNetwork.Staging => "staging", + KeetaNetwork.Test => "test", + _ => "dev", + }; + + /// + /// The API endpoint of representative + /// (numbered from one). Production networks carry a network infix; + /// dev does not. + /// + public static string RepresentativeApiUrl(this KeetaNetwork network, int representative = 1) + { + string alias = network.Alias(); + if (network == KeetaNetwork.Dev) + { + return $"https://rep{representative}.{alias}.api.keeta.com/api"; + } + + return $"https://rep{representative}.{alias}.network.api.keeta.com/api"; + } +} diff --git a/src/KeetaNet.Anchor/Services/Node/NodeModels.cs b/src/KeetaNet.Anchor/Services/Node/NodeModels.cs index cc7c23f..ae86f6e 100644 --- a/src/KeetaNet.Anchor/Services/Node/NodeModels.cs +++ b/src/KeetaNet.Anchor/Services/Node/NodeModels.cs @@ -51,3 +51,74 @@ public sealed record NodeRepresentative(Account Account, BigInteger Weight, stri /// (, milliseconds). /// public sealed record LedgerChecksum(BigInteger Checksum, DateTimeOffset? Moment, double MomentRangeMs); + +/// Which ledger a block lookup searches. +public enum LedgerSide +{ + /// The settled main ledger. + Main, + /// The unsettled side ledger. + Side, +} + +/// +/// Pagination/range bounds for . +/// / are block-hash cursors; +/// caps the page size (the node applies its own default +/// and maximum). +/// +public sealed record ChainQuery(BlockHash? Start = null, BlockHash? End = null, int? Limit = null); + +/// +/// A single page of an account's chain (most recent first) together with the +/// cursor for the next page: pass as the next query's +/// ; null once the chain is exhausted. The +/// caller owns the blocks and must dispose them. +/// +public sealed record ChainPage(IReadOnlyList Blocks, BlockHash? NextKey); + +/// +/// Pagination bounds for . +/// is the previous page's last staple id; +/// caps the page size. +/// +public sealed record HistoryQuery(BlockHash? Start = null, int? Limit = null); + +/// +/// One committed vote staple in an account's history: its transport bytes, +/// its id (the hash over the block hashes it covers), and the moment it was +/// committed. +/// +public sealed record NodeHistoryEntry(byte[] StapleBytes, BlockHash? Id, DateTimeOffset? Timestamp); + +/// +/// A single page of history together with the cursor for the next page: pass +/// as the next query's ; +/// null once the history is exhausted. +/// +public sealed record HistoryPage(IReadOnlyList Entries, BlockHash? NextKey); + +/// The principal an ACL entry grants permissions to. +public abstract record AclPrincipal +{ + private protected AclPrincipal() + { + } +} + +/// A concrete account principal. +public sealed record AclAccountPrincipal(Account Account) : AclPrincipal; + +/// +/// A certificate principal: any account presenting a certificate issued by +/// the certificate with , anchored to . +/// +public sealed record AclCertificatePrincipal(CertificateHash Hash, Account Account) : AclPrincipal; + +/// +/// An access-control entry granting the +/// permissions over , keyed under +/// . Carries live accounts and a permission set the +/// caller must dispose, like every other model carrying handles. +/// +public sealed record Acl(AclPrincipal? Principal, Account? Entity, Account? Target, Permissions Granted); diff --git a/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs b/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs index 6e49eb1..48d5ddd 100644 --- a/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs +++ b/src/KeetaNet.Anchor/Services/Node/TransmitOptions.cs @@ -24,6 +24,12 @@ public sealed class TransmitOptions /// public IList FeeTokenPriority { get; } = new List(); + /// + /// A pre-fetched vote quote (from ) + /// to attach to the temporary round, locking in the quoted fee. + /// + public byte[]? Quote { get; set; } + /// The fee-block factory, or null to pay no fee. public GenerateFeeBlock? FeeBlockFactory { get; set; } diff --git a/src/KeetaNet.Anchor/Services/Node/UserClient.cs b/src/KeetaNet.Anchor/Services/Node/UserClient.cs index f137488..9cfb071 100644 --- a/src/KeetaNet.Anchor/Services/Node/UserClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/UserClient.cs @@ -82,6 +82,40 @@ public Task> GetAllCertificates(CancellationToken can CancellationToken cancellationToken = default) => _client.GetCertificateByHash(Account, certificateHash, cancellationToken); + /// The head block of the operating account's chain, or null for a fresh account. + public Task GetHeadBlock(CancellationToken cancellationToken = default) => + _client.GetHeadBlock(Account, cancellationToken); + + /// The next pending (unreceived) block for the operating account, if any. + public Task GetPendingBlock(CancellationToken cancellationToken = default) => + _client.GetPendingBlock(Account, cancellationToken); + + /// + /// The block the operating account produced for the idempotent + /// , if any. + /// + public Task GetBlockFromIdempotent( + string key, + LedgerSide? side = null, + CancellationToken cancellationToken = default) => + _client.GetBlockFromIdempotent(Account, key, side, cancellationToken); + + /// A page of the operating account's block chain, most recent first. + public Task GetChain(ChainQuery? query = null, CancellationToken cancellationToken = default) => + _client.GetAccountChain(Account, query, cancellationToken); + + /// A page of the operating account's committed staple history. + public Task GetHistory(HistoryQuery? query = null, CancellationToken cancellationToken = default) => + _client.GetAccountHistory(Account, query, cancellationToken); + + /// ACL entries where the operating account is the principal. + public Task> GetAcls(CancellationToken cancellationToken = default) => + _client.GetAclsByPrincipal(Account, cancellationToken); + + /// ACL entries granted to the operating account as an entity. + public Task> GetAclsByEntity(CancellationToken cancellationToken = default) => + _client.GetAclsByEntity(Account, cancellationToken); + /// /// A builder for the operating account, signed by the bound signer and /// pre-set with the client's defaults. The caller positions it, appends @@ -110,6 +144,57 @@ public Task Transmit( CancellationToken cancellationToken = default) => _client.Transmit(blocks, OrDefaultFeePayer(options), cancellationToken); + /// + /// Position atop the operating account's + /// ledger head (opening a fresh chain when it has none), build its block, + /// and transmit it, the reference publishBuilder. The builder must + /// not carry a position of its own. + /// + public async Task Publish( + Crypto.BlockBuilder builder, + TransmitOptions? options = null, + CancellationToken cancellationToken = default) + { + TransmitOptions resolved = OrDefaultFeePayer(options); + AccountState state = await GetState(cancellationToken).ConfigureAwait(false); + + KeetaClient.PositionAfter(builder, state.HeadBlock?.ToString()); + using Crypto.Block block = builder.Build(); + + return await _client.Transmit(block, resolved, cancellationToken).ConfigureAwait(false); + } + + /// + /// Create a identifier under the operating account + /// and publish the creating block, returning the derived account. The + /// caller owns the returned account. + /// + public async Task GenerateIdentifier( + Crypto.IdentifierKind kind, + TransmitOptions? options = null, + CancellationToken cancellationToken = default) + { + TransmitOptions resolved = OrDefaultFeePayer(options); + AccountState state = await GetState(cancellationToken).ConfigureAwait(false); + + Crypto.Account identifier = Account.GenerateIdentifier(kind, state.HeadBlock); + try + { + using Crypto.BlockOperation claim = _runtime.Blocks.CreateIdentifier(identifier); + using Crypto.BlockBuilder builder = InitBuilder(); + KeetaClient.PositionAfter(builder, state.HeadBlock?.ToString()); + using Crypto.Block block = builder.AddOperation(claim).Build(); + + await _client.Transmit(block, resolved, cancellationToken).ConfigureAwait(false); + return identifier; + } + catch + { + identifier.Dispose(); + throw; + } + } + /// /// Send of to /// , carrying an optional @@ -137,26 +222,52 @@ public async Task SetRep( return await BuildAndTransmit(setRep, options, cancellationToken).ConfigureAwait(false); } - /// Release the owned ; the bound accounts stay with the caller. - public void Dispose() => _client.Dispose(); + /// + /// Publish the operating account's on-chain info. + /// is required for identifier accounts. + /// + public async Task SetInfo( + string name, + string description, + string metadata, + Crypto.Permissions? defaultPermission = null, + TransmitOptions? options = null, + CancellationToken cancellationToken = default) + { + using Crypto.BlockOperation setInfo = _runtime.Blocks.SetInfo(name, description, metadata, defaultPermission); + return await BuildAndTransmit(setInfo, options, cancellationToken).ConfigureAwait(false); + } /// - /// Build the operating account's one-operation block against its ledger - /// head (opening a fresh chain when it has none) and transmit it. + /// Apply to + /// with , optionally scoped to + /// (the operating account when omitted). /// + public async Task UpdatePermissions( + Crypto.Account principal, + Crypto.Permissions permissions, + Crypto.Account? target = null, + Crypto.AdjustMethod method = Crypto.AdjustMethod.Set, + TransmitOptions? options = null, + CancellationToken cancellationToken = default) + { + using Crypto.BlockOperation modify = _runtime.Blocks.ModifyPermissions(principal, permissions, method, target); + return await BuildAndTransmit(modify, options, cancellationToken).ConfigureAwait(false); + } + + /// Release the owned ; the bound accounts stay with the caller. + public void Dispose() => _client.Dispose(); + + /// Publish the operating account's one-operation block. private async Task BuildAndTransmit( Crypto.BlockOperation operation, TransmitOptions? options, CancellationToken cancellationToken) { - TransmitOptions resolved = OrDefaultFeePayer(options); - AccountState state = await GetState(cancellationToken).ConfigureAwait(false); - using Crypto.BlockBuilder builder = InitBuilder(); - KeetaClient.PositionAfter(builder, state.HeadBlock?.ToString()); - using Crypto.Block block = builder.AddOperation(operation).Build(); + builder.AddOperation(operation); - return await _client.Transmit(block, resolved, cancellationToken).ConfigureAwait(false); + return await Publish(builder, options, cancellationToken).ConfigureAwait(false); } /// Absent a fee-block factory, the bound signer pays any required fee itself. @@ -170,6 +281,7 @@ private TransmitOptions OrDefaultFeePayer(TransmitOptions? options) TransmitOptions resolved = TransmitOptions.WithFeeSigner(RequireSigner()); if (options is not null) { + resolved.Quote = options.Quote; foreach (Crypto.Account token in options.FeeTokenPriority) { resolved.FeeTokenPriority.Add(token); diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs index bc79179..d05a8ed 100644 --- a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs @@ -217,6 +217,142 @@ public async Task FeeBearingSendTransmitsAgainstTheLiveNode() harness.Shutdown(); } + /// The one base flag the ACL grant carries. + private static readonly BaseFlag[] AccessFlag = { BaseFlag.Access }; + + [Fact] + public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("node"); + LedgerNode node = LedgerNode.Start(harness); + + using var runtime = WasmRuntime.Load(); + using Account holder = runtime.Accounts.FromSeed(E2eSeeds.Subject, 0, E2eSeeds.Secp256k1); + using Account recipient = runtime.Accounts.FromSeed(E2eSeeds.Recipient, 0, E2eSeeds.Secp256k1); + using UserClient user = runtime.CreateUserClient(node.Api, holder, network: node.Network); + KeetaClient client = user.Client; + Account baseToken = client.BaseToken!; + + node.Fund(E2eSeeds.Subject, Funding); + + // Drive the ledger through the client's own writes: a send opens the + // chain, SET_INFO publishes metadata, and MODIFY_PERMISSIONS grants + // the recipient access on the holder's account. + const long Amount = 500; + Assert.True(await user.Send(recipient, Amount, baseToken, cancellationToken: cancellationToken)); + Assert.True(await user.SetInfo("HOLDER", "ledger reads fixture", "meta", cancellationToken: cancellationToken)); + + using Permissions access = runtime.Blocks.PermissionsFromFlags(AccessFlag); + Assert.True(await user.UpdatePermissions(recipient, access, cancellationToken: cancellationToken)); + + AccountState state = await user.GetState(cancellationToken); + Assert.Equal("HOLDER", state.Info!.Name); + Assert.NotNull(state.HeadBlock); + + // The head reads back as a live block originated by the holder, and + // fetching it by hash yields the identical block. An unknown hash is + // the node's "none" shape, not a failure. + using Block? head = await user.GetHeadBlock(cancellationToken); + Assert.NotNull(head); + Assert.Equal(state.HeadBlock!.Value, head!.Hash); + + using (Account originator = head.GetAccount()) + { + Assert.Equal(holder.PublicKeyString, originator.PublicKeyString); + } + + using Block? byHash = await client.GetBlock(head.Hash, cancellationToken: cancellationToken); + Assert.Equal(head.Hash, byHash!.Hash); + Assert.Null(await client.GetBlock(BlockHash.Parse(new string('0', 64)), cancellationToken: cancellationToken)); + + // The chain lists most recent first; a limit of one pages with a + // cursor, and the block behind the head names the head as successor. + ChainPage newest = await user.GetChain(new ChainQuery(Limit: 1), cancellationToken); + Assert.Equal(head.Hash, Assert.Single(newest.Blocks).Hash); + Assert.NotNull(newest.NextKey); + + ChainPage chain = await user.GetChain(cancellationToken: cancellationToken); + Assert.True(chain.Blocks.Count >= 2); + Assert.Equal(head.Hash, chain.Blocks[0].Hash); + + using Block? successor = await client.GetSuccessorBlock(chain.Blocks[1].Hash, cancellationToken); + Assert.Equal(head.Hash, successor!.Hash); + + // Account and global history both carry the committed staples. + HistoryPage history = await user.GetHistory(cancellationToken: cancellationToken); + Assert.NotEmpty(history.Entries); + Assert.All(history.Entries, entry => Assert.NotEmpty(entry.StapleBytes)); + Assert.All(history.Entries, entry => Assert.NotNull(entry.Timestamp)); + + HistoryPage global = await client.GetGlobalHistory(cancellationToken: cancellationToken); + Assert.NotEmpty(global.Entries); + + // The settled head retains its votes; nothing is pending and an + // unknown idempotent key resolves to no block. + IReadOnlyList? votes = await client.GetBlockVotes(head.Hash, cancellationToken: cancellationToken); + Assert.NotNull(votes); + Assert.NotEmpty(votes!); + foreach (Vote vote in votes!) + { + vote.Dispose(); + } + + Assert.Null(await user.GetPendingBlock(cancellationToken)); + Assert.Null(await user.GetBlockFromIdempotent(Guid.NewGuid().ToString("N"), cancellationToken: cancellationToken)); + + // The grant reads back typed from both directions: the recipient as + // principal, the holder as entity, carrying the access flag. + IReadOnlyList granted = await client.GetAclsByPrincipal(recipient, cancellationToken); + Acl grant = Assert.Single(granted); + AclAccountPrincipal principal = Assert.IsType(grant.Principal); + Assert.Equal(recipient.PublicKeyString, principal.Account.PublicKeyString); + Assert.Equal(holder.PublicKeyString, grant.Entity!.PublicKeyString); + Assert.Contains(BaseFlag.Access, grant.Granted.Flags); + + IReadOnlyList byEntity = await client.GetAclsByEntity(holder, cancellationToken); + Assert.Contains(byEntity, entry => entry.Principal is AclAccountPrincipal account + && account.Account.PublicKeyString == recipient.PublicKeyString); + + // A pre-fetched vote quote rides the transmit's temporary round. + using (Block quoted = BuildSend(runtime, user, recipient, Amount, state.HeadBlock)) + { + byte[] quote = await client.GetVoteQuote(new[] { quoted }, cancellationToken); + Assert.NotEmpty(quote); + + TransmitOptions options = TransmitOptions.WithFeeSigner(holder); + options.Quote = quote; + Assert.True(await client.Transmit(quoted, options, cancellationToken)); + } + + BigInteger credited = await client.GetAccountBalance(recipient, baseToken, cancellationToken); + Assert.Equal(new BigInteger(Amount * 2), credited); + + // A builder without a position publishes through the one-call path: + // the user client positions it on the live head and pays the fee. + using (BlockOperation send = runtime.Blocks.Send(recipient, Amount, baseToken)) + using (BlockBuilder builder = user.InitBuilder()) + { + builder.AddOperation(send); + Assert.True(await user.Publish(builder, cancellationToken: cancellationToken)); + } + + credited = await client.GetAccountBalance(recipient, baseToken, cancellationToken); + Assert.Equal(new BigInteger(Amount * 3), credited); + + // The one-call identifier claim derives against the pre-claim head, + // publishes the CREATE_IDENTIFIER block, and returns the account. + AccountState beforeClaim = await user.GetState(cancellationToken); + using Account tokenId = await user.GenerateIdentifier(IdentifierKind.Token, cancellationToken: cancellationToken); + using Account expectedId = holder.GenerateIdentifier(IdentifierKind.Token, beforeClaim.HeadBlock); + Assert.Equal(expectedId.PublicKeyString, tokenId.PublicKeyString); + + AccountState afterClaim = await user.GetState(cancellationToken); + Assert.NotEqual(beforeClaim.HeadBlock, afterClaim.HeadBlock); + + harness.Shutdown(); + } + /// /// A signed base-token send from 's operating /// account to , opening the chain when diff --git a/tests/KeetaNet.Anchor.Tests/NetworkTests.cs b/tests/KeetaNet.Anchor.Tests/NetworkTests.cs new file mode 100644 index 0000000..e8627ad --- /dev/null +++ b/tests/KeetaNet.Anchor.Tests/NetworkTests.cs @@ -0,0 +1,41 @@ +using Xunit; + +namespace KeetaNet.Anchor.Tests; + +/// +/// The well-known network registry: ids, aliases, and representative +/// endpoints must match the reference registry verbatim, and the +/// fromNetwork-style factories must bind them. +/// +public sealed class NetworkTests +{ + [Theory] + [InlineData(KeetaNetwork.Main, 0x5382, "main", "https://rep1.main.network.api.keeta.com/api")] + [InlineData(KeetaNetwork.Staging, 0x0053_8201, "staging", "https://rep1.staging.network.api.keeta.com/api")] + [InlineData(KeetaNetwork.Test, 0x5445_5354, "test", "https://rep1.test.network.api.keeta.com/api")] + [InlineData(KeetaNetwork.Dev, 0x0044_4556, "dev", "https://rep1.dev.api.keeta.com/api")] + public void TheRegistryMatchesTheReferenceValues(KeetaNetwork network, long id, string alias, string apiUrl) + { + Assert.Equal(id, network.Id()); + Assert.Equal(alias, network.Alias()); + Assert.Equal(apiUrl, network.RepresentativeApiUrl()); + } + + [Fact] + public void TheNetworkFactoriesBindTheNetworkAndDeriveItsBaseToken() + { + using var runtime = WasmRuntime.Load(); + + using KeetaClient client = runtime.CreateKeetaClient(KeetaNetwork.Test); + Assert.Equal(KeetaNetwork.Test.Id(), client.Network); + Assert.NotNull(client.BaseToken); + + // The bound network's base token is the deterministic derivation. + using Crypto.Account derived = runtime.Blocks.NetworkBaseToken(KeetaNetwork.Test.Id()); + Assert.Equal(derived.PublicKeyString, client.BaseToken!.PublicKeyString); + + using UserClient user = runtime.CreateUserClient(KeetaNetwork.Dev, signer: null); + Assert.True(user.IsReadOnly); + Assert.Equal(KeetaNetwork.Dev.Id(), user.Client.Network); + } +} From 079ba1d41cac3cd3dfe03a88ae891996ae34a622 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Wed, 29 Jul 2026 11:18:26 -0700 Subject: [PATCH 2/2] refactor(anchor)!: move provider ops --- .../AssetMovement/AssetMovementClient.cs | 273 ++++++------------ .../AssetMovement/AssetMovementModels.cs | 128 +++++++- .../Services/AssetMovement/AssetProvider.cs | 130 +++++++++ .../Services/AssetMovement/AssetTransfer.cs | 12 +- src/KeetaNet.Anchor/Services/Kyc/KycClient.cs | 56 ++-- src/KeetaNet.Anchor/Services/Kyc/KycModels.cs | 13 +- .../Services/Kyc/KycProvider.cs | 47 +++ .../AssetFlowTests.cs | 82 +++--- .../KeetaNet.Anchor.E2eTests/KycFlowTests.cs | 14 +- .../KeetaNet.Anchor.Tests/AssetModelTests.cs | 48 ++- tests/KeetaNet.Anchor.Tests/KycModelTests.cs | 6 +- 11 files changed, 493 insertions(+), 316 deletions(-) create mode 100644 src/KeetaNet.Anchor/Services/AssetMovement/AssetProvider.cs create mode 100644 src/KeetaNet.Anchor/Services/Kyc/KycProvider.cs 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); }