From 2f313ca39ead23a26bed0560a6afa1de58fd473f Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 9 Jul 2026 16:34:24 -0700 Subject: [PATCH 1/3] fix: update naming --- scripts/pins.env | 4 +- src/KeetaNet.Anchor/Crypto/Account.cs | 2 +- src/KeetaNet.Anchor/Crypto/AccountFactory.cs | 2 +- .../Interop/WasmRuntime.Crypto.cs | 5 +- src/KeetaNet.Anchor/Interop/WasmRuntime.cs | 8 ++- src/KeetaNet.Anchor/KeetaException.cs | 6 +- .../AssetMovement/KeetaBlockerException.cs | 63 +++++++++++++++++++ .../AssetFlowTests.cs | 19 ++++++ tests/node-harness/src/asset.ts | 13 ++++ 9 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 src/KeetaNet.Anchor/Services/AssetMovement/KeetaBlockerException.cs diff --git a/scripts/pins.env b/scripts/pins.env index a6ef468..ed4e4db 100644 --- a/scripts/pins.env +++ b/scripts/pins.env @@ -3,8 +3,8 @@ # The wasm core (scripts/build-wasm.sh). ANCHOR_WASI_CRATE="keetanetwork-anchor-client-wasi" -ANCHOR_WASI_VERSION="0.2.1" -ANCHOR_WASI_SHA256="1eeffcc3bff2fa78caf8df7b3774a7bb183c18e52ccaabc969d7c1bcee6e3380" +ANCHOR_WASI_VERSION="0.2.2" +ANCHOR_WASI_SHA256="ec7e987bb55b98efca8ad071f983135161d6fd19aec7780ee563674454c1051e" # The canonical node OpenAPI spec (scripts/generate-node-api.sh). NODE_CLIENT_CRATE="keetanetwork-client" diff --git a/src/KeetaNet.Anchor/Crypto/Account.cs b/src/KeetaNet.Anchor/Crypto/Account.cs index 7102c3a..588de32 100644 --- a/src/KeetaNet.Anchor/Crypto/Account.cs +++ b/src/KeetaNet.Anchor/Crypto/Account.cs @@ -12,7 +12,7 @@ internal Account(WasmRuntime runtime, int handle) } /// The account's textual keeta_... public-key string. - public string PublicKeyString => Runtime.AccountAddress(Handle); + public string PublicKeyString => Runtime.AccountPublicKeyString(Handle); /// The account's algorithm name. public string Algorithm => Runtime.AccountAlgorithm(Handle); diff --git a/src/KeetaNet.Anchor/Crypto/AccountFactory.cs b/src/KeetaNet.Anchor/Crypto/AccountFactory.cs index 811e218..96b845d 100644 --- a/src/KeetaNet.Anchor/Crypto/AccountFactory.cs +++ b/src/KeetaNet.Anchor/Crypto/AccountFactory.cs @@ -22,7 +22,7 @@ public Account FromSeed(string seed, uint index, string algorithm) /// Build a read-only account from its textual keeta_... public-key string. public Account FromPublicKeyString(string publicKeyString) { - int handle = _runtime.AccountFromAddress(publicKeyString); + int handle = _runtime.AccountFromPublicKeyString(publicKeyString); return new(_runtime, handle); } diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Crypto.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Crypto.cs index 19472a1..6513f5a 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Crypto.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Crypto.cs @@ -13,7 +13,8 @@ public sealed partial class WasmRuntime internal int AccountFromSeed(string seed, uint index, string algorithm) => DerivedAccount("keeta_account_from_seed", seed, index, algorithm); - internal int AccountFromAddress(string address) => ParseText("keeta_account_from_address", address); + internal int AccountFromPublicKeyString(string publicKeyString) => + ParseText("keeta_account_from_public_key_string", publicKeyString); internal int AccountFromPrivateKey(string privateKey, string algorithm) => AccountFromKeyMaterial("keeta_account_from_private_key", privateKey, algorithm); @@ -66,7 +67,7 @@ internal byte[] AccountEncrypt(int handle, byte[] plaintext) => internal byte[] AccountDecrypt(int handle, byte[] ciphertext) => WithHandleAndBytes("keeta_account_decrypt", handle, ciphertext); - internal string AccountAddress(int handle) => TextOf("keeta_account_address", handle); + internal string AccountPublicKeyString(int handle) => TextOf("keeta_account_public_key_string", handle); internal string AccountAlgorithm(int handle) => TextOf("keeta_account_algorithm", handle); diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.cs index 601b0c2..df54872 100644 --- a/src/KeetaNet.Anchor/Interop/WasmRuntime.cs +++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.cs @@ -552,7 +552,11 @@ private byte[] ReadAndFreeBytes(int handle) } } - /// Build an exception from the module's pending code/message. + /// + /// Build an exception from the module's pending code/message. + /// An asset-movement blocker code carries the blocker as JSON in the + /// message, surfaced typed as a . + /// private KeetaException LastError() { string code = ReadErrorPart(_lastErrorCode()); @@ -567,7 +571,7 @@ private KeetaException LastError() message = "operation failed"; } - return new KeetaException(code, message); + return KeetaBlockerException.TryDecode(code, message) ?? new KeetaException(code, message); } /// Read one optional last-error part (an empty string when absent). diff --git a/src/KeetaNet.Anchor/KeetaException.cs b/src/KeetaNet.Anchor/KeetaException.cs index e90a950..dab52d8 100644 --- a/src/KeetaNet.Anchor/KeetaException.cs +++ b/src/KeetaNet.Anchor/KeetaException.cs @@ -2,9 +2,11 @@ namespace KeetaNet.Anchor; /// /// A failure surfaced from the SDK (the wasm core or the node transport): a -/// programmatic plus a human-readable message. +/// programmatic plus a human-readable message. Failures +/// that carry a typed payload derive from it, such as +/// . /// -public sealed class KeetaException : Exception +public class KeetaException : Exception { /// The stable, machine-readable error code. public string Code { get; } diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/KeetaBlockerException.cs b/src/KeetaNet.Anchor/Services/AssetMovement/KeetaBlockerException.cs new file mode 100644 index 0000000..59e26e0 --- /dev/null +++ b/src/KeetaNet.Anchor/Services/AssetMovement/KeetaBlockerException.cs @@ -0,0 +1,63 @@ +using System.Text.Json; + +namespace KeetaNet.Anchor; + +/// +/// An anchor refusal carrying a typed asset-movement blocker: the operation +/// cannot proceed until the user resolves . +/// +public sealed class KeetaBlockerException : KeetaException +{ + /// Every recognized blocker transport code starts with this. + internal const string CodePrefix = "KEETA_ANCHOR_ASSET_MOVEMENT_"; + + /// The typed blocker the user must resolve. + public AssetMovementBlocker Blocker { get; } + + private KeetaBlockerException(string code, string message, AssetMovementBlocker blocker) + : base(code, message) + { + Blocker = blocker; + } + + /// + /// Rehydrate a blocker failure from the core's last-error parts, or null + /// when is not a blocker transport code or + /// does not decode as a blocker. + /// + internal static KeetaBlockerException? TryDecode(string code, string payload) + { + if (!code.StartsWith(CodePrefix, StringComparison.Ordinal)) + { + return null; + } + + AssetMovementBlocker? blocker; + try + { + blocker = JsonSerializer.Deserialize(payload, KeetaJson.Options); + } + catch (JsonException) + { + return null; + } + + if (blocker is null) + { + return null; + } + + return new KeetaBlockerException(code, Describe(blocker), blocker); + } + + /// A human-readable summary of what the user must resolve. + private static string Describe(AssetMovementBlocker blocker) => + blocker switch + { + AssetKycShareNeededBlocker => "the provider requires KYC attributes to be shared first", + AssetAdditionalKycNeededBlocker => "the provider requires additional KYC steps", + AssetOperationNotSupportedBlocker => "the provider does not support this operation", + AssetUserActionNeededBlocker => "the provider requires on-ledger user actions", + _ => "the provider reported a blocker", + }; +} diff --git a/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs index 9866cb0..885618b 100644 --- a/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs +++ b/tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs @@ -287,6 +287,25 @@ await Assert.ThrowsAsync( session.Shutdown(); } + [Fact] + public async Task ARefusedShareSurfacesTheTypedKycBlocker() + { + using var session = AssetSession.Open(); + (AssetMovementClient client, AssetAnchor anchor, CancellationToken cancellationToken) = session; + AssetProvider provider = await session.DiscoveredProviderAsync(); + + // The anchor refuses the magic attributes with a 403 blocker envelope + KeetaBlockerException refusal = await Assert.ThrowsAsync( + () => client.ShareKycAttributes(provider, new AssetShareKycRequest("blocked"), cancellationToken)); + + Assert.Equal("KEETA_ANCHOR_ASSET_MOVEMENT_KYC_SHARE_NEEDED", refusal.Code); + var share = Assert.IsType(refusal.Blocker); + Assert.Equal(BlockedAttributes, share.NeededAttributes); + Assert.Equal(new[] { anchor.SendToAddress }, share.ShareWithPrincipals); + + session.Shutdown(); + } + /// A push transfer moving the base token from the EVM location to Keeta. private static AssetTransferRequest PushTransfer(AssetAnchor anchor, object? recipient) => new( diff --git a/tests/node-harness/src/asset.ts b/tests/node-harness/src/asset.ts index eca07e5..04ce91c 100644 --- a/tests/node-harness/src/asset.ts +++ b/tests/node-harness/src/asset.ts @@ -335,6 +335,19 @@ function assetCallbacks(baseTokenAccount: TokenAccount, sendToAccount: SigningAc }, shareKYC: async function(request) { + /* + * A magic attributes string exercises the refusal path: the anchor + * throws the typed KYC-share-needed error, which the server + * serializes into a 403 blocker envelope for a binding to decode. + */ + if (request.attributes.includes('blocked')) { + throw(new assetCommon.Errors.KYCShareNeeded({ + neededAttributes: ['fullName', 'dateOfBirth'], + shareWithPrincipals: [sendToAccount], + acceptedIssuers: [[{ name: 'CN', value: 'Anchor Test CA' }]] + })); + } + /* * A magic attributes string exercises the pending path: the anchor * reports the share pending and hands back a root-relative promise From acbe08d0ae7697a340ea444ba80a28d23cf65699 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 9 Jul 2026 16:44:39 -0700 Subject: [PATCH 2/3] doc: add `beta` disclaimer --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 81f94ec..4f19dbf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# anchor-csharp +# anchor-csharp - BETA + +> Warning: This SDK is currently in beta and is subject to change without warning C# SDK for the KeetaNet anchor. The client logic runs inside a sandboxed WebAssembly core (`keetanetwork_anchor_client_wasi.wasm`, built from [anchor-rs](https://github.com/KeetaNetwork/anchor-rs)) hosted in-process by [Wasmtime](https://github.com/bytecodealliance/wasmtime-dotnet). From 674d6c3100e74c79ab545cce1ecddd96213d1ce6 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 9 Jul 2026 16:55:25 -0700 Subject: [PATCH 3/3] test: improve coverage --- .../AssetMovement/AssetMovementClient.cs | 3 +- .../AssetMovement/KeetaBlockerException.cs | 15 +++--- .../KeetaNet.Anchor.Tests/AssetModelTests.cs | 5 +- .../BlockerExceptionTests.cs | 48 +++++++++++++++++++ tests/KeetaNet.Anchor.Tests/KycModelTests.cs | 3 +- 5 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 tests/KeetaNet.Anchor.Tests/BlockerExceptionTests.cs diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs index f6456a0..2d963cc 100644 --- a/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs +++ b/src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs @@ -80,8 +80,7 @@ public async Task> GetProvidersForTransfer( /// /// The provider's advertised legal disclaimers, or null when its metadata - /// carries none. Malformed entries are skipped, mirroring the reference - /// getLegalDisclaimers. + /// carries none. Malformed entries are skipped. /// public IReadOnlyList? GetLegalDisclaimers(AssetProvider provider) { diff --git a/src/KeetaNet.Anchor/Services/AssetMovement/KeetaBlockerException.cs b/src/KeetaNet.Anchor/Services/AssetMovement/KeetaBlockerException.cs index 59e26e0..6074af9 100644 --- a/src/KeetaNet.Anchor/Services/AssetMovement/KeetaBlockerException.cs +++ b/src/KeetaNet.Anchor/Services/AssetMovement/KeetaBlockerException.cs @@ -23,7 +23,7 @@ private KeetaBlockerException(string code, string message, AssetMovementBlocker /// /// Rehydrate a blocker failure from the core's last-error parts, or null /// when is not a blocker transport code or - /// does not decode as a blocker. + /// does not decode as a recognized blocker. /// internal static KeetaBlockerException? TryDecode(string code, string payload) { @@ -42,22 +42,25 @@ private KeetaBlockerException(string code, string message, AssetMovementBlocker return null; } - if (blocker is null) + if (blocker is null || Describe(blocker) is not { } summary) { return null; } - return new KeetaBlockerException(code, Describe(blocker), blocker); + return new KeetaBlockerException(code, summary, blocker); } - /// A human-readable summary of what the user must resolve. - private static string Describe(AssetMovementBlocker blocker) => + /// + /// A human-readable summary of what the user must resolve, or null for a + /// shape that does not surface typed. + /// + private static string? Describe(AssetMovementBlocker blocker) => blocker switch { AssetKycShareNeededBlocker => "the provider requires KYC attributes to be shared first", AssetAdditionalKycNeededBlocker => "the provider requires additional KYC steps", AssetOperationNotSupportedBlocker => "the provider does not support this operation", AssetUserActionNeededBlocker => "the provider requires on-ledger user actions", - _ => "the provider reported a blocker", + _ => null, }; } diff --git a/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs b/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs index 86830bd..b8e2b6a 100644 --- a/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs +++ b/tests/KeetaNet.Anchor.Tests/AssetModelTests.cs @@ -5,7 +5,7 @@ namespace KeetaNet.Anchor.Tests; /// -/// The offline asset-movement model surface: typed blocker decoding from the +/// 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. /// @@ -82,8 +82,7 @@ public void LegalDisclaimersDecodeAndSkipMalformedEntries() using Account account = runtime.Accounts.FromSeed(TestSeeds.Subject, 0, TestSeeds.DefaultAlgorithm); using AssetMovementClient client = runtime.CreateAssetMovementClient(TestSeeds.NonRoutableAnchor, account.PublicKeyString, account); - // One well-formed markdown disclaimer and one with an unknown purpose; - // the malformed entry is skipped, mirroring the reference. + // One well-formed markdown disclaimer and one with an unknown purpose AssetProvider provider = Provider(legal: """ { "disclaimers": [ diff --git a/tests/KeetaNet.Anchor.Tests/BlockerExceptionTests.cs b/tests/KeetaNet.Anchor.Tests/BlockerExceptionTests.cs new file mode 100644 index 0000000..896c640 --- /dev/null +++ b/tests/KeetaNet.Anchor.Tests/BlockerExceptionTests.cs @@ -0,0 +1,48 @@ +using Xunit; + +namespace KeetaNet.Anchor.Tests; + +/// +/// The blocker-exception decode contract at the core error boundary: every +/// recognized blocker payload rehydrates typed, and anything else falls back +/// to the plain failure path so a newer core never breaks an older binding. +/// +public sealed class BlockerExceptionTests +{ + private const string ShareCode = "KEETA_ANCHOR_ASSET_MOVEMENT_KYC_SHARE_NEEDED"; + + [Theory] + [InlineData( + """{"type":"kycShareNeeded","tosFlow":null,"neededAttributes":["fullName"],"shareWithPrincipals":["keeta_p"],"acceptedIssuers":[]}""", + typeof(AssetKycShareNeededBlocker))] + [InlineData( + """{"type":"additionalKycNeeded","toCompleteFlow":null}""", + typeof(AssetAdditionalKycNeededBlocker))] + [InlineData( + """{"type":"operationNotSupported","forAsset":"asset","forRail":"ACH_DEBIT"}""", + typeof(AssetOperationNotSupportedBlocker))] + [InlineData( + """{"type":"userActionNeeded","actionsNeeded":[]}""", + typeof(AssetUserActionNeededBlocker))] + public void ARecognizedPayloadDecodesToItsTypedBlocker(string payload, Type blockerType) + { + KeetaBlockerException? decoded = KeetaBlockerException.TryDecode(ShareCode, payload); + + Assert.NotNull(decoded); + Assert.Equal(ShareCode, decoded!.Code); + Assert.IsType(blockerType, decoded.Blocker); + } + + // A non-blocker code, a blocker type this binding does not know (a newer + // core), an unrecognized "other" shape, a non-JSON message, and a JSON + // null must all fall back to the plain failure path instead of throwing + // out of the decoder. + [Theory] + [InlineData("SERVICE", """{"type":"kycShareNeeded"}""")] + [InlineData("KEETA_ANCHOR_ASSET_MOVEMENT_SOMETHING_NEW", """{"type":"somethingNew"}""")] + [InlineData(ShareCode, """{"type":"other","name":"SomeError","code":"SOMETHING_ELSE","message":"boom"}""")] + [InlineData(ShareCode, "not json at all")] + [InlineData(ShareCode, "null")] + public void AnUnrecognizedPayloadFallsBackToAPlainFailure(string code, string payload) => + Assert.Null(KeetaBlockerException.TryDecode(code, payload)); +} diff --git a/tests/KeetaNet.Anchor.Tests/KycModelTests.cs b/tests/KeetaNet.Anchor.Tests/KycModelTests.cs index c6897af..998f509 100644 --- a/tests/KeetaNet.Anchor.Tests/KycModelTests.cs +++ b/tests/KeetaNet.Anchor.Tests/KycModelTests.cs @@ -3,8 +3,7 @@ namespace KeetaNet.Anchor.Tests; /// -/// The offline KYC model folds, mirroring the reference -/// getSupportedCountries aggregation cases. +/// KYC model fold /// public sealed class KycModelTests {