Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down
4 changes: 2 additions & 2 deletions scripts/pins.env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/KeetaNet.Anchor/Crypto/Account.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal Account(WasmRuntime runtime, int handle)
}

/// <summary>The account's textual <c>keeta_...</c> public-key string.</summary>
public string PublicKeyString => Runtime.AccountAddress(Handle);
public string PublicKeyString => Runtime.AccountPublicKeyString(Handle);

/// <summary>The account's algorithm name.</summary>
public string Algorithm => Runtime.AccountAlgorithm(Handle);
Expand Down
2 changes: 1 addition & 1 deletion src/KeetaNet.Anchor/Crypto/AccountFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public Account FromSeed(string seed, uint index, string algorithm)
/// <summary>Build a read-only account from its textual <c>keeta_...</c> public-key string.</summary>
public Account FromPublicKeyString(string publicKeyString)
{
int handle = _runtime.AccountFromAddress(publicKeyString);
int handle = _runtime.AccountFromPublicKeyString(publicKeyString);
return new(_runtime, handle);
}

Expand Down
5 changes: 3 additions & 2 deletions src/KeetaNet.Anchor/Interop/WasmRuntime.Crypto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
8 changes: 6 additions & 2 deletions src/KeetaNet.Anchor/Interop/WasmRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,11 @@ private byte[] ReadAndFreeBytes(int handle)
}
}

/// <summary>Build an exception from the module's pending <c>code</c>/<c>message</c>.</summary>
/// <summary>
/// Build an exception from the module's pending <c>code</c>/<c>message</c>.
/// An asset-movement blocker code carries the blocker as JSON in the
/// message, surfaced typed as a <see cref="KeetaBlockerException"/>.
/// </summary>
private KeetaException LastError()
{
string code = ReadErrorPart(_lastErrorCode());
Expand All @@ -567,7 +571,7 @@ private KeetaException LastError()
message = "operation failed";
}

return new KeetaException(code, message);
return KeetaBlockerException.TryDecode(code, message) ?? new KeetaException(code, message);
}

/// <summary>Read one optional last-error part (an empty string when absent).</summary>
Expand Down
6 changes: 4 additions & 2 deletions src/KeetaNet.Anchor/KeetaException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ namespace KeetaNet.Anchor;

/// <summary>
/// A failure surfaced from the SDK (the wasm core or the node transport): a
/// programmatic <see cref="Code"/> plus a human-readable message.
/// programmatic <see cref="Code"/> plus a human-readable message. Failures
/// that carry a typed payload derive from it, such as
/// <see cref="KeetaBlockerException"/>.
/// </summary>
public sealed class KeetaException : Exception
public class KeetaException : Exception
{
/// <summary>The stable, machine-readable error code.</summary>
public string Code { get; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,7 @@ public async Task<IReadOnlyList<AssetProvider>> GetProvidersForTransfer(

/// <summary>
/// The provider's advertised legal disclaimers, or null when its metadata
/// carries none. Malformed entries are skipped, mirroring the reference
/// <c>getLegalDisclaimers</c>.
/// carries none. Malformed entries are skipped.
/// </summary>
public IReadOnlyList<AssetDisclaimer>? GetLegalDisclaimers(AssetProvider provider)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.Text.Json;

namespace KeetaNet.Anchor;

/// <summary>
/// An anchor refusal carrying a typed asset-movement blocker: the operation
/// cannot proceed until the user resolves <see cref="Blocker"/>.
/// </summary>
public sealed class KeetaBlockerException : KeetaException
{
/// <summary>Every recognized blocker transport code starts with this.</summary>
internal const string CodePrefix = "KEETA_ANCHOR_ASSET_MOVEMENT_";

/// <summary>The typed blocker the user must resolve.</summary>
public AssetMovementBlocker Blocker { get; }

private KeetaBlockerException(string code, string message, AssetMovementBlocker blocker)
: base(code, message)
{
Blocker = blocker;
}

/// <summary>
/// Rehydrate a blocker failure from the core's last-error parts, or null
/// when <paramref name="code"/> is not a blocker transport code or
/// <paramref name="payload"/> does not decode as a recognized blocker.
/// </summary>
internal static KeetaBlockerException? TryDecode(string code, string payload)
{
if (!code.StartsWith(CodePrefix, StringComparison.Ordinal))
{
return null;
}

AssetMovementBlocker? blocker;
try
{
blocker = JsonSerializer.Deserialize<AssetMovementBlocker>(payload, KeetaJson.Options);
}
catch (JsonException)
{
return null;
}

if (blocker is null || Describe(blocker) is not { } summary)
{
return null;
}

return new KeetaBlockerException(code, summary, blocker);
}

/// <summary>
/// A human-readable summary of what the user must resolve, or null for a
/// shape that does not surface typed.
/// </summary>
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,
};
}
19 changes: 19 additions & 0 deletions tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,25 @@ await Assert.ThrowsAsync<KeetaException>(
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<KeetaBlockerException>(
() => client.ShareKycAttributes(provider, new AssetShareKycRequest("blocked"), cancellationToken));

Assert.Equal("KEETA_ANCHOR_ASSET_MOVEMENT_KYC_SHARE_NEEDED", refusal.Code);
var share = Assert.IsType<AssetKycShareNeededBlocker>(refusal.Blocker);
Assert.Equal(BlockedAttributes, share.NeededAttributes);
Assert.Equal(new[] { anchor.SendToAddress }, share.ShareWithPrincipals);

session.Shutdown();
}

/// <summary>A push transfer moving the base token from the EVM location to Keeta.</summary>
private static AssetTransferRequest PushTransfer(AssetAnchor anchor, object? recipient) =>
new(
Expand Down
5 changes: 2 additions & 3 deletions tests/KeetaNet.Anchor.Tests/AssetModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace KeetaNet.Anchor.Tests;

/// <summary>
/// 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.
/// </summary>
Expand Down Expand Up @@ -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": [
Expand Down
48 changes: 48 additions & 0 deletions tests/KeetaNet.Anchor.Tests/BlockerExceptionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Xunit;

namespace KeetaNet.Anchor.Tests;

/// <summary>
/// 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.
/// </summary>
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));
}
3 changes: 1 addition & 2 deletions tests/KeetaNet.Anchor.Tests/KycModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
namespace KeetaNet.Anchor.Tests;

/// <summary>
/// The offline KYC model folds, mirroring the reference
/// <c>getSupportedCountries</c> aggregation cases.
/// KYC model fold
/// </summary>
public sealed class KycModelTests
{
Expand Down
13 changes: 13 additions & 0 deletions tests/node-harness/src/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading