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
8 changes: 4 additions & 4 deletions scripts/pins.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@

# The wasm core (scripts/build-wasm.sh).
ANCHOR_WASI_CRATE="keetanetwork-anchor-client-wasi"
ANCHOR_WASI_VERSION="0.2.2"
ANCHOR_WASI_SHA256="ec7e987bb55b98efca8ad071f983135161d6fd19aec7780ee563674454c1051e"
ANCHOR_WASI_VERSION="0.5.0"
ANCHOR_WASI_SHA256="15f10d85fea29b6edd680d8bdca7c225f02e774e3e5b73a0db728bf6e8993a64"

# 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"
NODE_CLIENT_VERSION="0.5.1"
NODE_CLIENT_SHA256="5fec3674e8721635018576842e1f65066a16bcc6b3601f62f3c6e682a48f54c6"
442 changes: 221 additions & 221 deletions src/KeetaNet.Anchor/Generated/Node/NodeApi.cs

Large diffs are not rendered by default.

42 changes: 40 additions & 2 deletions src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,44 @@ public async Task<IReadOnlyList<AssetProvider>> GetProvidersForTransfer(
return disclaimers;
}

/// <summary>
/// The provider's identifying details published under
/// <c>legal.anchorDetails</c>, or null when its metadata carries none. A
/// malformed description is dropped while the name and logo are kept.
/// </summary>
public AssetAnchorDetails? GetProviderAnchorDetails(AssetProvider provider)
{
if (provider.Legal is not { } legal
|| legal.ValueKind != JsonValueKind.Object
|| !legal.TryGetProperty("anchorDetails", out JsonElement details)
|| details.ValueKind != JsonValueKind.Object)
{
return null;
}

string? name = ReadOptionalString(details, "name");
string? logo = ReadOptionalString(details, "logo");

AssetRenderableContent? description = null;
if (details.TryGetProperty("description", out JsonElement rawDescription))
{
TryDeserialize(rawDescription, out description);
}

return new AssetAnchorDetails(name, description, logo);
}

/// <summary>The member's string value, or null when absent or not a string.</summary>
private static string? ReadOptionalString(JsonElement element, string name)
{
if (!element.TryGetProperty(name, out JsonElement found) || found.ValueKind != JsonValueKind.String)
{
return null;
}

return found.GetString();
}

