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).
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/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
new file mode 100644
index 0000000..6074af9
--- /dev/null
+++ b/src/KeetaNet.Anchor/Services/AssetMovement/KeetaBlockerException.cs
@@ -0,0 +1,66 @@
+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 recognized 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 || Describe(blocker) is not { } summary)
+ {
+ return null;
+ }
+
+ return new KeetaBlockerException(code, summary, 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",
+ _ => null,
+ };
+}
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/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
{
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