From 89fabbd6922f03c4500a811914183941edc9efb1 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Mon, 6 Jul 2026 16:15:49 -0700 Subject: [PATCH 1/5] feat(kyc): get all certificates --- scripts/build-wasm.sh | 4 +- .../Interop/WasmRuntime.AssetMovement.cs | 32 ++++++------ src/KeetaNet.Anchor/Interop/WasmRuntime.cs | 3 ++ .../AssetMovement/AssetMovementClient.cs | 16 +++--- src/KeetaNet.Anchor/Services/Kyc/KycClient.cs | 23 +++++++++ tests/KeetaNet.Anchor.E2eTests/Anchors.cs | 21 ++++++++ .../KeetaNet.Anchor.E2eTests/KycFlowTests.cs | 42 ++++++++++++++++ tests/node-harness/src/chain.ts | 26 ++++++---- tests/node-harness/src/kyc.ts | 50 +++++++++++++++++++ 9 files changed, 182 insertions(+), 35 deletions(-) diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 7f9af07..6122085 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -4,8 +4,8 @@ set -euo pipefail CRATE_NAME="keetanetwork-anchor-client-wasi" -CRATE_VERSION="0.1.1" -CRATE_SHA256="dd5c19da144a320d23d5b0b1af392ea6c94f6b7cb584e490bcf42155c768d9d6" +CRATE_VERSION="0.1.3" +CRATE_SHA256="ebf6df527b9b0c5f9981664eafc5d65a3c99e9ab07775560ae8b2a1354b2866c" ROOT="$(cd "$(dirname "$0")/.." && pwd)" BUILD_DIR="${ROOT}/.wasm-build" diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.AssetMovement.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.AssetMovement.cs index 65eb442..42c9835 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.AssetMovement.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.AssetMovement.cs @@ -43,34 +43,34 @@ internal Task AssetTransferStatus(int handle, string providerJson, strin internal Task AssetAccountStatus(int handle, string providerJson, CancellationToken cancellationToken) => RunAsync(() => WithHandleAndText("keeta_asset_account_status", handle, providerJson), cancellationToken); - internal Task AssetInitiateForwardingTemplate(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => - RunAsync(() => WithProviderAndArg("keeta_asset_initiate_forwarding_template", handle, providerJson, requestJson), cancellationToken); + internal Task AssetInitiatePersistentForwardingTemplate(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => + RunAsync(() => WithProviderAndArg("keeta_asset_initiate_persistent_forwarding_template", handle, providerJson, requestJson), cancellationToken); - internal Task AssetCreateForwardingTemplate(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => - RunAsync(() => WithProviderAndArg("keeta_asset_create_forwarding_template", handle, providerJson, requestJson), cancellationToken); + internal Task AssetCreatePersistentForwardingTemplate(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => + RunAsync(() => WithProviderAndArg("keeta_asset_create_persistent_forwarding_template", handle, providerJson, requestJson), cancellationToken); - internal Task AssetListForwardingTemplates(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => - RunAsync(() => WithProviderAndArg("keeta_asset_list_forwarding_templates", handle, providerJson, requestJson), cancellationToken); + internal Task AssetListForwardingAddressTemplates(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => + RunAsync(() => WithProviderAndArg("keeta_asset_list_forwarding_address_templates", handle, providerJson, requestJson), cancellationToken); - internal Task AssetCreateForwardingAddress(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => - RunAsync(() => WithProviderAndArg("keeta_asset_create_forwarding_address", handle, providerJson, requestJson), cancellationToken); + internal Task AssetCreatePersistentForwardingAddress(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => + RunAsync(() => WithProviderAndArg("keeta_asset_create_persistent_forwarding_address", handle, providerJson, requestJson), cancellationToken); internal Task AssetListForwardingAddresses(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => RunAsync(() => WithProviderAndArg("keeta_asset_list_forwarding_addresses", handle, providerJson, requestJson), cancellationToken); - internal Task AssetDeactivateForwardingTemplate(int handle, string providerJson, string id, CancellationToken cancellationToken) => - RunAsync(() => WithProviderAndArg("keeta_asset_deactivate_forwarding_template", handle, providerJson, id), cancellationToken); + internal Task AssetDeactivatePersistentForwardingTemplate(int handle, string providerJson, string id, CancellationToken cancellationToken) => + RunAsync(() => WithProviderAndArg("keeta_asset_deactivate_persistent_forwarding_template", handle, providerJson, id), cancellationToken); - internal Task AssetDeactivateForwardingAddress(int handle, string providerJson, string id, CancellationToken cancellationToken) => - RunAsync(() => WithProviderAndArg("keeta_asset_deactivate_forwarding_address", handle, providerJson, id), cancellationToken); + internal Task AssetDeactivatePersistentForwardingAddress(int handle, string providerJson, string id, CancellationToken cancellationToken) => + RunAsync(() => WithProviderAndArg("keeta_asset_deactivate_persistent_forwarding_address", handle, providerJson, id), cancellationToken); internal Task AssetListTransactions(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => RunAsync(() => WithProviderAndArg("keeta_asset_list_transactions", handle, providerJson, requestJson), cancellationToken); - internal Task AssetShareKyc(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => - RunAsync(() => WithProviderAndArg("keeta_asset_share_kyc", handle, providerJson, requestJson), cancellationToken); + internal Task AssetShareKycAttributes(int handle, string providerJson, string requestJson, CancellationToken cancellationToken) => + RunAsync(() => WithProviderAndArg("keeta_asset_share_kyc_attributes", handle, providerJson, requestJson), cancellationToken); - internal Task AssetShareKycAwait( + internal Task AssetShareKycAttributesAndWait( int handle, string providerJson, string requestJson, @@ -85,7 +85,7 @@ internal Task AssetShareKycAwait( Argument request = arguments.Write(requestJson); int result = Invoke( - "keeta_asset_share_kyc_await", + "keeta_asset_share_kyc_attributes_and_wait", handle, provider.Pointer, provider.Length, request.Pointer, request.Length, diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.cs index 231b24a..697197d 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.cs @@ -232,6 +232,9 @@ internal Task KycGetCertificates(int handle, string providerJson, string internal Task KycGetVerificationStatus(int handle, string providerJson, string id, CancellationToken cancellationToken) => RunAsync(() => WithProviderAndArg("keeta_kyc_get_verification_status", handle, providerJson, id), cancellationToken); + internal Task KycGetAllCertificates(int handle, string account, CancellationToken cancellationToken) => + RunAsync(() => WithHandleAndText("keeta_kyc_get_all_certificates", handle, account), cancellationToken); + internal void KycFree(int handle) => RunFree("keeta_kyc_free", handle); /// Perform the buffered request and return the response byte length. diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs index f0743ba..f3c797e 100644 --- a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs @@ -166,28 +166,28 @@ public Task InitiatePersistentForwardingTemplateAsync( AssetProvider provider, AssetInitiateTemplateRequest request, CancellationToken cancellationToken = default) => - ReadOperationAsync(Runtime.AssetInitiateForwardingTemplate, provider, request, cancellationToken); + ReadOperationAsync(Runtime.AssetInitiatePersistentForwardingTemplate, provider, request, cancellationToken); /// Create a persistent-forwarding template. public Task CreatePersistentForwardingTemplateAsync( AssetProvider provider, AssetCreateTemplateRequest request, CancellationToken cancellationToken = default) => - ReadOperationAsync(Runtime.AssetCreateForwardingTemplate, provider, request, cancellationToken); + ReadOperationAsync(Runtime.AssetCreatePersistentForwardingTemplate, provider, request, cancellationToken); /// List persistent-forwarding templates. public Task ListForwardingAddressTemplatesAsync( AssetProvider provider, AssetListTemplatesRequest request, CancellationToken cancellationToken = default) => - ReadOperationAsync(Runtime.AssetListForwardingTemplates, provider, request, cancellationToken); + ReadOperationAsync(Runtime.AssetListForwardingAddressTemplates, provider, request, cancellationToken); /// Create a persistent-forwarding address, returning its (obfuscated) details. public Task CreatePersistentForwardingAddressAsync( AssetProvider provider, AssetCreateAddressRequest request, CancellationToken cancellationToken = default) => - ReadOperationAsync(Runtime.AssetCreateForwardingAddress, provider, request, cancellationToken); + ReadOperationAsync(Runtime.AssetCreatePersistentForwardingAddress, provider, request, cancellationToken); /// List persistent-forwarding addresses. public Task ListForwardingAddressesAsync( @@ -201,14 +201,14 @@ public Task DeactivatePersistentForwardingTemplateAsync( AssetProvider provider, string id, CancellationToken cancellationToken = default) => - RunOperationForIdAsync(Runtime.AssetDeactivateForwardingTemplate, provider, id, cancellationToken); + RunOperationForIdAsync(Runtime.AssetDeactivatePersistentForwardingTemplate, provider, id, cancellationToken); /// Deactivate a persistent-forwarding address by id. public Task DeactivatePersistentForwardingAddressAsync( AssetProvider provider, string id, CancellationToken cancellationToken = default) => - RunOperationForIdAsync(Runtime.AssetDeactivateForwardingAddress, provider, id, cancellationToken); + RunOperationForIdAsync(Runtime.AssetDeactivatePersistentForwardingAddress, provider, id, cancellationToken); /// List asset-movement transactions. public Task ListTransactionsAsync( @@ -226,7 +226,7 @@ public Task ShareKycAttributesAsync( AssetProvider provider, AssetShareKycRequest request, CancellationToken cancellationToken = default) => - ReadOperationAsync(Runtime.AssetShareKyc, provider, request, cancellationToken); + ReadOperationAsync(Runtime.AssetShareKycAttributes, provider, request, cancellationToken); /// /// Share KYC attributes and, when the outcome is pending with a promise URL, @@ -244,7 +244,7 @@ public async Task ShareKycAttributesAndWaitAsync( int intervalMs = ToWholeMilliseconds(pollInterval); int timeoutMs = ToWholeMilliseconds(timeout); byte[] payload = await Runtime - .AssetShareKycAwait(Handle, providerJson, requestJson, intervalMs, timeoutMs, cancellationToken) + .AssetShareKycAttributesAndWait(Handle, providerJson, requestJson, intervalMs, timeoutMs, cancellationToken) .ConfigureAwait(false); return Read(payload); diff --git a/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs b/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs index b2b4953..dd21386 100644 --- a/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs +++ b/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs @@ -74,6 +74,29 @@ public async Task GetCertificatesAsync( return ParseOutcome(payload, "certificates", ready => new CertificatesOutcome(ready, null), retry => new CertificatesOutcome(null, retry)); } + /// + /// Every certificate (a keeta_ public key + /// string) has published on-chain, each with the intermediates recorded + /// alongside it. An account with no published certificates yields an empty list. + /// + public async Task> GetAllCertificatesAsync( + string account, + CancellationToken cancellationToken = default) + { + byte[] payload = await Runtime.KycGetAllCertificates(Handle, account, cancellationToken).ConfigureAwait(false); + return KeetaJson.ReadList(payload); + } + + /// + /// Every certificate has published on-chain, each + /// with the intermediates recorded alongside it. An account with no published + /// certificates yields an empty list. + /// + public Task> GetAllCertificatesAsync( + Crypto.Account account, + CancellationToken cancellationToken = default) => + GetAllCertificatesAsync(account.Address, cancellationToken); + /// Parse 's advertised issuer CA certificate. /// Use it as a trusted root when verifying an issued . public Crypto.Certificate GetCA(KycProvider provider) => diff --git a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs index b159e2a..f6cd1f3 100644 --- a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs +++ b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs @@ -28,6 +28,27 @@ public static KycAnchor Start(NodeHarness harness) } } +/// +/// A certificate chain the harness published on-chain for a fresh holder +/// : recorded with as +/// its intermediate bundle, and recorded without +/// intermediates, so a ledger read serves both shapes. +/// +internal sealed record PublishedChain(string Account, string Leaf, string Bare, string Ca) +{ + /// Publish the two-record chain on the running anchor's node. + public static PublishedChain Publish(NodeHarness harness) + { + JsonElement published = harness.Request("publishCertificateChain", new JsonObject()); + + return new PublishedChain( + published.GetProperty("account").GetString()!, + published.GetProperty("leaf").GetString()!, + published.GetProperty("bare").GetString()!, + published.GetProperty("ca").GetString()!); + } +} + /// /// A live asset-movement anchor HTTP server started by the harness, alongside /// the fixture values its callbacks report back. diff --git a/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs index d3f97a9..a2984da 100644 --- a/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs @@ -69,4 +69,46 @@ public async Task VerificationPathRunsAgainstTheLiveAnchor(string algorithm) harness.Shutdown(); } + + [Fact] + public async Task LedgerReadServesEveryPublishedCertificateRecord() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("kyc"); + KycAnchor anchor = KycAnchor.Start(harness); + + using var runtime = WasmRuntime.Load(); + using Account signer = runtime.Accounts.FromSeed(E2eSeeds.Caller, 0, E2eSeeds.Secp256k1); + using KycClient client = runtime.CreateKycClient(anchor.Api, anchor.Root, signer); + + // An account that never published anything reads back as an empty list; + // the Account overload resolves the address itself, as the reference does. + IReadOnlyList none = await client.GetAllCertificatesAsync(signer, cancellationToken); + Assert.Empty(none); + + // The harness records two certificates for a fresh holder: a leaf with + // the CA as its intermediate bundle, and a bare leaf without one. + PublishedChain chain = PublishedChain.Publish(harness); + + IReadOnlyList records = await client.GetAllCertificatesAsync(chain.Account, cancellationToken); + Assert.Equal(2, records.Count); + + Certificate chained = Assert.Single(records, record => record.Intermediates.Count == 1); + Certificate bare = Assert.Single(records, record => record.Intermediates.Count == 0); + + // Each served PEM must parse to the exact certificate the harness + // published, byte-for-byte at the DER level. + AssertSameCertificate(runtime, chain.Leaf, chained.Value); + AssertSameCertificate(runtime, chain.Ca, chained.Intermediates[0]); + AssertSameCertificate(runtime, chain.Bare, bare.Value); + + harness.Shutdown(); + } + + private static void AssertSameCertificate(WasmRuntime runtime, string expectedPem, string servedPem) + { + using CryptoCertificate expected = runtime.Certificates.Parse(expectedPem); + using CryptoCertificate served = runtime.Certificates.Parse(servedPem); + Assert.Equal(expected.ToDer(), served.ToDer()); + } } diff --git a/tests/node-harness/src/chain.ts b/tests/node-harness/src/chain.ts index 42ebf19..187ef7f 100644 --- a/tests/node-harness/src/chain.ts +++ b/tests/node-harness/src/chain.ts @@ -9,8 +9,9 @@ import type * as ResolverModule from '@keetanetwork/anchor/lib/resolver.js'; import type * as KeetaNetModule from '@keetanetwork/keetanet-client'; -import type * as NodeClientModule from '@keetanetwork/keetanet-node/dist/client'; import type * as NodeTestingModule from '@keetanetwork/keetanet-node/dist/lib/utils/helper_testing.js'; +// @ts-ignore - unsure why this fails the type checker +import type * as NodeClientModule from '@keetanetwork/keetanet-node/dist/client'; import { referenceResolver } from './core.js'; @@ -29,6 +30,7 @@ type ReferenceNode = Awaited>; export type UserClient = InstanceType; export type GenericAccount = InstanceType; +export type SigningAccount = ReturnType; export type ServiceMetadata = Parameters[0]; /** @@ -45,6 +47,8 @@ export interface ChainNode { give(account: GenericAccount, amount: bigint): Promise; /* Publish `metadata` on-chain to a fresh funded account; return its key. */ publish(metadata: ServiceMetadata): Promise; + /* A UserClient signing as `account`, for publishing under other accounts. */ + clientFor(account: SigningAccount): UserClient; } /** @@ -112,21 +116,25 @@ export async function bootChainNode(): Promise { await repClient.send(account, amount, repClient.baseToken, undefined, { account: repClientAccount }); }; - const publish = async function(metadata: ServiceMetadata): Promise { - const rootAccount = Account.fromSeed(Account.generateRandomSeed(), 0); - await give(rootAccount, 1_000n); - - const rootClient = new KeetaNet.UserClient({ + const clientFor = function(account: SigningAccount): UserClient { + return(new KeetaNet.UserClient({ client, network: node.config.network, networkAlias: node.config.networkAlias, - signer: rootAccount, + signer: account, usePublishAid: false - }); + })); + }; + + const publish = async function(metadata: ServiceMetadata): Promise { + const rootAccount = Account.fromSeed(Account.generateRandomSeed(), 0); + await give(rootAccount, 1_000n); + + const rootClient = clientFor(rootAccount); await rootClient.setInfo({ name: '', description: '', metadata: Metadata.formatMetadata(metadata) }); return(rootAccount.publicKeyString.get()); }; - return({ node, api: endpoints.api, repClient, give, publish }); + return({ node, api: endpoints.api, repClient, give, publish, clientFor }); } diff --git a/tests/node-harness/src/kyc.ts b/tests/node-harness/src/kyc.ts index 77a32cc..2fa5635 100644 --- a/tests/node-harness/src/kyc.ts +++ b/tests/node-harness/src/kyc.ts @@ -82,6 +82,10 @@ interface PublishMetadataRequest { metadata: ServiceMetadata; } +interface PublishCertificateChainRequest { + cmd: 'publishCertificateChain'; +} + interface ShutdownRequest { cmd: 'shutdown'; } @@ -149,6 +153,7 @@ type KycRequest = StopKycAnchorRequest | BuildMetadataRequest | PublishMetadataRequest | + PublishCertificateChainRequest | IssueCertificateRequest | DecodeCertificateRequest | ProveAttributeRequest | @@ -297,6 +302,50 @@ async function handlePublishMetadata(request: PublishMetadataRequest): Promise { + const current = kycAnchor; + if (current === undefined) { + throw(new Error('no running anchor: start the KYC anchor before publishing certificates')); + } + + const holder = Account.fromSeed(Account.generateRandomSeed(), 0); + await current.chain.give(holder, 1_000n); + + const buildLeaf = async function(serial: number): Promise { + const builder = new KeetaNetLib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: holder, + issuer: current.caAccount, + serial, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + (60 * 60 * 1000)) + }); + + return(await builder.build()); + }; + + const leaf = await buildLeaf(2); + const bare = await buildLeaf(3); + + const holderClient = current.chain.clientFor(holder); + const intermediates = new KeetaNetLib.Utils.Certificate.CertificateBundle([current.ca]); + await holderClient.modifyCertificate(KeetaNetLib.Block.AdjustMethod.ADD, leaf, intermediates); + await holderClient.modifyCertificate(KeetaNetLib.Block.AdjustMethod.ADD, bare, null); + + return({ + event: 'certificate-chain-published', + api: current.chain.api, + account: holder.publicKeyString.get(), + leaf: leaf.toPEM(), + bare: bare.toPEM(), + ca: current.ca.toPEM() + }); +} + /** * Recursively revive a JSON request value into the shape the reference builder * expects: an object `{ "__date": "" }` becomes a `Date` (at any depth), @@ -465,6 +514,7 @@ async function handle(request: KycRequest): Promise { case 'stopKycAnchor': return(await handleStopKycAnchor()); case 'buildMetadata': return(handleBuildMetadata(request)); case 'publishMetadata': return(await handlePublishMetadata(request)); + case 'publishCertificateChain': return(await handlePublishCertificateChain()); case 'issueCertificate': return(await handleIssueCertificate(request)); case 'decodeCertificate': return(await handleDecodeCertificate(request)); case 'proveAttribute': return(await handleProveAttribute(request)); From 82776bfe7e1fcafbb0586e6fccf257f1a877358e Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 7 Jul 2026 23:07:54 -0700 Subject: [PATCH 2/5] feat: better typing --- .config/dotnet-tools.json | 13 + .github/workflows/ci.yml | 4 +- .gitignore | 1 + Directory.Packages.props | 1 + Makefile | 7 +- README.md | 36 +- cspell.yaml | 4 + scripts/build-wasm.sh | 50 +- scripts/fetch-crate.sh | 46 + scripts/generate-node-api.sh | 39 + scripts/pins.env | 12 + ...chor.Extensions.DependencyInjection.csproj | 1 + ...etaNetAnchorServiceCollectionExtensions.cs | 16 + src/KeetaNet.Anchor/Crypto/AccountFactory.cs | 2 +- src/KeetaNet.Anchor/Crypto/Certificate.cs | 7 + .../Crypto/EncryptedContainerFactory.cs | 4 +- src/KeetaNet.Anchor/Crypto/Hashes.cs | 106 + src/KeetaNet.Anchor/Crypto/KycCertificate.cs | 4 +- src/KeetaNet.Anchor/Generated/Node/NodeApi.cs | 3977 +++++++++++++++++ .../Interop/WasmRuntime.Crypto.cs | 12 +- .../Interop/WasmRuntime.Surface.cs | 9 + src/KeetaNet.Anchor/Interop/WasmRuntime.cs | 15 +- src/KeetaNet.Anchor/KeetaException.cs | 13 +- src/KeetaNet.Anchor/KeetaJson.cs | 6 +- .../AssetMovement/AssetMovementBlockers.cs | 77 + .../AssetMovement/AssetMovementClient.cs | 116 +- .../AssetMovement/AssetMovementModels.cs | 87 +- .../Services/AssetMovement/AssetOrPair.cs | 75 + .../Services/AssetMovement/AssetTransfer.cs | 14 +- src/KeetaNet.Anchor/Services/Kyc/KycClient.cs | 41 +- src/KeetaNet.Anchor/Services/Kyc/KycModels.cs | 31 + .../Services/Node/NodeClient.cs | 284 ++ .../Services/Node/NodeModels.cs | 39 + tests/KeetaNet.Anchor.E2eTests/Anchors.cs | 16 +- .../AssetFlowTests.cs | 56 +- .../KeetaNet.Anchor.E2eTests/AssetSession.cs | 4 +- .../ContainerInteropTests.cs | 2 +- .../KeetaNet.Anchor.E2eTests/KycFlowTests.cs | 152 +- .../KeetaNet.Anchor.Tests/AssetModelTests.cs | 181 + .../KeetaNet.Anchor.Tests/ConcurrencyTests.cs | 4 +- tests/KeetaNet.Anchor.Tests/ContainerTests.cs | 2 +- tests/KeetaNet.Anchor.Tests/CryptoTests.cs | 17 +- .../DependencyInjectionTests.cs | 16 + tests/KeetaNet.Anchor.Tests/IssueTests.cs | 2 +- tests/KeetaNet.Anchor.Tests/KycModelTests.cs | 50 + tests/KeetaNet.Anchor.Tests/LifecycleTests.cs | 2 +- tests/KeetaNet.Anchor.Tests/TrustTests.cs | 84 + tests/node-harness/src/kyc.ts | 1 + 48 files changed, 5518 insertions(+), 220 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 scripts/fetch-crate.sh create mode 100644 scripts/generate-node-api.sh create mode 100644 scripts/pins.env create mode 100644 src/KeetaNet.Anchor/Crypto/Hashes.cs create mode 100644 src/KeetaNet.Anchor/Generated/Node/NodeApi.cs create mode 100644 src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementBlockers.cs create mode 100644 src/KeetaNet.Anchor/Services/AssetMovement/AssetOrPair.cs create mode 100644 src/KeetaNet.Anchor/Services/Node/NodeClient.cs create mode 100644 src/KeetaNet.Anchor/Services/Node/NodeModels.cs create mode 100644 tests/KeetaNet.Anchor.Tests/AssetModelTests.cs create mode 100644 tests/KeetaNet.Anchor.Tests/KycModelTests.cs create mode 100644 tests/KeetaNet.Anchor.Tests/TrustTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..ba0cf5f --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "nswag.consolecore": { + "version": "14.7.1", + "commands": [ + "nswag" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0eb3399..0b56f7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: ~/.cargo/registry ~/.cargo/git .wasm-build - key: wasm-${{ runner.os }}-${{ hashFiles('scripts/build-wasm.sh', 'rust-toolchain.toml') }} + key: wasm-${{ runner.os }}-${{ hashFiles('scripts/build-wasm.sh', 'scripts/fetch-crate.sh', 'scripts/pins.env', 'rust-toolchain.toml') }} - name: Set up Node.js uses: actions/setup-node@v4 @@ -136,7 +136,7 @@ jobs: ~/.cargo/registry ~/.cargo/git .wasm-build - key: wasm-${{ runner.os }}-${{ hashFiles('scripts/build-wasm.sh', 'rust-toolchain.toml') }} + key: wasm-${{ runner.os }}-${{ hashFiles('scripts/build-wasm.sh', 'scripts/fetch-crate.sh', 'scripts/pins.env', 'rust-toolchain.toml') }} - name: Build wasm core run: bash scripts/build-wasm.sh diff --git a/.gitignore b/.gitignore index 167e576..ae102ad 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ coverage*.info # Wasm core built from the pinned crates.io release (never committed) wasm/ .wasm-build/ +.node-api-build/ # Node harness build output node_modules/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 8bde0e2..85b04a6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/Makefile b/Makefile index 53b7a3f..83e74ea 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help developer restore build rebuild do-lint do-lint-ci test pack clean wasm node-harness release +.PHONY: help developer restore build rebuild do-lint do-lint-ci test pack clean wasm node-api node-harness release # Build configuration (Debug or Release) CONFIG ?= Release @@ -65,6 +65,10 @@ pack: build wasm: ./scripts/build-wasm.sh +# Regenerate the node REST transport from the pinned OpenAPI spec (committed) +node-api: + ./scripts/generate-node-api.sh + $(WASM_DEST): ./scripts/build-wasm.sh @@ -103,6 +107,7 @@ help: @echo " make do-lint - Lint code with formatting fixes (C# + harness + spelling)" @echo " make pack - Produce the NuGet packages into $(ARTIFACTS)/" @echo " make wasm - Build the P1 wasm core from the pinned crates.io release" + @echo " make node-api - Regenerate the node REST transport from the pinned OpenAPI spec" @echo " make clean - Remove build outputs" @echo "" @echo "CI Commands:" diff --git a/README.md b/README.md index 7405f00..38178ed 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,12 @@ using KeetaNet.Anchor.Crypto; using var runtime = WasmRuntime.Load(); ``` -The runtime exposes one factory per domain — `Accounts`, `Certificates`, `KycCertificates`, `Containers`, `Sharables` — plus `CreateKycClient` and `CreateAssetMovementClient` for the networked clients. Every handle-backed object they create implements `IDisposable`. Wrap them in `using` and dispose them before the runtime. +The runtime exposes one factory per domain - `Accounts`, `Certificates`, `KycCertificates`, `Containers`, `Sharables` - plus `CreateKycClient` and `CreateAssetMovementClient` for the networked clients. Every handle-backed object they create implements `IDisposable`. Wrap them in `using` and dispose them before the runtime. In an application using `Microsoft.Extensions.DependencyInjection`, register the runtime once instead of threading it around; the container owns its disposal: ```csharp -// Program.cs — requires the KeetaNet.Anchor.Extensions.DependencyInjection package. +// Program.cs - requires the KeetaNet.Anchor.Extensions.DependencyInjection package. builder.Services.AddKeetaNetAnchor(); // Any service: inject the singleton and create what you need per use. @@ -107,20 +107,20 @@ Provider results use the pending-or-ready shape: `Ready` carries the value, othe using KycClient kyc = runtime.CreateKycClient(nodeUrl, root, signer); string[] countries = { "US" }; -IReadOnlyList providers = await kyc.GetProvidersAsync(countries, cancellationToken); +IReadOnlyList providers = await kyc.GetProviders(countries, cancellationToken); KycProvider provider = providers[0]; // Start a verification. The user completes it in a browser at WebUrl. -VerificationOutcome created = await kyc.StartVerificationAsync(provider, countries, cancellationToken: cancellationToken); +VerificationOutcome created = await kyc.StartVerification(provider, countries, cancellationToken: cancellationToken); Verification verification = created.Ready!; Console.WriteLine(verification.WebUrl); Console.WriteLine(verification.ExpectedCost.Token); // Poll the provider's decision. -StatusOutcome status = await kyc.GetVerificationStatusAsync(provider, verification.Id, cancellationToken); +StatusOutcome status = await kyc.GetVerificationStatus(provider, verification.Id, cancellationToken); // Fetch the issued chain once ready; a pending fetch reports RetryAfterMs instead. -CertificatesOutcome outcome = await kyc.GetCertificatesAsync(provider, verification.Id, cancellationToken); +CertificatesOutcome outcome = await kyc.GetCertificates(provider, verification.Id, cancellationToken); Certificates issued = outcome.Ready!; string leafPem = issued.Results[0].Value; ``` @@ -235,13 +235,13 @@ byte[]? signerKey = opened.GetSigningAccount(); // type-prefixed public key, nul using AssetMovementClient assets = runtime.CreateAssetMovementClient(nodeUrl, root, signer); // Discovery: all providers, by id, by signer account, or by transfer shape. -IReadOnlyList providers = await assets.GetProvidersAsync(cancellationToken); +IReadOnlyList providers = await assets.GetProviders(cancellationToken); var search = new AssetProviderSearch(Asset: asset, From: "chain:evm:100", To: "chain:keeta:100"); -IReadOnlyList capable = await assets.GetProvidersForTransferAsync(search, cancellationToken); +IReadOnlyList capable = await assets.GetProvidersForTransfer(search, cancellationToken); AssetProvider provider = capable[0]; // Check the signer's readiness before transacting. -AssetAccountStatus account = await assets.GetAccountStatusAsync(provider, cancellationToken); +AssetAccountStatus account = await assets.GetAccountStatus(provider, cancellationToken); // Push transfer: simulate first, then promote the simulation. var request = new AssetTransferRequest( @@ -249,17 +249,17 @@ var request = new AssetTransferRequest( From: new AssetTransferSource("chain:evm:100"), To: new AssetTransferDestination("chain:keeta:100", recipientAddress), Value: "100"); -AssetSimulatedTransfer simulated = await assets.SimulateTransferAsync(provider, request, cancellationToken); -AssetTransfer transfer = await simulated.CreateTransferAsync(cancellationToken: cancellationToken); -AssetTransferStatus status = await transfer.GetTransferStatusAsync(cancellationToken); +AssetSimulatedTransfer simulated = await assets.SimulateTransfer(provider, request, cancellationToken); +AssetTransfer transfer = await simulated.CreateTransfer(cancellationToken: cancellationToken); +AssetTransferStatus status = await transfer.GetTransferStatus(cancellationToken); // Pull transfer (fiat rails): initiate, pick an instruction, execute. -AssetTransfer pull = await assets.InitiateTransferAsync(provider, pullRequest, cancellationToken); +AssetTransfer pull = await assets.InitiateTransfer(provider, pullRequest, cancellationToken); var instruction = new AssetPullInstruction("ACH_DEBIT", pull.InstructionChoices[0].GetProperty("pullFrom")); -AssetTransferStatus executed = await pull.ExecuteTransferAsync(instruction, cancellationToken); +AssetTransferStatus executed = await pull.ExecuteTransfer(instruction, cancellationToken); // Share KYC attributes; a pending outcome polls its promise URL inside the core. -AssetShareKycOutcome shared = await assets.ShareKycAttributesAndWaitAsync( +AssetShareKycOutcome shared = await assets.ShareKycAttributesAndWait( provider, new AssetShareKycRequest(exportedAttributes), pollInterval: TimeSpan.FromSeconds(1), @@ -267,7 +267,7 @@ AssetShareKycOutcome shared = await assets.ShareKycAttributesAndWaitAsync( cancellationToken: cancellationToken); ``` -Persistent forwarding follows the same pattern: `InitiatePersistentForwardingTemplateAsync` / `CreatePersistentForwardingTemplateAsync` / `CreatePersistentForwardingAddressAsync` create, the `List*Async` methods page, and the `Deactivate*Async` methods retire. +Persistent forwarding follows the same pattern: `InitiatePersistentForwardingTemplate` / `CreatePersistentForwardingTemplate` / `CreatePersistentForwardingAddress` create, the `List*` methods page, and the `Deactivate*` methods retire. ### Errors @@ -276,7 +276,7 @@ Every failure surfaced from the core throws `KeetaException`: a stable machine-r ```csharp try { - await assets.InitiateTransferAsync(provider, request, cancellationToken); + await assets.InitiateTransfer(provider, request, cancellationToken); } catch (KeetaException error) { @@ -295,7 +295,7 @@ Interop is Wasmtime-hosted WebAssembly, not native P/Invoke. Guest memory is a s ### SDK Rules: - **Thread-safe by construction.** A `WasmRuntime` owns one Wasmtime `Store`, which cannot be used from more than one thread. The runtime confines it to a dedicated dispatcher thread and serializes every call onto it, so any thread may use the SDK: offline operations dispatch synchronously, networked client operations are `async` and accept a `CancellationToken`. -- **Deterministic disposal.** Handle-backed types (`Account`, `Certificate`, `KycCertificate`, containers, the clients) implement `IDisposable` and release their wasm handle on `Dispose`. Wrap them in `using`. A finalizer backstop reclaims forgotten handles by enqueueing the free onto the dispatcher, but deterministic disposal remains the contract. +- **Deterministic disposal.** Handle-backed types (`Account`, `Certificate`, `KycCertificate`, containers, the clients) implement `IDisposable` and release their wasm handle on `Dispose`. Wrap them in `using`. ## License diff --git a/cspell.yaml b/cspell.yaml index 561b183..daae19d 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -57,6 +57,8 @@ words: - idisp - lifecycles - tlsv + - csclient + - nswag - rasn - rustfmt - userprofile @@ -66,3 +68,5 @@ words: - msys - maxdepth - powershell + - renderable + - solana diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 6122085..fba52ba 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -3,26 +3,17 @@ # the library embeds it. set -euo pipefail -CRATE_NAME="keetanetwork-anchor-client-wasi" -CRATE_VERSION="0.1.3" -CRATE_SHA256="ebf6df527b9b0c5f9981664eafc5d65a3c99e9ab07775560ae8b2a1354b2866c" - ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# shellcheck source=scripts/pins.env +source "${ROOT}/scripts/pins.env" +# shellcheck source=scripts/fetch-crate.sh +source "${ROOT}/scripts/fetch-crate.sh" + BUILD_DIR="${ROOT}/.wasm-build" -TARBALL="${BUILD_DIR}/${CRATE_NAME}-${CRATE_VERSION}.crate" -CRATE_DIR="${BUILD_DIR}/${CRATE_NAME}-${CRATE_VERSION}" +CRATE_DIR="${BUILD_DIR}/${ANCHOR_WASI_CRATE}-${ANCHOR_WASI_VERSION}" ARTIFACT="${CRATE_DIR}/target/wasm32-wasip1/release/keetanetwork_anchor_client_wasi.wasm" DEST="${ROOT}/src/KeetaNet.Anchor/wasm/keetanetwork_anchor_client_wasi.wasm" -DOWNLOAD_URL="https://crates.io/api/v1/crates/${CRATE_NAME}/${CRATE_VERSION}/download" - -checksum() { - local file="$1" - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "${file}" | cut -d' ' -f1 - else - shasum -a 256 "${file}" | cut -d' ' -f1 - fi -} # rasn-compiler probes `$CARGO_HOME/bin/rustfmt` and `$CARGO`-adjacent # `rustfmt` without the .exe suffix. When both miss it silently emits @@ -102,31 +93,6 @@ require_wasip1_target() { fi } -fetch_crate() { - mkdir -p "${BUILD_DIR}" - if [[ ! -f "${TARBALL}" ]]; then - echo "build-wasm: downloading ${CRATE_NAME} ${CRATE_VERSION} from crates.io" - # crates.io rejects requests without a User-Agent. - curl --fail --silent --show-error --location \ - --proto '=https' --tlsv1.2 \ - --user-agent "anchor-csharp build-wasm (https://github.com/KeetaNetwork/anchor-csharp)" \ - --output "${TARBALL}" "${DOWNLOAD_URL}" - fi - - actual="$(checksum "${TARBALL}")" - if [[ "${actual}" != "${CRATE_SHA256}" ]]; then - rm -f "${TARBALL}" - echo "build-wasm: checksum mismatch for ${TARBALL}" >&2 - echo " expected: ${CRATE_SHA256}" >&2 - echo " actual: ${actual}" >&2 - exit 1 - fi - - if [[ ! -d "${CRATE_DIR}" ]]; then - tar --extract --gzip --file "${TARBALL}" --directory "${BUILD_DIR}" - fi -} - build_artifact() { echo "build-wasm: cargo build (wasm32-wasip1, features p1, release)" cargo build \ @@ -139,7 +105,7 @@ build_artifact() { shim_rustfmt_for_windows require_wasip1_target -fetch_crate +fetch_crate "${ANCHOR_WASI_CRATE}" "${ANCHOR_WASI_VERSION}" "${ANCHOR_WASI_SHA256}" "${BUILD_DIR}" purge_stale_asn1_outputs build_artifact diff --git a/scripts/fetch-crate.sh b/scripts/fetch-crate.sh new file mode 100644 index 0000000..a73d737 --- /dev/null +++ b/scripts/fetch-crate.sh @@ -0,0 +1,46 @@ +# Shared pinned-crate fetch for the generation scripts. Source this file and +# call `fetch_crate`. + +checksum() { + local file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${file}" | cut -d' ' -f1 + else + shasum -a 256 "${file}" | cut -d' ' -f1 + fi +} + +# fetch_crate +# +# Download the pinned crates.io release into ``, verify its SHA-256, +# and extract it to `/-` (skipping work already done). +fetch_crate() { + local name="$1" version="$2" expected="$3" build_dir="$4" + local tarball="${build_dir}/${name}-${version}.crate" + local crate_dir="${build_dir}/${name}-${version}" + local url="https://crates.io/api/v1/crates/${name}/${version}/download" + + mkdir -p "${build_dir}" + if [[ ! -f "${tarball}" ]]; then + echo "fetch-crate: downloading ${name} ${version} from crates.io" + # crates.io rejects requests without a User-Agent. + curl --fail --silent --show-error --location \ + --proto '=https' --tlsv1.2 \ + --user-agent "anchor-csharp fetch-crate (https://github.com/KeetaNetwork/anchor-csharp)" \ + --output "${tarball}" "${url}" + fi + + local actual + actual="$(checksum "${tarball}")" + if [[ "${actual}" != "${expected}" ]]; then + rm -f "${tarball}" + echo "fetch-crate: checksum mismatch for ${tarball}" >&2 + echo " expected: ${expected}" >&2 + echo " actual: ${actual}" >&2 + exit 1 + fi + + if [[ ! -d "${crate_dir}" ]]; then + tar --extract --gzip --file "${tarball}" --directory "${build_dir}" + fi +} diff --git a/scripts/generate-node-api.sh b/scripts/generate-node-api.sh new file mode 100644 index 0000000..30319fc --- /dev/null +++ b/scripts/generate-node-api.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Generate the node REST transport (models + typed operations) from the +# canonical OpenAPI spec shipped in the pinned keetanetwork-client crate. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# shellcheck source=scripts/pins.env +source "${ROOT}/scripts/pins.env" +# shellcheck source=scripts/fetch-crate.sh +source "${ROOT}/scripts/fetch-crate.sh" + +BUILD_DIR="${ROOT}/.node-api-build" +CRATE_DIR="${BUILD_DIR}/${NODE_CLIENT_CRATE}-${NODE_CLIENT_VERSION}" +SPEC="${CRATE_DIR}/openapi/keetanet-node.yaml" +DEST="${ROOT}/src/KeetaNet.Anchor/Generated/Node/NodeApi.cs" + +generate_client() { + echo "generate-node-api: nswag openapi2csclient from ${SPEC}" + mkdir -p "$(dirname "${DEST}")" + (cd "${ROOT}" && dotnet tool restore >/dev/null && dotnet nswag openapi2csclient \ + "/input:${SPEC}" \ + /classname:NodeApi \ + /namespace:KeetaNet.Anchor.Generated.Node \ + "/output:${DEST}" \ + /jsonLibrary:SystemTextJson \ + /injectHttpClient:true \ + /disposeHttpClient:false \ + /generateClientInterfaces:false \ + /generateOptionalParameters:true \ + /generateNativeRecords:true \ + /useBaseUrl:true \ + /generateBaseUrlProperty:true \ + /exceptionClass:NodeApiException) +} + +fetch_crate "${NODE_CLIENT_CRATE}" "${NODE_CLIENT_VERSION}" "${NODE_CLIENT_SHA256}" "${BUILD_DIR}" +generate_client +echo "generate-node-api: node transport at ${DEST}" diff --git a/scripts/pins.env b/scripts/pins.env new file mode 100644 index 0000000..42d53cf --- /dev/null +++ b/scripts/pins.env @@ -0,0 +1,12 @@ +# Every pinned upstream crate release, in one place. Sourced by the scripts; +# bump versions and checksums here only. + +# The wasm core (scripts/build-wasm.sh). +ANCHOR_WASI_CRATE="keetanetwork-anchor-client-wasi" +ANCHOR_WASI_VERSION="0.2.0" +ANCHOR_WASI_SHA256="19c51c46f5efa58bcf4a37e324090bb91644ede5fcb0ae916983214e9092ff42" + +# The canonical node OpenAPI spec (scripts/generate-node-api.sh). +NODE_CLIENT_CRATE="keetanetwork-client" +NODE_CLIENT_VERSION="0.4.0" +NODE_CLIENT_SHA256="b43d33dc69ca0571499c19046b016271b66a7f4d9b2aa5539a2351b4c3231270" diff --git a/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNet.Anchor.Extensions.DependencyInjection.csproj b/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNet.Anchor.Extensions.DependencyInjection.csproj index 7a2895b..629f48e 100644 --- a/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNet.Anchor.Extensions.DependencyInjection.csproj +++ b/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNet.Anchor.Extensions.DependencyInjection.csproj @@ -13,6 +13,7 @@ + diff --git a/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNetAnchorServiceCollectionExtensions.cs b/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNetAnchorServiceCollectionExtensions.cs index 8254b12..a966076 100644 --- a/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNetAnchorServiceCollectionExtensions.cs +++ b/src/KeetaNet.Anchor.Extensions.DependencyInjection/KeetaNetAnchorServiceCollectionExtensions.cs @@ -19,4 +19,20 @@ public static IServiceCollection AddKeetaNetAnchor(this IServiceCollection servi services.TryAddSingleton(static _ => WasmRuntime.Load()); return services; } + + /// + /// Register a for the node API at + /// as a typed HTTP client, so its + /// comes from + /// IHttpClientFactory (pooled handlers, policy-friendly). + /// + /// The same collection, for chaining. + public static IServiceCollection AddKeetaNetAnchorNodeClient(this IServiceCollection services, string nodeUrl) + { + services.AddKeetaNetAnchor(); + services.AddHttpClient(nameof(NodeClient)) + .AddTypedClient((http, provider) => + provider.GetRequiredService().CreateNodeClient(nodeUrl, http)); + return services; + } } diff --git a/src/KeetaNet.Anchor/Crypto/AccountFactory.cs b/src/KeetaNet.Anchor/Crypto/AccountFactory.cs index 5be11b6..d9c0fb0 100644 --- a/src/KeetaNet.Anchor/Crypto/AccountFactory.cs +++ b/src/KeetaNet.Anchor/Crypto/AccountFactory.cs @@ -2,7 +2,7 @@ namespace KeetaNet.Anchor.Crypto; /// /// Creates objects owned by one runtime. Reached through -/// ; every account it creates must be +/// . Every account it creates must be /// disposed before that runtime. /// public sealed class AccountFactory diff --git a/src/KeetaNet.Anchor/Crypto/Certificate.cs b/src/KeetaNet.Anchor/Crypto/Certificate.cs index 8c76c83..9d5c543 100644 --- a/src/KeetaNet.Anchor/Crypto/Certificate.cs +++ b/src/KeetaNet.Anchor/Crypto/Certificate.cs @@ -58,5 +58,12 @@ public DateTimeOffset NotAfter /// public string SubjectPublicKey => Runtime.CertificateSubjectPublicKey(Handle); + /// + /// The SHA3-256 of the certificate's DER: the key the ledger stores a + /// published certificate under, so it feeds + /// . + /// + public CertificateHash Hash => CertificateHash.Parse(Runtime.CertificateHash(Convert.ToHexString(ToDer()))); + private protected override void Release(WasmRuntime runtime, int handle) => runtime.CertificateFree(handle); } diff --git a/src/KeetaNet.Anchor/Crypto/EncryptedContainerFactory.cs b/src/KeetaNet.Anchor/Crypto/EncryptedContainerFactory.cs index 73faa53..d919430 100644 --- a/src/KeetaNet.Anchor/Crypto/EncryptedContainerFactory.cs +++ b/src/KeetaNet.Anchor/Crypto/EncryptedContainerFactory.cs @@ -12,8 +12,8 @@ public sealed class EncryptedContainerFactory /// /// Build a plaintext container. A non-empty set - /// seals it to those accounts; attaches a detached - /// signature; overrides the default plaintext policy. + /// seals it to those accounts. attaches a detached + /// signature. overrides the default plaintext policy. /// public EncryptedContainer FromPlaintext( byte[] data, diff --git a/src/KeetaNet.Anchor/Crypto/Hashes.cs b/src/KeetaNet.Anchor/Crypto/Hashes.cs new file mode 100644 index 0000000..0708b35 --- /dev/null +++ b/src/KeetaNet.Anchor/Crypto/Hashes.cs @@ -0,0 +1,106 @@ +namespace KeetaNet.Anchor.Crypto; + +/// +/// The hash of a block, as the reference implementations model it (a 32-byte +/// value type, not a wasm handle). Hex is the transport form: parse with +/// , emit with . +/// +public readonly struct BlockHash : IEquatable +{ + /// The block hash length in bytes. + public const int Length = 32; + + private readonly string _hex; + + private BlockHash(string normalizedHex) + { + _hex = normalizedHex; + } + + /// Parse a 64-character hex block hash. Case is normalized away. + public static BlockHash Parse(string hex) => new(HexValue.Normalize(hex, Length)); + + /// The raw 32 hash bytes. + public byte[] ToBytes() => Convert.FromHexString(_hex); + + /// The lowercase hex transport form. + public override string ToString() => _hex ?? ""; + + /// + public bool Equals(BlockHash other) => string.Equals(_hex, other._hex, StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => obj is BlockHash other && Equals(other); + + /// + public override int GetHashCode() => _hex is null ? 0 : _hex.GetHashCode(StringComparison.Ordinal); + + /// Value equality over the hash bytes. + public static bool operator ==(BlockHash left, BlockHash right) => left.Equals(right); + + /// Value inequality over the hash bytes. + public static bool operator !=(BlockHash left, BlockHash right) => !left.Equals(right); +} + +/// +/// The hash a published certificate is stored under on the ledger: the +/// SHA3-256 of its DER, as the reference implementations model it. +/// +public readonly struct CertificateHash : IEquatable +{ + /// The SHA3-256 certificate hash length in bytes. + public const int Length = 32; + + private readonly string _hex; + + private CertificateHash(string normalizedHex) + { + _hex = normalizedHex; + } + + /// Parse a 64-character hex certificate hash. Case is normalized away. + public static CertificateHash Parse(string hex) => new(HexValue.Normalize(hex, Length)); + + /// The raw 32 hash bytes. + public byte[] ToBytes() => Convert.FromHexString(_hex); + + /// The lowercase hex transport form. + public override string ToString() => _hex ?? ""; + + /// + public bool Equals(CertificateHash other) => string.Equals(_hex, other._hex, StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => obj is CertificateHash other && Equals(other); + + /// + public override int GetHashCode() => _hex is null ? 0 : _hex.GetHashCode(StringComparison.Ordinal); + + /// Value equality over the hash bytes. + public static bool operator ==(CertificateHash left, CertificateHash right) => left.Equals(right); + + /// Value inequality over the hash bytes. + public static bool operator !=(CertificateHash left, CertificateHash right) => !left.Equals(right); +} + +/// Shared validation for the hex-transported hash value types. +internal static class HexValue +{ + /// + /// Validate that encodes exactly + /// bytes and lowercase it, so equality is + /// byte equality regardless of the producer's casing. + /// + public static string Normalize(string hex, int expectedBytes) + { + if (hex.Length != expectedBytes * 2) + { + throw new KeetaException("HASH_LENGTH", $"expected {expectedBytes * 2} hex characters, got {hex.Length}"); + } + + // Round-tripping through bytes rejects non-hex characters up front. + // (Convert.ToHexStringLower needs .NET 9. net8.0 is still targeted.) + byte[] value = Convert.FromHexString(hex); + return Convert.ToHexString(value).ToLowerInvariant(); + } +} diff --git a/src/KeetaNet.Anchor/Crypto/KycCertificate.cs b/src/KeetaNet.Anchor/Crypto/KycCertificate.cs index ef434ed..8493ca4 100644 --- a/src/KeetaNet.Anchor/Crypto/KycCertificate.cs +++ b/src/KeetaNet.Anchor/Crypto/KycCertificate.cs @@ -124,7 +124,7 @@ private sealed record AttributeEntry(string Name, bool Sensitive); /// /// A fluent builder for a KYC leaf certificate: collect a subject, issuer, /// validity window, and attributes, then the signed -/// leaf. Sensitive attributes are encrypted to the subject; the issuer +/// leaf. Sensitive attributes are encrypted to the subject. The issuer /// signs. The subject and issuer may use different signing algorithms. /// public sealed class KycCertificateBuilder @@ -142,7 +142,7 @@ public sealed class KycCertificateBuilder internal KycCertificateBuilder(WasmRuntime runtime) => _runtime = runtime; - /// The subject the leaf is issued to; sensitive attributes encrypt to its key. + /// The subject the leaf is issued to. Sensitive attributes encrypt to its key. /// A read-only (public-key) account suffices to issue. public KycCertificateBuilder Subject(Account subject) { diff --git a/src/KeetaNet.Anchor/Generated/Node/NodeApi.cs b/src/KeetaNet.Anchor/Generated/Node/NodeApi.cs new file mode 100644 index 0000000..12d0896 --- /dev/null +++ b/src/KeetaNet.Anchor/Generated/Node/NodeApi.cs @@ -0,0 +1,3977 @@ +//---------------------- +// +// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// +//---------------------- + +#pragma warning disable 108 // Disable "CS0108 '{derivedDto}.ToJson()' hides inherited member '{dtoBase}.ToJson()'. Use the new keyword if hiding was intended." +#pragma warning disable 114 // Disable "CS0114 '{derivedDto}.RaisePropertyChanged(String)' hides inherited member 'dtoBase.RaisePropertyChanged(String)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword." +#pragma warning disable 472 // Disable "CS0472 The result of the expression is always 'false' since a value of type 'Int32' is never equal to 'null' of type 'Int32?' +#pragma warning disable 612 // Disable "CS0612 '...' is obsolete" +#pragma warning disable 649 // Disable "CS0649 Field is never assigned to, and will always have its default value null" +#pragma warning disable 1573 // Disable "CS1573 Parameter '...' has no matching param tag in the XML comment for ... +#pragma warning disable 1591 // Disable "CS1591 Missing XML comment for publicly visible type or member ..." +#pragma warning disable 8073 // Disable "CS8073 The result of the expression is always 'false' since a value of type 'T' is never equal to 'null' of type 'T?'" +#pragma warning disable 3016 // Disable "CS3016 Arrays as attribute arguments is not CLS-compliant" +#pragma warning disable 8600 // Disable "CS8600 Converting null literal or possible null value to non-nullable type" +#pragma warning disable 8602 // Disable "CS8602 Dereference of a possibly null reference" +#pragma warning disable 8603 // Disable "CS8603 Possible null reference return" +#pragma warning disable 8604 // Disable "CS8604 Possible null reference argument for parameter" +#pragma warning disable 8625 // Disable "CS8625 Cannot convert null literal to non-nullable reference type" +#pragma warning disable 8765 // Disable "CS8765 Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes)." + +namespace KeetaNet.Anchor.Generated.Node +{ + using System = global::System; + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class NodeApi + { + #pragma warning disable 8618 + private string _baseUrl; + #pragma warning restore 8618 + + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public NodeApi(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + BaseUrl = "http://localhost:8080/api"; + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + public string BaseUrl + { + get { return _baseUrl; } + set + { + _baseUrl = value; + if (!string.IsNullOrEmpty(_baseUrl) && !_baseUrl.EndsWith("/")) + _baseUrl += '/'; + } + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Create a new vote + /// + /// + /// Submit blocks to a representative and receive a vote for them. + /// + /// Vote created successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task CreateVoteAsync(Body body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "vote" + urlBuilder_.Append("vote"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Create a vote quote + /// + /// + /// Generate a vote quote for the given blocks. + /// + /// Quote created successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task CreateVoteQuoteAsync(Body2 body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "vote/quote" + urlBuilder_.Append("vote/quote"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get votes for a block + /// + /// + /// Retrieve the votes the node holds for a specific block hash. + /// + /// 64-character hexadecimal block hash + /// Which ledger storage to read votes from (defaults to main). + /// Votes retrieved successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetBlockVotesAsync(string blockhash, Side? side = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (blockhash == null) + throw new System.ArgumentNullException("blockhash"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "vote/{blockhash}" + urlBuilder_.Append("vote/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(blockhash, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append('?'); + if (side != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("side")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(side, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get node version + /// + /// + /// Retrieve the version of this node software. + /// + /// Version retrieved successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetNodeVersionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/version" + urlBuilder_.Append("node/version"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get account state + /// + /// + /// Retrieve the head, representative, balances, and info for an account. + /// + /// Account public key + /// Account state retrieved successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetAccountStateAsync(string account, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get all account balances + /// + /// + /// Retrieve balances for all tokens held by an account. + /// + /// Account public key + /// Balances retrieved successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetAccountBalancesAsync(string account, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/balance" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/balance"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get account balance for a specific token + /// + /// + /// Retrieve the balance of a specific token for an account. + /// + /// Account public key + /// Token account address + /// Balance retrieved successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetAccountBalanceAsync(string account, string token, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + if (token == null) + throw new System.ArgumentNullException("token"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/balance/{token}" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/balance/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(token, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get account head block + /// + /// + /// Retrieve the head block for an account. + /// + /// Account public key + /// Head block retrieved successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetAccountHeadAsync(string account, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/head" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/head"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get pending block for account + /// + /// + /// Retrieve the next pending block for an account. + /// + /// Account public key + /// Pending block retrieved successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetPendingBlockAsync(string account, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/pending" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/pending"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get block by hash + /// + /// + /// Retrieve a specific block by its hash. + /// + /// 64-character hexadecimal block hash + /// Which ledger storage to read from (defaults to main). + /// Block retrieved successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetBlockAsync(string blockhash, Side2? side = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (blockhash == null) + throw new System.ArgumentNullException("blockhash"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/block/{blockhash}" + urlBuilder_.Append("node/ledger/block/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(blockhash, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append('?'); + if (side != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("side")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(side, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get successor block + /// + /// + /// Retrieve the block that follows the specified block. + /// + /// 64-character hexadecimal block hash + /// Successor block retrieved successfully + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetSuccessorBlockAsync(string blockhash, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (blockhash == null) + throw new System.ArgumentNullException("blockhash"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/block/{blockhash}/successor" + urlBuilder_.Append("node/ledger/block/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(blockhash, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/successor"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get node statistics + /// + /// Statistics + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetNodeStatsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/stats" + urlBuilder_.Append("node/stats"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get peers + /// + /// Peers + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetPeersAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/peers" + urlBuilder_.Append("node/peers"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get ledger checksum + /// + /// Checksum + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetLedgerChecksumAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/checksum" + urlBuilder_.Append("node/ledger/checksum"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get the node's representative + /// + /// Representative + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetNodeRepresentativeAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/representative" + urlBuilder_.Append("node/ledger/representative"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get a representative's weight + /// + /// Representative + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetRepresentativeAsync(string rep, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (rep == null) + throw new System.ArgumentNullException("rep"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/representative/{rep}" + urlBuilder_.Append("node/ledger/representative/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(rep, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get all representatives + /// + /// Representatives + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetAllRepresentativesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/representatives" + urlBuilder_.Append("node/ledger/representatives"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get global history + /// + /// History + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetGlobalHistoryAsync(string start = null, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/history" + urlBuilder_.Append("node/ledger/history"); + urlBuilder_.Append('?'); + if (start != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("start")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(start, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get account chain + /// + /// Chain + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetAccountChainAsync(string account, string start = null, string end = null, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/chain" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/chain"); + urlBuilder_.Append('?'); + if (start != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("start")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(start, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (end != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("end")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(end, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get account history + /// + /// History + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetAccountHistoryAsync(string account, string start = null, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/history" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/history"); + urlBuilder_.Append('?'); + if (start != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("start")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(start, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List ACLs by principal + /// + /// ACLs + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ListAclsByPrincipalAsync(string account, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/acl" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/acl"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List ACLs granted to an entity + /// + /// ACLs + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ListAclsByEntityAsync(string account, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/acl/granted" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/acl/granted"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List ACLs by principal with entity info + /// + /// ACL aggregate + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ListAclsAdditionalAsync(string account, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/acl/additional" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/acl/additional"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get account certificates + /// + /// Certificates + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetAccountCertificatesAsync(string account, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/certificates" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/certificates"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get certificate by hash + /// + /// Certificate + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetCertificateByHashAsync(string account, string certificateHash, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + if (certificateHash == null) + throw new System.ArgumentNullException("certificateHash"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/certificates/{certificateHash}" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/certificates/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(certificateHash, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get block by idempotent key + /// + /// Which ledger storage to search (defaults to main). + /// Block + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetBlockFromIdempotentAsync(string account, string idempotent, Side3? side = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (account == null) + throw new System.ArgumentNullException("account"); + + if (idempotent == null) + throw new System.ArgumentNullException("idempotent"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/account/{account}/idempotent/{idempotent}" + urlBuilder_.Append("node/ledger/account/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(account, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/idempotent/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(idempotent, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append('?'); + if (side != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("side")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(side, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get state for multiple accounts + /// + /// Comma-separated account public keys + /// Account states + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GetAccountStatesAsync(string accounts, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (accounts == null) + throw new System.ArgumentNullException("accounts"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/ledger/accounts/{accounts}" + urlBuilder_.Append("node/ledger/accounts/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(accounts, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get vote staples after a moment + /// + /// Vote staples + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetVoteStaplesAfterAsync(string start, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (start == null) + throw new System.ArgumentNullException("start"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/bootstrap/votes" + urlBuilder_.Append("node/bootstrap/votes"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("start")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(start, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Publish a vote staple + /// + /// + /// Publish a vote staple to the network. + /// + /// Publish result + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task PublishVoteStapleAsync(Body3 body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "node/publish" + urlBuilder_.Append("node/publish"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new NodeApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new NodeApiException("Server error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new NodeApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new NodeApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new NodeApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Error + { + + [System.Text.Json.Serialization.JsonPropertyName("error")] + public bool Error1 { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("message")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Message { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("type")] + public string Type { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("code")] + public string Code { get; set; } + + /// + /// Whether a LEDGER error may be retried + /// + [System.Text.Json.Serialization.JsonPropertyName("shouldRetry")] + public bool? ShouldRetry { get; set; } + + /// + /// Suggested retry delay in milliseconds + /// + [System.Text.Json.Serialization.JsonPropertyName("retryDelay")] + public int? RetryDelay { get; set; } + + /// + /// Accounts party to a LEDGER vote conflict + /// + [System.Text.Json.Serialization.JsonPropertyName("accounts")] + public System.Collections.Generic.ICollection Accounts { get; set; } + + /// + /// Block hash being published (LEDGER idempotent errors) + /// + [System.Text.Json.Serialization.JsonPropertyName("blockhash")] + public string Blockhash { get; set; } + + /// + /// Pre-existing block hash for the idempotent key + /// + [System.Text.Json.Serialization.JsonPropertyName("existingBlockhash")] + public string ExistingBlockhash { get; set; } + + /// + /// Account the idempotent key belongs to + /// + [System.Text.Json.Serialization.JsonPropertyName("account")] + public string Account { get; set; } + + /// + /// Idempotent key bytes, base64-encoded + /// + [System.Text.Json.Serialization.JsonPropertyName("idempotentKey")] + public string IdempotentKey { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// A vote encoded in base64 + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Vote + { + + /// + /// Binary vote data encoded as base64 + /// + [System.Text.Json.Serialization.JsonPropertyName("$binary")] + public string Binary { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// A vote quote encoded in base64 + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record VoteQuote + { + + /// + /// Binary vote quote data encoded as base64 + /// + [System.Text.Json.Serialization.JsonPropertyName("$binary")] + public string Binary { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// A block encoded in base64 + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Block + { + + /// + /// Binary block data encoded as base64 + /// + [System.Text.Json.Serialization.JsonPropertyName("$binary")] + public string Binary { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// A vote staple encoded in base64 + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record VoteStaple + { + + /// + /// Binary vote staple data encoded as base64 + /// + [System.Text.Json.Serialization.JsonPropertyName("$binary")] + public string Binary { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Certificate + { + + /// + /// PEM-encoded certificate + /// + [System.Text.Json.Serialization.JsonPropertyName("certificate")] + public string Certificate1 { get; set; } + + /// + /// PEM-encoded intermediate certificates + /// + [System.Text.Json.Serialization.JsonPropertyName("intermediates")] + public System.Collections.Generic.ICollection Intermediates { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record ACLRow + { + + /// + /// Whether the principal is an account or a certificate issuer + /// + [System.Text.Json.Serialization.JsonPropertyName("principalType")] + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + public ACLRowPrincipalType PrincipalType { get; set; } + + /// + /// The principal account address when principalType is ACCOUNT, or an object carrying the issuing certificate hash and its anchor account when principalType is CERTIFICATE + /// + [System.Text.Json.Serialization.JsonPropertyName("principal")] + public object Principal { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("entity")] + public string Entity { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("target")] + public string Target { get; set; } + + /// + /// Permission bitmaps as 0x-prefixed hexadecimal values ([base, external]) + /// + [System.Text.Json.Serialization.JsonPropertyName("permissions")] + public System.Collections.Generic.ICollection Permissions { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record HistoryEntry + { + + [System.Text.Json.Serialization.JsonPropertyName("voteStaple")] + public VoteStaple VoteStaple { get; set; } + + /// + /// Hexadecimal vote staple id + /// + [System.Text.Json.Serialization.JsonPropertyName("$id")] + public string Id { get; set; } + + /// + /// ISO 8601 timestamp + /// + [System.Text.Json.Serialization.JsonPropertyName("$timestamp")] + public string Timestamp { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Representative + { + + [System.Text.Json.Serialization.JsonPropertyName("representative")] + public string Representative1 { get; set; } + + /// + /// Voting weight as a 0x-prefixed hexadecimal BigInt + /// + [System.Text.Json.Serialization.JsonPropertyName("weight")] + public string Weight { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("endpoints")] + public RepresentativeEndpoints Endpoints { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// Network endpoints a representative can be reached at. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record RepresentativeEndpoints + { + + /// + /// REST API base URL + /// + [System.Text.Json.Serialization.JsonPropertyName("api")] + public string Api { get; set; } + + /// + /// P2P WebSocket URL + /// + [System.Text.Json.Serialization.JsonPropertyName("p2p")] + public string P2p { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// On-chain account info; `supply` is present only for token accounts. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record AccountInfo + { + + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("description")] + public string Description { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("metadata")] + public string Metadata { get; set; } + + /// + /// Total token supply as a 0x-prefixed hexadecimal BigInt (token accounts only) + /// + [System.Text.Json.Serialization.JsonPropertyName("supply")] + public string Supply { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record BalanceEntry + { + + /// + /// Token account address + /// + [System.Text.Json.Serialization.JsonPropertyName("token")] + public string Token { get; set; } + + /// + /// Settled balance as a 0x-prefixed hexadecimal BigInt + /// + [System.Text.Json.Serialization.JsonPropertyName("balance")] + public string Balance { get; set; } + + /// + /// Pending balance as a 0x-prefixed hexadecimal BigInt + /// + [System.Text.Json.Serialization.JsonPropertyName("pending")] + public string Pending { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Body + { + + /// + /// Array of base64-encoded blocks + /// + [System.Text.Json.Serialization.JsonPropertyName("blocks")] + [System.ComponentModel.DataAnnotations.Required] + public System.Collections.Generic.ICollection Blocks { get; set; } = new System.Collections.ObjectModel.Collection(); + + /// + /// Array of base64-encoded votes + /// + [System.Text.Json.Serialization.JsonPropertyName("votes")] + public System.Collections.Generic.ICollection Votes { get; set; } + + /// + /// Base64-encoded vote quote + /// + [System.Text.Json.Serialization.JsonPropertyName("quote")] + public string Quote { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Body2 + { + + /// + /// Array of base64-encoded blocks + /// + [System.Text.Json.Serialization.JsonPropertyName("blocks")] + [System.ComponentModel.DataAnnotations.Required] + public System.Collections.Generic.ICollection Blocks { get; set; } = new System.Collections.ObjectModel.Collection(); + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public enum Side + { + + [System.Runtime.Serialization.EnumMember(Value = @"main")] + Main = 0, + + [System.Runtime.Serialization.EnumMember(Value = @"side")] + Side = 1, + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public enum Side2 + { + + [System.Runtime.Serialization.EnumMember(Value = @"main")] + Main = 0, + + [System.Runtime.Serialization.EnumMember(Value = @"side")] + Side = 1, + + [System.Runtime.Serialization.EnumMember(Value = @"both")] + Both = 2, + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public enum Side3 + { + + [System.Runtime.Serialization.EnumMember(Value = @"main")] + Main = 0, + + [System.Runtime.Serialization.EnumMember(Value = @"side")] + Side = 1, + + [System.Runtime.Serialization.EnumMember(Value = @"both")] + Both = 2, + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Body3 + { + + /// + /// Base64-encoded vote staple + /// + [System.Text.Json.Serialization.JsonPropertyName("votesAndBlocks")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string VotesAndBlocks { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response + { + + [System.Text.Json.Serialization.JsonPropertyName("vote")] + public Vote Vote { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response2 + { + + [System.Text.Json.Serialization.JsonPropertyName("quote")] + public VoteQuote Quote { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response3 + { + + [System.Text.Json.Serialization.JsonPropertyName("blockhash")] + public string Blockhash { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("votes")] + public System.Collections.Generic.ICollection Votes { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response4 + { + + [System.Text.Json.Serialization.JsonPropertyName("node")] + public string Node { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response5 + { + + [System.Text.Json.Serialization.JsonPropertyName("account")] + public string Account { get; set; } + + /// + /// Head block hash as hexadecimal + /// + [System.Text.Json.Serialization.JsonPropertyName("currentHeadBlock")] + public string CurrentHeadBlock { get; set; } + + /// + /// Head block height as a 0x-prefixed hexadecimal BigInt + /// + [System.Text.Json.Serialization.JsonPropertyName("currentHeadBlockHeight")] + public string CurrentHeadBlockHeight { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("representative")] + public string Representative { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("info")] + public AccountInfo Info { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("balances")] + public System.Collections.Generic.ICollection Balances { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response6 + { + + [System.Text.Json.Serialization.JsonPropertyName("account")] + public string Account { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("balances")] + public System.Collections.Generic.ICollection Balances { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response7 + { + + [System.Text.Json.Serialization.JsonPropertyName("account")] + public string Account { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("token")] + public string Token { get; set; } + + /// + /// Settled balance as a 0x-prefixed hexadecimal BigInt + /// + [System.Text.Json.Serialization.JsonPropertyName("balance")] + public string Balance { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response8 + { + + [System.Text.Json.Serialization.JsonPropertyName("account")] + public string Account { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("block")] + public Block Block { get; set; } + + /// + /// Head block height as a 0x-prefixed hexadecimal BigInt + /// + [System.Text.Json.Serialization.JsonPropertyName("height")] + public string Height { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response9 + { + + [System.Text.Json.Serialization.JsonPropertyName("account")] + public string Account { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("block")] + public Block Block { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response10 + { + + [System.Text.Json.Serialization.JsonPropertyName("blockhash")] + public string Blockhash { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("block")] + public Block Block { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response11 + { + + [System.Text.Json.Serialization.JsonPropertyName("blockhash")] + public string Blockhash { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("successorBlock")] + public Block SuccessorBlock { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response12 + { + + [System.Text.Json.Serialization.JsonPropertyName("moment")] + public string Moment { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("momentRange")] + public double MomentRange { get; set; } + + /// + /// Checksum as a 0x-prefixed hexadecimal BigInt + /// + [System.Text.Json.Serialization.JsonPropertyName("checksum")] + public string Checksum { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response13 + { + + [System.Text.Json.Serialization.JsonPropertyName("representatives")] + public System.Collections.Generic.ICollection Representatives { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response14 + { + + [System.Text.Json.Serialization.JsonPropertyName("history")] + public System.Collections.Generic.ICollection History { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("nextKey")] + public string NextKey { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response15 + { + + [System.Text.Json.Serialization.JsonPropertyName("account")] + public string Account { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("blocks")] + public System.Collections.Generic.ICollection Blocks { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("nextKey")] + public string NextKey { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response16 + { + + [System.Text.Json.Serialization.JsonPropertyName("history")] + public System.Collections.Generic.ICollection History { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("nextKey")] + public string NextKey { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response17 + { + + [System.Text.Json.Serialization.JsonPropertyName("permissions")] + public System.Collections.Generic.ICollection Permissions { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response18 + { + + [System.Text.Json.Serialization.JsonPropertyName("permissions")] + public System.Collections.Generic.ICollection Permissions { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response19 + { + + [System.Text.Json.Serialization.JsonPropertyName("account")] + public string Account { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("certificates")] + public System.Collections.Generic.ICollection Certificates { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response20 : Certificate + { + + [System.Text.Json.Serialization.JsonPropertyName("account")] + public string Account { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response21 + { + + [System.Text.Json.Serialization.JsonPropertyName("block")] + public Block Block { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Anonymous + { + + [System.Text.Json.Serialization.JsonPropertyName("account")] + public string Account { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("currentHeadBlock")] + public string CurrentHeadBlock { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("currentHeadBlockHeight")] + public string CurrentHeadBlockHeight { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("representative")] + public string Representative { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("info")] + public AccountInfo Info { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("balances")] + public System.Collections.Generic.ICollection Balances { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response22 + { + + [System.Text.Json.Serialization.JsonPropertyName("voteStaples")] + public System.Collections.Generic.ICollection VoteStaples { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Response23 + { + + /// + /// Whether the vote staple was successfully published + /// + [System.Text.Json.Serialization.JsonPropertyName("publish")] + public bool Publish { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public enum ACLRowPrincipalType + { + + [System.Runtime.Serialization.EnumMember(Value = @"ACCOUNT")] + ACCOUNT = 0, + + [System.Runtime.Serialization.EnumMember(Value = @"CERTIFICATE")] + CERTIFICATE = 1, + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record Blocks + { + + [System.Text.Json.Serialization.JsonPropertyName("block")] + public Block Block { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class NodeApiException : System.Exception + { + public int StatusCode { get; private set; } + + public string Response { get; private set; } + + public System.Collections.Generic.IReadOnlyDictionary> Headers { get; private set; } + + public NodeApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Exception innerException) + : base(message + "\n\nStatus: " + statusCode + "\nResponse: \n" + ((response == null) ? "(null)" : response.Substring(0, response.Length >= 512 ? 512 : response.Length)), innerException) + { + StatusCode = statusCode; + Response = response; + Headers = headers; + } + + public override string ToString() + { + return string.Format("HTTP Response: \n\n{0}\n\n{1}", Response, base.ToString()); + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class NodeApiException : NodeApiException + { + public TResult Result { get; private set; } + + public NodeApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, TResult result, System.Exception innerException) + : base(message, statusCode, response, headers, innerException) + { + Result = result; + } + } + +} + +#pragma warning restore 108 +#pragma warning restore 114 +#pragma warning restore 472 +#pragma warning restore 612 +#pragma warning restore 649 +#pragma warning restore 1573 +#pragma warning restore 1591 +#pragma warning restore 8073 +#pragma warning restore 3016 +#pragma warning restore 8600 +#pragma warning restore 8602 +#pragma warning restore 8603 +#pragma warning restore 8604 +#pragma warning restore 8625 +#pragma warning restore 8765 \ No newline at end of file diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Crypto.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Crypto.cs index b03b24c..cb9202d 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Crypto.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Crypto.cs @@ -4,7 +4,7 @@ namespace KeetaNet.Anchor; /// -/// The offline crypto surface of the P1 core module: handle-based account, +/// The crypto surface of the P1 core module: handle-based account, /// base certificate, and KYC certificate objects. Every internal entry point /// dispatches onto the runtime's owner thread. /// @@ -112,6 +112,16 @@ internal bool CertificateValidAt(int handle, long unixMillis) => internal string CertificateSubjectPublicKey(int handle) => TextOf("keeta_certificate_subject_public_key", handle); + internal string CertificateHash(string hexDer) => + Run(() => + { + using var arguments = new ArgumentScope(this); + Argument der = arguments.Write(hexDer); + + int result = Invoke("keeta_certificate_hash", der.Pointer, der.Length); + return Text(result); + }); + internal void CertificateFree(int handle) => RunFree("keeta_certificate_free", handle); internal int KycCertificateParse(string pem) => ParseText("keeta_kyc_certificate_parse", pem); diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs index 71252ca..0baa4b6 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Surface.cs @@ -39,4 +39,13 @@ public KycClient CreateKycClient(string nodeUrl, string root, Account account) = /// public AssetMovementClient CreateAssetMovementClient(string nodeUrl, string root, Account account) => AssetMovementClient.WithAccount(this, nodeUrl, root, account); + + /// + /// Create a lite, read-only client for the node API at + /// . An injected + /// (for example from IHttpClientFactory) is borrowed, not disposed. + /// Absent one the client owns its own. + /// + public NodeClient CreateNodeClient(string nodeUrl, HttpClient? httpClient = null) => + new(this, nodeUrl, httpClient); } diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.cs index 697197d..601b0c2 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.cs @@ -14,7 +14,7 @@ namespace KeetaNet.Anchor; /// /// A runtime is thread-safe by construction: the thread-affine /// Wasmtime.Store lives on a dedicated dispatcher thread and every -/// keeta_* call is serialized onto it. Offline operations dispatch +/// keeta_* call is serialized onto it. Local operations dispatch /// synchronously while networked client operations are async and honor a /// before dispatch and during host HTTP and /// sleeps (the guest owns control flow between those points). @@ -47,7 +47,7 @@ public sealed partial class WasmRuntime : IDisposable private byte[] _pending = Array.Empty(); private CancellationToken _activeCancellation = CancellationToken.None; - // Debug-only leak accounting; see CountHandleAdopted/CountHandleReleased. + // Debug-only leak accounting. See CountHandleAdopted/CountHandleReleased. private int _outstandingHandles; private bool _disposed; @@ -169,10 +169,10 @@ private static byte[] EmbeddedCore() return payload.ToArray(); } - /// Run one offline operation on the dispatcher thread, blocking for its result. + /// Run one local operation on the dispatcher thread, blocking for its result. private TResult Run(Func work) => _dispatcher.Run(work); - /// Run one offline operation on the dispatcher thread without a result. + /// Run one local operation on the dispatcher thread without a result. private void Run(Action work) => _dispatcher.Run(work); /// @@ -232,9 +232,6 @@ internal Task KycGetCertificates(int handle, string providerJson, string internal Task KycGetVerificationStatus(int handle, string providerJson, string id, CancellationToken cancellationToken) => RunAsync(() => WithProviderAndArg("keeta_kyc_get_verification_status", handle, providerJson, id), cancellationToken); - internal Task KycGetAllCertificates(int handle, string account, CancellationToken cancellationToken) => - RunAsync(() => WithHandleAndText("keeta_kyc_get_all_certificates", handle, account), cancellationToken); - internal void KycFree(int handle) => RunFree("keeta_kyc_free", handle); /// Perform the buffered request and return the response byte length. @@ -377,7 +374,7 @@ private int HandleOf(string export, int handle) => /// /// Drive an export taking an object handle and one binary argument, yielding /// a bytes payload. Unlike , every caller is a - /// synchronous offline operation, so this dispatches itself. + /// synchronous local operation, so this dispatches itself. /// private byte[] WithHandleAndBytes(string export, int handle, byte[] value) => Run(() => @@ -623,7 +620,7 @@ private void WarnIfHandlesOutstanding() } } - /// Reject a call made after disposal; assert dispatcher-thread confinement. + /// Reject a call made after disposal. Assert dispatcher-thread confinement. private void EnsureUsable() { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/src/KeetaNet.Anchor/KeetaException.cs b/src/KeetaNet.Anchor/KeetaException.cs index 3f0986c..40b3304 100644 --- a/src/KeetaNet.Anchor/KeetaException.cs +++ b/src/KeetaNet.Anchor/KeetaException.cs @@ -1,8 +1,8 @@ namespace KeetaNet.Anchor; /// -/// A failure surfaced from the wasm core: a programmatic plus -/// a human-readable message. +/// A failure surfaced from the SDK (the wasm core or the node transport): a +/// programmatic plus a human-readable message. /// public sealed class KeetaException : Exception { @@ -15,6 +15,15 @@ public KeetaException(string code, string message) : base($"{code}: {message}") Code = code; } + /// + /// Build a failure from its stable and human-readable + /// , preserving the it wraps. + /// + public KeetaException(string code, string message, Exception cause) : base($"{code}: {message}", cause) + { + Code = code; + } + /// /// Parse a wasm code: message error string back into an exception. /// diff --git a/src/KeetaNet.Anchor/KeetaJson.cs b/src/KeetaNet.Anchor/KeetaJson.cs index 1393849..00f4666 100644 --- a/src/KeetaNet.Anchor/KeetaJson.cs +++ b/src/KeetaNet.Anchor/KeetaJson.cs @@ -13,7 +13,11 @@ internal static class KeetaJson { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), + new AssetMovementBlockerConverter(), + }, }; /// Deserialize a JSON array payload, mapping an absent body to an empty list. diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementBlockers.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementBlockers.cs new file mode 100644 index 0000000..af47287 --- /dev/null +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementBlockers.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace KeetaNet.Anchor; + +/// +/// A blocker an anchor reports that a user must resolve before an +/// asset-movement operation can proceed. The core rehydrates each anchor +/// error envelope into one of the typed shapes below. Anything it does not +/// recognize arrives unchanged as so no +/// information is lost. Decoded by the converter +/// registers, since only the core produces the shape. +/// +public abstract record AssetMovementBlocker; + +/// The user must share KYC attributes before proceeding. +/// +/// The provider-polymorphic members (, +/// ) cross as raw JSON, a null value marked +/// , so they round-trip unchanged. +/// +public sealed record AssetKycShareNeededBlocker( + JsonElement TosFlow, + IReadOnlyList? NeededAttributes, + IReadOnlyList ShareWithPrincipals, + JsonElement AcceptedIssuers) : AssetMovementBlocker; + +/// The user must complete additional KYC steps. +public sealed record AssetAdditionalKycNeededBlocker(JsonElement ToCompleteFlow) : AssetMovementBlocker; + +/// The requested operation is not supported for the given asset or rail. +public sealed record AssetOperationNotSupportedBlocker(JsonElement ForAsset, string? ForRail) : AssetMovementBlocker; + +/// The user must take one or more on-ledger actions. +public sealed record AssetUserActionNeededBlocker(IReadOnlyList ActionsNeeded) : AssetMovementBlocker; + +/// Any other anchor error, kept unchanged. +public sealed record AssetOtherBlocker(string Name, string? Code, string Message) : AssetMovementBlocker; + +/// +/// Decodes a blocker from the core's type-discriminated JSON. A manual +/// switch, not [JsonPolymorphic]: the core writes its keys in sorted +/// order, so the discriminator is not guaranteed to lead the object. +/// +internal sealed class AssetMovementBlockerConverter : JsonConverter +{ + /// + /// Match only the abstract base, so the derived-type deserialization the + /// decoder performs never re-enters this converter. + /// + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(AssetMovementBlocker); + + public override AssetMovementBlocker Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + JsonElement element = document.RootElement; + string? type = element.GetProperty("type").GetString(); + + return type switch + { + "kycShareNeeded" => Decode(element, options), + "additionalKycNeeded" => Decode(element, options), + "operationNotSupported" => Decode(element, options), + "userActionNeeded" => Decode(element, options), + "other" => Decode(element, options), + _ => throw new JsonException($"unknown asset-movement blocker type '{type}'"), + }; + } + + public override void Write(Utf8JsonWriter writer, AssetMovementBlocker value, JsonSerializerOptions options) => + throw new NotSupportedException("blockers are read from the core, never written"); + + private static AssetMovementBlocker Decode(JsonElement element, JsonSerializerOptions options) + where T : AssetMovementBlocker => + element.Deserialize(options) + ?? throw new JsonException($"could not decode a {typeof(T).Name}"); +} diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs index f3c797e..8027bc6 100644 --- a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs @@ -29,21 +29,27 @@ internal static AssetMovementClient WithAccount(WasmRuntime runtime, string node } /// Every advertised provider. - public async Task> GetProvidersAsync(CancellationToken cancellationToken = default) + public async Task> GetProviders(CancellationToken cancellationToken = default) { byte[] payload = await Runtime.AssetProviders(Handle, cancellationToken).ConfigureAwait(false); return KeetaJson.ReadList(payload); } /// The provider with , or null when none advertises it. - public async Task GetProviderByIdAsync(string id, CancellationToken cancellationToken = default) + public async Task GetProviderById(string id, CancellationToken cancellationToken = default) { byte[] payload = await Runtime.AssetProviderById(Handle, id, cancellationToken).ConfigureAwait(false); return ParseOptionalProvider(payload); } /// The provider signed by , or null when absent. - public async Task GetProviderByAccountAsync(string account, CancellationToken cancellationToken = default) + public async Task GetProviderByAccount( + Crypto.Account account, + CancellationToken cancellationToken = default) => + await GetProviderByAccount(account.Address, cancellationToken).ConfigureAwait(false); + + /// The provider signed by (a keeta_ public key string), or null when absent. + public async Task GetProviderByAccount(string account, CancellationToken cancellationToken = default) { byte[] payload = await Runtime.AssetProviderByAccount(Handle, account, cancellationToken).ConfigureAwait(false); return ParseOptionalProvider(payload); @@ -53,7 +59,7 @@ public async Task> GetProvidersAsync(CancellationTo /// Every provider whose advertised supportedAssets satisfies /// (asset, endpoints, and directional rails). /// - public async Task> GetProvidersForTransferAsync( + public async Task> GetProvidersForTransfer( AssetProviderSearch search, CancellationToken cancellationToken = default) { @@ -72,28 +78,59 @@ public async Task> GetProvidersForTransferAsync( /// public bool IsOperationSupported(AssetProvider provider, string operation) => provider.Operations.ContainsKey(operation); - /// The provider's advertised legal disclaimers, or null when none. - public JsonElement? GetLegalDisclaimers(AssetProvider provider) => provider.Legal; + /// + /// The provider's advertised legal disclaimers, or null when its metadata + /// carries none. Malformed entries are skipped, mirroring the reference + /// getLegalDisclaimers. + /// + 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 legal disclaimers advertised by the provider with /// , or null when the provider or its disclaimers are /// absent. /// - public async Task GetProviderLegalDisclaimersByIdAsync( + public async Task?> GetProviderLegalDisclaimersById( string id, CancellationToken cancellationToken = default) { - AssetProvider? provider = await GetProviderByIdAsync(id, cancellationToken).ConfigureAwait(false); - return provider?.Legal; + AssetProvider? provider = await GetProviderById(id, cancellationToken).ConfigureAwait(false); + if (provider is null) + { + return null; + } + + return GetLegalDisclaimers(provider); } /// /// The provider's display metadata for (an external /// chain asset id) at (a canonical location - /// string), or null when the provider advertises none. + /// string), or null when the provider advertises none or the entry does not + /// parse. /// - public JsonElement? GetAssetMetadataForLocation(AssetProvider provider, string location, string asset) + public AssetTokenMetadata? GetAssetMetadataForLocation(AssetProvider provider, string location, string asset) { if (provider.LocationMetadata is not { } metadata || metadata.ValueKind != JsonValueKind.Object) { @@ -108,16 +145,33 @@ public async Task> GetProvidersForTransferAsync( return null; } - if (!assets.TryGetProperty(asset, out JsonElement found)) + if (!assets.TryGetProperty(asset, out JsonElement found) + || !TryDeserialize(found, out AssetTokenMetadata? parsed)) { return null; } - return found; + 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 SimulateTransferAsync( + public async Task SimulateTransfer( AssetProvider provider, AssetTransferRequest request, CancellationToken cancellationToken = default) @@ -127,7 +181,7 @@ public async Task SimulateTransferAsync( } /// Initiate a transfer, returning a fluent handle. The request's recipient is required. - public async Task InitiateTransferAsync( + public async Task InitiateTransfer( AssetProvider provider, AssetTransferRequest request, CancellationToken cancellationToken = default) @@ -137,21 +191,21 @@ public async Task InitiateTransferAsync( } /// Execute a pull instruction for a transfer. - public Task ExecuteTransferAsync( + public Task ExecuteTransfer( AssetProvider provider, AssetExecuteRequest request, CancellationToken cancellationToken = default) => ReadOperationAsync(Runtime.AssetExecuteTransfer, provider, request, cancellationToken); /// Read the status of transfer . - public Task GetTransferStatusAsync( + public Task GetTransferStatus( AssetProvider provider, string id, CancellationToken cancellationToken = default) => ReadOperationForIdAsync(Runtime.AssetTransferStatus, provider, id, cancellationToken); /// Read whether the signer's account is ready to use this provider. - public async Task GetAccountStatusAsync( + public async Task GetAccountStatus( AssetProvider provider, CancellationToken cancellationToken = default) { @@ -162,67 +216,67 @@ public async Task GetAccountStatusAsync( } /// Open a persistent-forwarding template session. - public Task InitiatePersistentForwardingTemplateAsync( + public Task InitiatePersistentForwardingTemplate( AssetProvider provider, AssetInitiateTemplateRequest request, CancellationToken cancellationToken = default) => ReadOperationAsync(Runtime.AssetInitiatePersistentForwardingTemplate, provider, request, cancellationToken); /// Create a persistent-forwarding template. - public Task CreatePersistentForwardingTemplateAsync( + public Task CreatePersistentForwardingTemplate( AssetProvider provider, AssetCreateTemplateRequest request, CancellationToken cancellationToken = default) => ReadOperationAsync(Runtime.AssetCreatePersistentForwardingTemplate, provider, request, cancellationToken); /// List persistent-forwarding templates. - public Task ListForwardingAddressTemplatesAsync( + public Task ListForwardingAddressTemplates( AssetProvider provider, AssetListTemplatesRequest request, CancellationToken cancellationToken = default) => ReadOperationAsync(Runtime.AssetListForwardingAddressTemplates, provider, request, cancellationToken); /// Create a persistent-forwarding address, returning its (obfuscated) details. - public Task CreatePersistentForwardingAddressAsync( + public Task CreatePersistentForwardingAddress( AssetProvider provider, AssetCreateAddressRequest request, CancellationToken cancellationToken = default) => ReadOperationAsync(Runtime.AssetCreatePersistentForwardingAddress, provider, request, cancellationToken); /// List persistent-forwarding addresses. - public Task ListForwardingAddressesAsync( + public Task ListForwardingAddresses( AssetProvider provider, AssetListAddressesRequest request, CancellationToken cancellationToken = default) => ReadOperationAsync(Runtime.AssetListForwardingAddresses, provider, request, cancellationToken); /// Deactivate a persistent-forwarding template by id. - public Task DeactivatePersistentForwardingTemplateAsync( + public Task DeactivatePersistentForwardingTemplate( AssetProvider provider, string id, CancellationToken cancellationToken = default) => RunOperationForIdAsync(Runtime.AssetDeactivatePersistentForwardingTemplate, provider, id, cancellationToken); /// Deactivate a persistent-forwarding address by id. - public Task DeactivatePersistentForwardingAddressAsync( + public Task DeactivatePersistentForwardingAddress( AssetProvider provider, string id, CancellationToken cancellationToken = default) => RunOperationForIdAsync(Runtime.AssetDeactivatePersistentForwardingAddress, provider, id, cancellationToken); /// List asset-movement transactions. - public Task ListTransactionsAsync( + public Task ListTransactions( AssetProvider provider, AssetListTransactionsRequest request, CancellationToken cancellationToken = default) => ReadOperationAsync(Runtime.AssetListTransactions, provider, request, cancellationToken); /// - /// Share KYC attributes with the provider, returning the outcome verbatim. - /// A pending outcome carries the promise URL the caller must poll; use - /// to poll it automatically. + /// 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 ShareKycAttributesAsync( + public Task ShareKycAttributes( AssetProvider provider, AssetShareKycRequest request, CancellationToken cancellationToken = default) => @@ -232,7 +286,7 @@ public Task ShareKycAttributesAsync( /// 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 ShareKycAttributesAndWaitAsync( + public async Task ShareKycAttributesAndWait( AssetProvider provider, AssetShareKycRequest request, TimeSpan? pollInterval = null, diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs index 789c4d9..d252ef8 100644 --- a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -47,12 +48,9 @@ public sealed record AssetTransferSource(string Location, object? Source = null) /// public sealed record AssetTransferDestination(string Location, object? Recipient = null, string? DepositMessage = null); -/// -/// A request to simulate or initiate a transfer. is a -/// canonical asset string or a { from, to } pair. -/// +/// A request to simulate or initiate a transfer. public sealed record AssetTransferRequest( - object Asset, + AssetOrPair Asset, AssetTransferSource From, AssetTransferDestination To, string Value, @@ -69,7 +67,7 @@ public sealed record AssetPullInstruction(string Type, object PullFrom); public sealed record AssetExecuteRequest(string Id, AssetPullInstruction Instruction); /// A request to open a persistent-forwarding template session. -public sealed record AssetInitiateTemplateRequest(object Asset, string Location); +public sealed record AssetInitiateTemplateRequest(AssetOrPair Asset, string Location); /// /// A request to create a persistent-forwarding template: either a direct @@ -77,7 +75,7 @@ public sealed record AssetInitiateTemplateRequest(object Asset, string Location) /// ) or the completion of a session (). /// public sealed record AssetCreateTemplateRequest( - object? Asset = null, + AssetOrPair? Asset = null, string? Location = null, object? Address = null, string? Id = null, @@ -91,7 +89,7 @@ public sealed record AssetListTemplatesRequest( /// A request to create a persistent-forwarding address. public sealed record AssetCreateAddressRequest( string SourceLocation, - object Asset, + AssetOrPair Asset, string? OutgoingRail = null, string? IncomingRail = null, string? DestinationLocation = null, @@ -135,12 +133,11 @@ public sealed record AssetListTransactionsRequest( public sealed record AssetShareKycRequest(string Attributes, object? TosAgreement = null); /// -/// A transfer search: an optional asset (a canonical string or a -/// { from, to } pair), the endpoints value must move between, and the -/// directional rails each endpoint must advertise. +/// A transfer search: an optional asset, the endpoints value must move +/// between, and the directional rails each endpoint must advertise. /// public sealed record AssetProviderSearch( - object? Asset = null, + AssetOrPair? Asset = null, string? From = null, string? To = null, IReadOnlyList? InboundRails = null, @@ -177,9 +174,71 @@ public sealed record AssetShareKycOutcome( /// /// The signer's readiness with a provider: and, when -/// set, the polymorphic the caller must resolve first. +/// set, the typed the caller must resolve first. +/// +public sealed record AssetAccountStatus(bool ActionRequired, IReadOnlyList? Blockers = null); + +/// Why a provider publishes a disclaimer; the reference schema defines only general. +public enum AssetDisclaimerPurpose +{ + /// A general disclaimer. + General, +} + +/// How a disclaimer body is encoded. +public enum AssetContentType +{ + /// Markdown the client may render. + Markdown, + /// Plain text the client shows verbatim. + Plaintext, +} + +/// +/// Content a client may render directly (the reference +/// ClientRenderableContent): markdown or plain text with no display +/// guarantees, so it must carry context only, never critical information. +/// +public sealed record AssetRenderableContent(AssetContentType Type, string Content); + +/// One legal disclaimer a provider publishes under its legal metadata. +public sealed record AssetDisclaimer(AssetDisclaimerPurpose Purpose, AssetRenderableContent Content); + +/// +/// The token metadata a provider advertises for one asset at one location +/// (the reference AnchorTokenLocationMetadata). /// -public sealed record AssetAccountStatus(bool ActionRequired, IReadOnlyList? Blockers = null); +public sealed record AssetTokenMetadata( + [property: JsonConverter(typeof(FlexibleUIntConverter))] uint DecimalPlaces, + [property: JsonPropertyName("logoURI")] string? LogoUri = null, + string? DisplayName = null, + string? Ticker = null); + +/// +/// Reads a count published as a JSON number or a numeric string; the +/// reference TokenMetadataJSON allows both for decimalPlaces. +/// +internal sealed class FlexibleUIntConverter : JsonConverter +{ + public override uint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + return reader.GetUInt32(); + } + + string text = reader.GetString() ?? ""; + if (!uint.TryParse(text.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out uint parsed)) + { + throw new JsonException($"'{text}' is not a numeric count"); + } + + return parsed; + } + + public override void Write(Utf8JsonWriter writer, uint value, JsonSerializerOptions options) => + writer.WriteNumberValue(value); +} /// /// Typed access to the polymorphic asset-movement address surface diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetOrPair.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetOrPair.cs new file mode 100644 index 0000000..1b83e6e --- /dev/null +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetOrPair.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace KeetaNet.Anchor; + +/// +/// A single movable asset or a { from, to } conversion pair, each named +/// by its canonical string: an ISO currency code, a $-prefixed custom +/// currency, a Keeta token public key, or an external-chain asset. +/// +[JsonConverter(typeof(AssetOrPairConverter))] +public sealed record AssetOrPair +{ + private AssetOrPair(string? asset, string? from, string? to) + { + Asset = asset; + From = from; + To = to; + } + + /// The canonical asset when this names one asset, null for a pair. + public string? Asset { get; } + + /// The source asset when this is a conversion pair, null for a single asset. + public string? From { get; } + + /// The destination asset when this is a conversion pair, null for a single asset. + public string? To { get; } + + /// One asset, moved from and to the same denomination. + public static implicit operator AssetOrPair(string asset) => new(asset, null, null); + + /// A conversion pair: is exchanged into . + public static AssetOrPair Pair(string from, string to) => new(null, from, to); +} + +/// +/// Writes the canonical transport form (a bare string or { from, to }) +/// and reads either back. +/// +internal sealed class AssetOrPairConverter : JsonConverter +{ + public override AssetOrPair Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return reader.GetString() ?? ""; + } + + using var document = JsonDocument.ParseValue(ref reader); + JsonElement element = document.RootElement; + string? from = element.GetProperty("from").GetString(); + string? to = element.GetProperty("to").GetString(); + if (from is null || to is null) + { + throw new JsonException("an asset pair requires string 'from' and 'to' members"); + } + + return AssetOrPair.Pair(from, to); + } + + public override void Write(Utf8JsonWriter writer, AssetOrPair value, JsonSerializerOptions options) + { + if (value.Asset is { } asset) + { + writer.WriteStringValue(asset); + return; + } + + writer.WriteStartObject(); + writer.WriteString("from", value.From); + writer.WriteString("to", value.To); + writer.WriteEndObject(); + } +} diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetTransfer.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetTransfer.cs index 6a31dc0..18e8c2d 100644 --- a/src/KeetaNet.Anchor/Services/AssetMovement/AssetTransfer.cs +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetTransfer.cs @@ -30,10 +30,10 @@ internal AssetSimulatedTransfer( /// /// Initiate the simulated transfer with the same provider and request. - /// A simulation may omit the recipient; supply + /// A simulation may omit the recipient. Supply /// here to complete the destination before initiating. /// - public Task CreateTransferAsync( + public Task CreateTransfer( object? recipient = null, string? depositMessage = null, CancellationToken cancellationToken = default) @@ -45,7 +45,7 @@ public Task CreateTransferAsync( }; AssetTransferRequest request = _request with { To = to }; - return _client.InitiateTransferAsync(_provider, request, cancellationToken); + return _client.InitiateTransfer(_provider, request, cancellationToken); } } @@ -78,15 +78,15 @@ internal AssetTransfer( public IReadOnlyList InstructionChoices { get; } /// Read this transfer's current status. - public Task GetTransferStatusAsync(CancellationToken cancellationToken = default) => - _client.GetTransferStatusAsync(_provider, Id, cancellationToken); + public Task GetTransferStatus(CancellationToken cancellationToken = default) => + _client.GetTransferStatus(_provider, Id, cancellationToken); /// Execute a fiat pull for this transfer. - public Task ExecuteTransferAsync( + public Task ExecuteTransfer( AssetPullInstruction instruction, CancellationToken cancellationToken = default) { var request = new AssetExecuteRequest(Id, instruction); - return _client.ExecuteTransferAsync(_provider, request, cancellationToken); + return _client.ExecuteTransfer(_provider, request, cancellationToken); } } diff --git a/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs b/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs index dd21386..27e21d3 100644 --- a/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs +++ b/src/KeetaNet.Anchor/Services/Kyc/KycClient.cs @@ -29,7 +29,7 @@ internal static KycClient WithAccount(WasmRuntime runtime, string nodeUrl, strin } /// Every provider that serves all (ISO codes). - public async Task> GetProvidersAsync( + public async Task> GetProviders( IEnumerable countries, CancellationToken cancellationToken = default) { @@ -39,12 +39,22 @@ public async Task> GetProvidersAsync( return KeetaJson.ReadList(payload); } + /// + /// The countries any provider can validate, folded across every root + /// (the reference getSupportedCountries). + /// + public async Task GetSupportedCountries(CancellationToken cancellationToken = default) + { + IReadOnlyList providers = await GetProviders(Array.Empty(), cancellationToken).ConfigureAwait(false); + return SupportedCountries.FromProviders(providers); + } + /// /// Start a verification with for /// , optionally redirecting the user to /// when the flow ends. /// - public async Task StartVerificationAsync( + public async Task StartVerification( KycProvider provider, IEnumerable countries, string? redirect = null, @@ -61,7 +71,7 @@ public async Task StartVerificationAsync( } /// Fetch the certificates issued for verification . - public async Task GetCertificatesAsync( + public async Task GetCertificates( KycProvider provider, string id, CancellationToken cancellationToken = default) @@ -74,36 +84,13 @@ public async Task GetCertificatesAsync( return ParseOutcome(payload, "certificates", ready => new CertificatesOutcome(ready, null), retry => new CertificatesOutcome(null, retry)); } - /// - /// Every certificate (a keeta_ public key - /// string) has published on-chain, each with the intermediates recorded - /// alongside it. An account with no published certificates yields an empty list. - /// - public async Task> GetAllCertificatesAsync( - string account, - CancellationToken cancellationToken = default) - { - byte[] payload = await Runtime.KycGetAllCertificates(Handle, account, cancellationToken).ConfigureAwait(false); - return KeetaJson.ReadList(payload); - } - - /// - /// Every certificate has published on-chain, each - /// with the intermediates recorded alongside it. An account with no published - /// certificates yields an empty list. - /// - public Task> GetAllCertificatesAsync( - Crypto.Account account, - CancellationToken cancellationToken = default) => - GetAllCertificatesAsync(account.Address, cancellationToken); - /// Parse 's advertised issuer CA certificate. /// Use it as a trusted root when verifying an issued . public Crypto.Certificate GetCA(KycProvider provider) => Runtime.Certificates.Parse(provider.Ca); /// Read the status of verification . - public async Task GetVerificationStatusAsync( + public async Task GetVerificationStatus( KycProvider provider, string id, CancellationToken cancellationToken = default) diff --git a/src/KeetaNet.Anchor/Services/Kyc/KycModels.cs b/src/KeetaNet.Anchor/Services/Kyc/KycModels.cs index 2d5a266..645a787 100644 --- a/src/KeetaNet.Anchor/Services/Kyc/KycModels.cs +++ b/src/KeetaNet.Anchor/Services/Kyc/KycModels.cs @@ -18,6 +18,37 @@ public sealed record KycProvider( KycOperations Operations, IReadOnlyList? CountryCodes); +/// +/// The countries KYC providers can validate, aggregated across every root +/// (the reference getSupportedCountries): when +/// any provider publishes no country list, otherwise the sorted, deduplicated +/// union of the published codes. +/// +public sealed record SupportedCountries(bool Worldwide, IReadOnlyList Countries) +{ + /// Fold discovered into their aggregate coverage. + public static SupportedCountries FromProviders(IEnumerable providers) + { + var countries = new List(); + foreach (KycProvider provider in providers) + { + if (provider.CountryCodes is null) + { + return new SupportedCountries(true, Array.Empty()); + } + + countries.AddRange(provider.CountryCodes); + } + + string[] union = countries + .Distinct(StringComparer.Ordinal) + .OrderBy(code => code, StringComparer.Ordinal) + .ToArray(); + + return new SupportedCountries(false, union); + } +} + /// /// The cost a provider expects to charge for a verification: a /// and the / bounds, decimal strings in that token's units. diff --git a/src/KeetaNet.Anchor/Services/Node/NodeClient.cs b/src/KeetaNet.Anchor/Services/Node/NodeClient.cs new file mode 100644 index 0000000..16d72da --- /dev/null +++ b/src/KeetaNet.Anchor/Services/Node/NodeClient.cs @@ -0,0 +1,284 @@ +using System.Globalization; +using System.Numerics; + +using KeetaNet.Anchor.Generated.Node; + +using GeneratedCertificate = KeetaNet.Anchor.Generated.Node.Certificate; + +namespace KeetaNet.Anchor; + +/// +/// A lite, read-only client for the KeetaNet node API: the ledger reads the +/// reference node client performs, over the transport generated from the +/// canonical OpenAPI spec. +/// +public sealed class NodeClient : IDisposable +{ + private readonly WasmRuntime _runtime; + + /// The client-owned transport. Null when an injected one is borrowed. + private readonly HttpClient? _ownedHttp; + + private readonly NodeApi _api; + + /// + /// A client for the node API at . An injected + /// (for example from IHttpClientFactory) is + /// borrowed, not disposed. + /// + internal NodeClient(WasmRuntime runtime, string nodeUrl, HttpClient? http = null) + { + _runtime = runtime; + if (http is null) + { + _ownedHttp = new HttpClient(); + http = _ownedHttp; + } + + _api = new NodeApi(http) { BaseUrl = nodeUrl }; + } + + /// The node software version string. + public async Task GetNodeVersion(CancellationToken cancellationToken = default) + { + Response4 response = await Attempt(() => _api.GetNodeVersionAsync(cancellationToken)).ConfigureAwait(false); + return response.Node ?? ""; + } + + /// + /// The ledger state of : head block, delegated + /// representative, published info, and token balances. + /// + public async Task GetAccountState( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + Response5 state = await Attempt(() => _api.GetAccountStateAsync(account.Address, cancellationToken)).ConfigureAwait(false); + + NodeAccountInfo? info = null; + if (state.Info is not null) + { + info = new NodeAccountInfo( + state.Info.Name, + state.Info.Description, + state.Info.Metadata, + OptionalHexAmount(state.Info.Supply)); + } + + Crypto.Account? representative = null; + if (state.Representative is not null) + { + representative = _runtime.Accounts.FromAccount(state.Representative); + } + + Crypto.BlockHash? headBlock = null; + if (state.CurrentHeadBlock is not null) + { + headBlock = Crypto.BlockHash.Parse(state.CurrentHeadBlock); + } + + return new AccountState( + headBlock, + OptionalHexAmount(state.CurrentHeadBlockHeight), + representative, + info, + DecodeBalances(state.Balances)); + } + + /// Every token balance holds. + public async Task> GetAccountBalances( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + Response6 response = await Attempt(() => _api.GetAccountBalancesAsync(account.Address, cancellationToken)).ConfigureAwait(false); + return DecodeBalances(response.Balances); + } + + /// The settled balance of in base units. + public async Task GetAccountBalance( + Crypto.Account account, + Crypto.Account token, + CancellationToken cancellationToken = default) + { + Response7 response = await Attempt(() => _api.GetAccountBalanceAsync(account.Address, token.Address, cancellationToken)).ConfigureAwait(false); + return OptionalHexAmount(response.Balance) ?? BigInteger.Zero; + } + + /// + /// Every certificate has published on-chain, each + /// with the intermediates recorded alongside it. An account with no published + /// certificates yields an empty list. + /// + public async Task> GetAllCertificates( + Crypto.Account account, + CancellationToken cancellationToken = default) + { + Response19 response = await Attempt(() => _api.GetAccountCertificatesAsync(account.Address, cancellationToken)).ConfigureAwait(false); + ICollection records = response.Certificates ?? Array.Empty(); + + // A record with no certificate body is the node's "not found" shape. + // Drop it rather than surface an empty entry, as the reference does. + return records + .Where(record => record.Certificate1 is not null) + .Select(DecodeCertificate) + .ToArray(); + } + + /// + /// The certificate published under + /// (its ), + /// with its recorded intermediates. Null when the account never published it. + /// + public async Task GetCertificateByHash( + Crypto.Account account, + Crypto.CertificateHash certificateHash, + CancellationToken cancellationToken = default) + { + Response20 record = await Attempt(() => _api.GetCertificateByHashAsync(account.Address, certificateHash.ToString(), cancellationToken)).ConfigureAwait(false); + if (record.Certificate1 is null) + { + return null; + } + + return DecodeCertificate(record); + } + + /// + /// Read 's published certificates and evaluate + /// them against at , + /// the port of the reference verifyAccountCertificateChain. The + /// issuers are the only trust anchors. A record's own intermediates just + /// help bridge the chain. + /// + public async Task VerifyAccountCertificateChain( + Crypto.Account account, + IReadOnlyList trustedIssuers, + DateTimeOffset moment, + CancellationToken cancellationToken = default) + { + IReadOnlyList records = await GetAllCertificates(account, cancellationToken).ConfigureAwait(false); + return EvaluateCertificateChain(records, trustedIssuers, moment); + } + + /// + /// Evaluate already-fetched against + /// at . + /// A record that does not parse is skipped, never trusted. Skipped records + /// still count as published, so an account whose every record is malformed + /// reports , not + /// . + /// + public CertificateChainStatus EvaluateCertificateChain( + IReadOnlyList records, + IReadOnlyList trustedIssuers, + DateTimeOffset moment) + { + if (records.Count == 0) + { + return CertificateChainStatus.NoCerts; + } + + if (records.Any(record => RecordChainsToRoot(record, trustedIssuers, moment))) + { + return CertificateChainStatus.Trusted; + } + + return CertificateChainStatus.Untrusted; + } + + /// Release the HTTP resources the client owns. An injected is left alone. + public void Dispose() => _ownedHttp?.Dispose(); + + /// + /// Whether one published record chains to a trusted issuer at the moment. + /// A malformed certificate or intermediate makes the record fail closed. + /// + private bool RecordChainsToRoot( + Certificate record, + IReadOnlyList trustedIssuers, + DateTimeOffset moment) + { + try + { + using Crypto.KycCertificate leaf = _runtime.KycCertificates.Parse(record.Value); + + var intermediates = new List(record.Intermediates.Count); + try + { + foreach (string pem in record.Intermediates) + { + intermediates.Add(_runtime.Certificates.Parse(pem)); + } + + return leaf.Verify(trustedIssuers, intermediates, moment); + } + finally + { + foreach (Crypto.Certificate intermediate in intermediates) + { + intermediate.Dispose(); + } + } + } + catch (KeetaException) + { + return false; + } + } + + /// + /// Run one generated transport call, projecting its failure to a + /// with the stable NODE_STATUS code. + /// + private static async Task Attempt(Func> operation) + { + try + { + return await operation().ConfigureAwait(false); + } + catch (NodeApiException error) + { + throw new KeetaException("NODE_STATUS", $"node request failed with status {error.StatusCode}", error); + } + } + + /// Map a generated certificate record to the SDK's shared record. + private static Certificate DecodeCertificate(GeneratedCertificate record) => + new(record.Certificate1, record.Intermediates?.ToArray() ?? Array.Empty()); + + /// Map generated balance entries, treating absent amounts as zero. + private TokenBalance[] DecodeBalances(ICollection? balances) + { + if (balances is null) + { + return Array.Empty(); + } + + return balances + .Where(entry => entry.Token is not null) + .Select(entry => new TokenBalance( + _runtime.Accounts.FromAccount(entry.Token), + OptionalHexAmount(entry.Balance) ?? BigInteger.Zero, + OptionalHexAmount(entry.Pending) ?? BigInteger.Zero)) + .ToArray(); + } + + /// Parse a node amount (a 0x-prefixed hexadecimal BigInt), null when absent. + private static BigInteger? OptionalHexAmount(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + + string hex = value; + if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + hex = hex[2..]; + } + + // A leading zero keeps the parse unsigned: without it a high first + // nibble would flip BigInteger's two's-complement sign. + return BigInteger.Parse("0" + hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } +} diff --git a/src/KeetaNet.Anchor/Services/Node/NodeModels.cs b/src/KeetaNet.Anchor/Services/Node/NodeModels.cs new file mode 100644 index 0000000..9ad3640 --- /dev/null +++ b/src/KeetaNet.Anchor/Services/Node/NodeModels.cs @@ -0,0 +1,39 @@ +using System.Numerics; + +using KeetaNet.Anchor.Crypto; + +namespace KeetaNet.Anchor; + +/// +/// Outcome of evaluating an account's published certificates against a trust set. +/// +public enum CertificateChainStatus +{ + /// At least one published record chains to a trusted issuer. + Trusted, + + /// The account published no certificates. + NoCerts, + + /// Certificates exist but none chain to a trusted issuer. + Untrusted, +} + +/// An account's balance in one token, in that token's base units. +/// is the not-yet-settled amount, zero when none. +public sealed record TokenBalance(Account Token, BigInteger Balance, BigInteger Pending); + +/// On-chain account info. is present only for token accounts. +public sealed record NodeAccountInfo(string? Name, string? Description, string? Metadata, BigInteger? Supply); + +/// +/// The ledger state of an account: its head block hash and height, delegated +/// representative, published , and token balances. +/// A never-used account reads back with null head and empty balances. +/// +public sealed record AccountState( + BlockHash? HeadBlock, + BigInteger? HeadHeight, + Account? Representative, + NodeAccountInfo? Info, + IReadOnlyList Balances); diff --git a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs index f6cd1f3..fc511c0 100644 --- a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs +++ b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs @@ -6,9 +6,9 @@ namespace KeetaNet.Anchor.E2eTests; /// /// A live KYC anchor HTTP server started by the harness, with its service /// metadata published on-chain to and readable through the -/// node API at . +/// reference node's API at . /// -internal sealed record KycAnchor(string Api, string Root, string Ca, string ProviderId) +internal sealed record KycAnchor(string NodeApi, string Root, string Ca, string ProviderId) { /// Start a signed KYC anchor advertising the US country code. public static KycAnchor Start(NodeHarness harness) @@ -30,11 +30,12 @@ public static KycAnchor Start(NodeHarness harness) /// /// A certificate chain the harness published on-chain for a fresh holder -/// : recorded with as -/// its intermediate bundle, and recorded without -/// intermediates, so a ledger read serves both shapes. +/// : (addressable by ) +/// recorded with as its intermediate bundle, and +/// recorded without intermediates, so a ledger read serves +/// both shapes. /// -internal sealed record PublishedChain(string Account, string Leaf, string Bare, string Ca) +internal sealed record PublishedChain(string Account, string Leaf, string LeafHash, string Bare, string Ca) { /// Publish the two-record chain on the running anchor's node. public static PublishedChain Publish(NodeHarness harness) @@ -44,6 +45,7 @@ public static PublishedChain Publish(NodeHarness harness) return new PublishedChain( published.GetProperty("account").GetString()!, published.GetProperty("leaf").GetString()!, + published.GetProperty("leafHash").GetString()!, published.GetProperty("bare").GetString()!, published.GetProperty("ca").GetString()!); } @@ -54,7 +56,7 @@ public static PublishedChain Publish(NodeHarness harness) /// the fixture values its callbacks report back. /// internal sealed record AssetAnchor( - string Api, + string NodeApi, string Root, string ProviderId, string Signer, diff --git a/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs index 53debd4..7acbf26 100644 --- a/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs @@ -21,20 +21,20 @@ public async Task DiscoveryReadsThePublishedProvider() using var session = AssetSession.Open(); (AssetMovementClient client, AssetAnchor anchor, CancellationToken cancellationToken) = session; - IReadOnlyList providers = await client.GetProvidersAsync(cancellationToken); + IReadOnlyList providers = await client.GetProviders(cancellationToken); AssetProvider provider = Assert.Single(providers); Assert.Equal(anchor.ProviderId, provider.Id); Assert.True(client.IsOperationSupported(provider, "simulateTransfer")); - AssetProvider? byAccount = await client.GetProviderByAccountAsync(anchor.Signer, cancellationToken); + AssetProvider? byAccount = await client.GetProviderByAccount(anchor.Signer, cancellationToken); Assert.NotNull(byAccount); var advertised = new AssetProviderSearch(anchor.Asset, EvmLocation, KeetaLocation); - IReadOnlyList matches = await client.GetProvidersForTransferAsync(advertised, cancellationToken); + IReadOnlyList matches = await client.GetProvidersForTransfer(advertised, cancellationToken); Assert.Single(matches); var unadvertised = new AssetProviderSearch("evm:0xdeadbeef"); - IReadOnlyList none = await client.GetProvidersForTransferAsync(unadvertised, cancellationToken); + IReadOnlyList none = await client.GetProvidersForTransfer(unadvertised, cancellationToken); Assert.Empty(none); session.Shutdown(); @@ -47,32 +47,32 @@ public async Task TransfersRunEndToEndAgainstTheLiveAnchor() (AssetMovementClient client, AssetAnchor anchor, CancellationToken cancellationToken) = session; AssetProvider provider = await session.DiscoveredProviderAsync(); - AssetAccountStatus status = await client.GetAccountStatusAsync(provider, cancellationToken); + AssetAccountStatus status = await client.GetAccountStatus(provider, cancellationToken); Assert.False(status.ActionRequired); - AssetSimulatedTransfer simulated = await client.SimulateTransferAsync(provider, PushTransfer(anchor, anchor.SendToAddress), cancellationToken); + AssetSimulatedTransfer simulated = await client.SimulateTransfer(provider, PushTransfer(anchor, anchor.SendToAddress), cancellationToken); JsonElement simulatedInstruction = Assert.Single(simulated.InstructionChoices); Assert.Equal("KEETA_SEND", simulatedInstruction.GetProperty("type").GetString()); - AssetTransfer transfer = await client.InitiateTransferAsync(provider, PushTransfer(anchor, anchor.SendToAddress), cancellationToken); + AssetTransfer transfer = await client.InitiateTransfer(provider, PushTransfer(anchor, anchor.SendToAddress), cancellationToken); Assert.Equal("123", transfer.Id); Assert.Equal( anchor.SendToAddress, transfer.InstructionChoices[0].GetProperty("sendToAddress").GetString()); await Assert.ThrowsAsync( - () => client.InitiateTransferAsync(provider, PushTransfer(anchor, recipient: null), cancellationToken)); + () => client.InitiateTransfer(provider, PushTransfer(anchor, recipient: null), cancellationToken)); - AssetTransferStatus completed = await transfer.GetTransferStatusAsync(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.InitiateTransferAsync(provider, PullTransfer(anchor), cancellationToken); + AssetTransfer pull = await client.InitiateTransfer(provider, PullTransfer(anchor), cancellationToken); JsonElement pullInstruction = Assert.Single(pull.InstructionChoices); Assert.Equal("ACH_DEBIT", pullInstruction.GetProperty("type").GetString()); var instruction = new AssetPullInstruction("ACH_DEBIT", pullInstruction.GetProperty("pullFrom")); - AssetTransferStatus executed = await pull.ExecuteTransferAsync(instruction, cancellationToken); + AssetTransferStatus executed = await pull.ExecuteTransfer(instruction, cancellationToken); Assert.Equal("EXECUTED", executed.Transaction.GetProperty("status").GetString()); session.Shutdown(); @@ -85,12 +85,12 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor() (AssetMovementClient client, AssetAnchor anchor, CancellationToken cancellationToken) = session; AssetProvider provider = await session.DiscoveredProviderAsync(); - AssetTemplateSession templateSession = await client.InitiatePersistentForwardingTemplateAsync( + AssetTemplateSession templateSession = await client.InitiatePersistentForwardingTemplate( provider, 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.CreatePersistentForwardingTemplateAsync( + AssetForwardingTemplate template = await client.CreatePersistentForwardingTemplate( provider, new AssetCreateTemplateRequest(Asset: anchor.Asset, Location: EvmLocation, Address: anchor.SendToAddress), cancellationToken); @@ -102,18 +102,18 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor() plaidPublicToken = "public-sandbox-token", plaidAccountId = "account-1", }; - AssetForwardingTemplate completed = await client.CreatePersistentForwardingTemplateAsync( + AssetForwardingTemplate completed = await client.CreatePersistentForwardingTemplate( provider, new AssetCreateTemplateRequest(Id: templateSession.Id, Data: completionData), cancellationToken); Assert.Equal("template-id", completed.Id); - AssetTemplatePage templates = await client.ListForwardingAddressTemplatesAsync( + AssetTemplatePage templates = await client.ListForwardingAddressTemplates( provider, new AssetListTemplatesRequest(new[] { anchor.Asset }, new[] { EvmLocation }), cancellationToken); Assert.Single(templates.Templates); Assert.Equal("1", templates.Total); - JsonElement created = await client.CreatePersistentForwardingAddressAsync( + JsonElement created = await client.CreatePersistentForwardingAddress( provider, new AssetCreateAddressRequest( EvmLocation, @@ -125,13 +125,13 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor() Assert.Equal(anchor.SendToAddress, created.GetProperty("address").GetString()); Assert.Equal("10", created.GetProperty("fees").GetProperty("total").GetString()); - JsonElement fromTemplate = await client.CreatePersistentForwardingAddressAsync( + JsonElement fromTemplate = await client.CreatePersistentForwardingAddress( provider, new AssetCreateAddressRequest(EvmLocation, anchor.Asset, PersistentAddressTemplateId: template.Id), cancellationToken); Assert.Equal(anchor.SendToAddress, fromTemplate.GetProperty("address").GetString()); - AssetAddressPage addresses = await client.ListForwardingAddressesAsync( + AssetAddressPage addresses = await client.ListForwardingAddresses( provider, new AssetListAddressesRequest( new[] { new AssetAddressFilter(SourceLocation: EvmLocation, Asset: anchor.Asset) }, @@ -140,7 +140,7 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor() Assert.Single(addresses.Addresses); Assert.Equal("1", addresses.Total); - AssetTransactionPage transactions = await client.ListTransactionsAsync( + AssetTransactionPage transactions = await client.ListTransactions( provider, new AssetListTransactionsRequest( new[] { new AssetPersistentAddressFilter(EvmLocation, anchor.SendToAddress) }, @@ -150,11 +150,11 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor() JsonElement transaction = Assert.Single(transactions.Transactions); Assert.Equal("123", transaction.GetProperty("id").GetString()); - await client.DeactivatePersistentForwardingTemplateAsync(provider, template.Id, cancellationToken); - await client.DeactivatePersistentForwardingAddressAsync(provider, template.Id, cancellationToken); + await client.DeactivatePersistentForwardingTemplate(provider, template.Id, cancellationToken); + await client.DeactivatePersistentForwardingAddress(provider, template.Id, cancellationToken); await Assert.ThrowsAsync( - () => client.DeactivatePersistentForwardingTemplateAsync(provider, "does-not-exist", cancellationToken)); + () => client.DeactivatePersistentForwardingTemplate(provider, "does-not-exist", cancellationToken)); // An operation the provider does not advertise must surface a typed // error before any request leaves the client. @@ -163,7 +163,7 @@ await Assert.ThrowsAsync( .ToDictionary(operation => operation.Key, operation => operation.Value); AssetProvider narrowed = provider with { Operations = narrowedOperations }; await Assert.ThrowsAsync( - () => client.ListTransactionsAsync(narrowed, new AssetListTransactionsRequest(), cancellationToken)); + () => client.ListTransactions(narrowed, new AssetListTransactionsRequest(), cancellationToken)); session.Shutdown(); } @@ -175,17 +175,17 @@ public async Task ShareKycSettlesAndPollsAgainstTheLiveAnchor() (AssetMovementClient client, _, CancellationToken cancellationToken) = session; AssetProvider provider = await session.DiscoveredProviderAsync(); - AssetShareKycOutcome settled = await client.ShareKycAttributesAsync( + AssetShareKycOutcome settled = await client.ShareKycAttributes( provider, new AssetShareKycRequest("exported-attributes"), cancellationToken); Assert.False(settled.IsPending); - AssetShareKycOutcome withoutPolling = await client.ShareKycAttributesAndWaitAsync( + AssetShareKycOutcome withoutPolling = await client.ShareKycAttributesAndWait( provider, 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.ShareKycAttributesAndWaitAsync( + AssetShareKycOutcome polled = await client.ShareKycAttributesAndWait( provider, new AssetShareKycRequest("promise-flow"), pollInterval: TimeSpan.FromMilliseconds(1), @@ -194,7 +194,7 @@ public async Task ShareKycSettlesAndPollsAgainstTheLiveAnchor() Assert.False(polled.IsPending); await Assert.ThrowsAsync( - () => client.ShareKycAttributesAndWaitAsync( + () => client.ShareKycAttributesAndWait( provider, new AssetShareKycRequest("promise-stall"), pollInterval: TimeSpan.FromSeconds(1), @@ -215,7 +215,7 @@ private static AssetTransferRequest PushTransfer(AssetAnchor anchor, object? rec /// A pull transfer debiting a persistent bank address into the base token. private static AssetTransferRequest PullTransfer(AssetAnchor anchor) => new( - new { from = "USD", to = anchor.Asset }, + AssetOrPair.Pair("USD", anchor.Asset), new AssetTransferSource( BankLocation, new { type = "persistent-address", persistentAddressId = "TEST_PERSISTENT_ADDRESS_ID" }), diff --git a/tests/KeetaNet.Anchor.E2eTests/AssetSession.cs b/tests/KeetaNet.Anchor.E2eTests/AssetSession.cs index 2d71b74..f6bee6c 100644 --- a/tests/KeetaNet.Anchor.E2eTests/AssetSession.cs +++ b/tests/KeetaNet.Anchor.E2eTests/AssetSession.cs @@ -41,7 +41,7 @@ public static AssetSession Open() AssetAnchor anchor = AssetAnchor.Start(harness); WasmRuntime runtime = WasmRuntime.Load(); Account signer = runtime.Accounts.FromSeed(E2eSeeds.Caller, 0, E2eSeeds.Secp256k1); - AssetMovementClient client = runtime.CreateAssetMovementClient(anchor.Api, anchor.Root, signer); + AssetMovementClient client = runtime.CreateAssetMovementClient(anchor.NodeApi, anchor.Root, signer); return new AssetSession(harness, anchor, runtime, signer, client); } @@ -65,7 +65,7 @@ public void Deconstruct( /// The single provider the running anchor publishes. public async Task DiscoveredProviderAsync() { - AssetProvider? provider = await Client.GetProviderByIdAsync(Anchor.ProviderId, CancellationToken); + AssetProvider? provider = await Client.GetProviderById(Anchor.ProviderId, CancellationToken); Assert.NotNull(provider); return provider!; diff --git a/tests/KeetaNet.Anchor.E2eTests/ContainerInteropTests.cs b/tests/KeetaNet.Anchor.E2eTests/ContainerInteropTests.cs index e87d1f8..f804acf 100644 --- a/tests/KeetaNet.Anchor.E2eTests/ContainerInteropTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/ContainerInteropTests.cs @@ -49,7 +49,7 @@ public void CsharpDecryptsAndVerifiesTheTypescriptContainer() Assert.Equal(tsSigner.PublicKeyAndType, Convert.ToHexString(recoveredSigner!), ignoreCase: true); } - [Fact(Skip = "known zlib compression divergence in the TS reference breaks C#-to-TS signature validation; quarantined pending an upstream compression-parity fix")] + [Fact(Skip = "known zlib compression divergence in the TS reference breaks C#-to-TS signature validation; quarantined pending an upstream compression fix")] public void TypescriptDecryptsAndVerifiesTheCsharpContainer() { using var runtime = WasmRuntime.Load(); diff --git a/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs index a2984da..deb3346 100644 --- a/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/KycFlowTests.cs @@ -1,3 +1,5 @@ +using System.Numerics; + using KeetaNet.Anchor.Crypto; using Xunit; using CryptoCertificate = KeetaNet.Anchor.Crypto.Certificate; @@ -8,7 +10,7 @@ namespace KeetaNet.Anchor.E2eTests; /// The full KYC client path against a live reference anchor: discover the /// provider from on-chain metadata through the real node API, then create a /// verification, poll its status, and fetch certificates - once per signing -/// algorithm to prove request-signing parity on every curve. +/// algorithm to prove every curve signs requests the anchor accepts. /// public sealed class KycFlowTests { @@ -24,38 +26,44 @@ public async Task VerificationPathRunsAgainstTheLiveAnchor(string algorithm) using var runtime = WasmRuntime.Load(); using Account signer = runtime.Accounts.FromSeed(E2eSeeds.Caller, 0, algorithm); - using KycClient client = runtime.CreateKycClient(anchor.Api, anchor.Root, signer); + using KycClient client = runtime.CreateKycClient(anchor.NodeApi, anchor.Root, signer); - IReadOnlyList providers = await client.GetProvidersAsync(Countries, cancellationToken); + IReadOnlyList providers = await client.GetProviders(Countries, cancellationToken); KycProvider provider = Assert.Single(providers); Assert.Equal(anchor.ProviderId, provider.Id); + // The anchor advertises exactly the US, so the aggregate coverage is + // that one country, not worldwide. + SupportedCountries supported = await client.GetSupportedCountries(cancellationToken); + Assert.False(supported.Worldwide); + Assert.Equal(Countries, supported.Countries); + using CryptoCertificate ca = client.GetCA(provider); Assert.NotEmpty(ca.SubjectPublicKey); - VerificationOutcome created = await client.StartVerificationAsync(provider, Countries, cancellationToken: cancellationToken); + VerificationOutcome created = await client.StartVerification(provider, Countries, cancellationToken: cancellationToken); Assert.NotNull(created.Ready); Verification verification = created.Ready!; Assert.NotEmpty(verification.Id); Assert.NotEmpty(verification.WebUrl); Assert.NotEmpty(verification.ExpectedCost.Token); - // A redirect URL rides the signed create body; the server must accept + // A redirect URL rides the signed create body. The server must accept // the extra field and still assign a verification. - VerificationOutcome redirected = await client.StartVerificationAsync(provider, Countries, "https://example.test/done", cancellationToken); + VerificationOutcome redirected = await client.StartVerification(provider, Countries, "https://example.test/done", cancellationToken); Assert.NotNull(redirected.Ready); Assert.NotEmpty(redirected.Ready!.Id); - StatusOutcome status = await client.GetVerificationStatusAsync(provider, verification.Id, cancellationToken); + StatusOutcome status = await client.GetVerificationStatus(provider, verification.Id, cancellationToken); Assert.NotNull(status.Ready); Assert.Equal("pending", status.Ready!.Status); Assert.True(status.Ready.RequiresManualVerification); - CertificatesOutcome pending = await client.GetCertificatesAsync(provider, "pending", cancellationToken); + CertificatesOutcome pending = await client.GetCertificates(provider, "pending", cancellationToken); Assert.Null(pending.Ready); Assert.NotNull(pending.RetryAfterMs); - CertificatesOutcome ready = await client.GetCertificatesAsync(provider, "ready", cancellationToken); + CertificatesOutcome ready = await client.GetCertificates(provider, "ready", cancellationToken); Assert.NotNull(ready.Ready); Assert.NotEmpty(ready.Ready!.Results); @@ -63,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.GetCertificatesAsync(provider, issued.VerificationId, cancellationToken); + CertificatesOutcome chain = await client.GetCertificates(provider, issued.VerificationId, cancellationToken); Assert.NotNull(chain.Ready); Assert.Equal(2, chain.Ready!.Results.Count); @@ -78,19 +86,20 @@ public async Task LedgerReadServesEveryPublishedCertificateRecord() KycAnchor anchor = KycAnchor.Start(harness); using var runtime = WasmRuntime.Load(); - using Account signer = runtime.Accounts.FromSeed(E2eSeeds.Caller, 0, E2eSeeds.Secp256k1); - using KycClient client = runtime.CreateKycClient(anchor.Api, anchor.Root, signer); + using Account observer = runtime.Accounts.FromSeed(E2eSeeds.Caller, 0, E2eSeeds.Secp256k1); + using NodeClient client = runtime.CreateNodeClient(anchor.NodeApi); - // An account that never published anything reads back as an empty list; - // the Account overload resolves the address itself, as the reference does. - IReadOnlyList none = await client.GetAllCertificatesAsync(signer, cancellationToken); + // An account that never published anything reads back as an empty list. + // The Account overload resolves the address itself, as the reference does. + IReadOnlyList none = await client.GetAllCertificates(observer, cancellationToken); Assert.Empty(none); // The harness records two certificates for a fresh holder: a leaf with // the CA as its intermediate bundle, and a bare leaf without one. PublishedChain chain = PublishedChain.Publish(harness); + using Account holder = runtime.Accounts.FromAccount(chain.Account); - IReadOnlyList records = await client.GetAllCertificatesAsync(chain.Account, cancellationToken); + IReadOnlyList records = await client.GetAllCertificates(holder, cancellationToken); Assert.Equal(2, records.Count); Certificate chained = Assert.Single(records, record => record.Intermediates.Count == 1); @@ -102,9 +111,120 @@ public async Task LedgerReadServesEveryPublishedCertificateRecord() AssertSameCertificate(runtime, chain.Ca, chained.Intermediates[0]); AssertSameCertificate(runtime, chain.Bare, bare.Value); + // The C#-computed certificate hash is the exact key the ledger stores + // the record under. + using CryptoCertificate publishedLeaf = runtime.Certificates.Parse(chain.Leaf); + Assert.Equal(CertificateHash.Parse(chain.LeafHash), publishedLeaf.Hash); + + // The leaf is individually addressable by its hash, intermediates + // intact. An unpublished hash resolves to null, as the reference does. + Certificate? byHash = await client.GetCertificateByHash(holder, publishedLeaf.Hash, cancellationToken); + Assert.NotNull(byHash); + AssertSameCertificate(runtime, chain.Leaf, byHash!.Value); + Assert.Single(byHash.Intermediates); + + CertificateHash unpublished = CertificateHash.Parse(new string('0', 64)); + Certificate? unknown = await client.GetCertificateByHash(holder, unpublished, cancellationToken); + Assert.Null(unknown); + + harness.Shutdown(); + } + + [Fact] + public async Task BasicLedgerReadsReportTheHolderStateAndBalances() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("kyc"); + KycAnchor anchor = KycAnchor.Start(harness); + + using var runtime = WasmRuntime.Load(); + using NodeClient client = runtime.CreateNodeClient(anchor.NodeApi); + + string version = await client.GetNodeVersion(cancellationToken); + Assert.NotEmpty(version); + + // The chain holder was funded with base tokens before publishing (fees + // then nibble at the amount), so the three balance reads must agree on + // one positive settled amount under the base token. + PublishedChain chain = PublishedChain.Publish(harness); + using Account holder = runtime.Accounts.FromAccount(chain.Account); + + AccountState state = await client.GetAccountState(holder, cancellationToken); + Assert.NotNull(state.HeadBlock); + TokenBalance funding = Assert.Single(state.Balances); + Assert.True(funding.Balance > BigInteger.Zero); + + IReadOnlyList balances = await client.GetAccountBalances(holder, cancellationToken); + TokenBalance listed = Assert.Single(balances); + Assert.Equal(funding.Token.Address, listed.Token.Address); + Assert.Equal(funding.Balance, listed.Balance); + + // The token account the state read returned round-trips as the typed + // argument of the direct balance read. + BigInteger direct = await client.GetAccountBalance(holder, funding.Token, cancellationToken); + Assert.Equal(funding.Balance, direct); + harness.Shutdown(); } + [Fact] + public async Task PublishedChainStatusMatchesTheTrustSet() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("kyc"); + KycAnchor anchor = KycAnchor.Start(harness); + + using var runtime = WasmRuntime.Load(); + using NodeClient client = runtime.CreateNodeClient(anchor.NodeApi); + + PublishedChain chain = PublishedChain.Publish(harness); + using Account holder = runtime.Accounts.FromAccount(chain.Account); + using CryptoCertificate publisherCa = runtime.Certificates.Parse(chain.Ca); + CryptoCertificate[] trusted = { publisherCa }; + DateTimeOffset now = DateTimeOffset.UtcNow; + + // The published records chain to the anchor's CA, so trusting it + // reports trusted at the present moment. + CertificateChainStatus status = await client.VerifyAccountCertificateChain(holder, trusted, now, cancellationToken); + Assert.Equal(CertificateChainStatus.Trusted, status); + + // A trust set holding only a CA that never signed anything published + // leaves the same records untrusted. + using KycCertificate foreignAuthority = IssueForeignAuthority(runtime); + using CryptoCertificate foreignCa = foreignAuthority.Base(); + CryptoCertificate[] foreignTrust = { foreignCa }; + CertificateChainStatus foreign = await client.VerifyAccountCertificateChain(holder, foreignTrust, now, cancellationToken); + Assert.Equal(CertificateChainStatus.Untrusted, foreign); + + // The harness leaves expire an hour after publishing. Two days out the + // same trusted set no longer accepts them. + CertificateChainStatus expired = await client.VerifyAccountCertificateChain(holder, trusted, now.AddDays(2), cancellationToken); + Assert.Equal(CertificateChainStatus.Untrusted, expired); + + // An account that never published reports no-certs, not untrusted. + using Account observer = runtime.Accounts.FromSeed(E2eSeeds.Caller, 0, E2eSeeds.Secp256k1); + CertificateChainStatus none = await client.VerifyAccountCertificateChain(observer, trusted, now, cancellationToken); + Assert.Equal(CertificateChainStatus.NoCerts, none); + + harness.Shutdown(); + } + + /// A self-issued CA unrelated to anything the harness published. + private static KycCertificate IssueForeignAuthority(WasmRuntime runtime) + { + using Account account = runtime.Accounts.FromSeed(E2eSeeds.Recipient, 0, E2eSeeds.Secp256k1); + + return runtime.KycCertificates.Builder() + .Subject(account) + .Issuer(account) + .SubjectName("Foreign CA") + .IssuerName("Foreign CA") + .Serial(9) + .Validity(E2eSeeds.NotBefore, E2eSeeds.NotAfter) + .AsCertificateAuthority() + .Build(); + } + private static void AssertSameCertificate(WasmRuntime runtime, string expectedPem, string servedPem) { using CryptoCertificate expected = runtime.Certificates.Parse(expectedPem); diff --git a/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs b/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs new file mode 100644 index 0000000..7c1f2e7 --- /dev/null +++ b/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using KeetaNet.Anchor.Crypto; +using Xunit; + +namespace KeetaNet.Anchor.Tests; + +/// +/// The offline asset-movement model surface: typed blocker decoding from the +/// core's discriminated account-status shape, typed disclaimer and token +/// metadata reads, and the canonical asset transport form. +/// +public sealed class AssetModelTests +{ + private static readonly string[] ExpectedAttributes = { "fullName", "dateOfBirth" }; + private static readonly string[] ExpectedPrincipals = { "keeta_principal" }; + + [Fact] + public void AccountStatusBlockersDecodeIntoTheirTypedShapes() + { + // The exact shape the core's account-status binding emits; its keys + // arrive in sorted order, so the type discriminator trails the object. + string payload = """ + { + "actionRequired": true, + "blockers": [ + { + "acceptedIssuers": [[{ "name": "issuer", "value": "keeta_ca" }]], + "neededAttributes": ["fullName", "dateOfBirth"], + "shareWithPrincipals": ["keeta_principal"], + "tosFlow": null, + "type": "kycShareNeeded" + }, + { "toCompleteFlow": { "url": "https://flow.test" }, "type": "additionalKycNeeded" }, + { "forAsset": "USD", "forRail": "KEETA_SEND", "type": "operationNotSupported" }, + { "actionsNeeded": [{ "kind": "delegate" }], "type": "userActionNeeded" }, + { "code": "SOMETHING_ELSE", "message": "boom", "name": "SomeError", "type": "other" } + ] + } + """; + + AssetAccountStatus status = JsonSerializer.Deserialize(payload, KeetaJson.Options)!; + Assert.True(status.ActionRequired); + Assert.Equal(5, status.Blockers!.Count); + + var share = Assert.IsType(status.Blockers[0]); + Assert.Equal(JsonValueKind.Null, share.TosFlow.ValueKind); + Assert.Equal(ExpectedAttributes, share.NeededAttributes); + Assert.Equal(ExpectedPrincipals, share.ShareWithPrincipals); + Assert.Equal(JsonValueKind.Array, share.AcceptedIssuers.ValueKind); + + var additional = Assert.IsType(status.Blockers[1]); + Assert.Equal("https://flow.test", additional.ToCompleteFlow.GetProperty("url").GetString()); + + var unsupported = Assert.IsType(status.Blockers[2]); + Assert.Equal("USD", unsupported.ForAsset.GetString()); + Assert.Equal("KEETA_SEND", unsupported.ForRail); + + var action = Assert.IsType(status.Blockers[3]); + JsonElement needed = Assert.Single(action.ActionsNeeded); + Assert.Equal("delegate", needed.GetProperty("kind").GetString()); + + // An unrecognized anchor error is kept verbatim, never dropped. + var other = Assert.IsType(status.Blockers[4]); + Assert.Equal("SomeError", other.Name); + Assert.Equal("SOMETHING_ELSE", other.Code); + Assert.Equal("boom", other.Message); + } + + [Fact] + public void AnUnknownBlockerTypeRefusesToDecode() + { + string payload = """{ "actionRequired": true, "blockers": [{ "type": "mystery" }] }"""; + + Assert.Throws( + () => JsonSerializer.Deserialize(payload, KeetaJson.Options)); + } + + [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.Address, account); + + // One well-formed markdown disclaimer and one with an unknown purpose; + // the malformed entry is skipped, mirroring the reference. + AssetProvider provider = Provider(legal: """ + { + "disclaimers": [ + { "purpose": "general", "content": { "type": "markdown", "content": "# Terms" } }, + { "purpose": "unrecognized", "content": { "type": "plaintext", "content": "skip me" } } + ] + } + """); + + IReadOnlyList? disclaimers = client.GetLegalDisclaimers(provider); + Assert.NotNull(disclaimers); + AssetDisclaimer disclaimer = Assert.Single(disclaimers!); + Assert.Equal(AssetDisclaimerPurpose.General, disclaimer.Purpose); + Assert.Equal(AssetContentType.Markdown, disclaimer.Content.Type); + Assert.Equal("# Terms", disclaimer.Content.Content); + + // A provider without legal metadata reports none, not an empty list. + Assert.Null(client.GetLegalDisclaimers(Provider(legal: null))); + } + + [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.Address, 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: """ + { + "chain:evm:100": { + "assets": { + "text-places": { "decimalPlaces": "18", "logoURI": "https://logo.test/t.png", "displayName": "Token", "ticker": "$TOK" }, + "numeric-places": { "decimalPlaces": 6 }, + "garbage-places": { "decimalPlaces": "eighteen" } + } + } + } + """); + + AssetTokenMetadata? full = client.GetAssetMetadataForLocation(provider, "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"); + 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")); + } + + [Fact] + public void AssetOrPairRoundTripsItsCanonicalTransportForms() + { + // A single asset crosses as a bare string, a conversion as { from, to }, + // exactly what the core's request parser accepts. + AssetOrPair single = "evm:0x5"; + Assert.Equal("\"evm:0x5\"", JsonSerializer.Serialize(single, KeetaJson.Options)); + + AssetOrPair pair = AssetOrPair.Pair("USD", "evm:0x5"); + Assert.Equal("""{"from":"USD","to":"evm:0x5"}""", JsonSerializer.Serialize(pair, KeetaJson.Options)); + + Assert.Equal(single, JsonSerializer.Deserialize("\"evm:0x5\"", KeetaJson.Options)); + Assert.Equal(pair, JsonSerializer.Deserialize("""{"from":"USD","to":"evm:0x5"}""", KeetaJson.Options)); + } + + /// A minimal provider carrying only the polymorphic metadata under test. + private static AssetProvider Provider(string? legal = null, string? locationMetadata = null) + { + JsonElement? legalElement = null; + if (legal is not null) + { + legalElement = JsonSerializer.Deserialize(legal); + } + + JsonElement? locationElement = null; + if (locationMetadata is not null) + { + locationElement = JsonSerializer.Deserialize(locationMetadata); + } + + return new AssetProvider( + "provider-under-test", + new Dictionary(), + LocationMetadata: locationElement, + Legal: legalElement); + } +} diff --git a/tests/KeetaNet.Anchor.Tests/ConcurrencyTests.cs b/tests/KeetaNet.Anchor.Tests/ConcurrencyTests.cs index 53cc1c9..ede5831 100644 --- a/tests/KeetaNet.Anchor.Tests/ConcurrencyTests.cs +++ b/tests/KeetaNet.Anchor.Tests/ConcurrencyTests.cs @@ -74,7 +74,7 @@ public async Task PreCanceledTokenCancelsWithoutDispatching() await cancellation.CancelAsync(); await Assert.ThrowsAnyAsync( - () => client.GetProvidersAsync(Countries, cancellation.Token)); + () => client.GetProviders(Countries, cancellation.Token)); } [Fact] @@ -93,6 +93,6 @@ public async Task CancellationDuringHostHttpSurfacesAsCanceled() using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); await Assert.ThrowsAnyAsync( - () => client.GetProvidersAsync(Countries, cancellation.Token)); + () => client.GetProviders(Countries, cancellation.Token)); } } diff --git a/tests/KeetaNet.Anchor.Tests/ContainerTests.cs b/tests/KeetaNet.Anchor.Tests/ContainerTests.cs index ca41267..23f1c43 100644 --- a/tests/KeetaNet.Anchor.Tests/ContainerTests.cs +++ b/tests/KeetaNet.Anchor.Tests/ContainerTests.cs @@ -5,7 +5,7 @@ namespace KeetaNet.Anchor.Tests; /// -/// The offline encrypted-container surface: plaintext and encrypted encoding +/// The encrypted-container surface: plaintext and encrypted encoding /// round-trips, detached signatures, and the grant/revoke principal cycle. /// public sealed class ContainerTests diff --git a/tests/KeetaNet.Anchor.Tests/CryptoTests.cs b/tests/KeetaNet.Anchor.Tests/CryptoTests.cs index b927f48..1abc198 100644 --- a/tests/KeetaNet.Anchor.Tests/CryptoTests.cs +++ b/tests/KeetaNet.Anchor.Tests/CryptoTests.cs @@ -8,7 +8,7 @@ namespace KeetaNet.Anchor.Tests; /// -/// The offline crypto account surface: derivation, signing, and +/// The crypto account surface: derivation, signing, and /// encryption round-trips through the embedded core module. /// public sealed class CryptoTests @@ -172,6 +172,21 @@ public void FixtureCertificateRoundTripsThroughDer() Assert.Equal(parsed.ToPem(), fromDer.ToPem()); } + [Fact] + public void FixtureCertificateHashIsAStableLedgerKey() + { + using var runtime = WasmRuntime.Load(); + using CryptoCertificate parsed = runtime.Certificates.Parse(KycFixture.Pem); + + CertificateHash hash = parsed.Hash; + Assert.Equal(CertificateHash.Length, hash.ToBytes().Length); + Assert.Equal(hash, parsed.Hash); + Assert.Equal(hash, CertificateHash.Parse(hash.ToString().ToUpperInvariant())); + + using CryptoCertificate fromDer = runtime.Certificates.ParseDer(parsed.ToDer()); + Assert.Equal(hash, fromDer.Hash); + } + [Fact] public void FixtureKycCertificateReadsAndDecryptsAttributes() { diff --git a/tests/KeetaNet.Anchor.Tests/DependencyInjectionTests.cs b/tests/KeetaNet.Anchor.Tests/DependencyInjectionTests.cs index 964d51d..93fa727 100644 --- a/tests/KeetaNet.Anchor.Tests/DependencyInjectionTests.cs +++ b/tests/KeetaNet.Anchor.Tests/DependencyInjectionTests.cs @@ -30,4 +30,20 @@ public void RegistersOneSharedRuntimeTheContainerDisposes() Assert.True(runtime.IsDisposed); } + + [Fact] + public void RegistersANodeClientBackedByTheHttpClientFactory() + { + var services = new ServiceCollection(); + services.AddKeetaNetAnchorNodeClient("http://127.0.0.1:1/api/node"); + + using ServiceProvider provider = services.BuildServiceProvider(); + using NodeClient first = provider.GetRequiredService(); + using NodeClient second = provider.GetRequiredService(); + Assert.NotSame(first, second); + + // The node-client registration also provides the shared runtime. + WasmRuntime runtime = provider.GetRequiredService(); + Assert.False(runtime.IsDisposed); + } } diff --git a/tests/KeetaNet.Anchor.Tests/IssueTests.cs b/tests/KeetaNet.Anchor.Tests/IssueTests.cs index 8f93ab4..a311097 100644 --- a/tests/KeetaNet.Anchor.Tests/IssueTests.cs +++ b/tests/KeetaNet.Anchor.Tests/IssueTests.cs @@ -71,7 +71,7 @@ public void WrongShapeAccessorsRejectWithTheDecodeCode() KycAttributeValue email = leaf.GetAttribute("email", subject); - // An email is neither a timestamp nor JSON; each typed accessor must + // An email is neither a timestamp nor JSON. Each typed accessor must // refuse with the stable decode code instead of returning garbage. KeetaException notATimestamp = Assert.Throws(() => email.AsTimestamp()); Assert.Equal("ATTRIBUTE_DECODE", notATimestamp.Code); diff --git a/tests/KeetaNet.Anchor.Tests/KycModelTests.cs b/tests/KeetaNet.Anchor.Tests/KycModelTests.cs new file mode 100644 index 0000000..c6897af --- /dev/null +++ b/tests/KeetaNet.Anchor.Tests/KycModelTests.cs @@ -0,0 +1,50 @@ +using Xunit; + +namespace KeetaNet.Anchor.Tests; + +/// +/// The offline KYC model folds, mirroring the reference +/// getSupportedCountries aggregation cases. +/// +public sealed class KycModelTests +{ + private static readonly string[] UsAndGermany = { "US", "DE" }; + private static readonly string[] GermanyAndFrance = { "DE", "FR" }; + private static readonly string[] SortedUnion = { "DE", "FR", "US" }; + private static readonly string[] UsOnly = { "US" }; + + [Fact] + public void SupportedCountriesUnionIsSortedAndDeduplicated() + { + var providers = new[] { Provider("a", UsAndGermany), Provider("b", GermanyAndFrance) }; + + SupportedCountries folded = SupportedCountries.FromProviders(providers); + + Assert.False(folded.Worldwide); + Assert.Equal(SortedUnion, folded.Countries); + } + + [Fact] + public void AWorldwideProviderFoldsToWorldwide() + { + var providers = new[] { Provider("a", UsOnly), Provider("b", null) }; + + SupportedCountries folded = SupportedCountries.FromProviders(providers); + + Assert.True(folded.Worldwide); + Assert.Empty(folded.Countries); + } + + [Fact] + public void NoProvidersFoldToAnEmptyUnion() + { + 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) => + new(id, "ca-pem", new KycOperations(null, null, null, null, null), countryCodes); +} diff --git a/tests/KeetaNet.Anchor.Tests/LifecycleTests.cs b/tests/KeetaNet.Anchor.Tests/LifecycleTests.cs index e65e3f0..24e0a2d 100644 --- a/tests/KeetaNet.Anchor.Tests/LifecycleTests.cs +++ b/tests/KeetaNet.Anchor.Tests/LifecycleTests.cs @@ -72,7 +72,7 @@ public async Task ClientOutlivingRuntimeRefusesDispatch() runtime.Dispose(); await Assert.ThrowsAsync( - () => client.GetProvidersAsync(Countries, TestContext.Current.CancellationToken)); + () => client.GetProviders(Countries, TestContext.Current.CancellationToken)); } [Fact] diff --git a/tests/KeetaNet.Anchor.Tests/TrustTests.cs b/tests/KeetaNet.Anchor.Tests/TrustTests.cs new file mode 100644 index 0000000..99cf657 --- /dev/null +++ b/tests/KeetaNet.Anchor.Tests/TrustTests.cs @@ -0,0 +1,84 @@ +using KeetaNet.Anchor.Crypto; +using Xunit; + +// `Certificate` also names the KYC DTO record in `KeetaNet.Anchor`. +using CryptoCertificate = KeetaNet.Anchor.Crypto.Certificate; + +namespace KeetaNet.Anchor.Tests; + +/// +/// The certificate-chain trust gate: published records evaluated against a +/// trust set at a moment, the port of the reference +/// evaluate_certificate_chain cases. +/// +public sealed class TrustTests +{ + /// A moment inside the shared validity window. + private static readonly DateTimeOffset Moment = DateTimeOffset.FromUnixTimeSeconds(1_800_000_000); + + [Fact] + public void ChainStatusMatchesTheTrustSet() + { + using var runtime = WasmRuntime.Load(); + using NodeClient client = runtime.CreateNodeClient(TestSeeds.NonRoutableAnchor); + + using Account caAccount = runtime.Accounts.FromSeed(TestSeeds.Issuer, 0, TestSeeds.DefaultAlgorithm); + using Account subject = runtime.Accounts.FromSeed(TestSeeds.Subject, 0, TestSeeds.DefaultAlgorithm); + using Account foreignAccount = runtime.Accounts.FromSeed(TestSeeds.Recipient, 0, TestSeeds.DefaultAlgorithm); + + using KycCertificate ca = IssueAuthority(runtime, caAccount, "Anchor Test CA"); + using KycCertificate foreign = IssueAuthority(runtime, foreignAccount, "Foreign CA"); + using KycCertificate leaf = runtime.KycCertificates.Builder() + .Subject(subject) + .Issuer(caAccount) + .SubjectName("Anchor Test Leaf") + .IssuerName("Anchor Test CA") + .Serial(2) + .Validity(TestSeeds.NotBefore, TestSeeds.NotAfter) + .Build(); + + using CryptoCertificate trustedRoot = ca.Base(); + CryptoCertificate[] trusted = { trustedRoot }; + CryptoCertificate[] emptyTrust = Array.Empty(); + + Certificate leafRecord = Record(leaf); + Certificate caRecord = Record(ca); + Certificate foreignRecord = Record(foreign); + Certificate malformedRecord = new("not a pem", Array.Empty()); + + // One case per reference outcome. A malformed record is skipped, never + // trusted, and still counts as published (untrusted, not no-certs). + (string Label, Certificate[] Records, CryptoCertificate[] Trusted, CertificateChainStatus Expected)[] cases = + { + ("leaf chains to trusted CA", new[] { leafRecord }, trusted, CertificateChainStatus.Trusted), + ("trusted CA presented directly", new[] { caRecord }, trusted, CertificateChainStatus.Trusted), + ("foreign cert is untrusted", new[] { foreignRecord }, trusted, CertificateChainStatus.Untrusted), + ("no records means no certs", Array.Empty(), trusted, CertificateChainStatus.NoCerts), + ("empty trust set is untrusted", new[] { leafRecord }, emptyTrust, CertificateChainStatus.Untrusted), + ("malformed record reports untrusted", new[] { malformedRecord }, trusted, CertificateChainStatus.Untrusted), + }; + + var expected = cases.Select(current => (current.Label, current.Expected)).ToArray(); + var evaluated = cases + .Select(current => (current.Label, client.EvaluateCertificateChain(current.Records, current.Trusted, Moment))) + .ToArray(); + + Assert.Equal(expected, evaluated); + } + + /// A self-issued certificate authority under the shared validity window. + private static KycCertificate IssueAuthority(WasmRuntime runtime, Account account, string commonName) => + runtime.KycCertificates.Builder() + .Subject(account) + .Issuer(account) + .SubjectName(commonName) + .IssuerName(commonName) + .Serial(1) + .Validity(TestSeeds.NotBefore, TestSeeds.NotAfter) + .AsCertificateAuthority() + .Build(); + + /// The published-record shape of an issued certificate, without intermediates. + private static Certificate Record(KycCertificate certificate) => + new(certificate.ToPem(), Array.Empty()); +} diff --git a/tests/node-harness/src/kyc.ts b/tests/node-harness/src/kyc.ts index 2fa5635..468fd82 100644 --- a/tests/node-harness/src/kyc.ts +++ b/tests/node-harness/src/kyc.ts @@ -341,6 +341,7 @@ async function handlePublishCertificateChain(): Promise { api: current.chain.api, account: holder.publicKeyString.get(), leaf: leaf.toPEM(), + leafHash: leaf.hash().toString(), bare: bare.toPEM(), ca: current.ca.toPEM() }); From 4b8ec73b08fbcf6a1219141f2a01e0258c7ae48f Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 7 Jul 2026 23:45:03 -0700 Subject: [PATCH 3/5] test: close testing gaps --- tests/KeetaNet.Anchor.E2eTests/Anchors.cs | 99 ++++++++++- .../AssetFlowTests.cs | 65 +++++++ .../KeetaNet.Anchor.E2eTests/AssetSession.cs | 10 +- .../KeetaNet.Anchor.E2eTests/NodeFlowTests.cs | 99 +++++++++++ tests/node-harness/src/asset.ts | 61 ++++++- tests/node-harness/src/node.ts | 167 ++++++++++++++++++ 6 files changed, 491 insertions(+), 10 deletions(-) create mode 100644 tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs create mode 100644 tests/node-harness/src/node.ts diff --git a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs index fc511c0..0628b1a 100644 --- a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs +++ b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs @@ -51,6 +51,92 @@ public static PublishedChain Publish(NodeHarness harness) } } +/// +/// A live reference node started by the node harness, with helpers that mutate +/// its ledger (funding, account info, delegation) so the node client can read +/// every surface back over the real API. +/// +internal sealed class LedgerNode +{ + private readonly NodeHarness _harness; + + public string Api { get; } + public string BaseToken { get; } + public string Representative { get; } + + private LedgerNode(NodeHarness harness, string api, string baseToken, string representative) + { + _harness = harness; + Api = api; + BaseToken = baseToken; + Representative = representative; + } + + /// Boot the reference node with an initialized chain. + public static LedgerNode Start(NodeHarness harness) + { + JsonElement started = harness.Request("startNode"); + + return new LedgerNode( + harness, + started.GetProperty("api").GetString()!, + started.GetProperty("baseToken").GetString()!, + started.GetProperty("representative").GetString()!); + } + + /// Fund the seed-derived account, returning its address. + public string Fund(string seed, long amount) + { + var arguments = new JsonObject + { + ["seed"] = seed, + ["algorithm"] = "secp256k1", + ["amount"] = amount.ToString(System.Globalization.CultureInfo.InvariantCulture), + }; + + return _harness.Request("fund", arguments).GetProperty("account").GetString()!; + } + + /// Publish on-chain info for the seed-derived account. + public void SetInfo(string seed, string name, string description, string metadata) + { + var arguments = new JsonObject + { + ["seed"] = seed, + ["algorithm"] = "secp256k1", + ["name"] = name, + ["description"] = description, + ["metadata"] = metadata, + }; + + _harness.Request("setInfo", arguments); + } + + /// + /// Delegate the seed-derived account's weight to the account derived from + /// , returning the representative address. + /// + public string SetRep(string seed, string representativeSeed) + { + var arguments = new JsonObject + { + ["seed"] = seed, + ["algorithm"] = "secp256k1", + ["representativeSeed"] = representativeSeed, + ["representativeAlgorithm"] = "secp256k1", + }; + + return _harness.Request("setRep", arguments).GetProperty("representative").GetString()!; + } + + /// The account's head block hash as the reference client reports it, or null. + public string? Head(string account) + { + var arguments = new JsonObject { ["account"] = account }; + return _harness.Request("head", arguments).GetProperty("head").GetString(); + } +} + /// /// A live asset-movement anchor HTTP server started by the harness, alongside /// the fixture values its callbacks report back. @@ -63,10 +149,19 @@ internal sealed record AssetAnchor( string Asset, string SendToAddress) { - /// Start a signed asset-movement anchor. - public static AssetAnchor Start(NodeHarness harness) + /// + /// Start a signed asset-movement anchor. When + /// is given, the anchor reports that account as blocked from + /// getAccountStatus with one blocker of every recoverable kind. + /// + public static AssetAnchor Start(NodeHarness harness, string? blockedAccount = null) { var arguments = new JsonObject { ["sign"] = true }; + if (blockedAccount is not null) + { + arguments["blockedAccount"] = blockedAccount; + } + JsonElement started = harness.Request("startAssetAnchor", arguments); return new AssetAnchor( diff --git a/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs index 7acbf26..db4f3a9 100644 --- a/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs @@ -14,6 +14,9 @@ public sealed class AssetFlowTests private const string BankLocation = "bank-account:us"; private const string EvmLocation = "chain:evm:100"; private const string KeetaLocation = "chain:keeta:100"; + private const string EvmAsset = "evm:0xc0634090F2Fe6c6d75e61Be2b949464aBB498973"; + + private static readonly string[] BlockedAttributes = { "fullName", "dateOfBirth" }; [Fact] public async Task DiscoveryReadsThePublishedProvider() @@ -78,6 +81,68 @@ await Assert.ThrowsAsync( session.Shutdown(); } + [Fact] + public async Task AccountStatusServesTypedBlockersForABlockedCaller() + { + using var session = AssetSession.Open(blockCaller: true); + (AssetMovementClient client, AssetAnchor anchor, CancellationToken cancellationToken) = session; + AssetProvider provider = await session.DiscoveredProviderAsync(); + + AssetAccountStatus status = await client.GetAccountStatus(provider, cancellationToken); + Assert.True(status.ActionRequired); + Assert.Equal(2, status.Blockers!.Count); + + var share = Assert.IsType(status.Blockers[0]); + Assert.Equal(JsonValueKind.Null, share.TosFlow.ValueKind); + Assert.Equal(BlockedAttributes, share.NeededAttributes); + Assert.Equal(new[] { anchor.SendToAddress }, share.ShareWithPrincipals); + + using JsonElement.ArrayEnumerator issuerSets = share.AcceptedIssuers.EnumerateArray(); + JsonElement issuerSet = Assert.Single(issuerSets); + using JsonElement.ArrayEnumerator issuers = issuerSet.EnumerateArray(); + JsonElement issuer = Assert.Single(issuers); + Assert.Equal("CN", issuer.GetProperty("name").GetString()); + Assert.Equal("Anchor Test CA", issuer.GetProperty("value").GetString()); + + var unsupported = Assert.IsType(status.Blockers[1]); + Assert.Equal(anchor.Asset, unsupported.ForAsset.GetString()); + Assert.Equal("ACH_DEBIT", unsupported.ForRail); + + session.Shutdown(); + } + + [Fact] + public async Task PublishedLegalAndTokenMetadataRoundTrip() + { + using var session = AssetSession.Open(); + (AssetMovementClient client, AssetAnchor anchor, CancellationToken cancellationToken) = session; + AssetProvider provider = await session.DiscoveredProviderAsync(); + + IReadOnlyList? disclaimers = client.GetLegalDisclaimers(provider); + Assert.NotNull(disclaimers); + AssetDisclaimer disclaimer = Assert.Single(disclaimers!); + Assert.Equal(AssetDisclaimerPurpose.General, disclaimer.Purpose); + Assert.Equal(AssetContentType.Markdown, disclaimer.Content.Type); + Assert.Equal("Test disclaimer: use at your own risk.", disclaimer.Content.Content); + + // Looking the provider up by id serves the same disclaimers. + IReadOnlyList? byId = await client.GetProviderLegalDisclaimersById(anchor.ProviderId, cancellationToken); + Assert.Equal(disclaimers, byId); + + AssetTokenMetadata? metadata = client.GetAssetMetadataForLocation(provider, EvmLocation, EvmAsset); + Assert.NotNull(metadata); + Assert.Equal(18u, metadata!.DecimalPlaces); + Assert.Equal("Test Token", metadata.DisplayName); + Assert.Equal("$TEST", metadata.Ticker); + Assert.Equal("https://token.test/logo.png", metadata.LogoUri); + + // An asset the anchor publishes no display metadata for reports absent, + // not an error. + Assert.Null(client.GetAssetMetadataForLocation(provider, EvmLocation, "evm:0xdeadbeef")); + + session.Shutdown(); + } + [Fact] public async Task ForwardingAndListingRunAgainstTheLiveAnchor() { diff --git a/tests/KeetaNet.Anchor.E2eTests/AssetSession.cs b/tests/KeetaNet.Anchor.E2eTests/AssetSession.cs index f6bee6c..18e1a5f 100644 --- a/tests/KeetaNet.Anchor.E2eTests/AssetSession.cs +++ b/tests/KeetaNet.Anchor.E2eTests/AssetSession.cs @@ -32,15 +32,19 @@ private AssetSession( CancellationToken = TestContext.Current.CancellationToken; } - /// Boot the anchor and connect a signed client to it. - public static AssetSession Open() + /// + /// Boot the anchor and connect a signed client to it. With + /// the anchor reports the session's own + /// signer as blocked, so getAccountStatus serves typed blockers. + /// + public static AssetSession Open(bool blockCaller = false) { NodeHarness harness = NodeHarness.Spawn("asset"); try { - AssetAnchor anchor = AssetAnchor.Start(harness); WasmRuntime runtime = WasmRuntime.Load(); Account signer = runtime.Accounts.FromSeed(E2eSeeds.Caller, 0, E2eSeeds.Secp256k1); + AssetAnchor anchor = AssetAnchor.Start(harness, blockCaller ? signer.Address : null); AssetMovementClient client = runtime.CreateAssetMovementClient(anchor.NodeApi, anchor.Root, signer); return new AssetSession(harness, anchor, runtime, signer, client); diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs new file mode 100644 index 0000000..e544968 --- /dev/null +++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs @@ -0,0 +1,99 @@ +using System.Numerics; + +using KeetaNet.Anchor.Crypto; +using Xunit; + +namespace KeetaNet.Anchor.E2eTests; + +/// +/// The node client's read surface against a live reference node whose ledger +/// the harness mutates on request, the node-rs e2e pattern. +/// +public sealed class NodeFlowTests +{ + /// Base token funded to the holder. + private const long Funding = 1_000_000; + + [Fact] + public async Task LedgerReadsRoundTripAgainstTheLiveNode() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using var harness = NodeHarness.Spawn("node"); + LedgerNode node = LedgerNode.Start(harness); + + using var runtime = WasmRuntime.Load(); + using NodeClient client = runtime.CreateNodeClient(node.Api); + using Account baseToken = runtime.Accounts.FromAccount(node.BaseToken); + + string version = await client.GetNodeVersion(cancellationToken); + Assert.NotEmpty(version); + + // An account the ledger has never seen reads back empty: no head, no + // representative, no balances, and an info envelope with blank fields. + using Account observer = runtime.Accounts.FromSeed(E2eSeeds.Caller, 0, E2eSeeds.Secp256k1); + AccountState empty = await client.GetAccountState(observer, cancellationToken); + Assert.Null(empty.HeadBlock); + Assert.Null(empty.Representative); + Assert.NotNull(empty.Info); + Assert.True(string.IsNullOrEmpty(empty.Info!.Name)); + Assert.True(string.IsNullOrEmpty(empty.Info.Description)); + Assert.True(string.IsNullOrEmpty(empty.Info.Metadata)); + Assert.Null(empty.Info.Supply); + Assert.Empty(empty.Balances); + Assert.Empty(await client.GetAccountBalances(observer, cancellationToken)); + Assert.Equal(BigInteger.Zero, await client.GetAccountBalance(observer, baseToken, cancellationToken)); + + // The C#-derived holder must be the address the harness funds - the + // interop anchor proving both sides derive the same account. + using Account holder = runtime.Accounts.FromSeed(E2eSeeds.Subject, 0, E2eSeeds.Secp256k1); + string funded = node.Fund(E2eSeeds.Subject, Funding); + Assert.Equal(holder.Address, funded); + + // Before the holder publishes anything, the balance is the exact + // funded amount (the sender paid the transfer fee). + BigInteger initial = await client.GetAccountBalance(holder, baseToken, cancellationToken); + Assert.Equal(new BigInteger(Funding), initial); + + // Publish info and delegate weight through the reference client, then + // read both back through the node client's typed state. + node.SetInfo(E2eSeeds.Subject, "TREASURY", "Primary holder account", "tier-genesis"); + string representative = node.SetRep(E2eSeeds.Subject, E2eSeeds.Recipient); + using Account expectedRep = runtime.Accounts.FromSeed(E2eSeeds.Recipient, 0, E2eSeeds.Secp256k1); + Assert.Equal(expectedRep.Address, representative); + + AccountState state = await client.GetAccountState(holder, cancellationToken); + Assert.NotNull(state.Info); + Assert.Equal("TREASURY", state.Info!.Name); + Assert.Equal("Primary holder account", state.Info.Description); + Assert.Equal("tier-genesis", state.Info.Metadata); + Assert.Null(state.Info.Supply); + Assert.NotNull(state.Representative); + Assert.Equal(expectedRep.Address, state.Representative!.Address); + + // The state's head must be the exact head hash the reference client + // reports, and the height must reflect the two published blocks. + Assert.NotNull(state.HeadBlock); + string? head = node.Head(holder.Address); + Assert.NotNull(head); + Assert.Equal(BlockHash.Parse(head!), state.HeadBlock!.Value); + Assert.True(state.HeadHeight >= BigInteger.One); + + // Fees nibble at the funded amount; the state and the direct balance + // read must agree on the settled value under the base token. + TokenBalance settled = Assert.Single(state.Balances); + Assert.Equal(baseToken.Address, settled.Token.Address); + Assert.True(settled.Balance > BigInteger.Zero); + Assert.True(settled.Balance <= new BigInteger(Funding)); + + BigInteger direct = await client.GetAccountBalance(holder, baseToken, cancellationToken); + Assert.Equal(settled.Balance, direct); + + // The token account's own state carries the chain-initialized supply. + AccountState tokenState = await client.GetAccountState(baseToken, cancellationToken); + Assert.NotNull(tokenState.Info); + Assert.NotNull(tokenState.Info!.Supply); + Assert.True(tokenState.Info.Supply > BigInteger.Zero); + + harness.Shutdown(); + } +} diff --git a/tests/node-harness/src/asset.ts b/tests/node-harness/src/asset.ts index 4ba542a..eca07e5 100644 --- a/tests/node-harness/src/asset.ts +++ b/tests/node-harness/src/asset.ts @@ -9,10 +9,11 @@ import type * as ResolverModule from '@keetanetwork/anchor/lib/resolver.js'; import type * as AssetServerModule from '@keetanetwork/anchor/services/asset-movement/server.js'; +import type * as AssetCommonModule from '@keetanetwork/anchor/services/asset-movement/common.js'; import type { KeetaAssetMovementTransaction } from '@keetanetwork/anchor/services/asset-movement/common.js'; import type * as KeetaNetModule from '@keetanetwork/keetanet-client'; -import type { ChainNode, GenericAccount, UserClient } from './chain.js'; +import type { ChainNode, UserClient } from './chain.js'; import type { HarnessResponse } from './core.js'; import { bootChainNode } from './chain.js'; import { referenceResolver, runHarness } from './core.js'; @@ -20,6 +21,7 @@ import { referenceResolver, runHarness } from './core.js'; const refs = referenceResolver(); const resolver = await refs.anchor('lib/resolver.js'); const assetServer = await refs.anchor('services/asset-movement/server.js'); +const assetCommon = await refs.anchor('services/asset-movement/common.js'); const KeetaNet = refs.client(); const KeetaNetLib = KeetaNet.lib; @@ -28,6 +30,11 @@ const Metadata = resolver.default.Metadata; const metadataSigner = Account.fromSeed(Account.generateRandomSeed(), 0); +/** + * A seed-derived key account, as the blocker constructors require. + */ +type SigningAccount = ReturnType; + type TokenAccount = UserClient['baseToken']; type AssetServerInstance = InstanceType; type AssetServerConfig = ConstructorParameters[0]; @@ -47,6 +54,7 @@ interface StartAssetAnchorRequest { cmd: 'startAssetAnchor'; sign?: boolean; providerId?: string; + blockedAccount?: string; } interface StopAssetAnchorRequest { @@ -68,7 +76,7 @@ type AssetRequest = * except `simulateTransfer` (which the server publishes unauthenticated), so the * signed/unsigned split matches the C# client. */ -function assetCallbacks(baseTokenAccount: TokenAccount, sendToAccount: GenericAccount, moment: string): AssetMovementConfig { +function assetCallbacks(baseTokenAccount: TokenAccount, sendToAccount: SigningAccount, moment: string, blockedAccount: string | undefined): AssetMovementConfig { const baseToken = baseTokenAccount.publicKeyString.get(); const sendToAddress = sendToAccount.publicKeyString.get(); @@ -150,6 +158,28 @@ function assetCallbacks(baseTokenAccount: TokenAccount, sendToAccount: GenericAc return({ authenticationRequired: true, + legal: { + disclaimers: [ + { + purpose: 'general', + content: { type: 'markdown', content: 'Test disclaimer: use at your own risk.' } + } + ] + }, + + locationMetadata: { + 'chain:evm:100': { + assets: { + 'evm:0xc0634090F2Fe6c6d75e61Be2b949464aBB498973': { + decimalPlaces: 18, + displayName: 'Test Token', + ticker: '$TEST', + logoURI: 'https://token.test/logo.png' + } + } + } + }, + supportedAssets: [ { asset: baseToken, @@ -206,8 +236,29 @@ function assetCallbacks(baseTokenAccount: TokenAccount, sendToAccount: GenericAc }); }, - getAccountStatus: async function() { - return({ actionRequired: false }); + /* + * The magic blocked account reports action required with one blocker of + * every recoverable kind, so a binding decodes each typed shape. + */ + getAccountStatus: async function(account) { + if (blockedAccount === undefined || account.publicKeyString.get() !== blockedAccount) { + return({ actionRequired: false }); + } + + return({ + actionRequired: true, + errors: [ + new assetCommon.Errors.KYCShareNeeded({ + neededAttributes: ['fullName', 'dateOfBirth'], + shareWithPrincipals: [sendToAccount], + acceptedIssuers: [[{ name: 'CN', value: 'Anchor Test CA' }]] + }), + new assetCommon.Errors.OperationNotSupported({ + forAsset: baseToken, + forRail: 'ACH_DEBIT' + }) + ] + }); }, initiatePersistentForwardingTemplate: async function() { @@ -378,7 +429,7 @@ async function handleStartAssetAnchor(request: StartAssetAnchorRequest): Promise } })({ metadataSigner: sign ? metadataSigner : undefined, - assetMovement: assetCallbacks(baseTokenAccount, sendToAccount, moment) + assetMovement: assetCallbacks(baseTokenAccount, sendToAccount, moment, request.blockedAccount) }); await server.start(); diff --git a/tests/node-harness/src/node.ts b/tests/node-harness/src/node.ts new file mode 100644 index 0000000..70d56d9 --- /dev/null +++ b/tests/node-harness/src/node.ts @@ -0,0 +1,167 @@ +/* + * Node-ledger interop harness. + * + * Boots the in-memory reference node with an initialized chain and mutates + * ledger state on request (funding, account info, delegation), so a binding's + * node client can read every surface back over the real node API. + */ + +import type { ChainNode, SigningAccount } from './chain.js'; +import type { HarnessResponse } from './core.js'; +import { accountFromSeed } from './accounts.js'; +import { bootChainNode } from './chain.js'; +import { runHarness } from './core.js'; + +/** The running reference node, if any. */ +let chain: ChainNode | undefined; + +interface StartNodeRequest { + cmd: 'startNode'; +} + +/** Fund the seed-derived account with `amount` base token. */ +interface FundRequest { + cmd: 'fund'; + seed: string; + algorithm?: string; + amount: string; +} + +/** Publish on-chain account info for the seed-derived account. */ +interface SetInfoRequest { + cmd: 'setInfo'; + seed: string; + algorithm?: string; + name: string; + description: string; + metadata: string; +} + +/** + * Delegate the seed-derived account's weight to the account derived from + * `representativeSeed`, so the binding can derive the same address and assert + * the ledger echoes it. + */ +interface SetRepRequest { + cmd: 'setRep'; + seed: string; + algorithm?: string; + representativeSeed: string; + representativeAlgorithm?: string; +} + +/** Report an account's head block hash as the reference client sees it. */ +interface HeadRequest { + cmd: 'head'; + account: string; +} + +interface ShutdownRequest { + cmd: 'shutdown'; +} + +type NodeRequest = + StartNodeRequest | + FundRequest | + SetInfoRequest | + SetRepRequest | + HeadRequest | + ShutdownRequest; + +function running(): ChainNode { + if (chain === undefined) { + throw(new Error('no node is running; send startNode first')); + } + + return(chain); +} + +function signer(request: { seed: string; algorithm?: string }): SigningAccount { + return(accountFromSeed(request.seed, request.algorithm)); +} + +async function stopNode(): Promise { + const current = chain; + if (current === undefined) { + return; + } + + chain = undefined; + await current.node.stop(); +} + +async function handleStartNode(): Promise { + await stopNode(); + chain = await bootChainNode(); + + return({ + event: 'node-started', + api: chain.api, + baseToken: chain.repClient.baseToken.publicKeyString.get(), + representative: chain.repClient.account.publicKeyString.get() + }); +} + +async function handleFund(request: FundRequest): Promise { + const node = running(); + const account = signer(request); + + await node.give(account, BigInt(request.amount)); + + return({ event: 'funded', account: account.publicKeyString.get() }); +} + +async function handleSetInfo(request: SetInfoRequest): Promise { + const node = running(); + const account = signer(request); + const client = node.clientFor(account); + + await client.setInfo({ + name: request.name, + description: request.description, + metadata: request.metadata + }); + + return({ event: 'info-set', account: account.publicKeyString.get() }); +} + +async function handleSetRep(request: SetRepRequest): Promise { + const node = running(); + const account = signer(request); + const client = node.clientFor(account); + const representative = accountFromSeed(request.representativeSeed, request.representativeAlgorithm); + + const builder = client.initBuilder(); + builder.setRep(representative); + + await client.publishBuilder(builder); + + return({ + event: 'rep-set', + account: account.publicKeyString.get(), + representative: representative.publicKeyString.get() + }); +} + +async function handleHead(request: HeadRequest): Promise { + const node = running(); + const block = await node.repClient.client.getHeadBlock(request.account); + + return({ + event: 'head', + head: block === null ? null : block.hash.toString() + }); +} + +async function handle(request: NodeRequest): Promise { + switch (request.cmd) { + case 'startNode': return(await handleStartNode()); + case 'fund': return(await handleFund(request)); + case 'setInfo': return(await handleSetInfo(request)); + case 'setRep': return(await handleSetRep(request)); + case 'head': return(await handleHead(request)); + case 'shutdown': return({ event: 'shutdown' }); + } +} + +runHarness({ event: 'ready' }, handle, stopNode); From 25aed51ee9767a82effb899140ff872fff63381c Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 7 Jul 2026 23:49:46 -0700 Subject: [PATCH 4/5] feat(node): add a few more simple endpoints --- .../Services/Node/NodeClient.cs | 150 +++++++++++++++--- .../Services/Node/NodeModels.cs | 14 ++ .../KeetaNet.Anchor.E2eTests/NodeFlowTests.cs | 60 ++++++- 3 files changed, 198 insertions(+), 26 deletions(-) diff --git a/src/KeetaNet.Anchor/Services/Node/NodeClient.cs b/src/KeetaNet.Anchor/Services/Node/NodeClient.cs index 16d72da..68a88be 100644 --- a/src/KeetaNet.Anchor/Services/Node/NodeClient.cs +++ b/src/KeetaNet.Anchor/Services/Node/NodeClient.cs @@ -1,9 +1,11 @@ using System.Globalization; using System.Numerics; +using System.Text.Json; using KeetaNet.Anchor.Generated.Node; using GeneratedCertificate = KeetaNet.Anchor.Generated.Node.Certificate; +using GeneratedRepresentative = KeetaNet.Anchor.Generated.Node.Representative; namespace KeetaNet.Anchor; @@ -54,35 +56,91 @@ public async Task GetAccountState( CancellationToken cancellationToken = default) { Response5 state = await Attempt(() => _api.GetAccountStateAsync(account.Address, cancellationToken)).ConfigureAwait(false); + return DecodeState(state.CurrentHeadBlock, state.CurrentHeadBlockHeight, state.Representative, state.Info, state.Balances); + } - NodeAccountInfo? info = null; - if (state.Info is not null) - { - info = new NodeAccountInfo( - state.Info.Name, - state.Info.Description, - state.Info.Metadata, - OptionalHexAmount(state.Info.Supply)); - } + /// + /// The ledger state of several in one call, + /// one entry per account in request order. + /// + public async Task> GetAccountStates( + IReadOnlyList accounts, + CancellationToken cancellationToken = default) + { + string joined = string.Join(",", accounts.Select(account => account.Address)); + ICollection states = await Attempt(() => _api.GetAccountStatesAsync(joined, cancellationToken)).ConfigureAwait(false); - Crypto.Account? representative = null; - if (state.Representative is not null) - { - representative = _runtime.Accounts.FromAccount(state.Representative); - } + return states + .Select(item => DecodeState(item.CurrentHeadBlock, item.CurrentHeadBlockHeight, item.Representative, item.Info, item.Balances)) + .ToArray(); + } - Crypto.BlockHash? headBlock = null; - if (state.CurrentHeadBlock is not null) + /// + /// The total supply of , read from its account + /// state. Null for an account that is not a token. + /// + public async Task GetTokenSupply( + Crypto.Account token, + CancellationToken cancellationToken = default) + { + AccountState state = await GetAccountState(token, cancellationToken).ConfigureAwait(false); + return state.Info?.Supply; + } + + /// The point-in-time XOR checksum of the node's ledger. + public async Task GetLedgerChecksum(CancellationToken cancellationToken = default) + { + Response12 checksum = await Attempt(() => _api.GetLedgerChecksumAsync(cancellationToken)).ConfigureAwait(false); + + DateTimeOffset? moment = null; + if (!string.IsNullOrEmpty(checksum.Moment)) { - headBlock = Crypto.BlockHash.Parse(state.CurrentHeadBlock); + moment = DateTimeOffset.Parse(checksum.Moment, CultureInfo.InvariantCulture); } - return new AccountState( - headBlock, - OptionalHexAmount(state.CurrentHeadBlockHeight), - representative, - info, - DecodeBalances(state.Balances)); + return new LedgerChecksum( + OptionalHexAmount(checksum.Checksum) ?? BigInteger.Zero, + moment, + checksum.MomentRange); + } + + /// The node's own representative. + public async Task GetNodeRepresentative(CancellationToken cancellationToken = default) + { + GeneratedRepresentative representative = await Attempt(() => _api.GetNodeRepresentativeAsync(cancellationToken)).ConfigureAwait(false); + return DecodeRepresentative(representative); + } + + /// The named and its voting weight. + public async Task GetRepresentative( + Crypto.Account representative, + CancellationToken cancellationToken = default) + { + GeneratedRepresentative named = await Attempt(() => _api.GetRepresentativeAsync(representative.Address, cancellationToken)).ConfigureAwait(false); + return DecodeRepresentative(named); + } + + /// Every representative the node knows, with advertised endpoints. + public async Task> GetAllRepresentatives(CancellationToken cancellationToken = default) + { + Response13 response = await Attempt(() => _api.GetAllRepresentativesAsync(cancellationToken)).ConfigureAwait(false); + ICollection representatives = response.Representatives ?? Array.Empty(); + + return representatives.Select(DecodeRepresentative).ToArray(); + } + + /// Node statistics, as the opaque JSON the reference reports. + public async Task GetNodeStats(CancellationToken cancellationToken = default) + { + object stats = await Attempt(() => _api.GetNodeStatsAsync(cancellationToken)).ConfigureAwait(false); + return (JsonElement)stats; + } + + /// Connected peers, as the opaque JSON the reference reports. + public async Task GetNodePeers(CancellationToken cancellationToken = default) + { + object peers = await Attempt(() => _api.GetPeersAsync(cancellationToken)).ConfigureAwait(false); + return (JsonElement)peers; } /// Every token balance holds. @@ -246,6 +304,52 @@ private static async Task Attempt(Func> operation) private static Certificate DecodeCertificate(GeneratedCertificate record) => new(record.Certificate1, record.Intermediates?.ToArray() ?? Array.Empty()); + /// + /// Map one account's generated state fields to the typed + /// , shared by the single and batch reads. + /// + private AccountState DecodeState( + string? headBlock, + string? headHeight, + string? representative, + AccountInfo? generatedInfo, + ICollection? balances) + { + NodeAccountInfo? info = null; + if (generatedInfo is not null) + { + info = new NodeAccountInfo( + generatedInfo.Name, + generatedInfo.Description, + generatedInfo.Metadata, + OptionalHexAmount(generatedInfo.Supply)); + } + + Crypto.Account? delegated = null; + if (representative is not null) + { + delegated = _runtime.Accounts.FromAccount(representative); + } + + Crypto.BlockHash? head = null; + if (headBlock is not null) + { + head = Crypto.BlockHash.Parse(headBlock); + } + + return new AccountState(head, OptionalHexAmount(headHeight), delegated, info, DecodeBalances(balances)); + } + + /// + /// Map a generated representative to the typed model. The plural endpoint + /// advertises endpoints; the singular lookup does not. + /// + private NodeRepresentative DecodeRepresentative(GeneratedRepresentative representative) => + new( + _runtime.Accounts.FromAccount(representative.Representative1), + OptionalHexAmount(representative.Weight) ?? BigInteger.Zero, + representative.Endpoints?.Api); + /// Map generated balance entries, treating absent amounts as zero. private TokenBalance[] DecodeBalances(ICollection? balances) { diff --git a/src/KeetaNet.Anchor/Services/Node/NodeModels.cs b/src/KeetaNet.Anchor/Services/Node/NodeModels.cs index 9ad3640..cc7c23f 100644 --- a/src/KeetaNet.Anchor/Services/Node/NodeModels.cs +++ b/src/KeetaNet.Anchor/Services/Node/NodeModels.cs @@ -37,3 +37,17 @@ public sealed record AccountState( Account? Representative, NodeAccountInfo? Info, IReadOnlyList Balances); + +/// +/// A representative and its on-ledger voting weight. is +/// the REST endpoint the node advertises for it: the all-representatives read +/// includes it, the singular lookups do not. +/// +public sealed record NodeRepresentative(Account Account, BigInteger Weight, string? ApiUrl); + +/// +/// A point-in-time XOR checksum over the node's ledger, with the approximate +/// it was taken and half the measurement window +/// (, milliseconds). +/// +public sealed record LedgerChecksum(BigInteger Checksum, DateTimeOffset? Moment, double MomentRangeMs); diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs index e544968..56225d8 100644 --- a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs @@ -1,4 +1,5 @@ using System.Numerics; +using System.Text.Json; using KeetaNet.Anchor.Crypto; using Xunit; @@ -78,8 +79,8 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode() Assert.Equal(BlockHash.Parse(head!), state.HeadBlock!.Value); Assert.True(state.HeadHeight >= BigInteger.One); - // Fees nibble at the funded amount; the state and the direct balance - // read must agree on the settled value under the base token. + // Fees deduct from the funded amount, so the state and the direct + // balance read must agree on the settled value under the base token. TokenBalance settled = Assert.Single(state.Balances); Assert.Equal(baseToken.Address, settled.Token.Address); Assert.True(settled.Balance > BigInteger.Zero); @@ -88,12 +89,65 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode() BigInteger direct = await client.GetAccountBalance(holder, baseToken, cancellationToken); Assert.Equal(settled.Balance, direct); - // The token account's own state carries the chain-initialized supply. + // The token account's own state carries the chain-initialized supply, + // and the supply convenience serves the same value. A non-token + // account reports no supply at all. AccountState tokenState = await client.GetAccountState(baseToken, cancellationToken); Assert.NotNull(tokenState.Info); Assert.NotNull(tokenState.Info!.Supply); Assert.True(tokenState.Info.Supply > BigInteger.Zero); + BigInteger? supply = await client.GetTokenSupply(baseToken, cancellationToken); + Assert.Equal(tokenState.Info.Supply, supply); + Assert.Null(await client.GetTokenSupply(holder, cancellationToken)); + + // The batch read returns one state per account in request order, + // agreeing with the individual reads. + IReadOnlyList states = await client.GetAccountStates(new[] { holder, observer }, cancellationToken); + Assert.Equal(2, states.Count); + Assert.Equal(state.HeadBlock, states[0].HeadBlock); + Assert.Equal(settled.Balance, Assert.Single(states[0].Balances).Balance); + Assert.Null(states[1].HeadBlock); + Assert.Empty(states[1].Balances); + + await AssertRepresentativeReads(client, node, cancellationToken); + await AssertNodeDiagnostics(client, cancellationToken); + harness.Shutdown(); } + + /// + /// The three representative reads agree on the chain's one representative: + /// the node's own, the singular lookup, and the advertised set. + /// + private static async Task AssertRepresentativeReads(NodeClient client, LedgerNode node, CancellationToken cancellationToken) + { + NodeRepresentative own = await client.GetNodeRepresentative(cancellationToken); + Assert.Equal(node.Representative, own.Account.Address); + Assert.True(own.Weight > BigInteger.Zero); + + NodeRepresentative named = await client.GetRepresentative(own.Account, cancellationToken); + Assert.Equal(node.Representative, named.Account.Address); + Assert.Equal(own.Weight, named.Weight); + + // Only the plural read advertises the REST endpoint. + IReadOnlyList all = await client.GetAllRepresentatives(cancellationToken); + NodeRepresentative advertised = Assert.Single(all, entry => entry.Account.Address == node.Representative); + Assert.NotNull(advertised.ApiUrl); + Assert.NotEmpty(advertised.ApiUrl!); + } + + /// The diagnostic reads: checksum with a moment, stats and peers as JSON objects. + private static async Task AssertNodeDiagnostics(NodeClient client, CancellationToken cancellationToken) + { + LedgerChecksum checksum = await client.GetLedgerChecksum(cancellationToken); + Assert.NotEqual(BigInteger.Zero, checksum.Checksum); + Assert.NotNull(checksum.Moment); + + JsonElement stats = await client.GetNodeStats(cancellationToken); + Assert.Equal(JsonValueKind.Object, stats.ValueKind); + + JsonElement peers = await client.GetNodePeers(cancellationToken); + Assert.Equal(JsonValueKind.Object, peers.ValueKind); + } } From ed4fdb8d0f09b6c66a2ee69a8ea4c5b107644a56 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Wed, 8 Jul 2026 00:07:02 -0700 Subject: [PATCH 5/5] test: improve test coverage --- cspell.yaml | 1 + src/KeetaNet.Anchor/Crypto/Hashes.cs | 13 ++- src/KeetaNet.Anchor/KeetaException.cs | 16 ---- .../AssetMovement/AssetMovementModels.cs | 2 +- .../AssetFlowTests.cs | 14 ++++ .../KeetaNet.Anchor.E2eTests/NodeFlowTests.cs | 8 ++ tests/KeetaNet.Anchor.Tests/HashTests.cs | 80 +++++++++++++++++++ 7 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 tests/KeetaNet.Anchor.Tests/HashTests.cs diff --git a/cspell.yaml b/cspell.yaml index daae19d..fccfea9 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -56,6 +56,7 @@ words: - belgrave - idisp - lifecycles + - renderable - tlsv - csclient - nswag diff --git a/src/KeetaNet.Anchor/Crypto/Hashes.cs b/src/KeetaNet.Anchor/Crypto/Hashes.cs index 0708b35..23912bc 100644 --- a/src/KeetaNet.Anchor/Crypto/Hashes.cs +++ b/src/KeetaNet.Anchor/Crypto/Hashes.cs @@ -98,9 +98,14 @@ public static string Normalize(string hex, int expectedBytes) throw new KeetaException("HASH_LENGTH", $"expected {expectedBytes * 2} hex characters, got {hex.Length}"); } - // Round-tripping through bytes rejects non-hex characters up front. - // (Convert.ToHexStringLower needs .NET 9. net8.0 is still targeted.) - byte[] value = Convert.FromHexString(hex); - return Convert.ToHexString(value).ToLowerInvariant(); + try + { + byte[] value = Convert.FromHexString(hex); + return Convert.ToHexString(value).ToLowerInvariant(); + } + catch (FormatException error) + { + throw new KeetaException("HASH_FORMAT", "the hash is not valid hex", error); + } } } diff --git a/src/KeetaNet.Anchor/KeetaException.cs b/src/KeetaNet.Anchor/KeetaException.cs index 40b3304..e90a950 100644 --- a/src/KeetaNet.Anchor/KeetaException.cs +++ b/src/KeetaNet.Anchor/KeetaException.cs @@ -23,20 +23,4 @@ public KeetaException(string code, string message, Exception cause) : base($"{co { Code = code; } - - /// - /// Parse a wasm code: message error string back into an exception. - /// - internal static KeetaException Parse(string encoded) - { - int separator = encoded.IndexOf(": ", StringComparison.Ordinal); - if (separator < 0) - { - return new KeetaException("UNKNOWN", encoded); - } - - string code = encoded[..separator]; - string message = encoded[(separator + 2)..]; - return new KeetaException(code, message); - } } diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs index d252ef8..afcc216 100644 --- a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs @@ -190,7 +190,7 @@ public enum AssetContentType { /// Markdown the client may render. Markdown, - /// Plain text the client shows verbatim. + /// Plain text the client shows unchanged. Plaintext, } diff --git a/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs index db4f3a9..05a19b7 100644 --- a/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs @@ -57,6 +57,20 @@ public async Task TransfersRunEndToEndAgainstTheLiveAnchor() JsonElement simulatedInstruction = Assert.Single(simulated.InstructionChoices); Assert.Equal("KEETA_SEND", simulatedInstruction.GetProperty("type").GetString()); + // Promoting the simulation initiates with the simulated request: the + // provider reports the recipient that request carried. + AssetTransfer promoted = await simulated.CreateTransfer(cancellationToken: cancellationToken); + Assert.Equal("123", promoted.Id); + Assert.Equal( + $"123:{anchor.SendToAddress}", + Assert.Single(promoted.InstructionChoices).GetProperty("external").GetString()); + + // A recipient supplied at promotion overrides the simulated one. + AssetTransfer redirected = await simulated.CreateTransfer(anchor.Signer, "integration", cancellationToken); + Assert.Equal( + $"123:{anchor.Signer}", + Assert.Single(redirected.InstructionChoices).GetProperty("external").GetString()); + AssetTransfer transfer = await client.InitiateTransfer(provider, PushTransfer(anchor, anchor.SendToAddress), cancellationToken); Assert.Equal("123", transfer.Id); Assert.Equal( diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs index 56225d8..9d366f5 100644 --- a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs @@ -113,6 +113,14 @@ public async Task LedgerReadsRoundTripAgainstTheLiveNode() await AssertRepresentativeReads(client, node, cancellationToken); await AssertNodeDiagnostics(client, cancellationToken); + // A response from outside the node API surfaces as the stable typed + // failure, with the transport error preserved as its cause. + using NodeClient misRouted = runtime.CreateNodeClient(node.Api + "/bogus"); + KeetaException failure = await Assert.ThrowsAsync( + () => misRouted.GetNodeVersion(cancellationToken)); + Assert.Equal("NODE_STATUS", failure.Code); + Assert.NotNull(failure.InnerException); + harness.Shutdown(); } diff --git a/tests/KeetaNet.Anchor.Tests/HashTests.cs b/tests/KeetaNet.Anchor.Tests/HashTests.cs new file mode 100644 index 0000000..fe60227 --- /dev/null +++ b/tests/KeetaNet.Anchor.Tests/HashTests.cs @@ -0,0 +1,80 @@ +using KeetaNet.Anchor.Crypto; +using Xunit; + +namespace KeetaNet.Anchor.Tests; + +/// +/// The hash value types' public contract: parsing normalizes case, equality +/// is byte equality, and malformed hex is refused with a stable code. +/// +public sealed class HashTests +{ + private const string UpperHex = + "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + + private const string OtherHex = + "0000000000000000000000000000000000000000000000000000000000000001"; + + private const string NonHex = + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; + + [Fact] + public void BlockHashesCompareByValue() => + AssertValueSemantics( + BlockHash.Parse, + static hash => hash.ToBytes(), + static (left, right) => left == right, + static (left, right) => left != right); + + [Fact] + public void CertificateHashesCompareByValue() => + AssertValueSemantics( + CertificateHash.Parse, + static hash => hash.ToBytes(), + static (left, right) => left == right, + static (left, right) => left != right); + + [Theory] + [InlineData("", "HASH_LENGTH")] + [InlineData("abc123", "HASH_LENGTH")] + [InlineData(UpperHex + "00", "HASH_LENGTH")] + [InlineData(NonHex, "HASH_FORMAT")] + public void MalformedHexIsRefusedWithAStableCode(string hex, string expectedCode) + { + KeetaException blockFailure = Assert.Throws(() => BlockHash.Parse(hex)); + Assert.Equal(expectedCode, blockFailure.Code); + + KeetaException certificateFailure = Assert.Throws(() => CertificateHash.Parse(hex)); + Assert.Equal(expectedCode, certificateFailure.Code); + } + + /// + /// The value semantics both hash types share: case-insensitive parsing to + /// one lowercase identity, byte equality across every equality surface, + /// and a default instance that behaves as an empty value. + /// + private static void AssertValueSemantics( + Func parse, + Func toBytes, + Func equal, + Func notEqual) + where T : struct, IEquatable + { + T upper = parse(UpperHex); + T lower = parse(UpperHex.ToLowerInvariant()); + T other = parse(OtherHex); + + Assert.Equal(UpperHex.ToLowerInvariant(), upper.ToString()); + Assert.True(equal(upper, lower)); + Assert.True(upper.Equals((object)lower)); + Assert.Equal(upper.GetHashCode(), lower.GetHashCode()); + + Assert.True(notEqual(upper, other)); + Assert.False(upper.Equals(null)); + + Assert.Equal(Convert.FromHexString(UpperHex), toBytes(upper)); + + Assert.Equal("", default(T).ToString()); + Assert.True(equal(default, default)); + } +}