/// <summary>
/// The legal disclaimers advertised by the provider with
/// <paramref name="id"/>, or null when the provider or its disclaimers are
Expand Down Expand Up @@ -236,11 +274,11 @@ public Task<AssetTemplatePage> ListForwardingAddressTemplates(
ReadOperationAsync<AssetTemplatePage>(Runtime.AssetListForwardingAddressTemplates, provider, request, cancellationToken);

/// <summary>Create a persistent-forwarding address, returning its (obfuscated) details.</summary>
public Task<JsonElement> CreatePersistentForwardingAddress(
public Task<AssetForwardingAddress> CreatePersistentForwardingAddress(
AssetProvider provider,
AssetCreateAddressRequest request,
CancellationToken cancellationToken = default) =>
ReadOperationAsync<JsonElement>(Runtime.AssetCreatePersistentForwardingAddress, provider, request, cancellationToken);
ReadOperationAsync<AssetForwardingAddress>(Runtime.AssetCreatePersistentForwardingAddress, provider, request, cancellationToken);

/// <summary>List persistent-forwarding addresses.</summary>
public Task<AssetAddressPage> ListForwardingAddresses(
Expand Down
116 changes: 113 additions & 3 deletions src/KeetaNet.Anchor/Services/AssetMovement/AssetMovementModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,15 @@ public sealed record AssetCreateAddressRequest(
object? DestinationAddress = null,
string? PersistentAddressTemplateId = null);

/// <summary>One filter over persistent-forwarding addresses.</summary>
/// <summary>
/// One filter over persistent-forwarding addresses. <see cref="Asset"/> is a
/// single canonical asset or a <c>{ from, to }</c> conversion pair, matching
/// <see cref="AssetProviderSearch.Asset"/>.
/// </summary>
public sealed record AssetAddressFilter(
string? SourceLocation = null,
string? DestinationLocation = null,
string? Asset = null,
AssetOrPair? Asset = null,
string? DestinationAddress = null,
string? PersistentAddressTemplateId = null);

Expand Down Expand Up @@ -161,8 +165,104 @@ public sealed record AssetForwardingTemplate(string Id, JsonElement Location, Js
/// <summary>A page of persistent-forwarding templates.</summary>
public sealed record AssetTemplatePage(IReadOnlyList<JsonElement> Templates, string Total);

/// <summary>
/// A canonical asset id, or an id located at a canonical location (the
/// reference <c>AssetOrAssetWithLocation</c>). A bare id crosses the wire as a
/// string, a located id as <c>{ id, location }</c>.
/// </summary>
[JsonConverter(typeof(AssetOrAssetWithLocationConverter))]
public sealed record AssetOrAssetWithLocation(string Id, string? Location = null);

/// <summary>
/// Reads and writes the reference wire form: a bare id string when
/// <see cref="AssetOrAssetWithLocation.Location"/> is absent, otherwise an
/// <c>{ id, location }</c> object.
/// </summary>
internal sealed class AssetOrAssetWithLocationConverter : JsonConverter<AssetOrAssetWithLocation>
{
public override AssetOrAssetWithLocation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
return new AssetOrAssetWithLocation(reader.GetString() ?? "");
}

using var document = JsonDocument.ParseValue(ref reader);
JsonElement element = document.RootElement;
if (!element.TryGetProperty("id", out JsonElement id)
|| !element.TryGetProperty("location", out JsonElement location)
|| id.ValueKind != JsonValueKind.String
|| location.ValueKind != JsonValueKind.String)
{
throw new JsonException("a located asset requires string 'id' and 'location' members");
}

return new AssetOrAssetWithLocation(id.GetString()!, location.GetString());
}

public override void Write(Utf8JsonWriter writer, AssetOrAssetWithLocation value, JsonSerializerOptions options)
{
if (value.Location is null)
{
writer.WriteStringValue(value.Id);
return;
}

writer.WriteStartObject();
writer.WriteString("id", value.Id);
writer.WriteString("location", value.Location);
writer.WriteEndObject();
}
}

/// <summary>
/// One fee line item in a breakdown (the reference resolved/unresolved fee
/// line-item union flattened): a fixed fee carries <see cref="Value"/>, an
/// unresolved variable fee carries <see cref="BasisPoints"/>, a resolved
/// variable fee carries both.
/// </summary>
public sealed record AssetFeeLineItem(
string Purpose,
string? Value = null,
double? BasisPoints = null,
AssetOrAssetWithLocation? Asset = null,
AssetRenderableContent? Details = null);

/// <summary>
/// A fee breakdown: its line items, with an optional pre-computed total. An
/// unset <see cref="Total"/> is the sum of the line items; an unset
/// <see cref="TotalPricedIn"/> is the transferred asset.
/// </summary>
public sealed record AssetFeeBreakdown(
IReadOnlyList<AssetFeeLineItem> LineItems,
string? Total = null,
AssetOrAssetWithLocation? TotalPricedIn = null);

/// <summary>The smallest transfer a persistent-forwarding address accepts.</summary>
public sealed record AssetMinimumTransferValue(string Asset, string Value);

/// <summary>
/// One persistent-forwarding address (the reference
/// <c>KeetaPersistentForwardingAddressDetails</c>). <see cref="Address"/> and
/// the location/destination members stay raw JSON: each is resolved or
/// obfuscated at the provider's discretion; decode a resolved address with
/// <see cref="AssetAddress.Parse"/>.
/// </summary>
public sealed record AssetForwardingAddress(
JsonElement Address,
string? Id = null,
JsonElement? DepositMessage = null,
AssetOrPair? Asset = null,
JsonElement? SourceLocation = null,
JsonElement? DestinationLocation = null,
JsonElement? DestinationAddress = null,
string? OutgoingRail = null,
IReadOnlyList<string>? IncomingRail = null,
AssetMinimumTransferValue? MinimumTransferValue = null,
AssetFeeBreakdown? Fees = null);

/// <summary>A page of persistent-forwarding addresses.</summary>
public sealed record AssetAddressPage(IReadOnlyList<JsonElement> Addresses, string Total);
public sealed record AssetAddressPage(IReadOnlyList<AssetForwardingAddress> Addresses, string Total);

/// <summary>A page of asset-movement transactions.</summary>
public sealed record AssetTransactionPage(IReadOnlyList<JsonElement> Transactions, string Total);
Expand Down Expand Up @@ -204,6 +304,16 @@ public sealed record AssetRenderableContent(AssetContentType Type, string Conten
/// <summary>One legal disclaimer a provider publishes under its <c>legal</c> metadata.</summary>
public sealed record AssetDisclaimer(AssetDisclaimerPurpose Purpose, AssetRenderableContent Content);

/// <summary>
/// The identifying details a provider publishes under
/// <c>legal.anchorDetails</c> (the reference
/// <c>AnchorMetadataLegalAnchorDetails</c>).
/// </summary>
public sealed record AssetAnchorDetails(
string? Name = null,
AssetRenderableContent? Description = null,
string? Logo = null);

/// <summary>
/// The token metadata a provider advertises for one asset at one location
/// (the reference <c>AnchorTokenLocationMetadata</c>).
Expand Down
18 changes: 9 additions & 9 deletions src/KeetaNet.Anchor/Services/Node/NodeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ internal NodeClient(WasmRuntime runtime, string nodeUrl, HttpClient? http = null
/// <summary>The node software version string.</summary>
public async Task<string> GetNodeVersion(CancellationToken cancellationToken = default)
{
Response4 response = await Attempt(() => _api.GetNodeVersionAsync(cancellationToken)).ConfigureAwait(false);
GetNodeVersionResponse response = await Attempt(() => _api.GetNodeVersionAsync(cancellationToken)).ConfigureAwait(false);
return response.Node ?? "";
}

Expand All @@ -55,7 +55,7 @@ public async Task<AccountState> GetAccountState(
Crypto.Account account,
CancellationToken cancellationToken = default)
{
Response5 state = await Attempt(() => _api.GetAccountStateAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false);
GetAccountStateResponse state = await Attempt(() => _api.GetAccountStateAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false);
return DecodeState(state.CurrentHeadBlock, state.CurrentHeadBlockHeight, state.Representative, state.Info, state.Balances);
}

Expand All @@ -68,7 +68,7 @@ public async Task<IReadOnlyList<AccountState>> GetAccountStates(
CancellationToken cancellationToken = default)
{
string joined = string.Join(",", accounts.Select(account => account.PublicKeyString));
ICollection<Anonymous> states = await Attempt(() => _api.GetAccountStatesAsync(joined, cancellationToken)).ConfigureAwait(false);
ICollection<GetAccountStatesResponseItem> states = await Attempt(() => _api.GetAccountStatesAsync(joined, cancellationToken)).ConfigureAwait(false);

return states
.Select(item => DecodeState(item.CurrentHeadBlock, item.CurrentHeadBlockHeight, item.Representative, item.Info, item.Balances))
Expand All @@ -90,7 +90,7 @@ public async Task<IReadOnlyList<AccountState>> GetAccountStates(
/// <summary>The point-in-time XOR checksum of the node's ledger.</summary>
public async Task<LedgerChecksum> GetLedgerChecksum(CancellationToken cancellationToken = default)
{
Response12 checksum = await Attempt(() => _api.GetLedgerChecksumAsync(cancellationToken)).ConfigureAwait(false);
GetLedgerChecksumResponse checksum = await Attempt(() => _api.GetLedgerChecksumAsync(cancellationToken)).ConfigureAwait(false);

DateTimeOffset? moment = null;
if (!string.IsNullOrEmpty(checksum.Moment))
Expand Down Expand Up @@ -123,7 +123,7 @@ public async Task<NodeRepresentative> GetRepresentative(
/// <summary>Every representative the node knows, with advertised endpoints.</summary>
public async Task<IReadOnlyList<NodeRepresentative>> GetAllRepresentatives(CancellationToken cancellationToken = default)
{
Response13 response = await Attempt(() => _api.GetAllRepresentativesAsync(cancellationToken)).ConfigureAwait(false);
GetAllRepresentativesResponse response = await Attempt(() => _api.GetAllRepresentativesAsync(cancellationToken)).ConfigureAwait(false);
ICollection<GeneratedRepresentative> representatives = response.Representatives ?? Array.Empty<GeneratedRepresentative>();

return representatives.Select(DecodeRepresentative).ToArray();
Expand All @@ -148,7 +148,7 @@ public async Task<IReadOnlyList<TokenBalance>> GetAccountBalances(
Crypto.Account account,
CancellationToken cancellationToken = default)
{
Response6 response = await Attempt(() => _api.GetAccountBalancesAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false);
GetAccountBalancesResponse response = await Attempt(() => _api.GetAccountBalancesAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false);
return DecodeBalances(response.Balances);
}

Expand All @@ -158,7 +158,7 @@ public async Task<BigInteger> GetAccountBalance(
Crypto.Account token,
CancellationToken cancellationToken = default)
{
Response7 response = await Attempt(() => _api.GetAccountBalanceAsync(account.PublicKeyString, token.PublicKeyString, cancellationToken)).ConfigureAwait(false);
GetAccountBalanceResponse response = await Attempt(() => _api.GetAccountBalanceAsync(account.PublicKeyString, token.PublicKeyString, cancellationToken)).ConfigureAwait(false);
return OptionalHexAmount(response.Balance) ?? BigInteger.Zero;
}

Expand All @@ -171,7 +171,7 @@ public async Task<IReadOnlyList<Certificate>> GetAllCertificates(
Crypto.Account account,
CancellationToken cancellationToken = default)
{
Response19 response = await Attempt(() => _api.GetAccountCertificatesAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false);
GetAccountCertificatesResponse response = await Attempt(() => _api.GetAccountCertificatesAsync(account.PublicKeyString, cancellationToken)).ConfigureAwait(false);
ICollection<GeneratedCertificate> records = response.Certificates ?? Array.Empty<GeneratedCertificate>();

// A record with no certificate body is the node's "not found" shape.
Expand All @@ -192,7 +192,7 @@ public async Task<IReadOnlyList<Certificate>> GetAllCertificates(
Crypto.CertificateHash certificateHash,
CancellationToken cancellationToken = default)
{
Response20 record = await Attempt(() => _api.GetCertificateByHashAsync(account.PublicKeyString, certificateHash.ToString(), cancellationToken)).ConfigureAwait(false);
GetCertificateByHashResponse record = await Attempt(() => _api.GetCertificateByHashAsync(account.PublicKeyString, certificateHash.ToString(), cancellationToken)).ConfigureAwait(false);
if (record.Certificate1 is null)
{
return null;
Expand Down
50 changes: 43 additions & 7 deletions tests/KeetaNet.Anchor.E2eTests/AssetFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ public async Task PublishedLegalAndTokenMetadataRoundTrip()
// not an error.
Assert.Null(client.GetAssetMetadataForLocation(provider, EvmLocation, "evm:0xdeadbeef"));

// The identifying details under legal.anchorDetails decode typed.
AssetAnchorDetails? details = client.GetProviderAnchorDetails(provider);
Assert.NotNull(details);
Assert.Equal("Test Anchor", details!.Name);
Assert.NotNull(details.Description);
Assert.Equal(AssetContentType.Markdown, details.Description!.Type);
Assert.Equal("A reference anchor for interop tests.", details.Description.Content);
Assert.Equal("https://anchor.test/logo.svg", details.Logo);

session.Shutdown();
}

Expand Down Expand Up @@ -196,7 +205,7 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor()
Assert.Single(templates.Templates);
Assert.Equal("1", templates.Total);

JsonElement created = await client.CreatePersistentForwardingAddress(
AssetForwardingAddress created = await client.CreatePersistentForwardingAddress(
provider,
new AssetCreateAddressRequest(
EvmLocation,
Expand All @@ -205,23 +214,50 @@ public async Task ForwardingAndListingRunAgainstTheLiveAnchor()
DestinationLocation: KeetaLocation,
DestinationAddress: anchor.SendToAddress),
cancellationToken);
Assert.Equal(anchor.SendToAddress, created.GetProperty("address").GetString());
Assert.Equal("10", created.GetProperty("fees").GetProperty("total").GetString());

JsonElement fromTemplate = await client.CreatePersistentForwardingAddress(
Assert.Equal(anchor.SendToAddress, created.Address.GetString());

// The fee breakdown decodes typed: one variable line item carrying its
// basis points and renderable details, plus the pre-computed total.
Assert.NotNull(created.Fees);
Assert.Equal("10", created.Fees!.Total);
AssetFeeLineItem lineItem = Assert.Single(created.Fees.LineItems);
Assert.Equal("VALUE_VARIABLE", lineItem.Purpose);
Assert.Equal(50d, lineItem.BasisPoints);
Assert.Equal(AssetContentType.Markdown, lineItem.Details!.Type);

AssetForwardingAddress fromTemplate = await client.CreatePersistentForwardingAddress(
provider,
new AssetCreateAddressRequest(EvmLocation, anchor.Asset, PersistentAddressTemplateId: template.Id),
cancellationToken);
Assert.Equal(anchor.SendToAddress, fromTemplate.GetProperty("address").GetString());
Assert.Equal(anchor.SendToAddress, fromTemplate.Address.GetString());

AssetAddressPage addresses = await client.ListForwardingAddresses(
provider,
new AssetListAddressesRequest(
new[] { new AssetAddressFilter(SourceLocation: EvmLocation, Asset: anchor.Asset) },
new AssetPagination(10, 0)),
cancellationToken);
Assert.Single(addresses.Addresses);
AssetForwardingAddress listed = Assert.Single(addresses.Addresses);
Assert.Equal("1", addresses.Total);
Assert.Equal("template-id", listed.Id);
Assert.Equal(anchor.SendToAddress, listed.Address.GetString());
Assert.Equal(anchor.Asset, listed.Asset?.Asset);
Assert.Equal(EvmLocation, listed.SourceLocation?.GetString());
Assert.Equal(KeetaLocation, listed.DestinationLocation?.GetString());
Assert.NotNull(listed.MinimumTransferValue);
Assert.Equal("500", listed.MinimumTransferValue!.Value);
Assert.NotNull(listed.Fees);
Assert.Equal("10", listed.Fees!.Total);

// A conversion-pair filter crosses the wire in the reference `{ from,
// to }` form and passes the live anchor's request validation.
AssetAddressPage paired = await client.ListForwardingAddresses(
provider,
new AssetListAddressesRequest(
new[] { new AssetAddressFilter(SourceLocation: EvmLocation, Asset: AssetOrPair.Pair(anchor.Asset, "USD")) },
new AssetPagination(10, 0)),
cancellationToken);
Assert.Single(paired.Addresses);

AssetTransactionPage transactions = await client.ListTransactions(
provider,
Expand Down
Loading
Loading