From d4db47e9859a71a92d961cb8eb45d14a721135ee Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Wed, 25 Mar 2026 11:09:30 +0000 Subject: [PATCH 01/10] Add port discovery for automatic daemon detection across all SDKs antd now writes a daemon.port file on startup containing the REST and gRPC ports, enabling all SDK clients to auto-discover the daemon without hardcoded URLs. This supports managed mode where antd is spawned with --rest-port 0 for OS-assigned ports. Changes: - antd (Rust): port file lifecycle (atomic write/cleanup), --rest-port and --grpc-port CLI flags, pre-bound listeners for both servers - All 15 SDKs: discover module + auto-discover constructors for REST and gRPC clients - Default REST port corrected from 8080 to 8082 across all SDKs - ant-dev CLI: status/wallet/start commands use port-file discovery - Docs: README, llms.txt, llms-full.txt, skill.md updated Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 33 +++- ant-dev/src/ant_dev/cmd_start.py | 18 ++- ant-dev/src/ant_dev/cmd_status.py | 16 +- ant-dev/src/ant_dev/cmd_wallet.py | 16 +- antd-cpp/include/antd/client.hpp | 11 +- antd-cpp/include/antd/discover.hpp | 17 ++ antd-cpp/include/antd/grpc_client.hpp | 9 ++ antd-cpp/src/discover.cpp | 84 ++++++++++ antd-csharp/Antd.Sdk/AntdGrpcClient.cs | 10 ++ antd-csharp/Antd.Sdk/AntdRestClient.cs | 12 +- antd-csharp/Antd.Sdk/DaemonDiscovery.cs | 97 +++++++++++ antd-dart/lib/antd.dart | 1 + antd-dart/lib/src/client.dart | 21 ++- antd-dart/lib/src/discover.dart | 88 ++++++++++ antd-dart/lib/src/grpc_client.dart | 15 ++ antd-elixir/lib/antd.ex | 2 +- antd-elixir/lib/antd/client.ex | 30 +++- antd-elixir/lib/antd/discover.ex | 111 +++++++++++++ antd-elixir/lib/antd/grpc_client.ex | 24 +++ antd-go/README.md | 5 +- antd-go/client.go | 13 +- antd-go/discover.go | 101 ++++++++++++ antd-go/discover_test.go | 150 ++++++++++++++++++ antd-go/grpc_client.go | 12 ++ .../java/com/autonomi/antd/AntdClient.java | 16 +- .../com/autonomi/antd/DaemonDiscovery.java | 138 ++++++++++++++++ .../com/autonomi/antd/GrpcAntdClient.java | 14 ++ antd-js/src/discover.ts | 85 ++++++++++ antd-js/src/index.ts | 2 + antd-js/src/rest-client.ts | 22 ++- .../kotlin/com/autonomi/sdk/AntdGrpcClient.kt | 11 ++ .../kotlin/com/autonomi/sdk/AntdRestClient.kt | 13 +- .../com/autonomi/sdk/DaemonDiscovery.kt | 73 +++++++++ antd-lua/src/antd/client.lua | 17 +- antd-lua/src/antd/discover.lua | 96 +++++++++++ antd-lua/src/antd/init.lua | 17 +- antd-mcp/README.md | 11 +- antd-mcp/src/antd_mcp/discover.py | 102 ++++++++++++ antd-mcp/src/antd_mcp/server.py | 6 +- antd-php/src/AntdClient.php | 20 ++- antd-php/src/DaemonDiscovery.php | 129 +++++++++++++++ antd-py/src/antd/__init__.py | 8 +- antd-py/src/antd/_discover.py | 93 +++++++++++ antd-py/src/antd/_grpc.py | 30 ++++ antd-py/src/antd/_rest.py | 34 +++- antd-py/tests/test_discover.py | 128 +++++++++++++++ antd-ruby/lib/antd.rb | 1 + antd-ruby/lib/antd/client.rb | 15 +- antd-ruby/lib/antd/discover.rb | 98 ++++++++++++ antd-ruby/lib/antd/grpc_client.rb | 12 ++ antd-rust/src/client.rs | 19 ++- antd-rust/src/discover.rs | 112 +++++++++++++ antd-rust/src/grpc_client.rs | 9 ++ antd-rust/src/lib.rs | 2 + .../Sources/AntdSdk/AntdRestClient.swift | 2 +- .../Sources/AntdSdk/DaemonDiscovery.swift | 93 +++++++++++ antd-zig/src/antd.zig | 16 +- antd-zig/src/discover.zig | 96 +++++++++++ antd/Cargo.lock | 103 ++++++------ antd/Cargo.toml | 3 +- antd/src/config.rs | 34 ++++ antd/src/grpc/mod.rs | 7 +- antd/src/main.rs | 46 ++++-- antd/src/port_file.rs | 65 ++++++++ llms-full.txt | 128 +++++++++------ llms.txt | 22 ++- skill.md | 6 +- 67 files changed, 2690 insertions(+), 160 deletions(-) create mode 100644 antd-cpp/include/antd/discover.hpp create mode 100644 antd-cpp/src/discover.cpp create mode 100644 antd-csharp/Antd.Sdk/DaemonDiscovery.cs create mode 100644 antd-dart/lib/src/discover.dart create mode 100644 antd-elixir/lib/antd/discover.ex create mode 100644 antd-go/discover.go create mode 100644 antd-go/discover_test.go create mode 100644 antd-java/src/main/java/com/autonomi/antd/DaemonDiscovery.java create mode 100644 antd-js/src/discover.ts create mode 100644 antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/DaemonDiscovery.kt create mode 100644 antd-lua/src/antd/discover.lua create mode 100644 antd-mcp/src/antd_mcp/discover.py create mode 100644 antd-php/src/DaemonDiscovery.php create mode 100644 antd-py/src/antd/_discover.py create mode 100644 antd-py/tests/test_discover.py create mode 100644 antd-ruby/lib/antd/discover.rb create mode 100644 antd-rust/src/discover.rs create mode 100644 antd-swift/Sources/AntdSdk/DaemonDiscovery.swift create mode 100644 antd-zig/src/discover.zig create mode 100644 antd/src/port_file.rs diff --git a/README.md b/README.md index d101d85..9a054a1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,37 @@ A developer-friendly SDK for the [Autonomi](https://autonomi.com) decentralized **antd** is a local gateway daemon (written in Rust) that exposes the Autonomi network via REST and gRPC APIs. The SDKs and MCP server talk to antd — your application code never touches the network directly. +### Port Discovery + +All SDKs support automatic daemon discovery. When antd starts, it writes a `daemon.port` file containing the REST and gRPC ports to a platform-specific location: + +| Platform | Path | +|----------|------| +| Windows | `%APPDATA%\ant\daemon.port` | +| Linux | `~/.local/share/ant/daemon.port` (or `$XDG_DATA_HOME/ant/`) | +| macOS | `~/Library/Application Support/ant/daemon.port` | + +Every SDK provides an auto-discover constructor that reads this file and connects automatically: + +```python +# Python +client, url = RestClient.auto_discover() +``` + +```go +// Go +client, url := antd.NewClientAutoDiscover() +``` + +```typescript +// TypeScript +const { client, url } = RestClient.autoDiscover(); +``` + +This is especially useful in managed mode, where a parent process (e.g. indelible) spawns antd with `--rest-port 0` to let the OS assign a free port. The SDK discovers the actual port via the port file without any hardcoded configuration. + +If no port file is found, all SDKs fall back to the default REST endpoint (`http://localhost:8082`) or gRPC target (`localhost:50051`). + ## Components ### Infrastructure @@ -216,7 +247,7 @@ import ( ) func main() { - client := antd.NewClient(antd.DefaultBaseURL) + client, _ := antd.NewClientAutoDiscover() ctx := context.Background() health, err := client.Health(ctx) diff --git a/ant-dev/src/ant_dev/cmd_start.py b/ant-dev/src/ant_dev/cmd_start.py index 5c1a78e..e09e9d1 100644 --- a/ant-dev/src/ant_dev/cmd_start.py +++ b/ant-dev/src/ant_dev/cmd_start.py @@ -23,6 +23,19 @@ ) from .process import start_process, wait_for_http +DEFAULT_REST_URL = "http://localhost:8082" + +def _discover_rest_url() -> str: + """Try to discover antd REST URL via port file, fall back to default.""" + try: + from antd import discover_daemon_url + url = discover_daemon_url() + if url: + return url + except ImportError: + pass + return DEFAULT_REST_URL + # ── ANSI colours (disabled on Windows without VT support) ── @@ -128,15 +141,16 @@ def run(args) -> None: print() if ready: + rest_url = _discover_rest_url() print(green("=== Ready! ===")) print() - print(white(" REST: http://localhost:8082")) + print(white(f" REST: {rest_url}")) print(white(" gRPC: localhost:50051")) if wallet_key: print(white(f" Key: {wallet_key[:10]}...")) print() print(gray("Quick test:")) - print(gray(" curl http://localhost:8082/health")) + print(gray(f" curl {rest_url}/health")) print() print(gray("To tear down:")) print(gray(" ant dev stop")) diff --git a/ant-dev/src/ant_dev/cmd_status.py b/ant-dev/src/ant_dev/cmd_status.py index 96d6953..17cf856 100644 --- a/ant-dev/src/ant_dev/cmd_status.py +++ b/ant-dev/src/ant_dev/cmd_status.py @@ -10,6 +10,19 @@ from .env import is_windows, load_state from .process import is_alive +DEFAULT_REST_URL = "http://localhost:8082" + +def _discover_rest_url() -> str: + """Try to discover antd REST URL via port file, fall back to default.""" + try: + from antd import discover_daemon_url + url = discover_daemon_url() + if url: + return url + except ImportError: + pass + return DEFAULT_REST_URL + def _color(code: str, text: str) -> str: if is_windows() and "WT_SESSION" not in os.environ: @@ -48,7 +61,8 @@ def run(args) -> None: # Health check print() try: - r = httpx.get("http://localhost:8082/health", timeout=5) + rest_url = _discover_rest_url() + r = httpx.get(f"{rest_url}/health", timeout=5) data = r.json() ok = data.get("status") == "ok" or data.get("ok", False) network = data.get("network", "unknown") diff --git a/ant-dev/src/ant_dev/cmd_wallet.py b/ant-dev/src/ant_dev/cmd_wallet.py index caa381e..e28ae2d 100644 --- a/ant-dev/src/ant_dev/cmd_wallet.py +++ b/ant-dev/src/ant_dev/cmd_wallet.py @@ -8,6 +8,19 @@ from .env import load_state +DEFAULT_REST_URL = "http://localhost:8082" + +def _discover_rest_url() -> str: + """Try to discover antd REST URL via port file, fall back to default.""" + try: + from antd import discover_daemon_url + url = discover_daemon_url() + if url: + return url + except ImportError: + pass + return DEFAULT_REST_URL + def run(args) -> None: state = load_state() @@ -30,7 +43,8 @@ def run(args) -> None: # On local testnet the EVM testnet already funds this key. # Verify via health check. try: - r = httpx.get("http://localhost:8082/health", timeout=5) + rest_url = _discover_rest_url() + r = httpx.get(f"{rest_url}/health", timeout=5) data = r.json() if data.get("status") == "ok" or data.get("ok", False): print("Wallet is already funded on local testnet.") diff --git a/antd-cpp/include/antd/client.hpp b/antd-cpp/include/antd/client.hpp index 3f4a0cd..a759289 100644 --- a/antd-cpp/include/antd/client.hpp +++ b/antd-cpp/include/antd/client.hpp @@ -7,13 +7,14 @@ #include #include +#include "discover.hpp" #include "errors.hpp" #include "models.hpp" namespace antd { /// Default address of the antd daemon. -inline constexpr const char* kDefaultBaseURL = "http://localhost:8080"; +inline constexpr const char* kDefaultBaseURL = "http://localhost:8082"; /// Default request timeout in seconds (5 minutes). inline constexpr int kDefaultTimeoutSeconds = 300; @@ -34,6 +35,14 @@ class Client { Client(Client&&) noexcept; Client& operator=(Client&&) noexcept; + /// Create a client by auto-discovering the daemon port from the + /// daemon.port file. Falls back to kDefaultBaseURL if not found. + static Client auto_discover(int timeout_seconds = kDefaultTimeoutSeconds) { + auto url = discover_daemon_url(); + if (url.empty()) url = kDefaultBaseURL; + return Client(url, timeout_seconds); + } + // --- Health --- /// Check daemon status. diff --git a/antd-cpp/include/antd/discover.hpp b/antd-cpp/include/antd/discover.hpp new file mode 100644 index 0000000..6ac1695 --- /dev/null +++ b/antd-cpp/include/antd/discover.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace antd { + +/// Read the daemon.port file written by antd on startup and return the REST +/// base URL (e.g. "http://127.0.0.1:8082"). +/// Returns an empty string if the port file is not found or unreadable. +std::string discover_daemon_url(); + +/// Read the daemon.port file written by antd on startup and return the gRPC +/// target (e.g. "127.0.0.1:50051"). +/// Returns an empty string if the port file has no gRPC line or is unreadable. +std::string discover_grpc_target(); + +} // namespace antd diff --git a/antd-cpp/include/antd/grpc_client.hpp b/antd-cpp/include/antd/grpc_client.hpp index e82489c..98194a0 100644 --- a/antd-cpp/include/antd/grpc_client.hpp +++ b/antd-cpp/include/antd/grpc_client.hpp @@ -6,6 +6,7 @@ #include #include +#include "discover.hpp" #include "errors.hpp" #include "models.hpp" @@ -38,6 +39,14 @@ class GrpcClient { GrpcClient(GrpcClient&&) noexcept; GrpcClient& operator=(GrpcClient&&) noexcept; + /// Create a client by auto-discovering the daemon gRPC port from the + /// daemon.port file. Falls back to kDefaultGrpcTarget if not found. + static GrpcClient auto_discover() { + auto target = discover_grpc_target(); + if (target.empty()) target = kDefaultGrpcTarget; + return GrpcClient(target); + } + // --- Health --- /// Check daemon status. diff --git a/antd-cpp/src/discover.cpp b/antd-cpp/src/discover.cpp new file mode 100644 index 0000000..174a84a --- /dev/null +++ b/antd-cpp/src/discover.cpp @@ -0,0 +1,84 @@ +#include "antd/discover.hpp" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace antd { +namespace { + +constexpr const char* kPortFileName = "daemon.port"; +constexpr const char* kDataDirName = "ant"; + +/// Parse a port number (1-65535) from a string. Returns 0 on failure. +uint16_t parse_port(const std::string& s) { + try { + unsigned long n = std::stoul(s); + if (n == 0 || n > 65535) return 0; + return static_cast(n); + } catch (...) { + return 0; + } +} + +/// Return the platform-specific data directory for ant. +/// - Windows: %APPDATA%\ant +/// - macOS: ~/Library/Application Support/ant +/// - Linux: $XDG_DATA_HOME/ant or ~/.local/share/ant +fs::path data_dir() { +#ifdef _WIN32 + const char* appdata = std::getenv("APPDATA"); + if (!appdata || appdata[0] == '\0') return {}; + return fs::path(appdata) / kDataDirName; +#elif defined(__APPLE__) + const char* home = std::getenv("HOME"); + if (!home || home[0] == '\0') return {}; + return fs::path(home) / "Library" / "Application Support" / kDataDirName; +#else + const char* xdg = std::getenv("XDG_DATA_HOME"); + if (xdg && xdg[0] != '\0') { + return fs::path(xdg) / kDataDirName; + } + const char* home = std::getenv("HOME"); + if (!home || home[0] == '\0') return {}; + return fs::path(home) / ".local" / "share" / kDataDirName; +#endif +} + +/// Read the daemon.port file and return the REST and gRPC ports. +/// The file format is two lines: REST port on line 1, gRPC port on line 2. +std::pair read_port_file() { + auto dir = data_dir(); + if (dir.empty()) return {0, 0}; + + auto path = dir / kPortFileName; + std::ifstream ifs(path); + if (!ifs.is_open()) return {0, 0}; + + std::string line1, line2; + if (!std::getline(ifs, line1)) return {0, 0}; + std::getline(ifs, line2); // optional second line + + return {parse_port(line1), parse_port(line2)}; +} + +} // namespace + +std::string discover_daemon_url() { + auto [rest, grpc] = read_port_file(); + (void)grpc; + if (rest == 0) return {}; + return "http://127.0.0.1:" + std::to_string(rest); +} + +std::string discover_grpc_target() { + auto [rest, grpc] = read_port_file(); + (void)rest; + if (grpc == 0) return {}; + return "127.0.0.1:" + std::to_string(grpc); +} + +} // namespace antd diff --git a/antd-csharp/Antd.Sdk/AntdGrpcClient.cs b/antd-csharp/Antd.Sdk/AntdGrpcClient.cs index ba5b750..0cae715 100644 --- a/antd-csharp/Antd.Sdk/AntdGrpcClient.cs +++ b/antd-csharp/Antd.Sdk/AntdGrpcClient.cs @@ -24,6 +24,16 @@ public AntdGrpcClient(string target = "http://localhost:50051") _files = new FileService.FileServiceClient(_channel); } + /// + /// Creates an AntdGrpcClient by reading the daemon.port file written by antd. + /// Falls back to the default target if the port file is not found. + /// + public static AntdGrpcClient AutoDiscover() + { + var target = DaemonDiscovery.DiscoverGrpcTarget(); + return string.IsNullOrEmpty(target) ? new AntdGrpcClient() : new AntdGrpcClient(target); + } + public void Dispose() => _channel.Dispose(); private static AntdException Wrap(RpcException ex) => ExceptionMapping.FromGrpcStatus(ex); diff --git a/antd-csharp/Antd.Sdk/AntdRestClient.cs b/antd-csharp/Antd.Sdk/AntdRestClient.cs index f0fa8ce..64d78a9 100644 --- a/antd-csharp/Antd.Sdk/AntdRestClient.cs +++ b/antd-csharp/Antd.Sdk/AntdRestClient.cs @@ -15,12 +15,22 @@ public sealed class AntdRestClient : IAntdClient DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; - public AntdRestClient(string baseUrl = "http://localhost:8080", TimeSpan? timeout = null) + public AntdRestClient(string baseUrl = "http://localhost:8082", TimeSpan? timeout = null) { _baseUrl = baseUrl.TrimEnd('/'); _http = new HttpClient { BaseAddress = new Uri(_baseUrl), Timeout = timeout ?? TimeSpan.FromSeconds(300) }; } + /// + /// Creates an AntdRestClient by reading the daemon.port file written by antd. + /// Falls back to the default base URL if the port file is not found. + /// + public static AntdRestClient AutoDiscover(TimeSpan? timeout = null) + { + var url = DaemonDiscovery.DiscoverDaemonUrl(); + return string.IsNullOrEmpty(url) ? new AntdRestClient(timeout: timeout) : new AntdRestClient(url, timeout); + } + public void Dispose() => _http.Dispose(); // ── Helpers ── diff --git a/antd-csharp/Antd.Sdk/DaemonDiscovery.cs b/antd-csharp/Antd.Sdk/DaemonDiscovery.cs new file mode 100644 index 0000000..86ce519 --- /dev/null +++ b/antd-csharp/Antd.Sdk/DaemonDiscovery.cs @@ -0,0 +1,97 @@ +using System.Runtime.InteropServices; + +namespace Antd.Sdk; + +/// +/// Reads the daemon.port file written by antd on startup to auto-discover +/// the REST and gRPC ports. The file contains two lines: REST port on line 1, +/// gRPC port on line 2. +/// +public static class DaemonDiscovery +{ + private const string PortFileName = "daemon.port"; + private const string DataDirName = "ant"; + + /// + /// Reads line 1 of the daemon.port file and returns the REST base URL + /// (e.g. "http://127.0.0.1:8082"). Returns empty string on failure. + /// + public static string DiscoverDaemonUrl() + { + var (restPort, _) = ReadPortFile(); + return restPort > 0 ? $"http://127.0.0.1:{restPort}" : ""; + } + + /// + /// Reads line 2 of the daemon.port file and returns the gRPC target + /// (e.g. "http://127.0.0.1:50051"). Returns empty string on failure. + /// + public static string DiscoverGrpcTarget() + { + var (_, grpcPort) = ReadPortFile(); + return grpcPort > 0 ? $"http://127.0.0.1:{grpcPort}" : ""; + } + + private static (ushort restPort, ushort grpcPort) ReadPortFile() + { + var dir = DataDir(); + if (string.IsNullOrEmpty(dir)) + return (0, 0); + + var path = Path.Combine(dir, PortFileName); + if (!File.Exists(path)) + return (0, 0); + + try + { + var text = File.ReadAllText(path).Trim(); + var lines = text.Split('\n'); + + ushort rest = 0, grpc = 0; + if (lines.Length >= 1) + rest = ParsePort(lines[0]); + if (lines.Length >= 2) + grpc = ParsePort(lines[1]); + + return (rest, grpc); + } + catch + { + return (0, 0); + } + } + + private static ushort ParsePort(string s) + { + return ushort.TryParse(s.Trim(), out var port) ? port : (ushort)0; + } + + /// + /// Returns the platform-specific data directory for ant. + /// Windows: %APPDATA%\ant + /// macOS: ~/Library/Application Support/ant + /// Linux: $XDG_DATA_HOME/ant or ~/.local/share/ant + /// + private static string DataDir() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var appdata = Environment.GetEnvironmentVariable("APPDATA"); + return string.IsNullOrEmpty(appdata) ? "" : Path.Combine(appdata, DataDirName); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + var home = Environment.GetEnvironmentVariable("HOME"); + return string.IsNullOrEmpty(home) ? "" : Path.Combine(home, "Library", "Application Support", DataDirName); + } + + // Linux and others + var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (!string.IsNullOrEmpty(xdg)) + return Path.Combine(xdg, DataDirName); + + var homeDir = Environment.GetEnvironmentVariable("HOME"); + return string.IsNullOrEmpty(homeDir) ? "" : Path.Combine(homeDir, ".local", "share", DataDirName); + } +} diff --git a/antd-dart/lib/antd.dart b/antd-dart/lib/antd.dart index 53fe29f..85b6b10 100644 --- a/antd-dart/lib/antd.dart +++ b/antd-dart/lib/antd.dart @@ -2,5 +2,6 @@ library antd; export 'src/client.dart'; +export 'src/discover.dart'; export 'src/errors.dart'; export 'src/models.dart'; diff --git a/antd-dart/lib/src/client.dart b/antd-dart/lib/src/client.dart index d86cf05..0798ed5 100644 --- a/antd-dart/lib/src/client.dart +++ b/antd-dart/lib/src/client.dart @@ -3,11 +3,12 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; +import 'discover.dart'; import 'errors.dart'; import 'models.dart'; /// Default base URL for the antd daemon. -const defaultBaseUrl = 'http://localhost:8080'; +const defaultBaseUrl = 'http://localhost:8082'; /// Default request timeout. const defaultTimeout = Duration(minutes: 5); @@ -21,7 +22,7 @@ class AntdClient { /// Creates a new antd REST client. /// - /// [baseUrl] defaults to `http://localhost:8080`. + /// [baseUrl] defaults to `http://localhost:8082`. /// [timeout] defaults to 5 minutes. /// [httpClient] optionally provide a custom HTTP client (e.g. for testing). AntdClient({ @@ -33,6 +34,22 @@ class AntdClient { _httpClient = httpClient ?? http.Client(), _ownsClient = httpClient == null; + /// Creates an antd REST client by auto-discovering the daemon port from the + /// daemon.port file written by antd on startup. Falls back to [defaultBaseUrl] + /// if the port file is not found. + factory AntdClient.autoDiscover({ + Duration timeout = defaultTimeout, + http.Client? httpClient, + }) { + final discovered = discoverDaemonUrl(); + final baseUrl = discovered.isNotEmpty ? discovered : defaultBaseUrl; + return AntdClient( + baseUrl: baseUrl, + timeout: timeout, + httpClient: httpClient, + ); + } + /// Closes the HTTP client. Only closes if the client was created internally. void close() { if (_ownsClient) { diff --git a/antd-dart/lib/src/discover.dart b/antd-dart/lib/src/discover.dart new file mode 100644 index 0000000..51eb547 --- /dev/null +++ b/antd-dart/lib/src/discover.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +const _portFileName = 'daemon.port'; +const _dataDirName = 'ant'; + +/// Reads the daemon.port file written by antd on startup and returns the +/// REST base URL (e.g. "http://127.0.0.1:8082"). +/// Returns empty string if the port file is not found or unreadable. +String discoverDaemonUrl() { + final ports = _readPortFile(); + if (ports.$1 == 0) { + return ''; + } + return 'http://127.0.0.1:${ports.$1}'; +} + +/// Reads the daemon.port file written by antd on startup and returns the +/// gRPC target (e.g. "127.0.0.1:50051"). +/// Returns empty string if the port file is not found or has no gRPC line. +String discoverGrpcTarget() { + final ports = _readPortFile(); + if (ports.$2 == 0) { + return ''; + } + return '127.0.0.1:${ports.$2}'; +} + +/// Reads the daemon.port file and returns (restPort, grpcPort). +/// The file format is two lines: REST port on line 1, gRPC port on line 2. +/// A single-line file is valid (gRPC port will be 0). +(int, int) _readPortFile() { + final dir = _dataDir(); + if (dir.isEmpty) { + return (0, 0); + } + + final file = File('$dir${Platform.pathSeparator}$_portFileName'); + String data; + try { + data = file.readAsStringSync(); + } catch (_) { + return (0, 0); + } + + final lines = data.trim().split('\n'); + if (lines.isEmpty) { + return (0, 0); + } + + final rest = _parsePort(lines[0]); + final grpc = lines.length >= 2 ? _parsePort(lines[1]) : 0; + return (rest, grpc); +} + +int _parsePort(String s) { + final n = int.tryParse(s.trim()); + if (n == null || n < 1 || n > 65535) { + return 0; + } + return n; +} + +/// Returns the platform-specific data directory for ant. +/// - Windows: %APPDATA%\ant +/// - macOS: ~/Library/Application Support/ant +/// - Linux: $XDG_DATA_HOME/ant or ~/.local/share/ant +String _dataDir() { + if (Platform.isWindows) { + final appdata = Platform.environment['APPDATA'] ?? ''; + if (appdata.isEmpty) return ''; + return '$appdata${Platform.pathSeparator}$_dataDirName'; + } + + if (Platform.isMacOS) { + final home = Platform.environment['HOME'] ?? ''; + if (home.isEmpty) return ''; + return '$home/Library/Application Support/$_dataDirName'; + } + + // Linux and others + final xdg = Platform.environment['XDG_DATA_HOME'] ?? ''; + if (xdg.isNotEmpty) { + return '$xdg/$_dataDirName'; + } + final home = Platform.environment['HOME'] ?? ''; + if (home.isEmpty) return ''; + return '$home/.local/share/$_dataDirName'; +} diff --git a/antd-dart/lib/src/grpc_client.dart b/antd-dart/lib/src/grpc_client.dart index 74a0d2f..41228e4 100644 --- a/antd-dart/lib/src/grpc_client.dart +++ b/antd-dart/lib/src/grpc_client.dart @@ -16,6 +16,7 @@ import 'generated/antd/v1/common.pb.dart' as common_pb; import 'generated/antd/v1/files.pbgrpc.dart' as files_pb; import 'generated/antd/v1/files.pb.dart' as files_msg; +import 'discover.dart'; import 'errors.dart'; import 'models.dart'; @@ -105,6 +106,20 @@ class GrpcAntdClient { ), ); + /// Creates a gRPC client by auto-discovering the daemon port from the + /// daemon.port file written by antd on startup. Falls back to + /// `localhost:50051` if the port file is not found or has no gRPC line. + factory GrpcAntdClient.autoDiscover() { + final target = discoverGrpcTarget(); + if (target.isEmpty) { + return GrpcAntdClient.withChannel(); + } + final parts = target.split(':'); + final host = parts[0]; + final port = int.parse(parts[1]); + return GrpcAntdClient.withChannel(host: host, port: port); + } + /// Factory constructor that creates stubs from a single shared channel. factory GrpcAntdClient.withChannel({ String host = 'localhost', diff --git a/antd-elixir/lib/antd.ex b/antd-elixir/lib/antd.ex index 0749bc4..656f610 100644 --- a/antd-elixir/lib/antd.ex +++ b/antd-elixir/lib/antd.ex @@ -24,7 +24,7 @@ defmodule Antd do IO.puts("Retrieved: \#{data}") """ - defdelegate new(base_url \\ "http://localhost:8080", opts \\ []), to: Antd.Client + defdelegate new(base_url \\ "http://localhost:8082", opts \\ []), to: Antd.Client defdelegate health(client), to: Antd.Client defdelegate health!(client), to: Antd.Client defdelegate data_put_public(client, data), to: Antd.Client diff --git a/antd-elixir/lib/antd/client.ex b/antd-elixir/lib/antd/client.ex index 3956831..a9e8229 100644 --- a/antd-elixir/lib/antd/client.ex +++ b/antd-elixir/lib/antd/client.ex @@ -7,7 +7,7 @@ defmodule Antd.Client do raise on error. """ - @default_base_url "http://localhost:8080" + @default_base_url "http://localhost:8082" @default_timeout 300_000 defstruct base_url: @default_base_url, timeout: @default_timeout @@ -17,6 +17,32 @@ defmodule Antd.Client do timeout: integer() } + @doc """ + Creates a client using port discovery. + + Reads the daemon.port file to find the REST port. Falls back to the + default base URL if the port file is not found. + + ## Options + + * `:timeout` - HTTP request timeout in milliseconds (default: 300_000) + + ## Examples + + {client, url} = Antd.Client.auto_discover() + {client, url} = Antd.Client.auto_discover(timeout: 30_000) + """ + @spec auto_discover(keyword()) :: {t(), String.t()} + def auto_discover(opts \\ []) do + url = + case Antd.Discover.discover_daemon_url() do + "" -> @default_base_url + discovered -> discovered + end + + {new(url, opts), url} + end + @doc """ Creates a new client. @@ -28,7 +54,7 @@ defmodule Antd.Client do client = Antd.Client.new() client = Antd.Client.new("http://custom-host:9090") - client = Antd.Client.new("http://localhost:8080", timeout: 30_000) + client = Antd.Client.new("http://localhost:8082", timeout: 30_000) """ @spec new(String.t(), keyword()) :: t() def new(base_url \\ @default_base_url, opts \\ []) do diff --git a/antd-elixir/lib/antd/discover.ex b/antd-elixir/lib/antd/discover.ex new file mode 100644 index 0000000..e34dea4 --- /dev/null +++ b/antd-elixir/lib/antd/discover.ex @@ -0,0 +1,111 @@ +defmodule Antd.Discover do + @moduledoc """ + Auto-discovers the antd daemon by reading the `daemon.port` file that antd + writes on startup. + + The file contains two lines: REST port on line 1, gRPC port on line 2. + + Port file location is platform-specific: + - Windows: `%APPDATA%\\ant\\daemon.port` + - macOS: `~/Library/Application Support/ant/daemon.port` + - Linux: `$XDG_DATA_HOME/ant/daemon.port` or `~/.local/share/ant/daemon.port` + """ + + @port_file_name "daemon.port" + @data_dir_name "ant" + + @doc """ + Reads the daemon.port file and returns the REST base URL + (e.g. `"http://127.0.0.1:8082"`). + + Returns `""` if the port file is not found or unreadable. + """ + @spec discover_daemon_url() :: String.t() + def discover_daemon_url do + case read_port_file() do + {rest, _grpc} when rest > 0 -> "http://127.0.0.1:#{rest}" + _ -> "" + end + end + + @doc """ + Reads the daemon.port file and returns the gRPC target + (e.g. `"127.0.0.1:50051"`). + + Returns `""` if the port file is not found or has no gRPC line. + """ + @spec discover_grpc_target() :: String.t() + def discover_grpc_target do + case read_port_file() do + {_rest, grpc} when grpc > 0 -> "127.0.0.1:#{grpc}" + _ -> "" + end + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp read_port_file do + case data_dir() do + "" -> + {0, 0} + + dir -> + path = Path.join(dir, @port_file_name) + + case File.read(path) do + {:ok, contents} -> + lines = + contents + |> String.trim() + |> String.split("\n", trim: true) + + rest_port = parse_port(Enum.at(lines, 0, "")) + grpc_port = parse_port(Enum.at(lines, 1, "")) + {rest_port, grpc_port} + + {:error, _} -> + {0, 0} + end + end + end + + defp parse_port(s) do + case Integer.parse(String.trim(s)) do + {n, ""} when n > 0 and n <= 65535 -> n + _ -> 0 + end + end + + defp data_dir do + case :os.type() do + {:win32, _} -> + case System.get_env("APPDATA") do + nil -> "" + "" -> "" + appdata -> Path.join(appdata, @data_dir_name) + end + + {:unix, :darwin} -> + case System.get_env("HOME") do + nil -> "" + "" -> "" + home -> Path.join([home, "Library", "Application Support", @data_dir_name]) + end + + {:unix, _} -> + case System.get_env("XDG_DATA_HOME") do + xdg when is_binary(xdg) and xdg != "" -> + Path.join(xdg, @data_dir_name) + + _ -> + case System.get_env("HOME") do + nil -> "" + "" -> "" + home -> Path.join([home, ".local", "share", @data_dir_name]) + end + end + end + end +end diff --git a/antd-elixir/lib/antd/grpc_client.ex b/antd-elixir/lib/antd/grpc_client.ex index ffa0132..4b1074f 100644 --- a/antd-elixir/lib/antd/grpc_client.ex +++ b/antd-elixir/lib/antd/grpc_client.ex @@ -29,6 +29,30 @@ defmodule Antd.GrpcClient do channel: GRPC.Channel.t() | nil } + @doc """ + Creates a gRPC client using port discovery. + + Reads the daemon.port file to find the gRPC port. Falls back to the + default target if the port file is not found. + + ## Examples + + {:ok, client, target} = Antd.GrpcClient.auto_discover() + """ + @spec auto_discover() :: {:ok, t(), String.t()} | {:error, Exception.t()} + def auto_discover do + target = + case Antd.Discover.discover_grpc_target() do + "" -> @default_target + discovered -> discovered + end + + case new(target) do + {:ok, client} -> {:ok, client, target} + {:error, _} = err -> err + end + end + @doc """ Creates a new gRPC client and opens a channel to the daemon. diff --git a/antd-go/README.md b/antd-go/README.md index 9365ea4..05c14e0 100644 --- a/antd-go/README.md +++ b/antd-go/README.md @@ -59,7 +59,10 @@ ant dev start ## Configuration ```go -// Default: http://localhost:8080, 5 minute timeout +// Auto-discover daemon via port file (recommended) +client, url := antd.NewClientAutoDiscover() + +// Explicit URL (default: http://localhost:8082) client := antd.NewClient(antd.DefaultBaseURL) // Custom URL diff --git a/antd-go/client.go b/antd-go/client.go index b15272e..04e96ba 100644 --- a/antd-go/client.go +++ b/antd-go/client.go @@ -14,7 +14,7 @@ import ( ) // DefaultBaseURL is the default address of the antd daemon. -const DefaultBaseURL = "http://localhost:8080" +const DefaultBaseURL = "http://localhost:8082" // DefaultTimeout is the default request timeout. const DefaultTimeout = 5 * time.Minute @@ -39,6 +39,17 @@ type Client struct { http *http.Client } +// NewClientAutoDiscover creates a client that discovers the daemon URL automatically. +// It reads the port file written by antd on startup, falling back to DefaultBaseURL. +// Returns the client and the resolved URL. +func NewClientAutoDiscover(opts ...Option) (*Client, string) { + url := DiscoverDaemonURL() + if url == "" { + url = DefaultBaseURL + } + return NewClient(url, opts...), url +} + // NewClient creates a new antd REST client. func NewClient(baseURL string, opts ...Option) *Client { c := &Client{ diff --git a/antd-go/discover.go b/antd-go/discover.go new file mode 100644 index 0000000..b11b3fb --- /dev/null +++ b/antd-go/discover.go @@ -0,0 +1,101 @@ +package antd + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +const portFileName = "daemon.port" +const dataDirName = "ant" + +// DiscoverDaemonURL reads the daemon.port file written by antd on startup +// and returns the REST base URL (e.g. "http://127.0.0.1:8082"). +// Returns empty string if the port file is not found or unreadable. +func DiscoverDaemonURL() string { + rest, _ := readPortFile() + if rest == 0 { + return "" + } + return fmt.Sprintf("http://127.0.0.1:%d", rest) +} + +// DiscoverGrpcTarget reads the daemon.port file written by antd on startup +// and returns the gRPC target (e.g. "127.0.0.1:50051"). +// Returns empty string if the port file is not found or has no gRPC line. +func DiscoverGrpcTarget() string { + _, grpc := readPortFile() + if grpc == 0 { + return "" + } + return fmt.Sprintf("127.0.0.1:%d", grpc) +} + +// readPortFile reads the daemon.port file and returns the REST and gRPC ports. +// The file format is two lines: REST port on line 1, gRPC port on line 2. +// A single-line file is valid (gRPC port will be 0). +func readPortFile() (restPort, grpcPort uint16) { + dir := dataDir() + if dir == "" { + return 0, 0 + } + + data, err := os.ReadFile(filepath.Join(dir, portFileName)) + if err != nil { + return 0, 0 + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 1 { + return 0, 0 + } + + restPort = parsePort(lines[0]) + if len(lines) >= 2 { + grpcPort = parsePort(lines[1]) + } + return restPort, grpcPort +} + +func parsePort(s string) uint16 { + n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 16) + if err != nil { + return 0 + } + return uint16(n) +} + +// dataDir returns the platform-specific data directory for ant. +// - Windows: %APPDATA%\ant +// - macOS: ~/Library/Application Support/ant +// - Linux: $XDG_DATA_HOME/ant or ~/.local/share/ant +func dataDir() string { + switch runtime.GOOS { + case "windows": + appdata := os.Getenv("APPDATA") + if appdata == "" { + return "" + } + return filepath.Join(appdata, dataDirName) + + case "darwin": + home := os.Getenv("HOME") + if home == "" { + return "" + } + return filepath.Join(home, "Library", "Application Support", dataDirName) + + default: // linux and others + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, dataDirName) + } + home := os.Getenv("HOME") + if home == "" { + return "" + } + return filepath.Join(home, ".local", "share", dataDirName) + } +} diff --git a/antd-go/discover_test.go b/antd-go/discover_test.go new file mode 100644 index 0000000..a79e6da --- /dev/null +++ b/antd-go/discover_test.go @@ -0,0 +1,150 @@ +package antd + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// withTempPortFile creates a temp directory, writes a daemon.port file with the +// given content, and sets the environment so dataDir() returns that directory. +// It returns a cleanup function that restores the original env. +func withTempPortFile(t *testing.T, content string) (cleanup func()) { + t.Helper() + dir := t.TempDir() + antDir := filepath.Join(dir, dataDirName) + if err := os.MkdirAll(antDir, 0o755); err != nil { + t.Fatal(err) + } + if content != "" { + if err := os.WriteFile(filepath.Join(antDir, portFileName), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + // Override env so dataDir() finds our temp directory + switch runtime.GOOS { + case "windows": + old := os.Getenv("APPDATA") + os.Setenv("APPDATA", dir) + return func() { os.Setenv("APPDATA", old) } + case "darwin": + old := os.Getenv("HOME") + os.Setenv("HOME", dir) + // On macOS dataDir uses ~/Library/Application Support/ant, so adjust + macDir := filepath.Join(dir, "Library", "Application Support", dataDirName) + os.MkdirAll(macDir, 0o755) + if content != "" { + os.WriteFile(filepath.Join(macDir, portFileName), []byte(content), 0o644) + } + return func() { os.Setenv("HOME", old) } + default: + old := os.Getenv("XDG_DATA_HOME") + os.Setenv("XDG_DATA_HOME", dir) + return func() { + if old == "" { + os.Unsetenv("XDG_DATA_HOME") + } else { + os.Setenv("XDG_DATA_HOME", old) + } + } + } +} + +func TestDiscoverDaemonURL_ValidFile(t *testing.T) { + cleanup := withTempPortFile(t, "8082\n50051\n") + defer cleanup() + + url := DiscoverDaemonURL() + if url != "http://127.0.0.1:8082" { + t.Fatalf("expected http://127.0.0.1:8082, got %s", url) + } +} + +func TestDiscoverGrpcTarget_ValidFile(t *testing.T) { + cleanup := withTempPortFile(t, "8082\n50051\n") + defer cleanup() + + target := DiscoverGrpcTarget() + if target != "127.0.0.1:50051" { + t.Fatalf("expected 127.0.0.1:50051, got %s", target) + } +} + +func TestDiscoverDaemonURL_SingleLine(t *testing.T) { + cleanup := withTempPortFile(t, "9000\n") + defer cleanup() + + url := DiscoverDaemonURL() + if url != "http://127.0.0.1:9000" { + t.Fatalf("expected http://127.0.0.1:9000, got %s", url) + } + + // gRPC should be empty with single line + target := DiscoverGrpcTarget() + if target != "" { + t.Fatalf("expected empty gRPC target, got %s", target) + } +} + +func TestDiscoverDaemonURL_MissingFile(t *testing.T) { + cleanup := withTempPortFile(t, "") + defer cleanup() + + url := DiscoverDaemonURL() + if url != "" { + t.Fatalf("expected empty string, got %s", url) + } +} + +func TestDiscoverDaemonURL_InvalidContent(t *testing.T) { + cleanup := withTempPortFile(t, "not-a-number\n") + defer cleanup() + + url := DiscoverDaemonURL() + if url != "" { + t.Fatalf("expected empty string, got %s", url) + } +} + +func TestDiscoverDaemonURL_WhitespaceHandling(t *testing.T) { + cleanup := withTempPortFile(t, " 8082 \n 50051 \n") + defer cleanup() + + url := DiscoverDaemonURL() + if url != "http://127.0.0.1:8082" { + t.Fatalf("expected http://127.0.0.1:8082, got %s", url) + } + + target := DiscoverGrpcTarget() + if target != "127.0.0.1:50051" { + t.Fatalf("expected 127.0.0.1:50051, got %s", target) + } +} + +func TestNewClientAutoDiscover_WithPortFile(t *testing.T) { + cleanup := withTempPortFile(t, "9999\n") + defer cleanup() + + c, url := NewClientAutoDiscover() + if url != "http://127.0.0.1:9999" { + t.Fatalf("expected http://127.0.0.1:9999, got %s", url) + } + if c.baseURL != "http://127.0.0.1:9999" { + t.Fatalf("client baseURL mismatch: %s", c.baseURL) + } +} + +func TestNewClientAutoDiscover_Fallback(t *testing.T) { + cleanup := withTempPortFile(t, "") + defer cleanup() + + c, url := NewClientAutoDiscover() + if url != DefaultBaseURL { + t.Fatalf("expected %s, got %s", DefaultBaseURL, url) + } + if c.baseURL != DefaultBaseURL { + t.Fatalf("client baseURL mismatch: %s", c.baseURL) + } +} diff --git a/antd-go/grpc_client.go b/antd-go/grpc_client.go index 85469dc..b5aa136 100644 --- a/antd-go/grpc_client.go +++ b/antd-go/grpc_client.go @@ -47,6 +47,18 @@ type GrpcClient struct { file pb.FileServiceClient } +// NewGrpcClientAutoDiscover creates a gRPC client that discovers the daemon target +// automatically. It reads the port file written by antd on startup, falling back +// to DefaultGrpcTarget. Returns the client, the resolved target, and any error. +func NewGrpcClientAutoDiscover(opts ...GrpcOption) (*GrpcClient, string, error) { + target := DiscoverGrpcTarget() + if target == "" { + target = DefaultGrpcTarget + } + c, err := NewGrpcClient(target, opts...) + return c, target, err +} + // NewGrpcClient creates a new gRPC client connected to the given target // (e.g. "localhost:50051"). The connection is established lazily on first use. func NewGrpcClient(target string, opts ...GrpcOption) (*GrpcClient, error) { diff --git a/antd-java/src/main/java/com/autonomi/antd/AntdClient.java b/antd-java/src/main/java/com/autonomi/antd/AntdClient.java index fc3db20..f592c35 100644 --- a/antd-java/src/main/java/com/autonomi/antd/AntdClient.java +++ b/antd-java/src/main/java/com/autonomi/antd/AntdClient.java @@ -31,7 +31,7 @@ public class AntdClient implements AutoCloseable { /** Default daemon address. */ - public static final String DEFAULT_BASE_URL = "http://localhost:8080"; + public static final String DEFAULT_BASE_URL = "http://localhost:8082"; /** Default request timeout (5 minutes). */ public static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(5); @@ -40,6 +40,20 @@ public class AntdClient implements AutoCloseable { private final HttpClient httpClient; private final Duration timeout; + /** + * Creates a client that auto-discovers the daemon via the {@code daemon.port} file. + * Falls back to {@link #DEFAULT_BASE_URL} if discovery fails. + * + * @return a new AntdClient connected to the discovered or default URL + */ + public static AntdClient autoDiscover() { + String url = DaemonDiscovery.discoverDaemonUrl(); + if (url.isEmpty()) { + url = DEFAULT_BASE_URL; + } + return new AntdClient(url); + } + public AntdClient() { this(DEFAULT_BASE_URL, DEFAULT_TIMEOUT); } diff --git a/antd-java/src/main/java/com/autonomi/antd/DaemonDiscovery.java b/antd-java/src/main/java/com/autonomi/antd/DaemonDiscovery.java new file mode 100644 index 0000000..e9de895 --- /dev/null +++ b/antd-java/src/main/java/com/autonomi/antd/DaemonDiscovery.java @@ -0,0 +1,138 @@ +package com.autonomi.antd; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +/** + * Discovers the antd daemon by reading the {@code daemon.port} file + * that the daemon writes on startup. + * + *

The file contains two lines: the REST port on line 1 and the gRPC port on line 2. + * + *

Port file locations by platform: + *

    + *
  • Windows: {@code %APPDATA%\ant\daemon.port}
  • + *
  • macOS: {@code ~/Library/Application Support/ant/daemon.port}
  • + *
  • Linux: {@code $XDG_DATA_HOME/ant/daemon.port} or {@code ~/.local/share/ant/daemon.port}
  • + *
+ */ +public final class DaemonDiscovery { + + private static final String PORT_FILE_NAME = "daemon.port"; + private static final String DATA_DIR_NAME = "ant"; + + private DaemonDiscovery() {} + + /** + * Reads the daemon.port file and returns the REST base URL + * (e.g. {@code "http://127.0.0.1:8082"}). + * + * @return the discovered URL, or an empty string if discovery fails + */ + public static String discoverDaemonUrl() { + int port = readPort(0); + if (port == 0) { + return ""; + } + return "http://127.0.0.1:" + port; + } + + /** + * Reads the daemon.port file and returns the gRPC target + * (e.g. {@code "127.0.0.1:50051"}). + * + * @return the discovered gRPC target, or an empty string if discovery fails + */ + public static String discoverGrpcTarget() { + int port = readPort(1); + if (port == 0) { + return ""; + } + return "127.0.0.1:" + port; + } + + /** + * Reads the specified line from the port file and parses it as a port number. + * + * @param lineIndex 0 for REST port, 1 for gRPC port + * @return the port number, or 0 on failure + */ + private static int readPort(int lineIndex) { + Path dir = dataDir(); + if (dir == null) { + return 0; + } + + Path portFile = dir.resolve(PORT_FILE_NAME); + try { + List lines = Files.readAllLines(portFile); + if (lines.size() <= lineIndex) { + return 0; + } + return parsePort(lines.get(lineIndex)); + } catch (IOException e) { + return 0; + } + } + + /** + * Parses a port string into an integer in the valid port range (1-65535). + * + * @return the port number, or 0 if invalid + */ + private static int parsePort(String s) { + try { + int n = Integer.parseInt(s.trim()); + if (n < 1 || n > 65535) { + return 0; + } + return n; + } catch (NumberFormatException e) { + return 0; + } + } + + /** + * Returns the platform-specific data directory for ant. + *
    + *
  • Windows: {@code %APPDATA%\ant}
  • + *
  • macOS: {@code ~/Library/Application Support/ant}
  • + *
  • Linux: {@code $XDG_DATA_HOME/ant} or {@code ~/.local/share/ant}
  • + *
+ * + * @return the data directory path, or null if it cannot be determined + */ + private static Path dataDir() { + String os = System.getProperty("os.name", "").toLowerCase(); + + if (os.contains("win")) { + String appdata = System.getenv("APPDATA"); + if (appdata == null || appdata.isEmpty()) { + return null; + } + return Paths.get(appdata, DATA_DIR_NAME); + } + + if (os.contains("mac") || os.contains("darwin")) { + String home = System.getProperty("user.home"); + if (home == null || home.isEmpty()) { + return null; + } + return Paths.get(home, "Library", "Application Support", DATA_DIR_NAME); + } + + // Linux and others + String xdg = System.getenv("XDG_DATA_HOME"); + if (xdg != null && !xdg.isEmpty()) { + return Paths.get(xdg, DATA_DIR_NAME); + } + String home = System.getProperty("user.home"); + if (home == null || home.isEmpty()) { + return null; + } + return Paths.get(home, ".local", "share", DATA_DIR_NAME); + } +} diff --git a/antd-java/src/main/java/com/autonomi/antd/GrpcAntdClient.java b/antd-java/src/main/java/com/autonomi/antd/GrpcAntdClient.java index 8a970ae..37d2c2a 100644 --- a/antd-java/src/main/java/com/autonomi/antd/GrpcAntdClient.java +++ b/antd-java/src/main/java/com/autonomi/antd/GrpcAntdClient.java @@ -86,6 +86,20 @@ public class GrpcAntdClient implements AutoCloseable { private final GraphServiceGrpc.GraphServiceBlockingStub graphStub; private final FileServiceGrpc.FileServiceBlockingStub fileStub; + /** + * Creates a client that auto-discovers the daemon via the {@code daemon.port} file. + * Falls back to {@link #DEFAULT_TARGET} if discovery fails. + * + * @return a new GrpcAntdClient connected to the discovered or default target + */ + public static GrpcAntdClient autoDiscover() { + String target = DaemonDiscovery.discoverGrpcTarget(); + if (target.isEmpty()) { + target = DEFAULT_TARGET; + } + return new GrpcAntdClient(target); + } + /** * Creates a client connected to {@code localhost:50051} with plaintext (no TLS). */ diff --git a/antd-js/src/discover.ts b/antd-js/src/discover.ts new file mode 100644 index 0000000..18879f0 --- /dev/null +++ b/antd-js/src/discover.ts @@ -0,0 +1,85 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +const PORT_FILE_NAME = "daemon.port"; +const DATA_DIR_NAME = "ant"; + +/** + * Reads the daemon.port file written by antd on startup and returns the + * REST base URL (e.g. "http://127.0.0.1:8082"). + * Returns empty string if the port file is not found or unreadable. + */ +export function discoverDaemonUrl(): string { + const ports = readPortFile(); + if (ports.rest === 0) { + return ""; + } + return `http://127.0.0.1:${ports.rest}`; +} + +/** + * Reads the daemon.port file and returns the parsed REST and gRPC ports. + * The file format is two lines: REST port on line 1, gRPC port on line 2. + * A single-line file is valid (gRPC port will be 0). + */ +function readPortFile(): { rest: number; grpc: number } { + const dir = dataDir(); + if (dir === "") { + return { rest: 0, grpc: 0 }; + } + + let data: string; + try { + data = fs.readFileSync(path.join(dir, PORT_FILE_NAME), "utf-8"); + } catch { + return { rest: 0, grpc: 0 }; + } + + const lines = data.trim().split("\n"); + if (lines.length < 1) { + return { rest: 0, grpc: 0 }; + } + + const rest = parsePort(lines[0]); + const grpc = lines.length >= 2 ? parsePort(lines[1]) : 0; + return { rest, grpc }; +} + +function parsePort(s: string): number { + const n = parseInt(s.trim(), 10); + if (isNaN(n) || n < 1 || n > 65535) { + return 0; + } + return n; +} + +/** + * Returns the platform-specific data directory for ant. + * - Windows: %APPDATA%\ant + * - macOS: ~/Library/Application Support/ant + * - Linux: $XDG_DATA_HOME/ant or ~/.local/share/ant + */ +function dataDir(): string { + switch (process.platform) { + case "win32": { + const appdata = process.env.APPDATA ?? ""; + if (appdata === "") return ""; + return path.join(appdata, DATA_DIR_NAME); + } + case "darwin": { + const home = os.homedir(); + if (home === "") return ""; + return path.join(home, "Library", "Application Support", DATA_DIR_NAME); + } + default: { + const xdg = process.env.XDG_DATA_HOME ?? ""; + if (xdg !== "") { + return path.join(xdg, DATA_DIR_NAME); + } + const home = os.homedir(); + if (home === "") return ""; + return path.join(home, ".local", "share", DATA_DIR_NAME); + } + } +} diff --git a/antd-js/src/index.ts b/antd-js/src/index.ts index c2ec3fb..5266fa0 100644 --- a/antd-js/src/index.ts +++ b/antd-js/src/index.ts @@ -23,6 +23,8 @@ export { export { RestClient } from "./rest-client.js"; export type { RestClientOptions } from "./rest-client.js"; +export { discoverDaemonUrl } from "./discover.js"; + import { RestClient, type RestClientOptions } from "./rest-client.js"; /** Create a REST client for the antd daemon. */ diff --git a/antd-js/src/rest-client.ts b/antd-js/src/rest-client.ts index 268cdbe..add8b29 100644 --- a/antd-js/src/rest-client.ts +++ b/antd-js/src/rest-client.ts @@ -1,3 +1,4 @@ +import { discoverDaemonUrl } from "./discover.js"; import { fromHttpStatus } from "./errors.js"; import type { Archive, @@ -10,7 +11,7 @@ import type { /** Options for creating a REST client. */ export interface RestClientOptions { - /** Base URL of the antd daemon. Defaults to "http://localhost:8080". */ + /** Base URL of the antd daemon. Defaults to "http://localhost:8082". */ baseUrl?: string; /** Request timeout in milliseconds. Defaults to 300000 (5 minutes). */ timeout?: number; @@ -21,8 +22,25 @@ export class RestClient { private readonly baseUrl: string; private readonly timeout: number; + /** + * Creates a REST client by auto-discovering the daemon port from the + * daemon.port file written by antd on startup. Falls back to the default + * base URL if the port file is not found. + * + * @returns An object with the created `client` and the discovered `url` + * (empty string if discovery failed and default was used). + */ + static autoDiscover(options?: RestClientOptions): { client: RestClient; url: string } { + const discovered = discoverDaemonUrl(); + const opts: RestClientOptions = { ...options }; + if (discovered !== "") { + opts.baseUrl = discovered; + } + return { client: new RestClient(opts), url: discovered }; + } + constructor(options: RestClientOptions = {}) { - this.baseUrl = (options.baseUrl ?? "http://localhost:8080").replace(/\/+$/, ""); + this.baseUrl = (options.baseUrl ?? "http://localhost:8082").replace(/\/+$/, ""); this.timeout = options.timeout ?? 300_000; } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt index f470c01..b34f5f8 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt @@ -8,6 +8,17 @@ import io.grpc.Status class AntdGrpcClient(target: String = "localhost:50051") : IAntdClient { + companion object { + /** + * Create a client by auto-discovering the daemon gRPC port from the + * `daemon.port` file. Falls back to `localhost:50051` if not found. + */ + fun autoDiscover(): AntdGrpcClient { + val target = DaemonDiscovery.discoverGrpcTarget().ifEmpty { "localhost:50051" } + return AntdGrpcClient(target) + } + } + private val channel = ManagedChannelBuilder.forTarget(target).usePlaintext().build() private val healthStub = HealthServiceGrpcKt.HealthServiceCoroutineStub(channel) private val dataStub = DataServiceGrpcKt.DataServiceCoroutineStub(channel) diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt index 1bf4fcd..b17139e 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt @@ -18,10 +18,21 @@ import java.time.Duration import java.util.Base64 class AntdRestClient( - baseUrl: String = "http://localhost:8080", + baseUrl: String = "http://localhost:8082", timeout: Duration = Duration.ofSeconds(300), ) : IAntdClient { + companion object { + /** + * Create a client by auto-discovering the daemon port from the + * `daemon.port` file. Falls back to `http://localhost:8082` if not found. + */ + fun autoDiscover(timeout: Duration = Duration.ofSeconds(300)): AntdRestClient { + val url = DaemonDiscovery.discoverDaemonUrl().ifEmpty { "http://localhost:8082" } + return AntdRestClient(url, timeout) + } + } + private val baseUrl = baseUrl.trimEnd('/') private val http = OkHttpClient.Builder() .callTimeout(timeout) diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/DaemonDiscovery.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/DaemonDiscovery.kt new file mode 100644 index 0000000..1352fb8 --- /dev/null +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/DaemonDiscovery.kt @@ -0,0 +1,73 @@ +package com.autonomi.sdk + +import java.io.File +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Reads the `daemon.port` file written by antd on startup to auto-discover + * the REST and gRPC ports. + * + * The file contains two lines: REST port on line 1, gRPC port on line 2. + */ +object DaemonDiscovery { + + private const val PORT_FILE_NAME = "daemon.port" + private const val DATA_DIR_NAME = "ant" + + /** + * Returns the REST base URL (e.g. `"http://127.0.0.1:8082"`) discovered + * from the daemon.port file, or an empty string if not found. + */ + fun discoverDaemonUrl(): String { + val (rest, _) = readPortFile() + return if (rest == 0) "" else "http://127.0.0.1:$rest" + } + + /** + * Returns the gRPC target (e.g. `"127.0.0.1:50051"`) discovered from + * the daemon.port file, or an empty string if not found. + */ + fun discoverGrpcTarget(): String { + val (_, grpc) = readPortFile() + return if (grpc == 0) "" else "127.0.0.1:$grpc" + } + + private fun readPortFile(): Pair { + val dir = dataDir() ?: return 0 to 0 + val file = dir.resolve(PORT_FILE_NAME).toFile() + if (!file.exists()) return 0 to 0 + + return try { + val lines = file.readLines().map { it.trim() } + val rest = lines.getOrNull(0)?.toIntOrNull()?.takeIf { it in 1..65535 } ?: 0 + val grpc = lines.getOrNull(1)?.toIntOrNull()?.takeIf { it in 1..65535 } ?: 0 + rest to grpc + } catch (_: Exception) { + 0 to 0 + } + } + + private fun dataDir(): Path? { + val os = System.getProperty("os.name", "").lowercase() + return when { + os.contains("win") -> { + val appdata = System.getenv("APPDATA") ?: return null + Paths.get(appdata, DATA_DIR_NAME) + } + os.contains("mac") || os.contains("darwin") -> { + val home = System.getProperty("user.home") ?: return null + Paths.get(home, "Library", "Application Support", DATA_DIR_NAME) + } + else -> { + val xdg = System.getenv("XDG_DATA_HOME") + if (!xdg.isNullOrEmpty()) { + Paths.get(xdg, DATA_DIR_NAME) + } else { + val home = System.getProperty("user.home") ?: return null + Paths.get(home, ".local", "share", DATA_DIR_NAME) + } + } + } + } +} diff --git a/antd-lua/src/antd/client.lua b/antd-lua/src/antd/client.lua index fa867fd..bf6e526 100644 --- a/antd-lua/src/antd/client.lua +++ b/antd-lua/src/antd/client.lua @@ -7,18 +7,19 @@ local cjson = require("cjson") local base64 = require("antd.base64") local errors = require("antd.errors") local models = require("antd.models") +local discover = require("antd.discover") local Client = {} Client.__index = Client --- Default base URL for the antd daemon. -Client.DEFAULT_BASE_URL = "http://localhost:8080" +Client.DEFAULT_BASE_URL = "http://localhost:8082" --- Default request timeout in seconds. Client.DEFAULT_TIMEOUT = 300 --- Create a new antd client. --- @param base_url string base URL (default "http://localhost:8080") +-- @param base_url string base URL (default "http://localhost:8082") -- @param opts table optional settings: { timeout = number } -- @return Client function Client:new(base_url, opts) @@ -401,4 +402,16 @@ function Client:file_cost(path, is_public, include_archive) return str(j, "cost"), nil end +--- Create a client using daemon port discovery. +-- Falls back to the default base URL if discovery fails. +-- @param opts table optional settings: { timeout = number } +-- @return Client client, string url +function Client.auto_discover(opts) + local url = discover.daemon_url() + if url == "" then + url = Client.DEFAULT_BASE_URL + end + return Client:new(url, opts), url +end + return Client diff --git a/antd-lua/src/antd/discover.lua b/antd-lua/src/antd/discover.lua new file mode 100644 index 0000000..19da82f --- /dev/null +++ b/antd-lua/src/antd/discover.lua @@ -0,0 +1,96 @@ +--- Port discovery for the antd daemon. +-- Reads the `daemon.port` file written by antd on startup. +-- @module antd.discover + +local Discover = {} + +local PORT_FILE_NAME = "daemon.port" +local DATA_DIR_NAME = "ant" + +--- Returns true if running on Windows. +local function is_windows() + return package.config:sub(1, 1) == "\\" +end + +--- Returns the platform-specific data directory for ant. +-- @return string|nil directory path, or nil if not determinable +local function data_dir() + if is_windows() then + local appdata = os.getenv("APPDATA") + if not appdata or appdata == "" then return nil end + return appdata .. "\\" .. DATA_DIR_NAME + end + + -- Check for macOS by looking for ~/Library + local home = os.getenv("HOME") + if home and home ~= "" then + local lib = home .. "/Library" + local f = io.open(lib, "r") + if f then + f:close() + -- macOS + return home .. "/Library/Application Support/" .. DATA_DIR_NAME + end + end + + -- Linux / other Unix + local xdg = os.getenv("XDG_DATA_HOME") + if xdg and xdg ~= "" then + return xdg .. "/" .. DATA_DIR_NAME + end + if home and home ~= "" then + return home .. "/.local/share/" .. DATA_DIR_NAME + end + + return nil +end + +--- Read the daemon.port file and return the two port numbers. +-- @return number|nil REST port +-- @return number|nil gRPC port +local function read_port_file() + local dir = data_dir() + if not dir then return nil, nil end + + local sep = is_windows() and "\\" or "/" + local path = dir .. sep .. PORT_FILE_NAME + + local f = io.open(path, "r") + if not f then return nil, nil end + + local contents = f:read("*a") + f:close() + if not contents or contents == "" then return nil, nil end + + local lines = {} + for line in contents:gmatch("[^\r\n]+") do + lines[#lines + 1] = line + end + + if #lines < 1 then return nil, nil end + + local rest_port = tonumber(lines[1]) + local grpc_port = #lines >= 2 and tonumber(lines[2]) or nil + + return rest_port, grpc_port +end + +--- Discover the antd daemon REST URL. +-- Returns the URL (e.g. "http://127.0.0.1:8082") or "" if unavailable. +-- @return string +function Discover.daemon_url() + local rest = read_port_file() + if not rest or rest == 0 then return "" end + return string.format("http://127.0.0.1:%d", rest) +end + +--- Discover the antd daemon gRPC target. +-- Returns the target (e.g. "127.0.0.1:50051") or "" if unavailable. +-- @return string +function Discover.grpc_target() + local _, grpc = read_port_file() + if not grpc or grpc == 0 then return "" end + return string.format("127.0.0.1:%d", grpc) +end + +return Discover diff --git a/antd-lua/src/antd/init.lua b/antd-lua/src/antd/init.lua index 0ff90a5..1e8370c 100644 --- a/antd-lua/src/antd/init.lua +++ b/antd-lua/src/antd/init.lua @@ -4,6 +4,7 @@ local Client = require("antd.client") local models = require("antd.models") local errors = require("antd.errors") +local discover = require("antd.discover") local M = {} @@ -13,11 +14,25 @@ M._VERSION = "0.1.0" --- Default base URL for the antd daemon. M.DEFAULT_BASE_URL = Client.DEFAULT_BASE_URL +--- Discover the daemon REST URL from the port file. +-- @return string URL or "" if unavailable +M.discover_daemon_url = discover.daemon_url + +--- Discover the daemon gRPC target from the port file. +-- @return string target or "" if unavailable +M.discover_grpc_target = discover.grpc_target + +--- Create a client using daemon port discovery. +-- Falls back to the default base URL if discovery fails. +-- @param opts table optional settings: { timeout = number } +-- @return Client client, string url +M.auto_discover = Client.auto_discover + --- Default timeout in seconds. M.DEFAULT_TIMEOUT = Client.DEFAULT_TIMEOUT --- Create a new antd client. --- @param base_url string base URL (default "http://localhost:8080") +-- @param base_url string base URL (default "http://localhost:8082") -- @param opts table optional settings: { timeout = number } -- @return Client function M.new_client(base_url, opts) diff --git a/antd-mcp/README.md b/antd-mcp/README.md index 3a12fe1..56f514f 100644 --- a/antd-mcp/README.md +++ b/antd-mcp/README.md @@ -24,7 +24,9 @@ antd-mcp --sse | Variable | Default | Description | |----------|---------|-------------| -| `ANTD_BASE_URL` | `http://localhost:8080` | antd daemon URL | +| `ANTD_BASE_URL` | auto-discovered | antd daemon URL (overrides port-file discovery) | + +The MCP server automatically discovers the antd daemon via the `daemon.port` file written by antd on startup. Set `ANTD_BASE_URL` only if you need to override this (e.g. connecting to a remote daemon). If neither the env var nor port file is available, falls back to `http://127.0.0.1:8082`. ## Claude Desktop Configuration @@ -34,15 +36,14 @@ Add to your Claude Desktop config (`claude_desktop_config.json`): { "mcpServers": { "antd-autonomi": { - "command": "antd-mcp", - "env": { - "ANTD_BASE_URL": "http://localhost:8080" - } + "command": "antd-mcp" } } } ``` +The server will auto-discover the daemon via the port file. Add `"env": {"ANTD_BASE_URL": "http://your-host:port"}` only if you need to override discovery. + ## Tool Reference ### Data Operations diff --git a/antd-mcp/src/antd_mcp/discover.py b/antd-mcp/src/antd_mcp/discover.py new file mode 100644 index 0000000..613c1df --- /dev/null +++ b/antd-mcp/src/antd_mcp/discover.py @@ -0,0 +1,102 @@ +"""Port discovery for the antd daemon. + +The antd daemon writes a ``daemon.port`` file on startup containing two lines: + - Line 1: REST port + - Line 2: gRPC port + +This module reads that file using platform-specific data directory paths to +auto-discover the daemon without requiring manual configuration. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +_PORT_FILE_NAME = "daemon.port" +_DATA_DIR_NAME = "ant" + + +def _data_dir() -> Path | None: + """Return the platform-specific data directory for ant, or None.""" + if sys.platform == "win32": + appdata = os.environ.get("APPDATA") + if not appdata: + return None + return Path(appdata) / _DATA_DIR_NAME + + if sys.platform == "darwin": + home = os.environ.get("HOME") + if not home: + return None + return Path(home) / "Library" / "Application Support" / _DATA_DIR_NAME + + # Linux and other Unix-likes + xdg = os.environ.get("XDG_DATA_HOME") + if xdg: + return Path(xdg) / _DATA_DIR_NAME + home = os.environ.get("HOME") + if not home: + return None + return Path(home) / ".local" / "share" / _DATA_DIR_NAME + + +def _read_port_file() -> tuple[int, int]: + """Read the daemon.port file and return (rest_port, grpc_port). + + Returns (0, 0) if the file is missing or unreadable. + A single-line file is valid; grpc_port will be 0 in that case. + """ + data_dir = _data_dir() + if data_dir is None: + return 0, 0 + + port_file = data_dir / _PORT_FILE_NAME + try: + text = port_file.read_text().strip() + except (OSError, ValueError): + return 0, 0 + + lines = text.splitlines() + if not lines: + return 0, 0 + + rest_port = _parse_port(lines[0]) + grpc_port = _parse_port(lines[1]) if len(lines) >= 2 else 0 + return rest_port, grpc_port + + +def _parse_port(s: str) -> int: + """Parse a port string, returning 0 on failure.""" + try: + n = int(s.strip()) + if 1 <= n <= 65535: + return n + except ValueError: + pass + return 0 + + +def discover_daemon_url() -> str: + """Read the daemon.port file and return the REST base URL. + + Returns ``"http://127.0.0.1:{port}"`` on success, or ``""`` if the port + file is not found or unreadable. + """ + rest, _ = _read_port_file() + if rest == 0: + return "" + return f"http://127.0.0.1:{rest}" + + +def discover_grpc_target() -> str: + """Read the daemon.port file and return the gRPC target. + + Returns ``"127.0.0.1:{port}"`` on success, or ``""`` if the port file + is not found or has no gRPC line. + """ + _, grpc = _read_port_file() + if grpc == 0: + return "" + return f"127.0.0.1:{grpc}" diff --git a/antd-mcp/src/antd_mcp/server.py b/antd-mcp/src/antd_mcp/server.py index 2377e07..4168323 100644 --- a/antd-mcp/src/antd_mcp/server.py +++ b/antd-mcp/src/antd_mcp/server.py @@ -14,16 +14,20 @@ from antd.exceptions import AntdError from antd.models import GraphDescendant +from .discover import discover_daemon_url from .errors import format_error, format_unexpected_error # --------------------------------------------------------------------------- # Lifespan — create/close a single AsyncRestClient for the server's lifetime # --------------------------------------------------------------------------- +_DEFAULT_BASE_URL = "http://127.0.0.1:8082" + @asynccontextmanager async def lifespan(server: FastMCP): - base_url = os.environ.get("ANTD_BASE_URL", "http://localhost:8080") + # Priority: env var > port-file discovery > default + base_url = os.environ.get("ANTD_BASE_URL") or discover_daemon_url() or _DEFAULT_BASE_URL client = AsyncAntdClient(transport="rest", base_url=base_url) # Query the daemon's network on startup network = "unknown" diff --git a/antd-php/src/AntdClient.php b/antd-php/src/AntdClient.php index 7fdb39e..7a73775 100644 --- a/antd-php/src/AntdClient.php +++ b/antd-php/src/AntdClient.php @@ -25,7 +25,7 @@ class AntdClient private string $baseUrl; public function __construct( - string $baseUrl = 'http://localhost:8080', + string $baseUrl = 'http://localhost:8082', float $timeout = 300.0, ?Client $httpClient = null, ) { @@ -36,6 +36,24 @@ public function __construct( ]); } + /** + * Create a client using daemon port discovery. + * Falls back to http://localhost:8082 if discovery fails. + * + * @param float $timeout Request timeout in seconds. + * @param \GuzzleHttp\Client|null $httpClient Optional HTTP client. + * @return array{0: self, 1: string} [$client, $url] + */ + public static function autoDiscover(float $timeout = 300.0, ?Client $httpClient = null): array + { + $url = DaemonDiscovery::discoverDaemonUrl(); + if ($url === '') { + $url = 'http://localhost:8082'; + } + $client = new self($url, $timeout, $httpClient); + return [$client, $url]; + } + // --- Internal helpers --- private function b64Encode(string $data): string diff --git a/antd-php/src/DaemonDiscovery.php b/antd-php/src/DaemonDiscovery.php new file mode 100644 index 0000000..4f521a0 --- /dev/null +++ b/antd-php/src/DaemonDiscovery.php @@ -0,0 +1,129 @@ += 2 ? self::parsePort($lines[1]) : 0; + return [$rest, $grpc]; + } + + private static function parsePort(string $s): int + { + $n = (int) trim($s); + if ($n < 1 || $n > 65535) { + return 0; + } + return $n; + } + + private static function dataDir(): string + { + switch (PHP_OS_FAMILY) { + case 'Windows': + $appdata = getenv('APPDATA'); + if ($appdata === false || $appdata === '') { + return ''; + } + return $appdata . DIRECTORY_SEPARATOR . self::DATA_DIR_NAME; + + case 'Darwin': + $home = getenv('HOME'); + if ($home === false || $home === '') { + return ''; + } + return $home . '/Library/Application Support/' . self::DATA_DIR_NAME; + + default: // Linux and others + $xdg = getenv('XDG_DATA_HOME'); + if ($xdg !== false && $xdg !== '') { + return $xdg . '/' . self::DATA_DIR_NAME; + } + $home = getenv('HOME'); + if ($home === false || $home === '') { + return ''; + } + return $home . '/.local/share/' . self::DATA_DIR_NAME; + } + } +} diff --git a/antd-py/src/antd/__init__.py b/antd-py/src/antd/__init__.py index 3c1bc8c..8f3b9e7 100644 --- a/antd-py/src/antd/__init__.py +++ b/antd-py/src/antd/__init__.py @@ -19,6 +19,7 @@ HealthStatus, PutResult, ) +from ._discover import discover_daemon_url, discover_grpc_target from .exceptions import ( AntdError, AlreadyExistsError, @@ -32,6 +33,9 @@ ) __all__ = [ + # Discovery + "discover_daemon_url", + "discover_grpc_target", # Factory functions "AntdClient", "AsyncAntdClient", @@ -61,7 +65,7 @@ def AntdClient(transport: str = "rest", **kwargs): Args: transport: "rest" (default) or "grpc" **kwargs: Passed to the underlying client constructor. - REST: base_url (default "http://localhost:8080"), timeout + REST: base_url (default "http://localhost:8082"), timeout gRPC: target (default "localhost:50051") """ if transport == "rest": @@ -80,7 +84,7 @@ def AsyncAntdClient(transport: str = "rest", **kwargs): Args: transport: "rest" (default) or "grpc" **kwargs: Passed to the underlying client constructor. - REST: base_url (default "http://localhost:8080"), timeout + REST: base_url (default "http://localhost:8082"), timeout gRPC: target (default "localhost:50051") """ if transport == "rest": diff --git a/antd-py/src/antd/_discover.py b/antd-py/src/antd/_discover.py new file mode 100644 index 0000000..8119538 --- /dev/null +++ b/antd-py/src/antd/_discover.py @@ -0,0 +1,93 @@ +"""Daemon port-file discovery for antd. + +The antd daemon writes a ``daemon.port`` file on startup containing: + - Line 1: REST port + - Line 2: gRPC port + +This module reads that file to auto-discover the daemon's listen addresses. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +_PORT_FILE_NAME = "daemon.port" +_DATA_DIR_NAME = "ant" + + +def discover_daemon_url() -> str: + """Return the REST base URL from the daemon port file, or ``""`` on failure.""" + rest, _ = _read_port_file() + if rest == 0: + return "" + return f"http://127.0.0.1:{rest}" + + +def discover_grpc_target() -> str: + """Return the gRPC target from the daemon port file, or ``""`` on failure.""" + _, grpc = _read_port_file() + if grpc == 0: + return "" + return f"127.0.0.1:{grpc}" + + +def _read_port_file() -> tuple[int, int]: + """Read the daemon.port file and return ``(rest_port, grpc_port)``. + + A single-line file is valid (gRPC port will be 0). + Returns ``(0, 0)`` on any error. + """ + dir_path = _data_dir() + if not dir_path: + return 0, 0 + + port_file = Path(dir_path) / _PORT_FILE_NAME + try: + text = port_file.read_text(encoding="utf-8") + except OSError: + return 0, 0 + + lines = text.strip().splitlines() + if not lines: + return 0, 0 + + rest_port = _parse_port(lines[0]) + grpc_port = _parse_port(lines[1]) if len(lines) >= 2 else 0 + return rest_port, grpc_port + + +def _parse_port(s: str) -> int: + """Parse a port string, returning 0 on failure.""" + try: + n = int(s.strip()) + except (ValueError, TypeError): + return 0 + if 1 <= n <= 65535: + return n + return 0 + + +def _data_dir() -> str: + """Return the platform-specific data directory for ant, or ``""``.""" + if sys.platform == "win32": + appdata = os.environ.get("APPDATA", "") + if not appdata: + return "" + return os.path.join(appdata, _DATA_DIR_NAME) + + if sys.platform == "darwin": + home = os.environ.get("HOME", "") + if not home: + return "" + return os.path.join(home, "Library", "Application Support", _DATA_DIR_NAME) + + # Linux and others + xdg = os.environ.get("XDG_DATA_HOME", "") + if xdg: + return os.path.join(xdg, _DATA_DIR_NAME) + home = os.environ.get("HOME", "") + if not home: + return "" + return os.path.join(home, ".local", "share", _DATA_DIR_NAME) diff --git a/antd-py/src/antd/_grpc.py b/antd-py/src/antd/_grpc.py index 397a270..8f366a0 100644 --- a/antd-py/src/antd/_grpc.py +++ b/antd-py/src/antd/_grpc.py @@ -56,6 +56,21 @@ def _handle_rpc_error(e: grpc.RpcError) -> None: class GrpcClient: """Synchronous gRPC client for the antd daemon.""" + DEFAULT_TARGET = "localhost:50051" + + @classmethod + def auto_discover(cls, **kwargs) -> tuple["GrpcClient", str]: + """Create a client using daemon port discovery, falling back to the default target. + + Returns: + A tuple of ``(client, resolved_target)`` where *resolved_target* is + the gRPC target that was actually used (discovered or default). + """ + from ._discover import discover_grpc_target + + target = discover_grpc_target() or cls.DEFAULT_TARGET + return cls(target=target, **kwargs), target + def __init__(self, target: str = "localhost:50051"): self._channel = grpc.insecure_channel(target) self._health = health_pb2_grpc.HealthServiceStub(self._channel) @@ -256,6 +271,21 @@ def file_cost(self, path: str, is_public: bool = True, include_archive: bool = F class AsyncGrpcClient: """Asynchronous gRPC client for the antd daemon.""" + DEFAULT_TARGET = "localhost:50051" + + @classmethod + def auto_discover(cls, **kwargs) -> tuple["AsyncGrpcClient", str]: + """Create a client using daemon port discovery, falling back to the default target. + + Returns: + A tuple of ``(client, resolved_target)`` where *resolved_target* is + the gRPC target that was actually used (discovered or default). + """ + from ._discover import discover_grpc_target + + target = discover_grpc_target() or cls.DEFAULT_TARGET + return cls(target=target, **kwargs), target + def __init__(self, target: str = "localhost:50051"): self._channel = grpc.aio.insecure_channel(target) self._health = health_pb2_grpc.HealthServiceStub(self._channel) diff --git a/antd-py/src/antd/_rest.py b/antd-py/src/antd/_rest.py index de64ef1..3d8e6ff 100644 --- a/antd-py/src/antd/_rest.py +++ b/antd-py/src/antd/_rest.py @@ -43,10 +43,25 @@ def _check(resp: httpx.Response) -> None: class RestClient: """Synchronous REST client for the antd daemon.""" - def __init__(self, base_url: str = "http://localhost:8080", timeout: float = 300.0): + DEFAULT_BASE_URL = "http://localhost:8082" + + def __init__(self, base_url: str = "http://localhost:8082", timeout: float = 300.0): self._base = base_url.rstrip("/") self._http = httpx.Client(base_url=self._base, timeout=timeout) + @classmethod + def auto_discover(cls, **kwargs) -> tuple["RestClient", str]: + """Create a client using daemon port discovery, falling back to the default URL. + + Returns: + A tuple of ``(client, resolved_url)`` where *resolved_url* is the + URL that was actually used (discovered or default). + """ + from ._discover import discover_daemon_url + + url = discover_daemon_url() or cls.DEFAULT_BASE_URL + return cls(base_url=url, **kwargs), url + def close(self) -> None: self._http.close() @@ -210,10 +225,25 @@ def file_cost(self, path: str, is_public: bool = True, include_archive: bool = F class AsyncRestClient: """Asynchronous REST client for the antd daemon.""" - def __init__(self, base_url: str = "http://localhost:8080", timeout: float = 300.0): + DEFAULT_BASE_URL = "http://localhost:8082" + + def __init__(self, base_url: str = "http://localhost:8082", timeout: float = 300.0): self._base = base_url.rstrip("/") self._http = httpx.AsyncClient(base_url=self._base, timeout=timeout) + @classmethod + def auto_discover(cls, **kwargs) -> tuple["AsyncRestClient", str]: + """Create a client using daemon port discovery, falling back to the default URL. + + Returns: + A tuple of ``(client, resolved_url)`` where *resolved_url* is the + URL that was actually used (discovered or default). + """ + from ._discover import discover_daemon_url + + url = discover_daemon_url() or cls.DEFAULT_BASE_URL + return cls(base_url=url, **kwargs), url + async def close(self) -> None: await self._http.aclose() diff --git a/antd-py/tests/test_discover.py b/antd-py/tests/test_discover.py new file mode 100644 index 0000000..330c9c4 --- /dev/null +++ b/antd-py/tests/test_discover.py @@ -0,0 +1,128 @@ +"""Tests for antd._discover port-file discovery.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from antd._discover import ( + _data_dir, + _read_port_file, + discover_daemon_url, + discover_grpc_target, +) + + +def _write_port_file(tmp_path: Path, content: str, monkeypatch) -> None: + """Write a daemon.port file under tmp_path/ant/ and point env vars at it.""" + ant_dir = tmp_path / "ant" + ant_dir.mkdir(exist_ok=True) + (ant_dir / "daemon.port").write_text(content, encoding="utf-8") + # Use XDG_DATA_HOME on all platforms for test isolation + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + # Force sys.platform to linux so XDG_DATA_HOME is used + monkeypatch.setattr("sys.platform", "linux") + + +class TestDiscoverDaemonUrl: + def test_valid_file_both_lines(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "8082\n50051\n", monkeypatch) + assert discover_daemon_url() == "http://127.0.0.1:8082" + + def test_valid_file_single_line(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "9000\n", monkeypatch) + assert discover_daemon_url() == "http://127.0.0.1:9000" + + def test_missing_file(self, tmp_path, monkeypatch): + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + # No daemon.port file created + assert discover_daemon_url() == "" + + def test_invalid_content(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "not_a_number\n", monkeypatch) + assert discover_daemon_url() == "" + + def test_empty_file(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "", monkeypatch) + assert discover_daemon_url() == "" + + def test_whitespace_handling(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, " 8082 \n 50051 \n", monkeypatch) + assert discover_daemon_url() == "http://127.0.0.1:8082" + + def test_port_zero(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "0\n50051\n", monkeypatch) + assert discover_daemon_url() == "" + + def test_port_out_of_range(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "99999\n50051\n", monkeypatch) + assert discover_daemon_url() == "" + + +class TestDiscoverGrpcTarget: + def test_valid_file_both_lines(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "8082\n50051\n", monkeypatch) + assert discover_grpc_target() == "127.0.0.1:50051" + + def test_single_line_no_grpc(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "8082\n", monkeypatch) + assert discover_grpc_target() == "" + + def test_missing_file(self, tmp_path, monkeypatch): + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + assert discover_grpc_target() == "" + + def test_invalid_grpc_line(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "8082\nabc\n", monkeypatch) + assert discover_grpc_target() == "" + + def test_whitespace_handling(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, " 8082 \n 50051 \n", monkeypatch) + assert discover_grpc_target() == "127.0.0.1:50051" + + +class TestDataDir: + def test_windows(self, monkeypatch): + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.setenv("APPDATA", "C:\\Users\\test\\AppData\\Roaming") + result = _data_dir() + assert result == os.path.join("C:\\Users\\test\\AppData\\Roaming", "ant") + + def test_darwin(self, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setenv("HOME", "/Users/test") + result = _data_dir() + assert result == os.path.join("/Users/test", "Library", "Application Support", "ant") + + def test_linux_xdg(self, monkeypatch): + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setenv("XDG_DATA_HOME", "/custom/data") + result = _data_dir() + assert result == os.path.join("/custom/data", "ant") + + def test_linux_no_xdg(self, monkeypatch): + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.setenv("HOME", "/home/test") + result = _data_dir() + assert result == os.path.join("/home/test", ".local", "share", "ant") + + def test_linux_no_home(self, monkeypatch): + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.delenv("HOME", raising=False) + assert _data_dir() == "" + + def test_windows_no_appdata(self, monkeypatch): + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.delenv("APPDATA", raising=False) + assert _data_dir() == "" + + def test_darwin_no_home(self, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.delenv("HOME", raising=False) + assert _data_dir() == "" diff --git a/antd-ruby/lib/antd.rb b/antd-ruby/lib/antd.rb index 966ee9f..f4dc727 100644 --- a/antd-ruby/lib/antd.rb +++ b/antd-ruby/lib/antd.rb @@ -3,6 +3,7 @@ require_relative "antd/version" require_relative "antd/models" require_relative "antd/errors" +require_relative "antd/discover" require_relative "antd/client" # gRPC client is optional — requires the `grpc` gem and proto-generated stubs. diff --git a/antd-ruby/lib/antd/client.rb b/antd-ruby/lib/antd/client.rb index 0dcd18f..3a3f6ad 100644 --- a/antd-ruby/lib/antd/client.rb +++ b/antd-ruby/lib/antd/client.rb @@ -6,11 +6,24 @@ require "uri" module Antd - DEFAULT_BASE_URL = "http://localhost:8080" + DEFAULT_BASE_URL = "http://localhost:8082" DEFAULT_TIMEOUT = 300 # seconds # REST client for the antd daemon. class Client + # Creates a client using port discovery. + # + # Reads the daemon.port file to find the REST port. Falls back to the + # default base URL if the port file is not found. + # + # @param kwargs [Hash] options passed to +initialize+ (e.g. +:timeout+) + # @return [Array(Client, String)] the client and the resolved URL + def self.auto_discover(**kwargs) + url = Antd::Discover.daemon_url + url = DEFAULT_BASE_URL if url.empty? + [new(base_url: url, **kwargs), url] + end + # @param base_url [String] Base URL of the antd daemon # @param timeout [Integer] HTTP request timeout in seconds def initialize(base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT) diff --git a/antd-ruby/lib/antd/discover.rb b/antd-ruby/lib/antd/discover.rb new file mode 100644 index 0000000..c76d768 --- /dev/null +++ b/antd-ruby/lib/antd/discover.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rbconfig" + +module Antd + # Auto-discovers the antd daemon by reading the +daemon.port+ file that antd + # writes on startup. + # + # The file contains two lines: REST port on line 1, gRPC port on line 2. + # + # Port file location is platform-specific: + # - Windows: %APPDATA%\ant\daemon.port + # - macOS: ~/Library/Application Support/ant/daemon.port + # - Linux: $XDG_DATA_HOME/ant/daemon.port or ~/.local/share/ant/daemon.port + module Discover + PORT_FILE_NAME = "daemon.port" + DATA_DIR_NAME = "ant" + + # Reads the daemon.port file and returns the REST base URL + # (e.g. "http://127.0.0.1:8082"). + # + # @return [String] the URL, or "" if the port file is not found + def self.daemon_url + rest, _ = read_port_file + return "" if rest == 0 + + "http://127.0.0.1:#{rest}" + end + + # Reads the daemon.port file and returns the gRPC target + # (e.g. "127.0.0.1:50051"). + # + # @return [String] the target, or "" if the port file is not found + def self.grpc_target + _, grpc = read_port_file + return "" if grpc == 0 + + "127.0.0.1:#{grpc}" + end + + # @api private + def self.read_port_file + dir = data_dir + return [0, 0] if dir.empty? + + path = File.join(dir, PORT_FILE_NAME) + return [0, 0] unless File.exist?(path) + + lines = File.read(path).strip.split("\n") + rest_port = parse_port(lines[0]) + grpc_port = parse_port(lines[1]) + [rest_port, grpc_port] + rescue StandardError + [0, 0] + end + + # @api private + def self.parse_port(str) + return 0 if str.nil? + + s = str.strip + return 0 unless s.match?(/\A\d+\z/) + + n = s.to_i + (n > 0 && n <= 65535) ? n : 0 + end + + # @api private + def self.data_dir + host_os = RbConfig::CONFIG["host_os"] + + case host_os + when /mswin|mingw|cygwin/ + appdata = ENV["APPDATA"] + return "" if appdata.nil? || appdata.empty? + + File.join(appdata, DATA_DIR_NAME) + when /darwin/ + home = ENV["HOME"] + return "" if home.nil? || home.empty? + + File.join(home, "Library", "Application Support", DATA_DIR_NAME) + else + xdg = ENV["XDG_DATA_HOME"] + if xdg && !xdg.empty? + File.join(xdg, DATA_DIR_NAME) + else + home = ENV["HOME"] + return "" if home.nil? || home.empty? + + File.join(home, ".local", "share", DATA_DIR_NAME) + end + end + end + + private_class_method :read_port_file, :parse_port, :data_dir + end +end diff --git a/antd-ruby/lib/antd/grpc_client.rb b/antd-ruby/lib/antd/grpc_client.rb index 3e40d5e..aa5d618 100644 --- a/antd-ruby/lib/antd/grpc_client.rb +++ b/antd-ruby/lib/antd/grpc_client.rb @@ -25,6 +25,18 @@ module Antd # Provides the same 19 methods as the REST +Client+, but communicates over # gRPC using the proto-generated stubs from +antd/v1/*.proto+. class GrpcClient + # Creates a gRPC client using port discovery. + # + # Reads the daemon.port file to find the gRPC port. Falls back to the + # default target if the port file is not found. + # + # @return [Array(GrpcClient, String)] the client and the resolved target + def self.auto_discover + target = Antd::Discover.grpc_target + target = DEFAULT_GRPC_TARGET if target.empty? + [new(target: target), target] + end + # @param target [String] gRPC target address (default: "localhost:50051") def initialize(target: DEFAULT_GRPC_TARGET) @target = target diff --git a/antd-rust/src/client.rs b/antd-rust/src/client.rs index 96fdaf5..133928e 100644 --- a/antd-rust/src/client.rs +++ b/antd-rust/src/client.rs @@ -5,6 +5,7 @@ use base64::Engine; use reqwest; use serde_json::{json, Value}; +use crate::discover::discover_daemon_url; use crate::errors::{error_for_status, AntdError}; use crate::models::*; @@ -25,7 +26,7 @@ fn url_encode(s: &str) -> String { } /// Default base URL of the antd daemon. -pub const DEFAULT_BASE_URL: &str = "http://localhost:8080"; +pub const DEFAULT_BASE_URL: &str = "http://localhost:8082"; /// Default request timeout (5 minutes). pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); @@ -43,6 +44,22 @@ impl Client { Self::with_timeout(base_url, DEFAULT_TIMEOUT) } + /// Creates a client by auto-discovering the daemon port file, falling back + /// to [`DEFAULT_BASE_URL`] if discovery fails. + pub fn auto_discover() -> Self { + let url = discover_daemon_url() + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()); + Self::new(&url) + } + + /// Like [`auto_discover`](Self::auto_discover) but with a custom request + /// timeout. + pub fn auto_discover_with_timeout(timeout: Duration) -> Self { + let url = discover_daemon_url() + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()); + Self::with_timeout(&url, timeout) + } + /// Creates a new client with the given base URL and custom timeout. pub fn with_timeout(base_url: &str, timeout: Duration) -> Self { let http = reqwest::Client::builder() diff --git a/antd-rust/src/discover.rs b/antd-rust/src/discover.rs new file mode 100644 index 0000000..52d70ef --- /dev/null +++ b/antd-rust/src/discover.rs @@ -0,0 +1,112 @@ +//! Port discovery for the antd daemon. +//! +//! The antd daemon writes a `daemon.port` file on startup containing the REST +//! port on line 1 and the gRPC port on line 2. These helpers read that file +//! to auto-discover the daemon without hard-coding a port. + +use std::env; +use std::fs; +use std::path::PathBuf; + +const PORT_FILE_NAME: &str = "daemon.port"; +const DATA_DIR_NAME: &str = "ant"; + +/// Reads the daemon port file and returns the REST base URL +/// (e.g. `"http://127.0.0.1:8082"`), or `None` if the file is missing or +/// unreadable. +pub fn discover_daemon_url() -> Option { + let (rest, _) = read_port_file()?; + Some(format!("http://127.0.0.1:{rest}")) +} + +/// Reads the daemon port file and returns the gRPC target URL +/// (e.g. `"http://127.0.0.1:50051"`), or `None` if the file is missing or +/// has no gRPC line. +pub fn discover_grpc_target() -> Option { + let (_, grpc) = read_port_file()?; + let grpc = grpc?; + Some(format!("http://127.0.0.1:{grpc}")) +} + +/// Reads the `daemon.port` file and returns `(rest_port, Option)`. +fn read_port_file() -> Option<(u16, Option)> { + let dir = data_dir()?; + let path = dir.join(PORT_FILE_NAME); + let contents = fs::read_to_string(path).ok()?; + + let mut lines = contents.trim().lines(); + + let rest: u16 = lines.next()?.trim().parse().ok()?; + let grpc: Option = lines.next().and_then(|l| l.trim().parse().ok()); + + Some((rest, grpc)) +} + +/// Returns the platform-specific data directory for ant. +/// +/// - Windows: `%APPDATA%\ant` +/// - macOS: `~/Library/Application Support/ant` +/// - Linux: `$XDG_DATA_HOME/ant` or `~/.local/share/ant` +fn data_dir() -> Option { + #[cfg(target_os = "windows")] + { + let appdata = env::var("APPDATA").ok()?; + Some(PathBuf::from(appdata).join(DATA_DIR_NAME)) + } + + #[cfg(target_os = "macos")] + { + let home = env::var("HOME").ok()?; + Some(PathBuf::from(home).join("Library").join("Application Support").join(DATA_DIR_NAME)) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + if let Ok(xdg) = env::var("XDG_DATA_HOME") { + return Some(PathBuf::from(xdg).join(DATA_DIR_NAME)); + } + let home = env::var("HOME").ok()?; + Some(PathBuf::from(home).join(".local").join("share").join(DATA_DIR_NAME)) + } +} + +#[cfg(test)] +mod tests { + /// Simulate the same parsing logic used in `read_port_file`. + fn parse_port_contents(contents: &str) -> Option<(u16, Option)> { + let mut lines = contents.trim().lines(); + let rest: u16 = lines.next()?.trim().parse().ok()?; + let grpc: Option = lines.next().and_then(|l| l.trim().parse().ok()); + Some((rest, grpc)) + } + + #[test] + fn parse_two_line_port_file() { + let result = parse_port_contents("8082\n50051\n"); + assert_eq!(result, Some((8082, Some(50051)))); + } + + #[test] + fn parse_single_line_port_file() { + let result = parse_port_contents("8082\n"); + assert_eq!(result, Some((8082, None))); + } + + #[test] + fn parse_empty_returns_none() { + let result = parse_port_contents(""); + assert_eq!(result, None); + } + + #[test] + fn parse_invalid_port_returns_none() { + let result = parse_port_contents("notanumber\n"); + assert_eq!(result, None); + } + + #[test] + fn parse_with_whitespace() { + let result = parse_port_contents(" 8082 \n 50051 \n"); + assert_eq!(result, Some((8082, Some(50051)))); + } +} diff --git a/antd-rust/src/grpc_client.rs b/antd-rust/src/grpc_client.rs index 723d7bb..05fa05d 100644 --- a/antd-rust/src/grpc_client.rs +++ b/antd-rust/src/grpc_client.rs @@ -1,5 +1,6 @@ use tonic::transport::{Channel, Endpoint}; +use crate::discover::discover_grpc_target; use crate::errors::AntdError; use crate::models::*; @@ -44,6 +45,14 @@ impl GrpcClient { Self::connect(endpoint).await } + /// Creates a gRPC client by auto-discovering the daemon port file, + /// falling back to [`DEFAULT_GRPC_ENDPOINT`] if discovery fails. + pub async fn auto_discover() -> Result { + let endpoint = discover_grpc_target() + .unwrap_or_else(|| DEFAULT_GRPC_ENDPOINT.to_string()); + Self::connect(&endpoint).await + } + /// Connects to the antd gRPC server at the given endpoint. pub async fn connect(endpoint: &str) -> Result { let channel = Endpoint::from_shared(endpoint.to_string()) diff --git a/antd-rust/src/lib.rs b/antd-rust/src/lib.rs index 97ee375..aed7c6e 100644 --- a/antd-rust/src/lib.rs +++ b/antd-rust/src/lib.rs @@ -22,6 +22,7 @@ //! ``` pub mod client; +pub mod discover; pub mod errors; pub mod grpc_client; pub mod models; @@ -33,6 +34,7 @@ mod tests; mod grpc_tests; pub use client::{Client, DEFAULT_BASE_URL, DEFAULT_TIMEOUT}; +pub use discover::{discover_daemon_url, discover_grpc_target}; pub use errors::AntdError; pub use grpc_client::{GrpcClient, DEFAULT_GRPC_ENDPOINT}; pub use models::*; diff --git a/antd-swift/Sources/AntdSdk/AntdRestClient.swift b/antd-swift/Sources/AntdSdk/AntdRestClient.swift index b38bb40..6ae6250 100644 --- a/antd-swift/Sources/AntdSdk/AntdRestClient.swift +++ b/antd-swift/Sources/AntdSdk/AntdRestClient.swift @@ -5,7 +5,7 @@ public final class AntdRestClient: AntdClientProtocol, @unchecked Sendable { private let baseURL: String private let session: URLSession - public init(baseURL: String = "http://localhost:8080", timeout: TimeInterval = 300) { + public init(baseURL: String = "http://localhost:8082", timeout: TimeInterval = 300) { self.baseURL = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = timeout diff --git a/antd-swift/Sources/AntdSdk/DaemonDiscovery.swift b/antd-swift/Sources/AntdSdk/DaemonDiscovery.swift new file mode 100644 index 0000000..01a0150 --- /dev/null +++ b/antd-swift/Sources/AntdSdk/DaemonDiscovery.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Discovers the antd daemon by reading the `daemon.port` file written on startup. +/// +/// The port file contains two lines: REST port on line 1, gRPC port on line 2. +/// File location is platform-specific: +/// - macOS: `~/Library/Application Support/ant/daemon.port` +/// - Linux: `$XDG_DATA_HOME/ant/daemon.port` or `~/.local/share/ant/daemon.port` +/// - Windows: `%APPDATA%\ant\daemon.port` +public enum DaemonDiscovery { + + private static let portFileName = "daemon.port" + private static let dataDirName = "ant" + + /// Reads the daemon.port file and returns the REST base URL + /// (e.g. `"http://127.0.0.1:8082"`). Returns `""` if unavailable. + public static func discoverDaemonUrl() -> String { + guard let (rest, _) = readPortFile(), rest > 0 else { return "" } + return "http://127.0.0.1:\(rest)" + } + + /// Reads the daemon.port file and returns the gRPC target + /// (e.g. `"127.0.0.1:50051"`). Returns `""` if unavailable. + public static func discoverGrpcTarget() -> String { + guard let (_, grpc) = readPortFile(), grpc > 0 else { return "" } + return "127.0.0.1:\(grpc)" + } + + /// Create an ``AntdRestClient`` using the discovered daemon URL. + /// Falls back to `http://localhost:8082` if discovery fails. + /// + /// - Parameter timeout: Request timeout in seconds (default 300). + /// - Returns: A tuple of the client and the URL it connected to. + public static func autoDiscover(timeout: TimeInterval = 300) -> (client: AntdRestClient, url: String) { + var url = discoverDaemonUrl() + if url.isEmpty { + url = "http://localhost:8082" + } + let client = AntdRestClient(baseURL: url, timeout: timeout) + return (client, url) + } + + /// Create an ``AntdGrpcClient`` using the discovered gRPC target. + /// Falls back to `localhost:50051` if discovery fails. + /// + /// - Returns: A tuple of the client and the target it connected to. + public static func autoDiscoverGrpc() -> (client: AntdGrpcClient, target: String) { + var target = discoverGrpcTarget() + if target.isEmpty { + target = "localhost:50051" + } + let client = AntdGrpcClient(target: target) + return (client, target) + } + + // MARK: - Private + + private static func readPortFile() -> (rest: UInt16, grpc: UInt16)? { + guard let dir = dataDir() else { return nil } + let path = (dir as NSString).appendingPathComponent(portFileName) + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } + + let lines = contents.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: "\n") + guard !lines.isEmpty else { return nil } + + let rest = parsePort(lines[0]) + let grpc = lines.count >= 2 ? parsePort(lines[1]) : 0 + return (rest, grpc) + } + + private static func parsePort(_ s: String) -> UInt16 { + UInt16(s.trimmingCharacters(in: .whitespaces)) ?? 0 + } + + private static func dataDir() -> String? { + #if os(macOS) + guard let home = ProcessInfo.processInfo.environment["HOME"], !home.isEmpty else { return nil } + return (home as NSString).appendingPathComponent("Library/Application Support/\(dataDirName)") + #elseif os(Linux) + if let xdg = ProcessInfo.processInfo.environment["XDG_DATA_HOME"], !xdg.isEmpty { + return (xdg as NSString).appendingPathComponent(dataDirName) + } + guard let home = ProcessInfo.processInfo.environment["HOME"], !home.isEmpty else { return nil } + return (home as NSString).appendingPathComponent(".local/share/\(dataDirName)") + #elseif os(Windows) + guard let appdata = ProcessInfo.processInfo.environment["APPDATA"], !appdata.isEmpty else { return nil } + return (appdata as NSString).appendingPathComponent(dataDirName) + #else + return nil + #endif + } +} diff --git a/antd-zig/src/antd.zig b/antd-zig/src/antd.zig index eac71d5..2c42927 100644 --- a/antd-zig/src/antd.zig +++ b/antd-zig/src/antd.zig @@ -5,6 +5,7 @@ const http = std.http; pub const models = @import("models.zig"); pub const errors = @import("errors.zig"); pub const json_helpers = @import("json_helpers.zig"); +pub const discover = @import("discover.zig"); pub const HealthStatus = models.HealthStatus; pub const PutResult = models.PutResult; @@ -16,9 +17,11 @@ pub const AntdError = errors.AntdError; pub const ErrorInfo = errors.ErrorInfo; pub const errorForStatus = errors.errorForStatus; pub const JsonValue = json_helpers.JsonValue; +pub const discoverDaemonUrl = discover.discoverDaemonUrl; +pub const discoverGrpcTarget = discover.discoverGrpcTarget; /// Default antd daemon address. -pub const default_base_url = "http://localhost:8080"; +pub const default_base_url = "http://localhost:8082"; /// REST client for the antd daemon. pub const Client = struct { @@ -36,6 +39,17 @@ pub const Client = struct { }; } + /// Create a client using daemon port discovery. + /// Falls back to the default base URL if discovery fails. + /// Note: if a discovered URL is returned, the caller owns that memory. + pub fn autoDiscover(allocator: Allocator) Client { + const url = discover.discoverDaemonUrl(allocator); + return .{ + .allocator = allocator, + .base_url = url orelse default_base_url, + }; + } + /// Clean up client resources. pub fn deinit(self: *Client) void { if (self.last_error) |info| { diff --git a/antd-zig/src/discover.zig b/antd-zig/src/discover.zig new file mode 100644 index 0000000..68c7d15 --- /dev/null +++ b/antd-zig/src/discover.zig @@ -0,0 +1,96 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const builtin = @import("builtin"); + +const port_file_name = "daemon.port"; +const data_dir_name = "ant"; + +/// Reads the daemon.port file written by antd on startup and returns +/// the REST base URL (e.g. "http://127.0.0.1:8082"). +/// Returns null if the port file is not found or unreadable. +/// Caller owns the returned memory. +pub fn discoverDaemonUrl(allocator: Allocator) ?[]const u8 { + const ports = readPortFile(allocator) orelse return null; + if (ports.rest == 0) return null; + return std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{ports.rest}) catch null; +} + +/// Reads the daemon.port file written by antd on startup and returns +/// the gRPC target (e.g. "127.0.0.1:50051"). +/// Returns null if the port file is not found or has no gRPC line. +/// Caller owns the returned memory. +pub fn discoverGrpcTarget(allocator: Allocator) ?[]const u8 { + const ports = readPortFile(allocator) orelse return null; + if (ports.grpc == 0) return null; + return std.fmt.allocPrint(allocator, "127.0.0.1:{d}", .{ports.grpc}) catch null; +} + +const Ports = struct { + rest: u16, + grpc: u16, +}; + +fn readPortFile(allocator: Allocator) ?Ports { + const dir = dataDir(allocator) orelse return null; + defer allocator.free(dir); + + const path = std.fs.path.join(allocator, &.{ dir, port_file_name }) catch return null; + defer allocator.free(path); + + const file = std.fs.openFileAbsolute(path, .{}) catch return null; + defer file.close(); + + var buf: [256]u8 = undefined; + const n = file.readAll(&buf) catch return null; + if (n == 0) return null; + + const contents = std.mem.trimRight(u8, buf[0..n], &.{ ' ', '\t', '\r', '\n' }); + + var rest: u16 = 0; + var grpc: u16 = 0; + + var line_iter = std.mem.splitSequence(u8, contents, "\n"); + if (line_iter.next()) |first_line| { + rest = parsePort(first_line); + } + if (line_iter.next()) |second_line| { + grpc = parsePort(second_line); + } + + return .{ .rest = rest, .grpc = grpc }; +} + +fn parsePort(s: []const u8) u16 { + const trimmed = std.mem.trim(u8, s, &.{ ' ', '\t', '\r' }); + return std.fmt.parseInt(u16, trimmed, 10) catch 0; +} + +fn dataDir(allocator: Allocator) ?[]const u8 { + switch (builtin.os.tag) { + .windows => { + const appdata = std.process.getEnvVarOwned(allocator, "APPDATA") catch return null; + defer allocator.free(appdata); + if (appdata.len == 0) return null; + return std.fs.path.join(allocator, &.{ appdata, data_dir_name }) catch null; + }, + .macos => { + const home = std.process.getEnvVarOwned(allocator, "HOME") catch return null; + defer allocator.free(home); + if (home.len == 0) return null; + return std.fs.path.join(allocator, &.{ home, "Library", "Application Support", data_dir_name }) catch null; + }, + else => { + // Linux and other Unix-like systems + if (std.process.getEnvVarOwned(allocator, "XDG_DATA_HOME")) |xdg| { + defer allocator.free(xdg); + if (xdg.len > 0) { + return std.fs.path.join(allocator, &.{ xdg, data_dir_name }) catch null; + } + } else |_| {} + const home = std.process.getEnvVarOwned(allocator, "HOME") catch return null; + defer allocator.free(home); + if (home.len == 0) return null; + return std.fs.path.join(allocator, &.{ home, ".local", "share", data_dir_name }) catch null; + }, + } +} diff --git a/antd/Cargo.lock b/antd/Cargo.lock index 8b7261b..798f6ae 100644 --- a/antd/Cargo.lock +++ b/antd/Cargo.lock @@ -858,16 +858,68 @@ dependencies = [ "sha2", ] +[[package]] +name = "ant-node" +version = "0.5.0" +dependencies = [ + "aes-gcm-siv", + "ant-evm", + "blake3", + "bytes", + "chrono", + "clap", + "color-eyre", + "directories", + "evmlib", + "flate2", + "fs2", + "futures", + "heed", + "hex", + "hkdf", + "libp2p", + "lru", + "multihash", + "objc2", + "objc2-foundation", + "parking_lot", + "postcard", + "rand 0.8.5", + "reqwest 0.13.2", + "rmp-serde", + "saorsa-core", + "saorsa-pqc 0.5.0", + "self-replace", + "self_encryption", + "semver 1.0.27", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "toml", + "tracing", + "tracing-appender", + "tracing-subscriber", + "xor_name", + "zip", +] + [[package]] name = "antd" version = "0.2.0" dependencies = [ "ant-evm", + "ant-node", "axum 0.8.8", "base64", "blake3", "bytes", "clap", + "dirs 6.0.0", "evmlib", "futures", "hex", @@ -875,7 +927,6 @@ dependencies = [ "prost", "rand 0.8.5", "rmp-serde", - "saorsa-node", "serde", "serde_json", "thiserror 2.0.18", @@ -5410,56 +5461,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "saorsa-node" -version = "0.5.0" -dependencies = [ - "aes-gcm-siv", - "ant-evm", - "blake3", - "bytes", - "chrono", - "clap", - "color-eyre", - "directories", - "evmlib", - "flate2", - "fs2", - "futures", - "heed", - "hex", - "hkdf", - "libp2p", - "lru", - "multihash", - "objc2", - "objc2-foundation", - "parking_lot", - "postcard", - "rand 0.8.5", - "reqwest 0.13.2", - "rmp-serde", - "saorsa-core", - "saorsa-pqc 0.5.0", - "self-replace", - "self_encryption", - "semver 1.0.27", - "serde", - "serde_json", - "sha2", - "tar", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "toml", - "tracing", - "tracing-appender", - "tracing-subscriber", - "xor_name", - "zip", -] - [[package]] name = "saorsa-pqc" version = "0.4.2" diff --git a/antd/Cargo.toml b/antd/Cargo.toml index 66c24ce..8fa86b5 100644 --- a/antd/Cargo.toml +++ b/antd/Cargo.toml @@ -12,7 +12,8 @@ tower-http = { version = "0.6", features = ["cors", "trace"] } tonic = "0.12" prost = "0.13" tokio = { version = "1", features = ["full"] } -tokio-stream = "0.1" +tokio-stream = { version = "0.1", features = ["net"] } +dirs = "6" serde = { version = "1", features = ["derive"] } serde_json = "1" hex = "0.4" diff --git a/antd/src/config.rs b/antd/src/config.rs index 9ec52da..b5184f3 100644 --- a/antd/src/config.rs +++ b/antd/src/config.rs @@ -11,6 +11,14 @@ pub struct Config { #[arg(long, default_value = "0.0.0.0:50051", env = "ANTD_GRPC_ADDR")] pub grpc_addr: String, + /// REST API port (overrides --rest-addr port; use 0 for OS-assigned) + #[arg(long, env = "ANTD_REST_PORT")] + pub rest_port: Option, + + /// gRPC port (overrides --grpc-addr port; use 0 for OS-assigned) + #[arg(long, env = "ANTD_GRPC_PORT")] + pub grpc_port: Option, + /// Network mode: default, local #[arg(long, default_value = "default", env = "ANTD_NETWORK")] pub network: String, @@ -23,3 +31,29 @@ pub struct Config { #[arg(long, default_value_t = false, env = "ANTD_CORS")] pub cors: bool, } + +impl Config { + /// Resolve the REST listen address, applying --rest-port override if set. + pub fn resolved_rest_addr(&self) -> Result { + let mut addr: std::net::SocketAddr = self + .rest_addr + .parse() + .map_err(|e| format!("invalid REST address: {e}"))?; + if let Some(port) = self.rest_port { + addr.set_port(port); + } + Ok(addr) + } + + /// Resolve the gRPC listen address, applying --grpc-port override if set. + pub fn resolved_grpc_addr(&self) -> Result { + let mut addr: std::net::SocketAddr = self + .grpc_addr + .parse() + .map_err(|e| format!("invalid gRPC address: {e}"))?; + if let Some(port) = self.grpc_port { + addr.set_port(port); + } + Ok(addr) + } +} diff --git a/antd/src/grpc/mod.rs b/antd/src/grpc/mod.rs index 1e6b0de..24e3704 100644 --- a/antd/src/grpc/mod.rs +++ b/antd/src/grpc/mod.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use tokio::net::TcpListener; +use tokio_stream::wrappers::TcpListenerStream; use tonic::transport::Server; use crate::state::AppState; @@ -15,7 +17,7 @@ use service::pb::{ health_service_server::HealthServiceServer, }; -pub async fn serve(addr: std::net::SocketAddr, state: Arc) -> Result<(), Box> { +pub async fn serve(listener: TcpListener, state: Arc) -> Result<(), Box> { let data_svc = DataServiceServer::new(service::DataServiceImpl { state: state.clone() }); let chunk_svc = ChunkServiceServer::new(service::ChunkServiceImpl { state: state.clone() }); let graph_svc = GraphServiceServer::new(service::GraphServiceImpl { state: state.clone() }); @@ -23,6 +25,7 @@ pub async fn serve(addr: std::net::SocketAddr, state: Arc) -> Result<( let event_svc = EventServiceServer::new(service::EventServiceImpl { state: state.clone() }); let health_svc = HealthServiceServer::new(service::HealthServiceImpl { network: state.network.clone() }); + let addr = listener.local_addr()?; tracing::info!("gRPC server listening on {addr}"); Server::builder() @@ -32,7 +35,7 @@ pub async fn serve(addr: std::net::SocketAddr, state: Arc) -> Result<( .add_service(graph_svc) .add_service(file_svc) .add_service(event_svc) - .serve(addr) + .serve_with_incoming(TcpListenerStream::new(listener)) .await?; Ok(()) diff --git a/antd/src/main.rs b/antd/src/main.rs index 7376704..a523ea9 100644 --- a/antd/src/main.rs +++ b/antd/src/main.rs @@ -8,6 +8,7 @@ use ant_node::core::{CoreNodeConfig, MultiAddr, NodeMode, P2PNode}; mod config; mod error; mod grpc; +mod port_file; mod rest; mod state; mod types; @@ -23,16 +24,36 @@ async fn main() -> Result<(), Box> { let config = Config::parse(); + // Resolve listen addresses (applying --rest-port / --grpc-port overrides) + let rest_addr = config.resolved_rest_addr()?; + let grpc_addr = config.resolved_grpc_addr()?; + + // Bind listeners early to capture actual ports (important for port 0) + let rest_listener = tokio::net::TcpListener::bind(rest_addr).await + .map_err(|e| format!("failed to bind REST listener on {rest_addr}: {e}"))?; + let grpc_listener = tokio::net::TcpListener::bind(grpc_addr).await + .map_err(|e| format!("failed to bind gRPC listener on {grpc_addr}: {e}"))?; + + let actual_rest_addr = rest_listener.local_addr()?; + let actual_grpc_addr = grpc_listener.local_addr()?; + // Banner println!(); println!(" antd — Autonomi REST + gRPC Gateway"); println!(" =================================="); - println!(" REST: http://{}", config.rest_addr); - println!(" gRPC: {}", config.grpc_addr); + println!(" REST: http://{}", actual_rest_addr); + println!(" gRPC: {}", actual_grpc_addr); println!(" Network: {}", config.network); println!(" CORS: {}", if config.cors { "enabled" } else { "disabled" }); println!(); + // Write port file for SDK discovery + let port_file_path = port_file::write(actual_rest_addr.port(), actual_grpc_addr.port()); + match &port_file_path { + Some(p) => tracing::info!(path = %p.display(), "port file written"), + None => tracing::warn!("could not determine data directory — port file not written"), + } + // Parse bootstrap peers let bootstrap_peers: Vec = config .peers @@ -145,31 +166,20 @@ async fn main() -> Result<(), Box> { wallet, }); - // Parse addresses - let rest_addr: std::net::SocketAddr = config - .rest_addr - .parse() - .map_err(|e| format!("invalid REST address: {e}"))?; - let grpc_addr: std::net::SocketAddr = config - .grpc_addr - .parse() - .map_err(|e| format!("invalid gRPC address: {e}"))?; - // Build REST router let app = rest::router(state.clone(), config.cors); // Spawn both servers let grpc_state = state.clone(); let grpc_handle = tokio::spawn(async move { - if let Err(e) = grpc::serve(grpc_addr, grpc_state).await { + if let Err(e) = grpc::serve(grpc_listener, grpc_state).await { tracing::error!("gRPC server error: {e}"); } }); let rest_handle = tokio::spawn(async move { - tracing::info!("REST server listening on {rest_addr}"); - let listener = tokio::net::TcpListener::bind(rest_addr).await.unwrap(); - axum::serve(listener, app) + tracing::info!("REST server listening on {actual_rest_addr}"); + axum::serve(rest_listener, app) .with_graceful_shutdown(shutdown_signal()) .await .unwrap(); @@ -180,6 +190,10 @@ async fn main() -> Result<(), Box> { _ = grpc_handle => tracing::info!("gRPC server shut down"), } + // Cleanup port file on shutdown + port_file::remove(); + tracing::info!("port file removed"); + Ok(()) } diff --git a/antd/src/port_file.rs b/antd/src/port_file.rs new file mode 100644 index 0000000..fadd6cb --- /dev/null +++ b/antd/src/port_file.rs @@ -0,0 +1,65 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +const PORT_FILE_NAME: &str = "daemon.port"; +const DATA_DIR_NAME: &str = "ant"; + +/// Returns the platform-specific data directory for ant. +/// +/// - Windows: `%APPDATA%\ant` +/// - macOS: `~/Library/Application Support/ant` +/// - Linux: `$XDG_DATA_HOME/ant` or `~/.local/share/ant` +fn data_dir() -> Option { + dirs::data_dir().map(|d| d.join(DATA_DIR_NAME)) +} + +/// Returns the full path to the port file. +fn port_file_path() -> Option { + data_dir().map(|d| d.join(PORT_FILE_NAME)) +} + +/// Writes the port file atomically. +/// +/// Format: two lines — REST port on line 1, gRPC port on line 2. +/// Writes to a temp file first then renames for atomicity. +pub fn write(rest_port: u16, grpc_port: u16) -> Option { + let dir = data_dir()?; + if let Err(e) = fs::create_dir_all(&dir) { + tracing::warn!(path = %dir.display(), error = %e, "failed to create data directory"); + return None; + } + + let target = dir.join(PORT_FILE_NAME); + let tmp = dir.join(format!("{PORT_FILE_NAME}.tmp")); + + let contents = format!("{rest_port}\n{grpc_port}\n"); + + let result = (|| -> std::io::Result<()> { + let mut f = fs::File::create(&tmp)?; + f.write_all(contents.as_bytes())?; + f.sync_all()?; + fs::rename(&tmp, &target)?; + Ok(()) + })(); + + match result { + Ok(()) => Some(target), + Err(e) => { + tracing::warn!(path = %target.display(), error = %e, "failed to write port file"); + let _ = fs::remove_file(&tmp); + None + } + } +} + +/// Removes the port file. Best-effort; logs on failure. +pub fn remove() { + if let Some(path) = port_file_path() { + if path.exists() { + if let Err(e) = fs::remove_file(&path) { + tracing::warn!(path = %path.display(), error = %e, "failed to remove port file"); + } + } + } +} diff --git a/llms-full.txt b/llms-full.txt index bfee75d..fc04fca 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,15 +1,45 @@ # antd SDK — Complete API Reference -> JavaScript/TypeScript, Python, C#, Kotlin, Swift, Go, Java, Rust, C++, Ruby, PHP, Dart, Lua, Elixir, and Zig SDKs for the Autonomi network via the antd daemon. The daemon manages wallet, payments, and network connectivity. Clients connect over REST (default port 8080) or gRPC (default port 50051). +> JavaScript/TypeScript, Python, C#, Kotlin, Swift, Go, Java, Rust, C++, Ruby, PHP, Dart, Lua, Elixir, and Zig SDKs for the Autonomi network via the antd daemon. The daemon manages wallet, payments, and network connectivity. Clients connect over REST (default port 8082) or gRPC (default port 50051). All SDKs support automatic port discovery. + +## Port Discovery + +All SDKs can automatically discover a running antd daemon. On startup, antd writes a `daemon.port` file containing the REST port (line 1) and gRPC port (line 2) to a platform-specific location: + +- **Windows:** `%APPDATA%\ant\daemon.port` +- **Linux:** `$XDG_DATA_HOME/ant/daemon.port` (or `~/.local/share/ant/daemon.port`) +- **macOS:** `~/Library/Application Support/ant/daemon.port` + +Every SDK provides auto-discover constructors that read this file and connect automatically, falling back to the default ports if no file is found: + +| Language | REST Auto-Discover | gRPC Auto-Discover | +|----------|-------------------|-------------------| +| Python | `RestClient.auto_discover()` | `GrpcClient.auto_discover()` | +| Go | `antd.NewClientAutoDiscover()` | `antd.NewGrpcClientAutoDiscover()` | +| TypeScript | `RestClient.autoDiscover()` | N/A | +| C# | `AntdRestClient.AutoDiscover()` | `AntdGrpcClient.AutoDiscover()` | +| Java | `AntdClient.autoDiscover()` | `GrpcAntdClient.autoDiscover()` | +| Rust | `Client::auto_discover()` | `GrpcClient::auto_discover()` | +| Kotlin | `AntdRestClient.autoDiscover()` | `AntdGrpcClient.autoDiscover()` | +| C++ | `Client::auto_discover()` | `GrpcClient::auto_discover()` | +| Dart | `AntdClient.autoDiscover()` | `GrpcAntdClient.autoDiscover()` | +| Elixir | `Antd.Client.auto_discover()` | `Antd.GrpcClient.auto_discover()` | +| Ruby | `Antd::Client.auto_discover` | `Antd::GrpcClient.auto_discover` | +| Swift | `DaemonDiscovery.autoDiscover()` | `DaemonDiscovery.autoDiscoverGrpc()` | +| PHP | `AntdClient::autoDiscover()` | N/A | +| Lua | `Client.auto_discover()` | N/A | +| Zig | `Client.autoDiscover(allocator)` | N/A | + +This is especially useful when antd is spawned with `--rest-port 0` (OS assigns a free port), as in managed mode. ## Quick Start ```python from antd import AntdClient -client = AntdClient() # REST, localhost:8080 +client = AntdClient() # REST, localhost:8082 client = AntdClient(transport="grpc") # gRPC, localhost:50051 -client = AntdClient(base_url="http://remote:8080") +client = AntdClient(base_url="http://remote:8082") # Store and retrieve data result = client.data_put_public(b"hello world") @@ -20,8 +50,8 @@ data = client.data_get_public(result.address) # b"hello world" ```typescript import { createClient } from "antd"; -const client = createClient(); // REST, localhost:8080 -const client = createClient({ baseUrl: "http://remote:8080" }); +const client = createClient(); // REST, localhost:8082 +const client = createClient({ baseUrl: "http://remote:8082" }); const result = await client.dataPutPublic(Buffer.from("hello")); const data = await client.dataGetPublic(result.address); @@ -30,7 +60,7 @@ const data = await client.dataGetPublic(result.address); ```csharp using Antd.Sdk; -var client = AntdClientFactory.CreateRest(); // REST, localhost:8080 +var client = AntdClientFactory.CreateRest(); // REST, localhost:8082 var client = AntdClientFactory.CreateGrpc(); // gRPC, localhost:50051 var result = await client.DataPutPublicAsync(Encoding.UTF8.GetBytes("hello")); @@ -42,7 +72,7 @@ import com.autonomi.sdk.* import kotlinx.coroutines.runBlocking fun main() = runBlocking { - val client = AntdClient.createRest() // REST, localhost:8080 + val client = AntdClient.createRest() // REST, localhost:8082 val client = AntdClient.createGrpc() // gRPC, localhost:50051 val result = client.dataPutPublic("hello".toByteArray()) @@ -54,7 +84,7 @@ fun main() = runBlocking { // Swift (macOS only — iOS apps should use the FFI bindings) import AntdSdk -let client = try AntdClient.createRest() // REST, localhost:8080 +let client = try AntdClient.createRest() // REST, localhost:8082 let client = try AntdClient.createGrpc() // gRPC, localhost:50051 let result = try await client.dataPutPublic("hello".data(using: .utf8)!) @@ -65,7 +95,7 @@ let data = try await client.dataGetPublic(address: result.address) // Go import antd "github.com/WithAutonomi/ant-sdk/antd-go" -client := antd.NewClient(antd.DefaultBaseURL) // REST, localhost:8080 +client := antd.NewClient(antd.DefaultBaseURL) // REST, localhost:8082 ctx := context.Background() result, _ := client.DataPutPublic(ctx, []byte("hello")) @@ -77,7 +107,7 @@ data, _ := client.DataGetPublic(ctx, result.Address) // Maven: com.autonomiantd-java0.1.0 import com.autonomi.antd.AntdClient; -try (AntdClient client = AntdClient.create()) { // REST, localhost:8080 +try (AntdClient client = AntdClient.create()) { // REST, localhost:8082 PutResult result = client.dataPutPublic("hello".getBytes()); byte[] data = client.dataGetPublic(result.address()); } @@ -89,7 +119,7 @@ use antd_client::Client; #[tokio::main] async fn main() -> Result<(), antd_client::AntdError> { - let client = Client::new("http://localhost:8080"); // REST, localhost:8080 + let client = Client::new("http://localhost:8082"); // REST, localhost:8082 let result = client.data_put_public(b"hello").await?; let data = client.data_get_public(&result.address).await?; @@ -101,8 +131,8 @@ async fn main() -> Result<(), antd_client::AntdError> { // C++ — CMake FetchContent from GitHub #include -antd::Client client; // REST, localhost:8080 -antd::Client client("http://remote:8080"); +antd::Client client; // REST, localhost:8082 +antd::Client client("http://remote:8082"); auto result = client.dataPutPublic("hello"); auto data = client.dataGetPublic(result.address); @@ -112,8 +142,8 @@ auto data = client.dataGetPublic(result.address); # Ruby — gem install antd require "antd" -client = Antd::Client.new # REST, localhost:8080 -client = Antd::Client.new(base_url: "http://remote:8080") +client = Antd::Client.new # REST, localhost:8082 +client = Antd::Client.new(base_url: "http://remote:8082") result = client.data_put_public("hello") data = client.data_get_public(result.address) @@ -124,8 +154,8 @@ data = client.data_get_public(result.address) dataPutPublic("hello"); $data = $client->dataGetPublic($result->address); @@ -135,8 +165,8 @@ $data = $client->dataGetPublic($result->address); // Dart — dart pub add antd import 'package:antd/antd.dart'; -final client = AntdClient(); // REST, localhost:8080 -final client = AntdClient(baseUrl: 'http://remote:8080'); +final client = AntdClient(); // REST, localhost:8082 +final client = AntdClient(baseUrl: 'http://remote:8082'); final result = await client.dataPutPublic('hello'.codeUnits); final data = await client.dataGetPublic(result.address); @@ -146,8 +176,8 @@ final data = await client.dataGetPublic(result.address); -- Lua — luarocks install antd local antd = require("antd") -local client = antd.Client.new() -- REST, localhost:8080 -local client = antd.Client.new({base_url = "http://remote:8080"}) +local client = antd.Client.new() -- REST, localhost:8082 +local client = antd.Client.new({base_url = "http://remote:8082"}) local result = client:data_put_public("hello") local data = client:data_get_public(result.address) @@ -155,8 +185,8 @@ local data = client:data_get_public(result.address) ```elixir # Elixir — {:antd, "~> 0.1"} in mix.exs deps -client = Antd.Client.new() # REST, localhost:8080 -client = Antd.Client.new(base_url: "http://remote:8080") +client = Antd.Client.new() # REST, localhost:8082 +client = Antd.Client.new(base_url: "http://remote:8082") {:ok, result} = Antd.Client.data_put_public(client, "hello") {:ok, data} = Antd.Client.data_get_public(client, result.address) @@ -166,7 +196,7 @@ client = Antd.Client.new(base_url: "http://remote:8080") // Zig — add dependency in build.zig.zon const antd = @import("antd"); -var client = try antd.Client.init(.{}); // REST, localhost:8080 +var client = try antd.Client.init(.{}); // REST, localhost:8082 defer client.deinit(); const result = try client.dataPutPublic("hello"); @@ -386,8 +416,8 @@ JS/TS, PHP, Lua, and Zig are REST-only. ```typescript import { createClient } from "antd"; -const client = createClient(); // REST, localhost:8080 -const client = createClient({ baseUrl: "http://remote:8080" }); // custom URL +const client = createClient(); // REST, localhost:8082 +const client = createClient({ baseUrl: "http://remote:8082" }); // custom URL const client = createClient({ timeout: 60_000 }); // custom timeout (ms) // Health @@ -427,7 +457,7 @@ client.fileCost(path: string, isPublic?: boolean, includeArchive?: boolean): Pro ```python from antd import AntdClient, AsyncAntdClient -client = AntdClient(transport="rest", base_url="http://localhost:8080") +client = AntdClient(transport="rest", base_url="http://localhost:8082") client = AntdClient(transport="grpc", target="localhost:50051") # Health @@ -467,7 +497,7 @@ client.file_cost(path: str, is_public: bool = True, include_archive: bool = Fals ```csharp using Antd.Sdk; -IAntdClient client = AntdClientFactory.CreateRest(); // localhost:8080 +IAntdClient client = AntdClientFactory.CreateRest(); // localhost:8082 IAntdClient client = AntdClientFactory.CreateGrpc(); // localhost:50051 // Health @@ -507,7 +537,7 @@ Task FileCostAsync(string path, bool isPublic = true, bool includeArchiv ```kotlin import com.autonomi.sdk.* -val client = AntdClient.createRest() // localhost:8080 +val client = AntdClient.createRest() // localhost:8082 val client = AntdClient.createGrpc() // localhost:50051 // Health @@ -549,7 +579,7 @@ suspend fun fileCost(path: String, isPublic: Boolean = true, includeArchive: Boo ```swift import AntdSdk -let client = try AntdClient.createRest() // localhost:8080 +let client = try AntdClient.createRest() // localhost:8082 let client = try AntdClient.createGrpc() // localhost:50051 // Health @@ -589,7 +619,7 @@ func fileCost(path: String, isPublic: Bool, includeArchive: Bool) async throws - ```go import antd "github.com/WithAutonomi/ant-sdk/antd-go" -client := antd.NewClient(antd.DefaultBaseURL) // localhost:8080 +client := antd.NewClient(antd.DefaultBaseURL) // localhost:8082 // Health func (c *Client) Health(ctx context.Context) (*HealthStatus, error) @@ -630,8 +660,8 @@ func (c *Client) FileCost(ctx context.Context, path string, isPublic bool, inclu // Maven: com.autonomiantd-java0.1.0 import com.autonomi.antd.AntdClient; -AntdClient client = AntdClient.create(); // REST, localhost:8080 -AntdClient client = AntdClient.create("http://remote:8080"); +AntdClient client = AntdClient.create(); // REST, localhost:8082 +AntdClient client = AntdClient.create("http://remote:8082"); // Health HealthStatus health() throws AntdException @@ -673,7 +703,7 @@ String fileCost(String path, boolean isPublic, boolean includeArchive) throws An // cargo add antd-client use antd_client::{Client, PutResult, HealthStatus, GraphEntry, GraphDescendant, Archive, AntdError}; -let client = Client::new("http://localhost:8080"); // REST, localhost:8080 +let client = Client::new("http://localhost:8082"); // REST, localhost:8082 // Health async fn health(&self) -> Result @@ -715,8 +745,8 @@ async fn file_cost(&self, path: &str, is_public: bool, include_archive: bool) -> // CMake FetchContent from GitHub #include -antd::Client client; // REST, localhost:8080 -antd::Client client("http://remote:8080"); +antd::Client client; // REST, localhost:8082 +antd::Client client("http://remote:8082"); // Health antd::HealthStatus health() // throws antd::AntdError @@ -758,8 +788,8 @@ std::string fileCost(const std::string& path, bool isPublic = true, bool include ```ruby require "antd" -client = Antd::Client.new # localhost:8080 -client = Antd::Client.new(base_url: "http://remote:8080") +client = Antd::Client.new # localhost:8082 +client = Antd::Client.new(base_url: "http://remote:8082") # Health client.health → HealthStatus @@ -798,8 +828,8 @@ client.file_cost(path, is_public: true, include_archive: false) → String ```php use Autonomi\AntdClient; -$client = AntdClient::create(); // localhost:8080 -$client = AntdClient::create("http://remote:8080"); +$client = AntdClient::create(); // localhost:8082 +$client = AntdClient::create("http://remote:8082"); // Health $client->health(): HealthStatus @@ -838,8 +868,8 @@ $client->fileCost(string $path, bool $isPublic = true, bool $includeArchive = fa ```dart import 'package:antd/antd.dart'; -final client = AntdClient(); // localhost:8080 -final client = AntdClient(baseUrl: 'http://remote:8080'); +final client = AntdClient(); // localhost:8082 +final client = AntdClient(baseUrl: 'http://remote:8082'); // Health Future health() @@ -878,8 +908,8 @@ Future fileCost(String path, {bool isPublic = true, bool includeArchive ```lua local antd = require("antd") -local client = antd.Client.new() -- localhost:8080 -local client = antd.Client.new({base_url = "http://remote:8080"}) +local client = antd.Client.new() -- localhost:8082 +local client = antd.Client.new({base_url = "http://remote:8082"}) -- Health client:health() → HealthStatus @@ -916,8 +946,8 @@ client:file_cost(path, is_public, include_archive) → string ## Elixir SDK Method Signatures ```elixir -client = Antd.Client.new() # localhost:8080 -client = Antd.Client.new(base_url: "http://remote:8080") +client = Antd.Client.new() # localhost:8082 +client = Antd.Client.new(base_url: "http://remote:8082") # Health Antd.Client.health(client) :: {:ok, HealthStatus.t()} | {:error, term()} @@ -956,8 +986,8 @@ Antd.Client.file_cost(client, path, is_public \\ true, include_archive \\ false) ```zig const antd = @import("antd"); -var client = try antd.Client.init(.{}); // localhost:8080 -var client = try antd.Client.init(.{ .base_url = "http://remote:8080" }); +var client = try antd.Client.init(.{}); // localhost:8082 +var client = try antd.Client.init(.{ .base_url = "http://remote:8082" }); defer client.deinit(); // Health @@ -1128,7 +1158,7 @@ use antd_client::Client; #[tokio::main] async fn main() -> Result<(), antd_client::AntdError> { - let client = Client::new("http://localhost:8080"); + let client = Client::new("http://localhost:8082"); let result = client.data_put_public(b"Hello, Autonomi!").await?; println!("Stored at: {}, cost: {}", result.address, result.cost); diff --git a/llms.txt b/llms.txt index 4f34e35..76e1e3f 100644 --- a/llms.txt +++ b/llms.txt @@ -6,7 +6,7 @@ ```python from antd import AntdClient -client = AntdClient() # REST on localhost:8080 +client = AntdClient() # REST on localhost:8082 result = client.data_put_public(b"hello world") data = client.data_get_public(result.address) ``` @@ -102,7 +102,23 @@ data = client.data_get_public(result.address) - [C++ examples](antd-cpp/examples/) — runnable example files - [Java examples](antd-java/examples/) — runnable example files -## Default Ports +## Port Discovery -- REST: `http://localhost:8080` +All SDKs support automatic daemon discovery via a `daemon.port` file written by antd on startup. Every SDK provides an auto-discover constructor: + +```python +client, url = RestClient.auto_discover() # Python +``` +```go +client, url := antd.NewClientAutoDiscover() // Go +``` +```typescript +const { client, url } = RestClient.autoDiscover(); // TypeScript +``` + +Port file locations: `%APPDATA%\ant\daemon.port` (Windows), `~/.local/share/ant/daemon.port` (Linux), `~/Library/Application Support/ant/daemon.port` (macOS). File format: two lines — REST port, gRPC port. + +## Default Ports (fallback when no port file) + +- REST: `http://localhost:8082` - gRPC: `localhost:50051` diff --git a/skill.md b/skill.md index 1759813..54eb41f 100644 --- a/skill.md +++ b/skill.md @@ -6,12 +6,14 @@ You are helping a developer build an application on the **Autonomi** decentraliz Autonomi is a permanent, decentralized data network. Data is content-addressed (immutable). Storage is pay-once, reads are free. -**How it works:** A local Rust daemon (`antd`) connects to the network and exposes REST + gRPC APIs. Your app talks to antd through a language SDK. The developer never touches the network directly. +**How it works:** A local Rust daemon (`antd`) connects to the network and exposes REST + gRPC APIs. Your app talks to antd through a language SDK. The developer never touches the network directly. All SDKs support automatic daemon discovery via a port file written by antd on startup. ``` App → SDK → antd daemon (localhost) → Autonomi Network ``` +**Port discovery:** antd writes `daemon.port` to the platform data dir (`%APPDATA%\ant\` on Windows, `~/.local/share/ant/` on Linux, `~/Library/Application Support/ant/` on macOS). All SDKs provide auto-discover constructors that read this file. When generating client code, prefer the auto-discover constructor (e.g. `NewClientAutoDiscover()` in Go, `RestClient.auto_discover()` in Python) over hardcoded URLs. Default fallback: REST on `localhost:8082`, gRPC on `localhost:50051`. + For detailed API signatures and endpoint documentation, see: - **[llms.txt](llms.txt)** — concise overview of all REST endpoints, gRPC services, error codes, and SDK links - **[llms-full.txt](llms-full.txt)** — complete reference with method signatures for all 12 languages, request/response formats, and runnable examples @@ -128,7 +130,7 @@ When a developer asks to build something, follow this sequence: 1. **Pick the language** — ask if not obvious from context 2. **Start the daemon** — remind them: `ant dev start` (or `pip install -e ant-dev/ && ant dev start`) -3. **Create the client** — show the 2-line connection code for their language +3. **Create the client** — use the auto-discover constructor for their language (falls back to defaults if antd port file isn't present) 4. **Check health** — `client.health()` to verify the daemon is running 5. **Match their use case to a primitive** — use the tables above 6. **Estimate cost** — call the `*_cost` method before any write From d1b2fd8c987319224ead2f6d6acf9c0bec0ad05e Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Wed, 25 Mar 2026 11:26:03 +0000 Subject: [PATCH 02/10] Fix cross-platform issues in port discovery - Windows: remove target before rename (fs::rename fails if target exists on Windows, unlike Unix atomic replace) - Stale port file detection: antd now writes its PID as line 3 of the port file. Go SDK checks if the process is still alive before trusting the discovered ports (platform-specific: signal 0 on Unix, FindProcess on Windows) - Split Go processAlive into discover_unix.go / discover_windows.go with build tags for clean cross-compilation Co-Authored-By: Claude Opus 4.6 (1M context) --- antd-go/discover.go | 15 ++++++++++++++- antd-go/discover_unix.go | 19 +++++++++++++++++++ antd-go/discover_windows.go | 18 ++++++++++++++++++ antd/src/port_file.rs | 10 +++++++++- 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 antd-go/discover_unix.go create mode 100644 antd-go/discover_windows.go diff --git a/antd-go/discover.go b/antd-go/discover.go index b11b3fb..b752140 100644 --- a/antd-go/discover.go +++ b/antd-go/discover.go @@ -35,8 +35,10 @@ func DiscoverGrpcTarget() string { } // readPortFile reads the daemon.port file and returns the REST and gRPC ports. -// The file format is two lines: REST port on line 1, gRPC port on line 2. +// The file format is: REST port (line 1), gRPC port (line 2), PID (line 3). // A single-line file is valid (gRPC port will be 0). +// If a PID is present and the process is not running, the file is considered +// stale and both ports are returned as 0. func readPortFile() (restPort, grpcPort uint16) { dir := dataDir() if dir == "" { @@ -53,6 +55,15 @@ func readPortFile() (restPort, grpcPort uint16) { return 0, 0 } + // Check PID on line 3 — if present and process is dead, file is stale + if len(lines) >= 3 { + if pid, err := strconv.Atoi(strings.TrimSpace(lines[2])); err == nil && pid > 0 { + if !processAlive(pid) { + return 0, 0 + } + } + } + restPort = parsePort(lines[0]) if len(lines) >= 2 { grpcPort = parsePort(lines[1]) @@ -60,6 +71,8 @@ func readPortFile() (restPort, grpcPort uint16) { return restPort, grpcPort } +// processAlive is implemented per-platform in discover_unix.go and discover_windows.go. + func parsePort(s string) uint16 { n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 16) if err != nil { diff --git a/antd-go/discover_unix.go b/antd-go/discover_unix.go new file mode 100644 index 0000000..a0a8a83 --- /dev/null +++ b/antd-go/discover_unix.go @@ -0,0 +1,19 @@ +//go:build !windows + +package antd + +import ( + "os" + "syscall" +) + +// processAlive checks whether a process with the given PID exists +// by sending signal 0 (a no-op that checks process existence). +func processAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + err = proc.Signal(syscall.Signal(0)) + return err == nil +} diff --git a/antd-go/discover_windows.go b/antd-go/discover_windows.go new file mode 100644 index 0000000..56643c7 --- /dev/null +++ b/antd-go/discover_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package antd + +import ( + "os" +) + +// processAlive checks whether a process with the given PID exists. +// On Windows, os.FindProcess opens a handle and fails if the process doesn't exist. +func processAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + proc.Release() + return true +} diff --git a/antd/src/port_file.rs b/antd/src/port_file.rs index fadd6cb..b9aea07 100644 --- a/antd/src/port_file.rs +++ b/antd/src/port_file.rs @@ -23,6 +23,8 @@ fn port_file_path() -> Option { /// /// Format: two lines — REST port on line 1, gRPC port on line 2. /// Writes to a temp file first then renames for atomicity. +/// On Windows, removes the target first since rename fails if it exists. +/// Also removes any stale port file from a previous crashed instance. pub fn write(rest_port: u16, grpc_port: u16) -> Option { let dir = data_dir()?; if let Err(e) = fs::create_dir_all(&dir) { @@ -33,12 +35,18 @@ pub fn write(rest_port: u16, grpc_port: u16) -> Option { let target = dir.join(PORT_FILE_NAME); let tmp = dir.join(format!("{PORT_FILE_NAME}.tmp")); - let contents = format!("{rest_port}\n{grpc_port}\n"); + let pid = std::process::id(); + let contents = format!("{rest_port}\n{grpc_port}\n{pid}\n"); let result = (|| -> std::io::Result<()> { let mut f = fs::File::create(&tmp)?; f.write_all(contents.as_bytes())?; f.sync_all()?; + // On Windows, rename fails if target exists — remove it first. + // This is not atomic on Windows but is the best we can do. + if cfg!(windows) { + let _ = fs::remove_file(&target); + } fs::rename(&tmp, &target)?; Ok(()) })(); From 75fea7b00b1f8a9deb8830fdbbd12e53bf00693c Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Wed, 25 Mar 2026 11:30:25 +0000 Subject: [PATCH 03/10] Add stale port file detection via PID checking to all SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit antd now writes its PID as line 3 of the port file. All SDKs validate the PID before trusting discovered ports, preventing connections to dead daemons after crashes. Platform-specific approaches per language: - Go: signal 0 (Unix), FindProcess (Windows) via build tags - Python/MCP: os.kill(pid, 0) — cross-platform on Python 3 - Rust: kill -0 (Unix), tasklist (Windows) via cfg - Java/Kotlin: ProcessHandle.of(pid).isPresent() — cross-platform - C#: Process.GetProcessById — cross-platform on .NET 8+ - TypeScript: process.kill(pid, 0) — cross-platform in Node.js - C++: kill(pid, 0) (Unix), OpenProcess (Windows) via ifdef - Ruby: Process.kill(0, pid) — cross-platform - Elixir: System.cmd("kill", ["-0", pid]) on Unix, trust on Windows - Swift: kill(pid_t, 0) via C interop - Dart: kill -0 on Unix, trust on Windows - Lua: os.execute("kill -0") on Unix, trust on Windows - PHP: posix_kill or /proc check on Unix, tasklist on Windows - Zig: /proc check on Linux, trust on other platforms All backward-compatible with 2-line port files (no PID). Co-Authored-By: Claude Opus 4.6 (1M context) --- antd-cpp/include/antd/discover.hpp | 6 +- antd-cpp/src/discover.cpp | 42 +++++- antd-csharp/Antd.Sdk/DaemonDiscovery.cs | 29 +++- antd-dart/lib/src/discover.dart | 37 ++++- antd-elixir/lib/antd/discover.ex | 35 ++++- .../com/autonomi/antd/DaemonDiscovery.java | 29 +++- antd-js/src/discover.ts | 31 ++++- .../com/autonomi/sdk/DaemonDiscovery.kt | 20 ++- antd-lua/src/antd/discover.lua | 27 ++++ antd-mcp/src/antd_mcp/discover.py | 38 +++++- antd-php/src/DaemonDiscovery.php | 32 ++++- antd-py/src/antd/_discover.py | 34 +++++ antd-py/tests/test_discover.py | 44 ++++++ antd-ruby/lib/antd/discover.rb | 34 ++++- antd-rust/src/discover.rs | 128 +++++++++++++++--- .../Sources/AntdSdk/DaemonDiscovery.swift | 24 +++- antd-zig/src/discover.zig | 29 ++++ 17 files changed, 581 insertions(+), 38 deletions(-) diff --git a/antd-cpp/include/antd/discover.hpp b/antd-cpp/include/antd/discover.hpp index 6ac1695..2b8da74 100644 --- a/antd-cpp/include/antd/discover.hpp +++ b/antd-cpp/include/antd/discover.hpp @@ -6,12 +6,14 @@ namespace antd { /// Read the daemon.port file written by antd on startup and return the REST /// base URL (e.g. "http://127.0.0.1:8082"). -/// Returns an empty string if the port file is not found or unreadable. +/// Returns an empty string if the port file is missing, unreadable, or stale +/// (i.e. the recorded PID is no longer alive). std::string discover_daemon_url(); /// Read the daemon.port file written by antd on startup and return the gRPC /// target (e.g. "127.0.0.1:50051"). -/// Returns an empty string if the port file has no gRPC line or is unreadable. +/// Returns an empty string if the port file has no gRPC line, is unreadable, +/// or stale (i.e. the recorded PID is no longer alive). std::string discover_grpc_target(); } // namespace antd diff --git a/antd-cpp/src/discover.cpp b/antd-cpp/src/discover.cpp index 174a84a..ddf7d1b 100644 --- a/antd-cpp/src/discover.cpp +++ b/antd-cpp/src/discover.cpp @@ -5,6 +5,14 @@ #include #include +#ifdef _WIN32 +#include +#else +#include +#include +#include +#endif + namespace fs = std::filesystem; namespace antd { @@ -13,6 +21,21 @@ namespace { constexpr const char* kPortFileName = "daemon.port"; constexpr const char* kDataDirName = "ant"; +/// Check whether a process with the given PID is alive. +bool process_alive(unsigned long pid) { +#ifdef _WIN32 + HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, + static_cast(pid)); + if (h == NULL) return false; + CloseHandle(h); + return true; +#else + // kill(pid, 0) checks existence without sending a signal. + // EPERM means the process exists but we lack permission to signal it. + return kill(static_cast(pid), 0) == 0 || errno == EPERM; +#endif +} + /// Parse a port number (1-65535) from a string. Returns 0 on failure. uint16_t parse_port(const std::string& s) { try { @@ -49,7 +72,9 @@ fs::path data_dir() { } /// Read the daemon.port file and return the REST and gRPC ports. -/// The file format is two lines: REST port on line 1, gRPC port on line 2. +/// The file format is: REST port (line 1), gRPC port (line 2), PID (line 3). +/// If a PID is present and the process is not alive, the file is stale and +/// {0, 0} is returned. std::pair read_port_file() { auto dir = data_dir(); if (dir.empty()) return {0, 0}; @@ -58,9 +83,20 @@ std::pair read_port_file() { std::ifstream ifs(path); if (!ifs.is_open()) return {0, 0}; - std::string line1, line2; + std::string line1, line2, line3; if (!std::getline(ifs, line1)) return {0, 0}; - std::getline(ifs, line2); // optional second line + std::getline(ifs, line2); // optional second line (gRPC port) + std::getline(ifs, line3); // optional third line (PID) + + // Stale-file detection: if a PID is recorded, verify the process is alive. + if (!line3.empty()) { + try { + unsigned long pid = std::stoul(line3); + if (pid > 0 && !process_alive(pid)) return {0, 0}; + } catch (...) { + // Malformed PID line — ignore and proceed without the check. + } + } return {parse_port(line1), parse_port(line2)}; } diff --git a/antd-csharp/Antd.Sdk/DaemonDiscovery.cs b/antd-csharp/Antd.Sdk/DaemonDiscovery.cs index 86ce519..73e54cb 100644 --- a/antd-csharp/Antd.Sdk/DaemonDiscovery.cs +++ b/antd-csharp/Antd.Sdk/DaemonDiscovery.cs @@ -4,8 +4,10 @@ namespace Antd.Sdk; /// /// Reads the daemon.port file written by antd on startup to auto-discover -/// the REST and gRPC ports. The file contains two lines: REST port on line 1, -/// gRPC port on line 2. +/// the REST and gRPC ports. The file contains up to three lines: REST port +/// on line 1, gRPC port on line 2, and daemon PID on line 3. If a PID is +/// present and the process is no longer alive, the file is considered stale +/// and discovery returns empty. /// public static class DaemonDiscovery { @@ -53,6 +55,16 @@ private static (ushort restPort, ushort grpcPort) ReadPortFile() if (lines.Length >= 2) grpc = ParsePort(lines[1]); + // Line 3 is the daemon PID. If present and the process is + // no longer running, the port file is stale. + if (lines.Length >= 3 + && int.TryParse(lines[2].Trim(), out var pid) + && pid > 0 + && !ProcessAlive(pid)) + { + return (0, 0); + } + return (rest, grpc); } catch @@ -61,6 +73,19 @@ private static (ushort restPort, ushort grpcPort) ReadPortFile() } } + private static bool ProcessAlive(int pid) + { + try + { + System.Diagnostics.Process.GetProcessById(pid); + return true; + } + catch (ArgumentException) + { + return false; + } + } + private static ushort ParsePort(string s) { return ushort.TryParse(s.Trim(), out var port) ? port : (ushort)0; diff --git a/antd-dart/lib/src/discover.dart b/antd-dart/lib/src/discover.dart index 51eb547..440d7e5 100644 --- a/antd-dart/lib/src/discover.dart +++ b/antd-dart/lib/src/discover.dart @@ -26,8 +26,13 @@ String discoverGrpcTarget() { } /// Reads the daemon.port file and returns (restPort, grpcPort). -/// The file format is two lines: REST port on line 1, gRPC port on line 2. -/// A single-line file is valid (gRPC port will be 0). +/// The file format is up to three lines: +/// line 1: REST port +/// line 2: gRPC port +/// line 3: PID of the antd process +/// A single-line file is valid (gRPC port will be 0, no PID check). +/// If a PID is present and the process is not alive, the port file is +/// considered stale and (0, 0) is returned. (int, int) _readPortFile() { final dir = _dataDir(); if (dir.isEmpty) { @@ -47,11 +52,39 @@ String discoverGrpcTarget() { return (0, 0); } + // If a PID is recorded on line 3, verify the process is still alive. + if (lines.length >= 3) { + final pid = int.tryParse(lines[2].trim()); + if (pid != null && pid > 0 && !_isProcessAlive(pid)) { + return (0, 0); + } + } + final rest = _parsePort(lines[0]); final grpc = lines.length >= 2 ? _parsePort(lines[1]) : 0; return (rest, grpc); } +/// Returns true if a process with the given [pid] is currently running. +/// +/// On non-Windows platforms, uses `kill -0 ` which sends no signal but +/// returns exit code 0 if the process exists. +/// On Windows, Dart lacks a clean way to probe a PID without side effects, +/// so we optimistically return true (trust the port file). +bool _isProcessAlive(int pid) { + if (Platform.isWindows) { + // No reliable non-destructive PID probe in Dart on Windows. + return true; + } + try { + final result = Process.runSync('kill', ['-0', pid.toString()]); + return result.exitCode == 0; + } catch (_) { + // If we can't run the check, assume alive to avoid false negatives. + return true; + } +} + int _parsePort(String s) { final n = int.tryParse(s.trim()); if (n == null || n < 1 || n > 65535) { diff --git a/antd-elixir/lib/antd/discover.ex b/antd-elixir/lib/antd/discover.ex index e34dea4..ba52b7c 100644 --- a/antd-elixir/lib/antd/discover.ex +++ b/antd-elixir/lib/antd/discover.ex @@ -3,7 +3,11 @@ defmodule Antd.Discover do Auto-discovers the antd daemon by reading the `daemon.port` file that antd writes on startup. - The file contains two lines: REST port on line 1, gRPC port on line 2. + The file contains up to three lines: REST port (line 1), gRPC port (line 2), + and optionally the daemon PID (line 3). + + If a PID is present and the process is no longer alive, the port file is + considered stale and discovery returns empty. Port file location is platform-specific: - Windows: `%APPDATA%\\ant\\daemon.port` @@ -63,7 +67,13 @@ defmodule Antd.Discover do rest_port = parse_port(Enum.at(lines, 0, "")) grpc_port = parse_port(Enum.at(lines, 1, "")) - {rest_port, grpc_port} + pid = parse_pid(Enum.at(lines, 2, "")) + + if pid > 0 and not process_alive?(pid) do + {0, 0} + else + {rest_port, grpc_port} + end {:error, _} -> {0, 0} @@ -78,6 +88,27 @@ defmodule Antd.Discover do end end + defp parse_pid(s) do + case Integer.parse(String.trim(s)) do + {n, ""} when n > 0 -> n + _ -> 0 + end + end + + defp process_alive?(pid) do + case :os.type() do + {:unix, _} -> + case System.cmd("kill", ["-0", to_string(pid)], stderr_to_stdout: true) do + {_, 0} -> true + _ -> false + end + + {:win32, _} -> + # On Windows, trust the port file — no reliable zero-signal check. + true + end + end + defp data_dir do case :os.type() do {:win32, _} -> diff --git a/antd-java/src/main/java/com/autonomi/antd/DaemonDiscovery.java b/antd-java/src/main/java/com/autonomi/antd/DaemonDiscovery.java index e9de895..29dcd00 100644 --- a/antd-java/src/main/java/com/autonomi/antd/DaemonDiscovery.java +++ b/antd-java/src/main/java/com/autonomi/antd/DaemonDiscovery.java @@ -10,7 +10,9 @@ * Discovers the antd daemon by reading the {@code daemon.port} file * that the daemon writes on startup. * - *

The file contains two lines: the REST port on line 1 and the gRPC port on line 2. + *

The file contains up to three lines: the REST port on line 1, the gRPC port + * on line 2, and an optional PID on line 3. If a PID is present and the process + * is no longer alive, the port file is considered stale and discovery returns empty. * *

Port file locations by platform: *

    @@ -56,6 +58,8 @@ public static String discoverGrpcTarget() { /** * Reads the specified line from the port file and parses it as a port number. + * If line 3 contains a PID and that process is no longer alive, the port file + * is considered stale and 0 is returned. * * @param lineIndex 0 for REST port, 1 for gRPC port * @return the port number, or 0 on failure @@ -72,12 +76,35 @@ private static int readPort(int lineIndex) { if (lines.size() <= lineIndex) { return 0; } + + // Check for stale port file via PID on line 3 + if (lines.size() >= 3) { + String pidStr = lines.get(2).trim(); + if (!pidStr.isEmpty()) { + try { + long pid = Long.parseLong(pidStr); + if (!processAlive(pid)) { + return 0; + } + } catch (NumberFormatException e) { + // Malformed PID line — ignore and continue + } + } + } + return parsePort(lines.get(lineIndex)); } catch (IOException e) { return 0; } } + /** + * Returns true if a process with the given PID is currently alive. + */ + private static boolean processAlive(long pid) { + return ProcessHandle.of(pid).isPresent(); + } + /** * Parses a port string into an integer in the valid port range (1-65535). * diff --git a/antd-js/src/discover.ts b/antd-js/src/discover.ts index 18879f0..4e7681d 100644 --- a/antd-js/src/discover.ts +++ b/antd-js/src/discover.ts @@ -20,8 +20,13 @@ export function discoverDaemonUrl(): string { /** * Reads the daemon.port file and returns the parsed REST and gRPC ports. - * The file format is two lines: REST port on line 1, gRPC port on line 2. - * A single-line file is valid (gRPC port will be 0). + * The file format is up to three lines: + * line 1: REST port + * line 2: gRPC port + * line 3: PID of the antd process + * A single-line file is valid (gRPC port will be 0, no PID check). + * If a PID is present and the process is not alive, the port file is + * considered stale and { rest: 0, grpc: 0 } is returned. */ function readPortFile(): { rest: number; grpc: number } { const dir = dataDir(); @@ -41,11 +46,33 @@ function readPortFile(): { rest: number; grpc: number } { return { rest: 0, grpc: 0 }; } + // If a PID is recorded on line 3, verify the process is still alive. + if (lines.length >= 3) { + const pid = parseInt(lines[2].trim(), 10); + if (!isNaN(pid) && pid > 0 && !isProcessAlive(pid)) { + return { rest: 0, grpc: 0 }; + } + } + const rest = parsePort(lines[0]); const grpc = lines.length >= 2 ? parsePort(lines[1]) : 0; return { rest, grpc }; } +/** + * Returns true if a process with the given PID is currently running. + * Uses process.kill(pid, 0) which sends no signal but throws if the + * process does not exist. Works on both Unix and Windows in Node.js. + */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + function parsePort(s: string): number { const n = parseInt(s.trim(), 10); if (isNaN(n) || n < 1 || n > 65535) { diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/DaemonDiscovery.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/DaemonDiscovery.kt index 1352fb8..70670a7 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/DaemonDiscovery.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/DaemonDiscovery.kt @@ -8,7 +8,9 @@ import java.nio.file.Paths * Reads the `daemon.port` file written by antd on startup to auto-discover * the REST and gRPC ports. * - * The file contains two lines: REST port on line 1, gRPC port on line 2. + * The file contains up to three lines: REST port on line 1, gRPC port on line 2, + * and an optional PID on line 3. If a PID is present and the process is no longer + * alive, the port file is considered stale and discovery returns empty. */ object DaemonDiscovery { @@ -40,6 +42,16 @@ object DaemonDiscovery { return try { val lines = file.readLines().map { it.trim() } + + // Check for stale port file via PID on line 3 + val pidStr = lines.getOrNull(2) + if (!pidStr.isNullOrEmpty()) { + val pid = pidStr.toLongOrNull() + if (pid != null && !processAlive(pid)) { + return 0 to 0 + } + } + val rest = lines.getOrNull(0)?.toIntOrNull()?.takeIf { it in 1..65535 } ?: 0 val grpc = lines.getOrNull(1)?.toIntOrNull()?.takeIf { it in 1..65535 } ?: 0 rest to grpc @@ -48,6 +60,12 @@ object DaemonDiscovery { } } + /** + * Returns true if a process with the given PID is currently alive. + */ + private fun processAlive(pid: Long): Boolean = + ProcessHandle.of(pid).isPresent + private fun dataDir(): Path? { val os = System.getProperty("os.name", "").lowercase() return when { diff --git a/antd-lua/src/antd/discover.lua b/antd-lua/src/antd/discover.lua index 19da82f..85d88cc 100644 --- a/antd-lua/src/antd/discover.lua +++ b/antd-lua/src/antd/discover.lua @@ -12,6 +12,25 @@ local function is_windows() return package.config:sub(1, 1) == "\\" end +--- Check if a process with the given PID is alive. +-- On Windows, always returns true (trust the port file). +-- On Unix, uses `kill -0 ` which succeeds if the process exists. +-- @param pid number +-- @return boolean +local function is_process_alive(pid) + if is_windows() then + return true + end + -- Validate pid is numeric to prevent command injection + local pid_str = tostring(math.floor(pid)) + if not pid_str:match("^%d+$") then + return true + end + local ok = os.execute("kill -0 " .. pid_str .. " >/dev/null 2>&1") + -- Lua 5.1 returns a number (0 = success), Lua 5.2+ returns true/nil + return ok == true or ok == 0 +end + --- Returns the platform-specific data directory for ant. -- @return string|nil directory path, or nil if not determinable local function data_dir() @@ -69,6 +88,14 @@ local function read_port_file() if #lines < 1 then return nil, nil end + -- Line 3: PID of the daemon process (optional stale-detection) + if #lines >= 3 then + local pid = tonumber(lines[3]) + if pid and pid > 0 and not is_process_alive(pid) then + return nil, nil + end + end + local rest_port = tonumber(lines[1]) local grpc_port = #lines >= 2 and tonumber(lines[2]) or nil diff --git a/antd-mcp/src/antd_mcp/discover.py b/antd-mcp/src/antd_mcp/discover.py index 613c1df..82e37d2 100644 --- a/antd-mcp/src/antd_mcp/discover.py +++ b/antd-mcp/src/antd_mcp/discover.py @@ -1,8 +1,14 @@ """Port discovery for the antd daemon. -The antd daemon writes a ``daemon.port`` file on startup containing two lines: +The antd daemon writes a ``daemon.port`` file on startup containing up to three +lines: - Line 1: REST port - Line 2: gRPC port + - Line 3: PID of the daemon process (optional) + +When line 3 is present, this module validates that the process is still alive. +If the PID refers to a dead process the port file is considered stale and +discovery returns empty results. This module reads that file using platform-specific data directory paths to auto-discover the daemon without requiring manual configuration. @@ -42,10 +48,29 @@ def _data_dir() -> Path | None: return Path(home) / ".local" / "share" / _DATA_DIR_NAME +def _is_pid_alive(pid: int) -> bool: + """Check whether a process with the given PID is still running. + + Uses ``os.kill(pid, 0)`` which works cross-platform on Python 3. + """ + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + # Process exists but we lack permission to signal it. + return True + except OSError: + # Unable to determine — assume alive to avoid false negatives. + return True + return True + + def _read_port_file() -> tuple[int, int]: """Read the daemon.port file and return (rest_port, grpc_port). - Returns (0, 0) if the file is missing or unreadable. + Returns (0, 0) if the file is missing, unreadable, or stale (the + recorded PID no longer refers to a running process). A single-line file is valid; grpc_port will be 0 in that case. """ data_dir = _data_dir() @@ -62,6 +87,15 @@ def _read_port_file() -> tuple[int, int]: if not lines: return 0, 0 + # Line 3 (optional): PID of the daemon process. + if len(lines) >= 3: + try: + pid = int(lines[2].strip()) + except ValueError: + pid = None + if pid is not None and not _is_pid_alive(pid): + return 0, 0 + rest_port = _parse_port(lines[0]) grpc_port = _parse_port(lines[1]) if len(lines) >= 2 else 0 return rest_port, grpc_port diff --git a/antd-php/src/DaemonDiscovery.php b/antd-php/src/DaemonDiscovery.php index 4f521a0..5f1c6bd 100644 --- a/antd-php/src/DaemonDiscovery.php +++ b/antd-php/src/DaemonDiscovery.php @@ -7,7 +7,9 @@ /** * Discovers the antd daemon by reading the `daemon.port` file written on startup. * - * The port file contains two lines: REST port on line 1, gRPC port on line 2. + * The port file contains up to three lines: REST port (line 1), gRPC port (line 2), + * and PID of the daemon process (line 3). If a PID is present and the process is + * not alive, the file is considered stale and discovery returns empty. * File location is platform-specific: * - Windows: %APPDATA%\ant\daemon.port * - macOS: ~/Library/Application Support/ant/daemon.port @@ -83,11 +85,39 @@ private static function readPortFile(): array return [0, 0]; } + // Line 3: PID of the daemon process (optional stale-detection) + if (count($lines) >= 3) { + $pid = (int) trim($lines[2]); + if ($pid > 0 && !self::isProcessAlive($pid)) { + return [0, 0]; + } + } + $rest = self::parsePort($lines[0]); $grpc = count($lines) >= 2 ? self::parsePort($lines[1]) : 0; return [$rest, $grpc]; } + /** + * Check if a process with the given PID is alive. + * Uses posix_kill if available, falls back to /proc on Linux, + * or tasklist on Windows. + */ + private static function isProcessAlive(int $pid): bool + { + if (PHP_OS_FAMILY === 'Windows') { + $out = shell_exec("tasklist /FI \"PID eq {$pid}\" /NH 2>NUL"); + return $out !== null && stripos($out, (string) $pid) !== false; + } + + // Unix: prefer posix_kill, fall back to /proc + if (function_exists('posix_kill')) { + return posix_kill($pid, 0); + } + + return file_exists("/proc/{$pid}"); + } + private static function parsePort(string $s): int { $n = (int) trim($s); diff --git a/antd-py/src/antd/_discover.py b/antd-py/src/antd/_discover.py index 8119538..150b3be 100644 --- a/antd-py/src/antd/_discover.py +++ b/antd-py/src/antd/_discover.py @@ -3,8 +3,11 @@ The antd daemon writes a ``daemon.port`` file on startup containing: - Line 1: REST port - Line 2: gRPC port + - Line 3: PID of the daemon process (optional, for staleness detection) This module reads that file to auto-discover the daemon's listen addresses. +If a PID is present and the process is no longer running, the port file is +considered stale and discovery returns empty results. """ from __future__ import annotations @@ -53,11 +56,42 @@ def _read_port_file() -> tuple[int, int]: if not lines: return 0, 0 + # Check PID staleness (line 3, if present) + if len(lines) >= 3: + pid = _parse_pid(lines[2]) + if pid is not None and not _is_process_alive(pid): + return 0, 0 + rest_port = _parse_port(lines[0]) grpc_port = _parse_port(lines[1]) if len(lines) >= 2 else 0 return rest_port, grpc_port +def _parse_pid(s: str) -> int | None: + """Parse a PID string, returning ``None`` if absent or invalid.""" + try: + n = int(s.strip()) + except (ValueError, TypeError): + return None + if n > 0: + return n + return None + + +def _is_process_alive(pid: int) -> bool: + """Return ``True`` if a process with *pid* is currently running.""" + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + # Process exists but we lack permission to signal it — still alive. + return True + except OSError: + return False + return True + + def _parse_port(s: str) -> int: """Parse a port string, returning 0 on failure.""" try: diff --git a/antd-py/tests/test_discover.py b/antd-py/tests/test_discover.py index 330c9c4..d457ad8 100644 --- a/antd-py/tests/test_discover.py +++ b/antd-py/tests/test_discover.py @@ -9,6 +9,7 @@ from antd._discover import ( _data_dir, + _is_process_alive, _read_port_file, discover_daemon_url, discover_grpc_target, @@ -85,6 +86,49 @@ def test_whitespace_handling(self, tmp_path, monkeypatch): assert discover_grpc_target() == "127.0.0.1:50051" +class TestStalePidDetection: + """Port file with a PID that doesn't correspond to a running process.""" + + def test_stale_pid_returns_empty_url(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "8082\n50051\n99999999\n", monkeypatch) + assert discover_daemon_url() == "" + + def test_stale_pid_returns_empty_grpc(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "8082\n50051\n99999999\n", monkeypatch) + assert discover_grpc_target() == "" + + def test_stale_pid_read_port_file(self, tmp_path, monkeypatch): + _write_port_file(tmp_path, "8082\n50051\n99999999\n", monkeypatch) + assert _read_port_file() == (0, 0) + + def test_alive_pid_returns_url(self, tmp_path, monkeypatch): + """Use our own PID, which is guaranteed to be alive.""" + pid = os.getpid() + _write_port_file(tmp_path, f"8082\n50051\n{pid}\n", monkeypatch) + assert discover_daemon_url() == "http://127.0.0.1:8082" + + def test_alive_pid_returns_grpc(self, tmp_path, monkeypatch): + pid = os.getpid() + _write_port_file(tmp_path, f"8082\n50051\n{pid}\n", monkeypatch) + assert discover_grpc_target() == "127.0.0.1:50051" + + def test_no_pid_line_still_works(self, tmp_path, monkeypatch): + """Old two-line format without PID should still work.""" + _write_port_file(tmp_path, "8082\n50051\n", monkeypatch) + assert discover_daemon_url() == "http://127.0.0.1:8082" + + def test_invalid_pid_line_treated_as_absent(self, tmp_path, monkeypatch): + """Non-numeric PID line is ignored (not treated as stale).""" + _write_port_file(tmp_path, "8082\n50051\nnotapid\n", monkeypatch) + assert discover_daemon_url() == "http://127.0.0.1:8082" + + def test_is_process_alive_dead(self): + assert _is_process_alive(99999999) is False + + def test_is_process_alive_self(self): + assert _is_process_alive(os.getpid()) is True + + class TestDataDir: def test_windows(self, monkeypatch): monkeypatch.setattr("sys.platform", "win32") diff --git a/antd-ruby/lib/antd/discover.rb b/antd-ruby/lib/antd/discover.rb index c76d768..92f033d 100644 --- a/antd-ruby/lib/antd/discover.rb +++ b/antd-ruby/lib/antd/discover.rb @@ -6,7 +6,11 @@ module Antd # Auto-discovers the antd daemon by reading the +daemon.port+ file that antd # writes on startup. # - # The file contains two lines: REST port on line 1, gRPC port on line 2. + # The file contains up to three lines: REST port (line 1), gRPC port (line 2), + # and optionally the daemon PID (line 3). + # + # If a PID is present and the process is no longer alive, the port file is + # considered stale and discovery returns empty. # # Port file location is platform-specific: # - Windows: %APPDATA%\ant\daemon.port @@ -49,6 +53,10 @@ def self.read_port_file lines = File.read(path).strip.split("\n") rest_port = parse_port(lines[0]) grpc_port = parse_port(lines[1]) + pid = parse_pid(lines[2]) + + return [0, 0] if pid > 0 && !process_alive?(pid) + [rest_port, grpc_port] rescue StandardError [0, 0] @@ -93,6 +101,28 @@ def self.data_dir end end - private_class_method :read_port_file, :parse_port, :data_dir + # @api private + def self.parse_pid(str) + return 0 if str.nil? + + s = str.strip + return 0 unless s.match?(/\A\d+\z/) + + n = s.to_i + n > 0 ? n : 0 + end + + # @api private + def self.process_alive?(pid) + Process.kill(0, pid) + true + rescue Errno::ESRCH + false + rescue Errno::EPERM + # Process exists but we lack permission to signal it — still alive. + true + end + + private_class_method :read_port_file, :parse_port, :parse_pid, :process_alive?, :data_dir end end diff --git a/antd-rust/src/discover.rs b/antd-rust/src/discover.rs index 52d70ef..7600228 100644 --- a/antd-rust/src/discover.rs +++ b/antd-rust/src/discover.rs @@ -1,8 +1,10 @@ //! Port discovery for the antd daemon. //! //! The antd daemon writes a `daemon.port` file on startup containing the REST -//! port on line 1 and the gRPC port on line 2. These helpers read that file -//! to auto-discover the daemon without hard-coding a port. +//! port on line 1, the gRPC port on line 2, and its PID on line 3. These +//! helpers read that file to auto-discover the daemon without hard-coding a +//! port. If a PID is present and the process is no longer alive, the port +//! file is considered stale and discovery returns `None`. use std::env; use std::fs; @@ -12,16 +14,16 @@ const PORT_FILE_NAME: &str = "daemon.port"; const DATA_DIR_NAME: &str = "ant"; /// Reads the daemon port file and returns the REST base URL -/// (e.g. `"http://127.0.0.1:8082"`), or `None` if the file is missing or -/// unreadable. +/// (e.g. `"http://127.0.0.1:8082"`), or `None` if the file is missing, +/// unreadable, or stale (PID no longer alive). pub fn discover_daemon_url() -> Option { let (rest, _) = read_port_file()?; Some(format!("http://127.0.0.1:{rest}")) } /// Reads the daemon port file and returns the gRPC target URL -/// (e.g. `"http://127.0.0.1:50051"`), or `None` if the file is missing or -/// has no gRPC line. +/// (e.g. `"http://127.0.0.1:50051"`), or `None` if the file is missing, +/// has no gRPC line, or is stale (PID no longer alive). pub fn discover_grpc_target() -> Option { let (_, grpc) = read_port_file()?; let grpc = grpc?; @@ -29,19 +31,63 @@ pub fn discover_grpc_target() -> Option { } /// Reads the `daemon.port` file and returns `(rest_port, Option)`. +/// +/// If the file contains a PID on line 3 and that process is not alive, the +/// port file is stale and this returns `None`. fn read_port_file() -> Option<(u16, Option)> { let dir = data_dir()?; let path = dir.join(PORT_FILE_NAME); let contents = fs::read_to_string(path).ok()?; + parse_port_contents_checked(&contents, process_alive) +} + +/// Parses port file contents and validates the PID using the supplied checker. +/// +/// The `pid_checker` callback allows tests to substitute their own liveness +/// logic without spawning real processes. +fn parse_port_contents_checked( + contents: &str, + pid_checker: fn(u32) -> bool, +) -> Option<(u16, Option)> { let mut lines = contents.trim().lines(); let rest: u16 = lines.next()?.trim().parse().ok()?; let grpc: Option = lines.next().and_then(|l| l.trim().parse().ok()); + // Line 3: optional PID — if present and the process is dead, file is stale. + if let Some(pid_line) = lines.next() { + if let Ok(pid) = pid_line.trim().parse::() { + if !pid_checker(pid) { + return None; + } + } + } + Some((rest, grpc)) } +/// Checks whether a process with the given PID is currently alive. +#[cfg(unix)] +fn process_alive(pid: u32) -> bool { + // `kill -0` checks process existence without sending a signal. + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .output() + .map(|o| o.status.success()) + .unwrap_or(true) // if we can't check, trust the file +} + +/// Checks whether a process with the given PID is currently alive. +#[cfg(windows)] +fn process_alive(pid: u32) -> bool { + std::process::Command::new("tasklist") + .args(["/FI", &format!("PID eq {}", pid), "/NH"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) + .unwrap_or(true) // if we can't check, trust the file +} + /// Returns the platform-specific data directory for ant. /// /// - Windows: `%APPDATA%\ant` @@ -72,41 +118,89 @@ fn data_dir() -> Option { #[cfg(test)] mod tests { - /// Simulate the same parsing logic used in `read_port_file`. - fn parse_port_contents(contents: &str) -> Option<(u16, Option)> { - let mut lines = contents.trim().lines(); - let rest: u16 = lines.next()?.trim().parse().ok()?; - let grpc: Option = lines.next().and_then(|l| l.trim().parse().ok()); - Some((rest, grpc)) + use super::parse_port_contents_checked; + + /// Stub: process is always alive. + fn alive(_pid: u32) -> bool { + true + } + + /// Stub: process is always dead. + fn dead(_pid: u32) -> bool { + false + } + + fn parse(contents: &str) -> Option<(u16, Option)> { + parse_port_contents_checked(contents, alive) } #[test] fn parse_two_line_port_file() { - let result = parse_port_contents("8082\n50051\n"); + let result = parse("8082\n50051\n"); assert_eq!(result, Some((8082, Some(50051)))); } #[test] fn parse_single_line_port_file() { - let result = parse_port_contents("8082\n"); + let result = parse("8082\n"); assert_eq!(result, Some((8082, None))); } #[test] fn parse_empty_returns_none() { - let result = parse_port_contents(""); + let result = parse(""); assert_eq!(result, None); } #[test] fn parse_invalid_port_returns_none() { - let result = parse_port_contents("notanumber\n"); + let result = parse("notanumber\n"); assert_eq!(result, None); } #[test] fn parse_with_whitespace() { - let result = parse_port_contents(" 8082 \n 50051 \n"); + let result = parse(" 8082 \n 50051 \n"); + assert_eq!(result, Some((8082, Some(50051)))); + } + + #[test] + fn parse_three_line_with_pid_alive() { + let result = parse_port_contents_checked("8082\n50051\n12345\n", alive); + assert_eq!(result, Some((8082, Some(50051)))); + } + + #[test] + fn parse_three_line_with_pid_dead_returns_none() { + let result = parse_port_contents_checked("8082\n50051\n12345\n", dead); + assert_eq!(result, None); + } + + #[test] + fn parse_pid_only_rest_port_alive() { + // Two lines: rest port + PID (no gRPC). The PID occupies line 2 but + // it won't parse as a valid port (PIDs are typically > 65535 or the + // daemon uses a known range). However if the PID *does* parse as a + // u16, it would be treated as the gRPC port and line 3 would be + // absent. This test verifies the three-line format specifically. + let result = parse_port_contents_checked("8082\n50051\n99999\n", alive); + assert_eq!(result, Some((8082, Some(50051)))); + } + + #[test] + fn stale_file_no_grpc_port() { + // rest port, no gRPC, PID dead — but PID is on line 3 so we need + // something on line 2. If line 2 is not a valid port, grpc is None + // and line 2's value is consumed. Line 3 is the PID. + let result = parse_port_contents_checked("8082\n\n12345\n", dead); + assert_eq!(result, None); + } + + #[test] + fn no_pid_line_always_succeeds() { + // Legacy two-line format — no PID check performed. + let result = parse_port_contents_checked("8082\n50051\n", dead); + // `dead` is never called because there is no third line. assert_eq!(result, Some((8082, Some(50051)))); } } diff --git a/antd-swift/Sources/AntdSdk/DaemonDiscovery.swift b/antd-swift/Sources/AntdSdk/DaemonDiscovery.swift index 01a0150..1eda050 100644 --- a/antd-swift/Sources/AntdSdk/DaemonDiscovery.swift +++ b/antd-swift/Sources/AntdSdk/DaemonDiscovery.swift @@ -2,7 +2,9 @@ import Foundation /// Discovers the antd daemon by reading the `daemon.port` file written on startup. /// -/// The port file contains two lines: REST port on line 1, gRPC port on line 2. +/// The port file contains up to three lines: REST port (line 1), gRPC port (line 2), +/// and PID of the daemon process (line 3). If a PID is present and the process is +/// not alive, the file is considered stale and discovery returns empty. /// File location is platform-specific: /// - macOS: `~/Library/Application Support/ant/daemon.port` /// - Linux: `$XDG_DATA_HOME/ant/daemon.port` or `~/.local/share/ant/daemon.port` @@ -64,11 +66,31 @@ public enum DaemonDiscovery { .components(separatedBy: "\n") guard !lines.isEmpty else { return nil } + // Line 3: PID of the daemon process (optional) + if lines.count >= 3, let pid = Int32(lines[2].trimmingCharacters(in: .whitespaces)), pid > 0 { + if !isProcessAlive(pid) { + return nil + } + } + let rest = parsePort(lines[0]) let grpc = lines.count >= 2 ? parsePort(lines[1]) : 0 return (rest, grpc) } + /// Check if a process with the given PID is alive. + /// Uses the C `kill` function with signal 0 — this doesn't send a signal but + /// checks whether the process exists. Returns true if alive or if permission + /// is denied (EPERM means it exists but we can't signal it). + private static func isProcessAlive(_ pid: Int32) -> Bool { + #if os(Windows) + // On Windows, trust the port file — kill(pid, 0) is not available. + return true + #else + return kill(pid_t(pid), 0) == 0 || errno == EPERM + #endif + } + private static func parsePort(_ s: String) -> UInt16 { UInt16(s.trimmingCharacters(in: .whitespaces)) ?? 0 } diff --git a/antd-zig/src/discover.zig b/antd-zig/src/discover.zig index 68c7d15..c575489 100644 --- a/antd-zig/src/discover.zig +++ b/antd-zig/src/discover.zig @@ -48,6 +48,7 @@ fn readPortFile(allocator: Allocator) ?Ports { var rest: u16 = 0; var grpc: u16 = 0; + var pid_line: ?[]const u8 = null; var line_iter = std.mem.splitSequence(u8, contents, "\n"); if (line_iter.next()) |first_line| { @@ -56,10 +57,38 @@ fn readPortFile(allocator: Allocator) ?Ports { if (line_iter.next()) |second_line| { grpc = parsePort(second_line); } + if (line_iter.next()) |third_line| { + pid_line = third_line; + } + + // Line 3: PID of the daemon process (optional stale-detection) + if (pid_line) |pl| { + const trimmed = std.mem.trim(u8, pl, &.{ ' ', '\t', '\r' }); + if (trimmed.len > 0) { + const pid = std.fmt.parseInt(i32, trimmed, 10) catch 0; + if (pid > 0 and !isProcessAlive(pid)) { + return null; + } + } + } return .{ .rest = rest, .grpc = grpc }; } +/// Check if a process with the given PID is alive. +/// On Linux, checks if /proc/{pid} exists. +/// On other platforms (Windows, macOS), trusts the port file. +fn isProcessAlive(pid: i32) bool { + if (builtin.os.tag == .linux) { + var path_buf: [32]u8 = undefined; + const path = std.fmt.bufPrint(&path_buf, "/proc/{d}", .{pid}) catch return true; + std.fs.accessAbsolute(path, .{}) catch return false; + return true; + } + // On Windows, macOS, and other platforms, trust the port file + return true; +} + fn parsePort(s: []const u8) u16 { const trimmed = std.mem.trim(u8, s, &.{ ' ', '\t', '\r' }); return std.fmt.parseInt(u16, trimmed, 10) catch 0; From 01466259c262d38e0596b7fbc1e09ea29c9d5ef2 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Wed, 25 Mar 2026 12:16:12 +0000 Subject: [PATCH 04/10] Add CORS hardening and wallet endpoints with SDK bindings CORS: Restrict allowed origin to http://127.0.0.1:{port} instead of permissive Any. Prevents cross-origin CSRF from malicious webpages. Non-browser clients (SDKs, CLI, AI agents) are unaffected as they don't send Origin headers. Matches approach from ant-client. Wallet: Add GET /v1/wallet/address and GET /v1/wallet/balance REST endpoints to antd. Returns 400 if no EVM wallet is configured. Also adds NotImplemented error variant (HTTP 501 / gRPC UNIMPLEMENTED) for stubbed endpoints. SDK bindings added for wallet_address() and wallet_balance() across all 15 language SDKs + MCP server (2 new MCP tools). Co-Authored-By: Claude Opus 4.6 (1M context) --- antd-cpp/include/antd/client.hpp | 8 +++ antd-cpp/include/antd/models.hpp | 11 ++++ antd-cpp/src/client.cpp | 19 ++++++ antd-csharp/Antd.Sdk/AntdRestClient.cs | 21 +++++++ antd-csharp/Antd.Sdk/IAntdClient.cs | 4 ++ antd-csharp/Antd.Sdk/Models.cs | 6 ++ antd-dart/lib/src/client.dart | 14 +++++ antd-dart/lib/src/models.dart | 39 ++++++++++++ antd-elixir/lib/antd/client.ex | 36 +++++++++++ antd-elixir/lib/antd/models.ex | 23 +++++++ antd-go/client.go | 23 +++++++ antd-go/models.go | 11 ++++ .../java/com/autonomi/antd/AntdClient.java | 12 ++++ .../autonomi/antd/models/WalletAddress.java | 8 +++ .../autonomi/antd/models/WalletBalance.java | 9 +++ antd-js/src/index.ts | 2 + antd-js/src/models.ts | 11 ++++ antd-js/src/rest-client.ts | 14 +++++ .../kotlin/com/autonomi/sdk/AntdGrpcClient.kt | 10 +++ .../kotlin/com/autonomi/sdk/AntdRestClient.kt | 12 ++++ .../kotlin/com/autonomi/sdk/IAntdClient.kt | 4 ++ .../main/kotlin/com/autonomi/sdk/Models.kt | 17 +++++ antd-lua/src/antd/client.lua | 18 ++++++ antd-lua/src/antd/models.lua | 20 ++++++ antd-mcp/src/antd_mcp/server.py | 62 ++++++++++++++++--- antd-php/src/AntdClient.php | 56 +++++++++++++++++ antd-py/src/antd/__init__.py | 4 ++ antd-py/src/antd/_rest.py | 30 +++++++++ antd-py/src/antd/models.py | 13 ++++ antd-ruby/lib/antd/client.rb | 16 +++++ antd-ruby/lib/antd/models.rb | 6 ++ antd-rust/src/client.rs | 25 ++++++++ antd-rust/src/models.rs | 16 +++++ .../Sources/AntdSdk/AntdClientProtocol.swift | 4 ++ .../Sources/AntdSdk/AntdGrpcClient.swift | 2 + .../Sources/AntdSdk/AntdRestClient.swift | 21 +++++++ antd-swift/Sources/AntdSdk/Models.swift | 20 ++++++ antd-zig/src/antd.zig | 18 ++++++ antd-zig/src/json_helpers.zig | 38 ++++++++++++ antd-zig/src/models.zig | 20 ++++++ antd/src/error.rs | 5 ++ antd/src/main.rs | 2 +- antd/src/rest/mod.rs | 19 +++++- antd/src/rest/wallet.rs | 37 +++++++++++ antd/src/types.rs | 16 +++++ 45 files changed, 771 insertions(+), 11 deletions(-) create mode 100644 antd-java/src/main/java/com/autonomi/antd/models/WalletAddress.java create mode 100644 antd-java/src/main/java/com/autonomi/antd/models/WalletBalance.java create mode 100644 antd/src/rest/wallet.rs diff --git a/antd-cpp/include/antd/client.hpp b/antd-cpp/include/antd/client.hpp index a759289..5f71c22 100644 --- a/antd-cpp/include/antd/client.hpp +++ b/antd-cpp/include/antd/client.hpp @@ -113,6 +113,14 @@ class Client { /// Estimate the cost of uploading a file. std::string file_cost(std::string_view path, bool is_public, bool include_archive); + // --- Wallet --- + + /// Get the wallet address configured on the daemon. + WalletAddress wallet_address(); + + /// Get the wallet balance (tokens and gas). + WalletBalance wallet_balance(); + private: struct Impl; std::unique_ptr impl_; diff --git a/antd-cpp/include/antd/models.hpp b/antd-cpp/include/antd/models.hpp index 3a1ea1c..4311237 100644 --- a/antd-cpp/include/antd/models.hpp +++ b/antd-cpp/include/antd/models.hpp @@ -46,4 +46,15 @@ struct Archive { std::vector entries; }; +/// Wallet address response. +struct WalletAddress { + std::string address; // 0x-prefixed hex +}; + +/// Wallet balance response. +struct WalletBalance { + std::string balance; // atto tokens as string + std::string gas_balance; // atto tokens as string +}; + } // namespace antd diff --git a/antd-cpp/src/client.cpp b/antd-cpp/src/client.cpp index 264f81c..a725d6c 100644 --- a/antd-cpp/src/client.cpp +++ b/antd-cpp/src/client.cpp @@ -325,4 +325,23 @@ std::string Client::file_cost(std::string_view path, bool is_public, bool includ return j.value("cost", ""); } +// --------------------------------------------------------------------------- +// Wallet +// --------------------------------------------------------------------------- + +WalletAddress Client::wallet_address() { + auto j = impl_->do_json("GET", "/v1/wallet/address"); + return WalletAddress{ + .address = j.value("address", ""), + }; +} + +WalletBalance Client::wallet_balance() { + auto j = impl_->do_json("GET", "/v1/wallet/balance"); + return WalletBalance{ + .balance = j.value("balance", ""), + .gas_balance = j.value("gas_balance", ""), + }; +} + } // namespace antd diff --git a/antd-csharp/Antd.Sdk/AntdRestClient.cs b/antd-csharp/Antd.Sdk/AntdRestClient.cs index 64d78a9..61dc465 100644 --- a/antd-csharp/Antd.Sdk/AntdRestClient.cs +++ b/antd-csharp/Antd.Sdk/AntdRestClient.cs @@ -220,6 +220,20 @@ public async Task FileCostAsync(string path, bool isPublic = true, bool return resp.Cost; } + // ── Wallet ── + + public async Task WalletAddressAsync() + { + var resp = await GetJsonAsync("/v1/wallet/address"); + return new WalletAddress(resp.Address); + } + + public async Task WalletBalanceAsync() + { + var resp = await GetJsonAsync("/v1/wallet/balance"); + return new WalletBalance(resp.Balance, resp.GasBalance); + } + // ── Internal DTOs for JSON deserialization ── private sealed record HealthResponseDto( @@ -259,4 +273,11 @@ private sealed record ArchiveEntryDto( private sealed record ArchiveDto( [property: JsonPropertyName("entries")] List? Entries); + + private sealed record WalletAddressDto( + [property: JsonPropertyName("address")] string Address); + + private sealed record WalletBalanceDto( + [property: JsonPropertyName("balance")] string Balance, + [property: JsonPropertyName("gas_balance")] string GasBalance); } diff --git a/antd-csharp/Antd.Sdk/IAntdClient.cs b/antd-csharp/Antd.Sdk/IAntdClient.cs index 4ba37e8..ef1ffdf 100644 --- a/antd-csharp/Antd.Sdk/IAntdClient.cs +++ b/antd-csharp/Antd.Sdk/IAntdClient.cs @@ -30,4 +30,8 @@ public interface IAntdClient : IDisposable Task ArchiveGetPublicAsync(string address); Task ArchivePutPublicAsync(Archive archive); Task FileCostAsync(string path, bool isPublic = true, bool includeArchive = false); + + // Wallet + Task WalletAddressAsync(); + Task WalletBalanceAsync(); } diff --git a/antd-csharp/Antd.Sdk/Models.cs b/antd-csharp/Antd.Sdk/Models.cs index 8ff377c..6f67c80 100644 --- a/antd-csharp/Antd.Sdk/Models.cs +++ b/antd-csharp/Antd.Sdk/Models.cs @@ -17,3 +17,9 @@ public sealed record ArchiveEntry(string Path, string Address, ulong Created, ul /// An archive manifest containing file entries. public sealed record Archive(List Entries); + +/// Wallet address from the antd daemon. +public sealed record WalletAddress(string Address); + +/// Wallet balance from the antd daemon. +public sealed record WalletBalance(string Balance, string GasBalance); diff --git a/antd-dart/lib/src/client.dart b/antd-dart/lib/src/client.dart index 0798ed5..f74b619 100644 --- a/antd-dart/lib/src/client.dart +++ b/antd-dart/lib/src/client.dart @@ -300,4 +300,18 @@ class AntdClient { }); return json!['cost'] as String; } + + // --- Wallet --- + + /// Returns the wallet address configured on the daemon. + Future walletAddress() async { + final json = await _doJson('GET', '/v1/wallet/address'); + return WalletAddress.fromJson(json!); + } + + /// Returns the wallet balance (tokens and gas). + Future walletBalance() async { + final json = await _doJson('GET', '/v1/wallet/balance'); + return WalletBalance.fromJson(json!); + } } diff --git a/antd-dart/lib/src/models.dart b/antd-dart/lib/src/models.dart index 3c2b503..7e28774 100644 --- a/antd-dart/lib/src/models.dart +++ b/antd-dart/lib/src/models.dart @@ -177,3 +177,42 @@ class Archive { @override String toString() => 'Archive(entries: $entries)'; } + +/// WalletAddress is the wallet address response. +class WalletAddress { + /// The 0x-prefixed hex address. + final String address; + + const WalletAddress({required this.address}); + + factory WalletAddress.fromJson(Map json) { + return WalletAddress( + address: json['address'] as String? ?? '', + ); + } + + @override + String toString() => 'WalletAddress(address: $address)'; +} + +/// WalletBalance is the wallet balance response. +class WalletBalance { + /// Token balance in atto tokens as a string. + final String balance; + + /// Gas balance in atto tokens as a string. + final String gasBalance; + + const WalletBalance({required this.balance, required this.gasBalance}); + + factory WalletBalance.fromJson(Map json) { + return WalletBalance( + balance: json['balance'] as String? ?? '', + gasBalance: json['gas_balance'] as String? ?? '', + ); + } + + @override + String toString() => + 'WalletBalance(balance: $balance, gasBalance: $gasBalance)'; +} diff --git a/antd-elixir/lib/antd/client.ex b/antd-elixir/lib/antd/client.ex index a9e8229..d5eee9a 100644 --- a/antd-elixir/lib/antd/client.ex +++ b/antd-elixir/lib/antd/client.ex @@ -424,6 +424,42 @@ defmodule Antd.Client do unwrap!(file_cost(client, path, is_public, include_archive)) end + # --------------------------------------------------------------------------- + # Wallet + # --------------------------------------------------------------------------- + + @doc "Returns the wallet address configured on the daemon." + @spec wallet_address(t()) :: {:ok, Antd.WalletAddress.t()} | {:error, Exception.t()} + def wallet_address(%__MODULE__{} = client) do + case do_json(client, :get, "/v1/wallet/address", nil) do + {:ok, body} -> + {:ok, %Antd.WalletAddress{address: body["address"]}} + + {:error, _} = err -> + err + end + end + + @doc "Like `wallet_address/1` but raises on error." + @spec wallet_address!(t()) :: Antd.WalletAddress.t() + def wallet_address!(client), do: unwrap!(wallet_address(client)) + + @doc "Returns the wallet balance and gas balance." + @spec wallet_balance(t()) :: {:ok, Antd.WalletBalance.t()} | {:error, Exception.t()} + def wallet_balance(%__MODULE__{} = client) do + case do_json(client, :get, "/v1/wallet/balance", nil) do + {:ok, body} -> + {:ok, %Antd.WalletBalance{balance: body["balance"], gas_balance: body["gas_balance"]}} + + {:error, _} = err -> + err + end + end + + @doc "Like `wallet_balance/1` but raises on error." + @spec wallet_balance!(t()) :: Antd.WalletBalance.t() + def wallet_balance!(client), do: unwrap!(wallet_balance(client)) + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- diff --git a/antd-elixir/lib/antd/models.ex b/antd-elixir/lib/antd/models.ex index 450c3cf..655493e 100644 --- a/antd-elixir/lib/antd/models.ex +++ b/antd-elixir/lib/antd/models.ex @@ -73,3 +73,26 @@ defmodule Antd.Archive do entries: [Antd.ArchiveEntry.t()] } end + +defmodule Antd.WalletAddress do + @moduledoc "Wallet address result." + + @enforce_keys [:address] + defstruct [:address] + + @type t :: %__MODULE__{ + address: String.t() + } +end + +defmodule Antd.WalletBalance do + @moduledoc "Wallet balance result." + + @enforce_keys [:balance, :gas_balance] + defstruct [:balance, :gas_balance] + + @type t :: %__MODULE__{ + balance: String.t(), + gas_balance: String.t() + } +end diff --git a/antd-go/client.go b/antd-go/client.go index 04e96ba..d425f2a 100644 --- a/antd-go/client.go +++ b/antd-go/client.go @@ -426,3 +426,26 @@ func (c *Client) FileCost(ctx context.Context, path string, isPublic bool, inclu } return str(j, "cost"), nil } + +// --- Wallet --- + +// WalletAddress returns the wallet's public address. +func (c *Client) WalletAddress(ctx context.Context) (*WalletAddress, error) { + j, _, err := c.doJSON(ctx, http.MethodGet, "/v1/wallet/address", nil) + if err != nil { + return nil, err + } + return &WalletAddress{Address: str(j, "address")}, nil +} + +// WalletBalance returns the wallet's token and gas balances. +func (c *Client) WalletBalance(ctx context.Context) (*WalletBalance, error) { + j, _, err := c.doJSON(ctx, http.MethodGet, "/v1/wallet/balance", nil) + if err != nil { + return nil, err + } + return &WalletBalance{ + Balance: str(j, "balance"), + GasBalance: str(j, "gas_balance"), + }, nil +} diff --git a/antd-go/models.go b/antd-go/models.go index 84d128b..d8d9d74 100644 --- a/antd-go/models.go +++ b/antd-go/models.go @@ -39,3 +39,14 @@ type ArchiveEntry struct { type Archive struct { Entries []ArchiveEntry `json:"entries"` } + +// WalletAddress is the result of a wallet address query. +type WalletAddress struct { + Address string `json:"address"` // hex with 0x prefix +} + +// WalletBalance is the result of a wallet balance query. +type WalletBalance struct { + Balance string `json:"balance"` // token balance in atto + GasBalance string `json:"gas_balance"` // gas balance in wei +} diff --git a/antd-java/src/main/java/com/autonomi/antd/AntdClient.java b/antd-java/src/main/java/com/autonomi/antd/AntdClient.java index f592c35..de41c69 100644 --- a/antd-java/src/main/java/com/autonomi/antd/AntdClient.java +++ b/antd-java/src/main/java/com/autonomi/antd/AntdClient.java @@ -332,4 +332,16 @@ public String fileCost(String path, boolean isPublic, boolean includeArchive) { Map j = doJson("POST", "/v1/cost/file", body); return str(j, "cost"); } + + // ── Wallet ── + + public WalletAddress walletAddress() { + Map j = doJson("GET", "/v1/wallet/address", null); + return new WalletAddress(str(j, "address")); + } + + public WalletBalance walletBalance() { + Map j = doJson("GET", "/v1/wallet/balance", null); + return new WalletBalance(str(j, "balance"), str(j, "gas_balance")); + } } diff --git a/antd-java/src/main/java/com/autonomi/antd/models/WalletAddress.java b/antd-java/src/main/java/com/autonomi/antd/models/WalletAddress.java new file mode 100644 index 0000000..bd3a53a --- /dev/null +++ b/antd-java/src/main/java/com/autonomi/antd/models/WalletAddress.java @@ -0,0 +1,8 @@ +package com.autonomi.antd.models; + +/** + * Wallet address from the antd daemon. + * + * @param address hex-encoded address, e.g. "0x..." + */ +public record WalletAddress(String address) {} diff --git a/antd-java/src/main/java/com/autonomi/antd/models/WalletBalance.java b/antd-java/src/main/java/com/autonomi/antd/models/WalletBalance.java new file mode 100644 index 0000000..614d14b --- /dev/null +++ b/antd-java/src/main/java/com/autonomi/antd/models/WalletBalance.java @@ -0,0 +1,9 @@ +package com.autonomi.antd.models; + +/** + * Wallet balance from the antd daemon. + * + * @param balance balance in atto tokens (as a string to preserve precision) + * @param gasBalance gas balance in atto tokens (as a string to preserve precision) + */ +public record WalletBalance(String balance, String gasBalance) {} diff --git a/antd-js/src/index.ts b/antd-js/src/index.ts index 5266fa0..ac2f5e1 100644 --- a/antd-js/src/index.ts +++ b/antd-js/src/index.ts @@ -5,6 +5,8 @@ export type { GraphEntry, ArchiveEntry, Archive, + WalletAddress, + WalletBalance, } from "./models.js"; export { diff --git a/antd-js/src/models.ts b/antd-js/src/models.ts index 5ef6440..0a423e4 100644 --- a/antd-js/src/models.ts +++ b/antd-js/src/models.ts @@ -37,3 +37,14 @@ export interface ArchiveEntry { export interface Archive { entries: ArchiveEntry[]; } + +/** Wallet address response. */ +export interface WalletAddress { + address: string; // 0x-prefixed hex +} + +/** Wallet balance response. */ +export interface WalletBalance { + balance: string; // atto tokens as string + gasBalance: string; // atto tokens as string +} diff --git a/antd-js/src/rest-client.ts b/antd-js/src/rest-client.ts index add8b29..9006cf7 100644 --- a/antd-js/src/rest-client.ts +++ b/antd-js/src/rest-client.ts @@ -7,6 +7,8 @@ import type { GraphEntry, HealthStatus, PutResult, + WalletAddress, + WalletBalance, } from "./models.js"; /** Options for creating a REST client. */ @@ -289,4 +291,16 @@ export class RestClient { }); return j.cost; } + + // ---- Wallet ---- + + async walletAddress(): Promise { + const j = await this.getJson<{ address: string }>("/v1/wallet/address"); + return { address: j.address }; + } + + async walletBalance(): Promise { + const j = await this.getJson<{ balance: string; gas_balance: string }>("/v1/wallet/balance"); + return { balance: j.balance, gasBalance: j.gas_balance }; + } } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt index b34f5f8..9904687 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt @@ -169,4 +169,14 @@ class AntdGrpcClient(target: String = "localhost:50051") : IAntdClient { }) resp.attoTokens } catch (ex: StatusRuntimeException) { throw wrap(ex) } + + // ── Wallet ── + + override suspend fun walletAddress(): WalletAddress { + throw UnsupportedOperationException("walletAddress is not yet supported via gRPC") + } + + override suspend fun walletBalance(): WalletBalance { + throw UnsupportedOperationException("walletBalance is not yet supported via gRPC") + } } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt index b17139e..afa1039 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt @@ -246,4 +246,16 @@ class AntdRestClient( val resp = postJson("/v1/cost/file", body) return resp.cost } + + // ── Wallet ── + + override suspend fun walletAddress(): WalletAddress { + val resp = getJson("/v1/wallet/address") + return WalletAddress(resp.address) + } + + override suspend fun walletBalance(): WalletBalance { + val resp = getJson("/v1/wallet/balance") + return WalletBalance(resp.balance, resp.gasBalance) + } } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt index 80959aa..8a22f90 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt @@ -38,4 +38,8 @@ interface IAntdClient : Closeable { suspend fun archiveGetPublic(address: String): Archive suspend fun archivePutPublic(archive: Archive): PutResult suspend fun fileCost(path: String, isPublic: Boolean = true, includeArchive: Boolean = false): String + + // Wallet + suspend fun walletAddress(): WalletAddress + suspend fun walletBalance(): WalletBalance } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/Models.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/Models.kt index e229a0c..95700a3 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/Models.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/Models.kt @@ -21,6 +21,12 @@ data class ArchiveEntry(val path: String, val address: String, val created: ULon /** An archive manifest containing file entries. */ data class Archive(val entries: List) +/** Wallet address response. */ +data class WalletAddress(val address: String) + +/** Wallet balance response. */ +data class WalletBalance(val balance: String, val gasBalance: String) + // ── Internal DTOs for JSON deserialization ── @Serializable @@ -78,3 +84,14 @@ internal data class ArchiveEntryDto( internal data class ArchiveDto( val entries: List? = null, ) + +@Serializable +internal data class WalletAddressDto( + val address: String, +) + +@Serializable +internal data class WalletBalanceDto( + val balance: String, + @SerialName("gas_balance") val gasBalance: String, +) diff --git a/antd-lua/src/antd/client.lua b/antd-lua/src/antd/client.lua index bf6e526..bb0eb6a 100644 --- a/antd-lua/src/antd/client.lua +++ b/antd-lua/src/antd/client.lua @@ -402,6 +402,24 @@ function Client:file_cost(path, is_public, include_archive) return str(j, "cost"), nil end +-- ── Wallet ── + +--- Get the wallet's public address. +-- @return table|nil {address=string}, error|nil +function Client:wallet_address() + local j, _, err = self:_do_json("GET", "/v1/wallet/address", nil) + if err then return nil, err end + return { address = str(j, "address") }, nil +end + +--- Get the wallet's token and gas balances. +-- @return table|nil {balance=string, gas_balance=string}, error|nil +function Client:wallet_balance() + local j, _, err = self:_do_json("GET", "/v1/wallet/balance", nil) + if err then return nil, err end + return { balance = str(j, "balance"), gas_balance = str(j, "gas_balance") }, nil +end + --- Create a client using daemon port discovery. -- Falls back to the default base URL if discovery fails. -- @param opts table optional settings: { timeout = number } diff --git a/antd-lua/src/antd/models.lua b/antd-lua/src/antd/models.lua index ce1394e..06a21ec 100644 --- a/antd-lua/src/antd/models.lua +++ b/antd-lua/src/antd/models.lua @@ -78,4 +78,24 @@ function M.new_archive(entries) } end +--- Create a WalletAddress table. +-- @param address string wallet address (e.g. "0x...") +-- @return table +function M.new_wallet_address(address) + return { + address = address, + } +end + +--- Create a WalletBalance table. +-- @param balance string token balance in atto tokens +-- @param gas_balance string gas balance in atto tokens +-- @return table +function M.new_wallet_balance(balance, gas_balance) + return { + balance = balance, + gas_balance = gas_balance, + } +end + return M diff --git a/antd-mcp/src/antd_mcp/server.py b/antd-mcp/src/antd_mcp/server.py index 4168323..9a71705 100644 --- a/antd-mcp/src/antd_mcp/server.py +++ b/antd-mcp/src/antd_mcp/server.py @@ -269,7 +269,53 @@ async def check_balance() -> str: # --------------------------------------------------------------------------- -# Tool 7: chunk_put +# Tool 7: wallet_address +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def wallet_address() -> str: + """Get the wallet's public address from the antd daemon. + + Returns: + JSON with the wallet address (e.g. "0x..."), or error details. + Returns an error if no wallet is configured. + """ + client, network = _get_ctx() + try: + result = await client.wallet_address() + return _ok({"address": result.address}, network) + except AntdError as exc: + return _err_antd(exc, network) + except Exception as exc: + return _err(exc, network) + + +# --------------------------------------------------------------------------- +# Tool 8: wallet_balance +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def wallet_balance() -> str: + """Get the wallet's token and gas balances from the antd daemon. + + Returns: + JSON with balance (token balance) and gas_balance (gas token balance), + both as strings in atto units. Returns an error if no wallet is configured. + """ + client, network = _get_ctx() + try: + result = await client.wallet_balance() + return _ok({"balance": result.balance, "gas_balance": result.gas_balance}, network) + except AntdError as exc: + return _err_antd(exc, network) + except Exception as exc: + return _err(exc, network) + + +# --------------------------------------------------------------------------- +# Tool 9: chunk_put # --------------------------------------------------------------------------- @@ -297,7 +343,7 @@ async def chunk_put( # --------------------------------------------------------------------------- -# Tool 8: chunk_get +# Tool 10: chunk_get # --------------------------------------------------------------------------- @@ -324,7 +370,7 @@ async def chunk_get( # --------------------------------------------------------------------------- -# Tool 9: create_graph_entry +# Tool 11: create_graph_entry # --------------------------------------------------------------------------- @@ -364,7 +410,7 @@ async def create_graph_entry( # --------------------------------------------------------------------------- -# Tool 10: get_graph_entry +# Tool 12: get_graph_entry # --------------------------------------------------------------------------- @@ -400,7 +446,7 @@ async def get_graph_entry( # --------------------------------------------------------------------------- -# Tool 11: graph_entry_exists +# Tool 13: graph_entry_exists # --------------------------------------------------------------------------- @@ -427,7 +473,7 @@ async def graph_entry_exists( # --------------------------------------------------------------------------- -# Tool 12: graph_entry_cost +# Tool 14: graph_entry_cost # --------------------------------------------------------------------------- @@ -454,7 +500,7 @@ async def graph_entry_cost( # --------------------------------------------------------------------------- -# Tool 13: archive_get +# Tool 15: archive_get # --------------------------------------------------------------------------- @@ -493,7 +539,7 @@ async def archive_get( # --------------------------------------------------------------------------- -# Tool 14: archive_put +# Tool 16: archive_put # --------------------------------------------------------------------------- diff --git a/antd-php/src/AntdClient.php b/antd-php/src/AntdClient.php index 7a73775..e70776d 100644 --- a/antd-php/src/AntdClient.php +++ b/antd-php/src/AntdClient.php @@ -768,6 +768,62 @@ public function archivePutPublicAsync(Archive $archive): PromiseInterface ); } + // --- Wallet --- + + /** + * Get the wallet's public address. + * + * @return array{address: string} + * @throws AntdError if no wallet is configured (HTTP 400) + */ + public function walletAddress(): array + { + $json = $this->doJson('GET', '/v1/wallet/address'); + return ['address' => $json['address'] ?? '']; + } + + /** + * Async: Get the wallet's public address. + * + * @return PromiseInterface + */ + public function walletAddressAsync(): PromiseInterface + { + return $this->doJsonAsync('GET', '/v1/wallet/address')->then( + fn(?array $json) => ['address' => $json['address'] ?? ''], + ); + } + + /** + * Get the wallet's token and gas balances. + * + * @return array{balance: string, gas_balance: string} + * @throws AntdError if no wallet is configured (HTTP 400) + */ + public function walletBalance(): array + { + $json = $this->doJson('GET', '/v1/wallet/balance'); + return [ + 'balance' => $json['balance'] ?? '', + 'gas_balance' => $json['gas_balance'] ?? '', + ]; + } + + /** + * Async: Get the wallet's token and gas balances. + * + * @return PromiseInterface + */ + public function walletBalanceAsync(): PromiseInterface + { + return $this->doJsonAsync('GET', '/v1/wallet/balance')->then( + fn(?array $json) => [ + 'balance' => $json['balance'] ?? '', + 'gas_balance' => $json['gas_balance'] ?? '', + ], + ); + } + /** * Estimate the cost of uploading a file. */ diff --git a/antd-py/src/antd/__init__.py b/antd-py/src/antd/__init__.py index 8f3b9e7..872ef66 100644 --- a/antd-py/src/antd/__init__.py +++ b/antd-py/src/antd/__init__.py @@ -18,6 +18,8 @@ GraphEntry, HealthStatus, PutResult, + WalletAddress, + WalletBalance, ) from ._discover import discover_daemon_url, discover_grpc_target from .exceptions import ( @@ -46,6 +48,8 @@ "GraphDescendant", "GraphEntry", "PutResult", + "WalletAddress", + "WalletBalance", # Exceptions "AntdError", "AlreadyExistsError", diff --git a/antd-py/src/antd/_rest.py b/antd-py/src/antd/_rest.py index 3d8e6ff..19f64f7 100644 --- a/antd-py/src/antd/_rest.py +++ b/antd-py/src/antd/_rest.py @@ -15,6 +15,8 @@ GraphEntry, HealthStatus, PutResult, + WalletAddress, + WalletBalance, ) if TYPE_CHECKING: @@ -221,6 +223,20 @@ def file_cost(self, path: str, is_public: bool = True, include_archive: bool = F _check(resp) return resp.json()["cost"] + # --- Wallet --- + + def wallet_address(self) -> WalletAddress: + resp = self._http.get("/v1/wallet/address") + _check(resp) + j = resp.json() + return WalletAddress(address=j["address"]) + + def wallet_balance(self) -> WalletBalance: + resp = self._http.get("/v1/wallet/balance") + _check(resp) + j = resp.json() + return WalletBalance(balance=j["balance"], gas_balance=j["gas_balance"]) + class AsyncRestClient: """Asynchronous REST client for the antd daemon.""" @@ -402,3 +418,17 @@ async def file_cost(self, path: str, is_public: bool = True, include_archive: bo }) _check(resp) return resp.json()["cost"] + + # --- Wallet --- + + async def wallet_address(self) -> WalletAddress: + resp = await self._http.get("/v1/wallet/address") + _check(resp) + j = resp.json() + return WalletAddress(address=j["address"]) + + async def wallet_balance(self) -> WalletBalance: + resp = await self._http.get("/v1/wallet/balance") + _check(resp) + j = resp.json() + return WalletBalance(balance=j["balance"], gas_balance=j["gas_balance"]) diff --git a/antd-py/src/antd/models.py b/antd-py/src/antd/models.py index 21d6e8e..a3b460c 100644 --- a/antd-py/src/antd/models.py +++ b/antd-py/src/antd/models.py @@ -48,3 +48,16 @@ class ArchiveEntry: class Archive: """A collection of archive entries.""" entries: list[ArchiveEntry] = field(default_factory=list) + + +@dataclass(frozen=True) +class WalletAddress: + """Wallet address from the antd daemon.""" + address: str # hex, e.g. "0x..." + + +@dataclass(frozen=True) +class WalletBalance: + """Wallet balance from the antd daemon.""" + balance: str # atto tokens as string + gas_balance: str # atto gas tokens as string diff --git a/antd-ruby/lib/antd/client.rb b/antd-ruby/lib/antd/client.rb index 3a3f6ad..b10141a 100644 --- a/antd-ruby/lib/antd/client.rb +++ b/antd-ruby/lib/antd/client.rb @@ -232,6 +232,22 @@ def file_cost(path, is_public, include_archive) j["cost"] end + # --- Wallet --- + + # Get the wallet address configured on the daemon. + # @return [WalletAddress] + def wallet_address + j = do_json(:get, "/v1/wallet/address") + WalletAddress.new(address: j["address"]) + end + + # Get the wallet balance and gas balance. + # @return [WalletBalance] + def wallet_balance + j = do_json(:get, "/v1/wallet/balance") + WalletBalance.new(balance: j["balance"], gas_balance: j["gas_balance"]) + end + private def b64_encode(data) diff --git a/antd-ruby/lib/antd/models.rb b/antd-ruby/lib/antd/models.rb index 71dde71..c4f5c39 100644 --- a/antd-ruby/lib/antd/models.rb +++ b/antd-ruby/lib/antd/models.rb @@ -18,4 +18,10 @@ module Antd # A collection of archive entries. Archive = Struct.new(:entries, keyword_init: true) + + # Wallet address result. + WalletAddress = Struct.new(:address, keyword_init: true) + + # Wallet balance result. + WalletBalance = Struct.new(:balance, :gas_balance, keyword_init: true) end diff --git a/antd-rust/src/client.rs b/antd-rust/src/client.rs index 133928e..e5b316d 100644 --- a/antd-rust/src/client.rs +++ b/antd-rust/src/client.rs @@ -506,4 +506,29 @@ impl Client { let j = j.unwrap_or_default(); Ok(Self::str_field(&j, "cost")) } + + // --- Wallet --- + + /// Returns the wallet address configured in the daemon. + pub async fn wallet_address(&self) -> Result { + let (j, _) = self + .do_json(reqwest::Method::GET, "/v1/wallet/address", None) + .await?; + let j = j.unwrap_or_default(); + Ok(WalletAddress { + address: Self::str_field(&j, "address"), + }) + } + + /// Returns the wallet balance from the daemon. + pub async fn wallet_balance(&self) -> Result { + let (j, _) = self + .do_json(reqwest::Method::GET, "/v1/wallet/balance", None) + .await?; + let j = j.unwrap_or_default(); + Ok(WalletBalance { + balance: Self::str_field(&j, "balance"), + gas_balance: Self::str_field(&j, "gas_balance"), + }) + } } diff --git a/antd-rust/src/models.rs b/antd-rust/src/models.rs index e8bf682..bec5577 100644 --- a/antd-rust/src/models.rs +++ b/antd-rust/src/models.rs @@ -49,3 +49,19 @@ pub struct ArchiveEntry { pub struct Archive { pub entries: Vec, } + +/// Wallet address from the antd daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletAddress { + /// Hex-encoded address, e.g. "0x...". + pub address: String, +} + +/// Wallet balance from the antd daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletBalance { + /// Balance in atto tokens as a string. + pub balance: String, + /// Gas balance in atto tokens as a string. + pub gas_balance: String, +} diff --git a/antd-swift/Sources/AntdSdk/AntdClientProtocol.swift b/antd-swift/Sources/AntdSdk/AntdClientProtocol.swift index 42be06b..a12d48d 100644 --- a/antd-swift/Sources/AntdSdk/AntdClientProtocol.swift +++ b/antd-swift/Sources/AntdSdk/AntdClientProtocol.swift @@ -34,4 +34,8 @@ public protocol AntdClientProtocol: Sendable { func archiveGetPublic(address: String) async throws -> Archive func archivePutPublic(archive: Archive) async throws -> PutResult func fileCost(path: String, isPublic: Bool, includeArchive: Bool) async throws -> String + + // Wallet + func walletAddress() async throws -> WalletAddress + func walletBalance() async throws -> WalletBalance } diff --git a/antd-swift/Sources/AntdSdk/AntdGrpcClient.swift b/antd-swift/Sources/AntdSdk/AntdGrpcClient.swift index 8c36bba..c04f1a4 100644 --- a/antd-swift/Sources/AntdSdk/AntdGrpcClient.swift +++ b/antd-swift/Sources/AntdSdk/AntdGrpcClient.swift @@ -43,4 +43,6 @@ public final class AntdGrpcClient: AntdClientProtocol, @unchecked Sendable { public func archiveGetPublic(address: String) async throws -> Archive { throw notImplemented() } public func archivePutPublic(archive: Archive) async throws -> PutResult { throw notImplemented() } public func fileCost(path: String, isPublic: Bool = true, includeArchive: Bool = false) async throws -> String { throw notImplemented() } + public func walletAddress() async throws -> WalletAddress { throw notImplemented() } + public func walletBalance() async throws -> WalletBalance { throw notImplemented() } } diff --git a/antd-swift/Sources/AntdSdk/AntdRestClient.swift b/antd-swift/Sources/AntdSdk/AntdRestClient.swift index 6ae6250..bf50e1e 100644 --- a/antd-swift/Sources/AntdSdk/AntdRestClient.swift +++ b/antd-swift/Sources/AntdSdk/AntdRestClient.swift @@ -185,6 +185,18 @@ public final class AntdRestClient: AntdClientProtocol, @unchecked Sendable { let resp: CostDTO = try await postJSON("/v1/cost/file", body: body) return resp.cost } + + // MARK: - Wallet + + public func walletAddress() async throws -> WalletAddress { + let resp: WalletAddressDTO = try await getJSON("/v1/wallet/address") + return WalletAddress(address: resp.address) + } + + public func walletBalance() async throws -> WalletBalance { + let resp: WalletBalanceDTO = try await getJSON("/v1/wallet/balance") + return WalletBalance(balance: resp.balance, gasBalance: resp.gasBalance) + } } // MARK: - Internal DTOs @@ -236,6 +248,15 @@ private struct ArchiveDTO: Decodable { let entries: [ArchiveEntryDTO]? } +private struct WalletAddressDTO: Decodable { + let address: String +} + +private struct WalletBalanceDTO: Decodable { + let balance: String + let gasBalance: String +} + extension JSONDecoder { static let snakeCase: JSONDecoder = { let decoder = JSONDecoder() diff --git a/antd-swift/Sources/AntdSdk/Models.swift b/antd-swift/Sources/AntdSdk/Models.swift index 9ab921e..59c763a 100644 --- a/antd-swift/Sources/AntdSdk/Models.swift +++ b/antd-swift/Sources/AntdSdk/Models.swift @@ -73,3 +73,23 @@ public struct Archive: Sendable, Equatable { self.entries = entries } } + +/// Wallet address result. +public struct WalletAddress: Sendable, Equatable { + public let address: String + + public init(address: String) { + self.address = address + } +} + +/// Wallet balance result. +public struct WalletBalance: Sendable, Equatable { + public let balance: String + public let gasBalance: String + + public init(balance: String, gasBalance: String) { + self.balance = balance + self.gasBalance = gasBalance + } +} diff --git a/antd-zig/src/antd.zig b/antd-zig/src/antd.zig index 2c42927..4151593 100644 --- a/antd-zig/src/antd.zig +++ b/antd-zig/src/antd.zig @@ -13,6 +13,8 @@ pub const GraphDescendant = models.GraphDescendant; pub const GraphEntry = models.GraphEntry; pub const ArchiveEntry = models.ArchiveEntry; pub const Archive = models.Archive; +pub const WalletAddress = models.WalletAddress; +pub const WalletBalance = models.WalletBalance; pub const AntdError = errors.AntdError; pub const ErrorInfo = errors.ErrorInfo; pub const errorForStatus = errors.errorForStatus; @@ -295,6 +297,22 @@ pub const Client = struct { return json_helpers.parseCost(self.allocator, resp); } + // --- Wallet --- + + /// Get the wallet's public address. + pub fn walletAddress(self: *Client) !WalletAddress { + const resp = try self.doRequest(.GET, "/v1/wallet/address", null) orelse return error.JsonError; + defer self.allocator.free(resp); + return json_helpers.parseWalletAddress(self.allocator, resp); + } + + /// Get the wallet's token and gas balances. + pub fn walletBalance(self: *Client) !WalletBalance { + const resp = try self.doRequest(.GET, "/v1/wallet/balance", null) orelse return error.JsonError; + defer self.allocator.free(resp); + return json_helpers.parseWalletBalance(self.allocator, resp); + } + // --- Files --- /// Upload a local file to the network. diff --git a/antd-zig/src/json_helpers.zig b/antd-zig/src/json_helpers.zig index 17b4aac..7bfa235 100644 --- a/antd-zig/src/json_helpers.zig +++ b/antd-zig/src/json_helpers.zig @@ -374,6 +374,44 @@ pub fn buildJsonBody(allocator: Allocator, fields: []const struct { key: []const return buf.toOwnedSlice(allocator); } +/// Parse a WalletAddress from a JSON response body. +pub fn parseWalletAddress(allocator: Allocator, body: []const u8) !models.WalletAddress { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return error.JsonError; + defer parsed.deinit(); + const root = parsed.value; + + const obj = switch (root) { + .object => |o| o, + else => return error.JsonError, + }; + + return .{ + .address = dupeString(allocator, obj.get("address") orelse .null) catch + return error.JsonError, + }; +} + +/// Parse a WalletBalance from a JSON response body. +pub fn parseWalletBalance(allocator: Allocator, body: []const u8) !models.WalletBalance { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return error.JsonError; + defer parsed.deinit(); + const root = parsed.value; + + const obj = switch (root) { + .object => |o| o, + else => return error.JsonError, + }; + + return .{ + .balance = dupeString(allocator, obj.get("balance") orelse .null) catch + return error.JsonError, + .gas_balance = dupeString(allocator, obj.get("gas_balance") orelse .null) catch + return error.JsonError, + }; +} + /// Extract the "error" message from a JSON error response body. pub fn parseErrorMessage(allocator: Allocator, body: []const u8) ?[]const u8 { const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return null; diff --git a/antd-zig/src/models.zig b/antd-zig/src/models.zig index 7d2aecb..dbe57a2 100644 --- a/antd-zig/src/models.zig +++ b/antd-zig/src/models.zig @@ -68,6 +68,26 @@ pub const ArchiveEntry = struct { } }; +/// Result of a wallet address query. +pub const WalletAddress = struct { + address: []const u8, + + pub fn deinit(self: WalletAddress, allocator: Allocator) void { + allocator.free(self.address); + } +}; + +/// Result of a wallet balance query. +pub const WalletBalance = struct { + balance: []const u8, + gas_balance: []const u8, + + pub fn deinit(self: WalletBalance, allocator: Allocator) void { + allocator.free(self.balance); + allocator.free(self.gas_balance); + } +}; + /// A collection of archive entries. pub const Archive = struct { entries: []const ArchiveEntry, diff --git a/antd/src/error.rs b/antd/src/error.rs index f6f6c79..b900f44 100644 --- a/antd/src/error.rs +++ b/antd/src/error.rs @@ -25,6 +25,9 @@ pub enum AntdError { #[error("Timeout: {0}")] Timeout(String), + #[error("Not implemented: {0}")] + NotImplemented(String), + #[error("Internal error: {0}")] Internal(String), } @@ -44,6 +47,7 @@ impl IntoResponse for AntdError { AntdError::Network(_) => StatusCode::BAD_GATEWAY, AntdError::TooLarge => StatusCode::PAYLOAD_TOO_LARGE, AntdError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT, + AntdError::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED, AntdError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, }; let body = serde_json::to_string(&ErrorBody { @@ -64,6 +68,7 @@ impl From for tonic::Status { AntdError::Network(msg) => tonic::Status::unavailable(msg), AntdError::TooLarge => tonic::Status::resource_exhausted("too large for memory"), AntdError::Timeout(msg) => tonic::Status::deadline_exceeded(msg), + AntdError::NotImplemented(msg) => tonic::Status::unimplemented(msg), AntdError::Internal(msg) => tonic::Status::internal(msg), } } diff --git a/antd/src/main.rs b/antd/src/main.rs index a523ea9..f695a97 100644 --- a/antd/src/main.rs +++ b/antd/src/main.rs @@ -167,7 +167,7 @@ async fn main() -> Result<(), Box> { }); // Build REST router - let app = rest::router(state.clone(), config.cors); + let app = rest::router(state.clone(), config.cors, actual_rest_addr.port()); // Spawn both servers let grpc_state = state.clone(); diff --git a/antd/src/rest/mod.rs b/antd/src/rest/mod.rs index d53c5d0..d5db591 100644 --- a/antd/src/rest/mod.rs +++ b/antd/src/rest/mod.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use axum::extract::State; +use axum::http::{HeaderValue, Method}; use axum::routing::{get, head, post}; use axum::{Json, Router}; use tower_http::cors::CorsLayer; @@ -13,8 +14,9 @@ pub mod data; pub mod events; pub mod files; pub mod graph; +pub mod wallet; -pub fn router(state: Arc, enable_cors: bool) -> Router { +pub fn router(state: Arc, enable_cors: bool, rest_port: u16) -> Router { let app = Router::new() // Health .route("/health", get(health)) @@ -42,10 +44,23 @@ pub fn router(state: Arc, enable_cors: bool) -> Router { .route("/v1/archives/public", post(files::archive_put_public)) // Cost .route("/v1/cost/file", post(files::file_cost)) + // Wallet + .route("/v1/wallet/address", get(wallet::wallet_address)) + .route("/v1/wallet/balance", get(wallet::wallet_balance)) .with_state(state); if enable_cors { - app.layer(CorsLayer::permissive()) + // Restrict CORS to the daemon's own localhost origin to prevent + // cross-origin CSRF from malicious webpages. Non-browser clients + // (SDKs, CLI, AI agents) don't send Origin headers so are unaffected. + let origin: HeaderValue = format!("http://127.0.0.1:{rest_port}") + .parse() + .expect("valid origin header"); + let cors = CorsLayer::new() + .allow_origin(origin) + .allow_methods([Method::GET, Method::POST, Method::HEAD, Method::OPTIONS]) + .allow_headers(tower_http::cors::Any); + app.layer(cors) } else { app } diff --git a/antd/src/rest/wallet.rs b/antd/src/rest/wallet.rs new file mode 100644 index 0000000..420f628 --- /dev/null +++ b/antd/src/rest/wallet.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::Json; + +use crate::error::AntdError; +use crate::state::AppState; +use crate::types::*; + +pub async fn wallet_address( + State(state): State>, +) -> Result, AntdError> { + let wallet = state.wallet.as_ref() + .ok_or_else(|| AntdError::BadRequest("no EVM wallet configured".into()))?; + + Ok(Json(WalletAddressResponse { + address: format!("{:#x}", wallet.address()), + })) +} + +pub async fn wallet_balance( + State(state): State>, +) -> Result, AntdError> { + let wallet = state.wallet.as_ref() + .ok_or_else(|| AntdError::BadRequest("no EVM wallet configured".into()))?; + + let balance = wallet.balance_of_tokens().await + .map_err(|e| AntdError::Internal(format!("failed to get token balance: {e}")))?; + + let gas_balance = wallet.balance_of_gas_tokens().await + .map_err(|e| AntdError::Internal(format!("failed to get gas balance: {e}")))?; + + Ok(Json(WalletBalanceResponse { + balance: balance.to_string(), + gas_balance: gas_balance.to_string(), + })) +} diff --git a/antd/src/types.rs b/antd/src/types.rs index feeacb6..c40dde6 100644 --- a/antd/src/types.rs +++ b/antd/src/types.rs @@ -167,6 +167,22 @@ fn default_true() -> bool { true } +// ── Wallet ── + +#[derive(Serialize)] +pub struct WalletBalanceResponse { + /// Token balance in atto (smallest unit). + pub balance: String, + /// Gas token balance in wei. + pub gas_balance: String, +} + +#[derive(Serialize)] +pub struct WalletAddressResponse { + /// The wallet's public address (hex with 0x prefix). + pub address: String, +} + // ── Health ── #[derive(Serialize)] From 22cbbc1e8df92cbac763a809103d652f41dfcff9 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Thu, 26 Mar 2026 09:52:27 +0000 Subject: [PATCH 05/10] Refactor antd to use ant-core Client instead of raw ant-node Replace direct ant-node protocol handling with ant-core's high-level Client API. This eliminates ~200 lines of manual chunk orchestration (quote collection, payment proof building, protocol message encoding) and unblocks future data/file endpoint implementation. Changes: - Cargo.toml: ant-node path dep replaced with ant-core git dep - state.rs: AppState holds ant_core::data::Client instead of raw P2PNode + Wallet - main.rs: Construct Client::from_node().with_wallet() - chunks.rs: chunk_put/chunk_get use Client methods directly - grpc/service.rs: Same refactor for gRPC chunk handlers - error.rs: AntdError::from_core() replaces From - wallet.rs: Access wallet via client.wallet() Zero ant_node references remain in antd source code. Co-Authored-By: Claude Opus 4.6 (1M context) --- antd/Cargo.lock | 231 +++++++++++++++++++++++++++++++++- antd/Cargo.toml | 2 +- antd/src/error.rs | 41 +++--- antd/src/grpc/service.rs | 119 +++--------------- antd/src/main.rs | 64 +++++----- antd/src/rest/chunks.rs | 265 +++------------------------------------ antd/src/rest/wallet.rs | 4 +- antd/src/state.rs | 15 +-- 8 files changed, 320 insertions(+), 421 deletions(-) diff --git a/antd/Cargo.lock b/antd/Cargo.lock index 798f6ae..6e739c4 100644 --- a/antd/Cargo.lock +++ b/antd/Cargo.lock @@ -826,6 +826,48 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ant-core" +version = "0.1.0" +source = "git+https://github.com/WithAutonomi/ant-client#1a90ca8edd66b22b35526f8575440d1e2f10554a" +dependencies = [ + "ant-evm", + "ant-node", + "async-stream", + "axum 0.8.8", + "bytes", + "evmlib", + "flate2", + "fs2", + "futures", + "futures-core", + "futures-util", + "hex", + "libc", + "libp2p", + "lru", + "multihash", + "postcard", + "rand 0.8.5", + "reqwest 0.12.28", + "rmp-serde", + "saorsa-pqc 0.5.0", + "self_encryption", + "serde", + "serde_json", + "tar", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa", + "windows-sys 0.61.2", + "xor_name", + "zip", +] + [[package]] name = "ant-evm" version = "0.1.21" @@ -860,7 +902,9 @@ dependencies = [ [[package]] name = "ant-node" -version = "0.5.0" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9161d53c72cfcc0dd8fee14bff64c0bad06642f074d52c5c91d9ee203acb4042" dependencies = [ "aes-gcm-siv", "ant-evm", @@ -912,8 +956,8 @@ dependencies = [ name = "antd" version = "0.2.0" dependencies = [ + "ant-core", "ant-evm", - "ant-node", "axum 0.8.8", "base64", "blake3", @@ -2541,6 +2585,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -2756,6 +2809,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3268,6 +3336,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -3286,9 +3370,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.2", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -4064,6 +4150,23 @@ dependencies = [ "unsigned-varint 0.7.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.29.0" @@ -4260,12 +4363,50 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -5069,15 +5210,21 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-core", + "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -5088,13 +5235,16 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower 0.5.3", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -5590,7 +5740,7 @@ dependencies = [ "serde_yaml", "slab", "socket2 0.5.10", - "system-configuration", + "system-configuration 0.6.1", "thiserror 2.0.18", "time", "tinyvec", @@ -6160,6 +6310,17 @@ dependencies = [ "system-configuration-sys", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -6353,6 +6514,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6800,6 +6971,30 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", +] + [[package]] name = "uuid" version = "1.21.0" @@ -6818,6 +7013,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -6957,6 +7158,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -7148,6 +7362,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.2.0" diff --git a/antd/Cargo.toml b/antd/Cargo.toml index 8fa86b5..eed9b6f 100644 --- a/antd/Cargo.toml +++ b/antd/Cargo.toml @@ -4,7 +4,7 @@ version = "0.2.0" edition = "2021" [dependencies] -ant-node = { path = "../../ant-node" } +ant-core = { git = "https://github.com/WithAutonomi/ant-client" } ant-evm = "0.1.19" evmlib = "0.4.9" axum = { version = "0.8", features = ["macros"] } diff --git a/antd/src/error.rs b/antd/src/error.rs index b900f44..9ca332a 100644 --- a/antd/src/error.rs +++ b/antd/src/error.rs @@ -32,6 +32,25 @@ pub enum AntdError { Internal(String), } +impl AntdError { + /// Convert an ant-core error into an AntdError. + pub fn from_core(e: ant_core::data::Error) -> Self { + use ant_core::data::Error; + match e { + Error::AlreadyStored => AntdError::AlreadyExists("already stored".into()), + Error::InvalidData(msg) => AntdError::BadRequest(msg), + Error::Payment(msg) => AntdError::Payment(msg), + Error::Network(msg) => AntdError::Network(msg), + Error::Timeout(msg) => AntdError::Timeout(msg), + Error::InsufficientPeers(msg) => AntdError::Network(msg), + Error::Protocol(msg) => AntdError::Internal(msg), + Error::Encryption(msg) => AntdError::Internal(msg), + Error::Serialization(msg) => AntdError::Internal(msg), + other => AntdError::Internal(other.to_string()), + } + } +} + #[derive(Serialize)] struct ErrorBody { error: String, @@ -73,25 +92,3 @@ impl From for tonic::Status { } } } - -// Conversion from ant-node protocol errors - -impl From for AntdError { - fn from(e: ant_node::ant_protocol::ProtocolError) -> Self { - use ant_node::ant_protocol::ProtocolError; - match e { - ProtocolError::ChunkTooLarge { size, max_size } => { - AntdError::BadRequest(format!("chunk size {size} exceeds maximum {max_size}")) - } - ProtocolError::MessageTooLarge { size, max_size } => { - AntdError::BadRequest(format!("message size {size} exceeds maximum {max_size}")) - } - ProtocolError::AddressMismatch { .. } => { - AntdError::BadRequest(format!("address mismatch: {e}")) - } - ProtocolError::PaymentFailed(msg) => AntdError::Payment(msg), - ProtocolError::StorageFailed(msg) => AntdError::Internal(msg), - other => AntdError::Internal(other.to_string()), - } - } -} diff --git a/antd/src/grpc/service.rs b/antd/src/grpc/service.rs index c02c135..a9723f9 100644 --- a/antd/src/grpc/service.rs +++ b/antd/src/grpc/service.rs @@ -1,7 +1,9 @@ use std::sync::Arc; +use bytes::Bytes; use tonic::{Request, Response, Status}; +use crate::error::AntdError; use crate::state::AppState; // Generated protobuf modules @@ -11,7 +13,7 @@ pub mod pb { } fn not_implemented(op: &str) -> Status { - Status::unimplemented(format!("{op} not yet implemented for ant-node")) + Status::unimplemented(format!("{op} not yet implemented")) } // ── HealthService ── @@ -83,53 +85,13 @@ impl pb::chunk_service_server::ChunkService for ChunkServiceImpl { .try_into() .map_err(|_| Status::invalid_argument("address must be 32 bytes"))?; - // Use the same chunk_get logic as REST - // TODO: Factor out shared chunk client logic - use ant_node::ant_protocol::{ - ChunkGetRequest as ProtoGetReq, ChunkGetResponse as ProtoGetResp, - ChunkMessage, ChunkMessageBody, - }; - use ant_node::client::send_and_await_chunk_response; - use std::time::Duration; + let chunk = self.state.client.chunk_get(&address).await + .map_err(|e| tonic::Status::from(AntdError::from_core(e)))? + .ok_or_else(|| Status::not_found("chunk not found"))?; - let connected_peers = self.state.node.connected_peers().await; - let peer_id = connected_peers.first() - .ok_or_else(|| Status::unavailable("not connected to any peers"))?; - let peer_addrs: Vec<_> = self.state.bootstrap_peers.clone(); - - let request_id = rand::random::(); - let msg = ChunkMessage { - request_id, - body: ChunkMessageBody::GetRequest(ProtoGetReq::new(address)), - }; - let msg_bytes = msg.encode() - .map_err(|e| Status::internal(format!("encode error: {e}")))?; - - let content: Vec = send_and_await_chunk_response( - &self.state.node, - peer_id, - msg_bytes, - request_id, - Duration::from_secs(30), - &peer_addrs, - |body| match body { - ChunkMessageBody::GetResponse(ProtoGetResp::Success { content, .. }) => { - Some(Ok(content)) - } - ChunkMessageBody::GetResponse(ProtoGetResp::NotFound { .. }) => { - Some(Err(Status::not_found("chunk not found"))) - } - ChunkMessageBody::GetResponse(ProtoGetResp::Error(e)) => { - Some(Err(Status::internal(e.to_string()))) - } - _ => None, - }, - |e| Status::unavailable(format!("failed to send: {e}")), - || Status::deadline_exceeded("chunk get timed out"), - ) - .await?; - - Ok(Response::new(pb::GetChunkResponse { data: content })) + Ok(Response::new(pb::GetChunkResponse { + data: chunk.content.to_vec(), + })) } async fn put( @@ -138,64 +100,19 @@ impl pb::chunk_service_server::ChunkService for ChunkServiceImpl { ) -> Result, Status> { let data = request.into_inner().data; - use ant_node::ant_protocol::{ - ChunkMessage, ChunkMessageBody, MAX_CHUNK_SIZE, - ChunkPutRequest as ProtoPutReq, ChunkPutResponse as ProtoPutResp, - }; - use ant_node::client::{compute_address, send_and_await_chunk_response}; - use std::time::Duration; - - if data.len() > MAX_CHUNK_SIZE { - return Err(Status::invalid_argument(format!( - "chunk size {} exceeds maximum {MAX_CHUNK_SIZE}", data.len() - ))); + if self.state.client.wallet().is_none() { + return Err(Status::failed_precondition( + "no EVM wallet configured — set AUTONOMI_WALLET_KEY", + )); } - let address = compute_address(&data); - - let connected_peers = self.state.node.connected_peers().await; - let peer_id = connected_peers.first() - .ok_or_else(|| Status::unavailable("not connected to any peers"))?; - let peer_addrs: Vec<_> = self.state.bootstrap_peers.clone(); - - let request_id = rand::random::(); - let msg = ChunkMessage { - request_id, - body: ChunkMessageBody::PutRequest(ProtoPutReq::new(address, data)), - }; - let msg_bytes = msg.encode() - .map_err(|e| Status::internal(format!("encode error: {e}")))?; - - let result_address: [u8; 32] = send_and_await_chunk_response( - &self.state.node, - peer_id, - msg_bytes, - request_id, - Duration::from_secs(30), - &peer_addrs, - |body| match body { - ChunkMessageBody::PutResponse(ProtoPutResp::Success { address }) => { - Some(Ok(address)) - } - ChunkMessageBody::PutResponse(ProtoPutResp::AlreadyExists { address }) => { - Some(Ok(address)) - } - ChunkMessageBody::PutResponse(ProtoPutResp::PaymentRequired { message }) => { - Some(Err(Status::failed_precondition(message))) - } - ChunkMessageBody::PutResponse(ProtoPutResp::Error(e)) => { - Some(Err(Status::internal(e.to_string()))) - } - _ => None, - }, - |e| Status::unavailable(format!("failed to send: {e}")), - || Status::deadline_exceeded("chunk put timed out"), - ) - .await?; + let content = Bytes::from(data); + let address = self.state.client.chunk_put(content).await + .map_err(|e| tonic::Status::from(AntdError::from_core(e)))?; Ok(Response::new(pb::PutChunkResponse { - cost: Some(pb::Cost { atto_tokens: "0".into() }), - address: hex::encode(result_address), + cost: Some(pb::Cost { atto_tokens: String::new() }), + address: hex::encode(address), })) } } diff --git a/antd/src/main.rs b/antd/src/main.rs index f695a97..314d2d0 100644 --- a/antd/src/main.rs +++ b/antd/src/main.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use clap::Parser; use tracing_subscriber::EnvFilter; -use ant_node::core::{CoreNodeConfig, MultiAddr, NodeMode, P2PNode}; +use ant_core::data::{ + Client, ClientConfig, CoreNodeConfig, MultiAddr, NodeMode, P2PNode, Wallet, +}; mod config; mod error; @@ -126,44 +128,42 @@ async fn main() -> Result<(), Box> { let peers = node.connected_peers().await; tracing::info!(count = peers.len(), peers = ?peers, "peer status at startup"); + // Build ant-core Client from the P2P node + let node = Arc::new(node); + let mut client = Client::from_node(node, ClientConfig::default()); + // Load EVM wallet if configured - let wallet = match std::env::var("AUTONOMI_WALLET_KEY") { - Ok(wallet_key) => { - let rpc_url = std::env::var("EVM_RPC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:8545".to_string()); - let token_addr = std::env::var("EVM_PAYMENT_TOKEN_ADDRESS") - .unwrap_or_default(); - let payments_addr = std::env::var("EVM_DATA_PAYMENTS_ADDRESS") - .unwrap_or_default(); - tracing::info!(%rpc_url, "loading EVM wallet..."); - let network = evmlib::Network::new_custom( - &rpc_url, - &token_addr, - &payments_addr, - None, - ); - match evmlib::wallet::Wallet::new_from_private_key(network, &wallet_key) { - Ok(w) => { - tracing::info!(address = %w.address(), "EVM wallet loaded"); - Some(w) - } - Err(e) => { - tracing::warn!("failed to load EVM wallet: {e}"); - None - } + if let Ok(wallet_key) = std::env::var("AUTONOMI_WALLET_KEY") { + let rpc_url = std::env::var("EVM_RPC_URL") + .unwrap_or_else(|_| "http://127.0.0.1:8545".to_string()); + let token_addr = std::env::var("EVM_PAYMENT_TOKEN_ADDRESS") + .unwrap_or_default(); + let payments_addr = std::env::var("EVM_DATA_PAYMENTS_ADDRESS") + .unwrap_or_default(); + tracing::info!(%rpc_url, "loading EVM wallet..."); + let network = evmlib::Network::new_custom( + &rpc_url, + &token_addr, + &payments_addr, + None, + ); + match Wallet::new_from_private_key(network, &wallet_key) { + Ok(w) => { + tracing::info!(address = %w.address(), "EVM wallet loaded"); + client = client.with_wallet(w); + } + Err(e) => { + tracing::warn!("failed to load EVM wallet: {e}"); } } - Err(_) => { - tracing::info!("no AUTONOMI_WALLET_KEY set — write operations will fail"); - None - } - }; + } else { + tracing::info!("no AUTONOMI_WALLET_KEY set — write operations will fail"); + } let state = Arc::new(AppState { - node: Arc::new(node), + client, network: config.network.clone(), bootstrap_peers, - wallet, }); // Build REST router diff --git a/antd/src/rest/chunks.rs b/antd/src/rest/chunks.rs index fb80cb4..8560869 100644 --- a/antd/src/rest/chunks.rs +++ b/antd/src/rest/chunks.rs @@ -1,62 +1,15 @@ use std::sync::Arc; -use std::time::Duration; use axum::extract::{Path, State}; use axum::Json; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; - -use ant_node::ant_protocol::{ - ChunkGetRequest, ChunkGetResponse as ProtoGetResponse, - ChunkMessage, ChunkMessageBody, - ChunkPutRequest as ProtoPutRequest, ChunkPutResponse as ProtoPutResponse, - ChunkQuoteRequest, ChunkQuoteResponse as ProtoQuoteResponse, - MAX_CHUNK_SIZE, -}; -use ant_node::client::compute_address; -use ant_node::payment::single_node::REQUIRED_QUOTES; -use ant_node::client::send_and_await_chunk_response; +use bytes::Bytes; use crate::error::AntdError; use crate::state::AppState; use crate::types::*; -/// Default timeout for chunk operations. -const CHUNK_TIMEOUT: Duration = Duration::from_secs(30); - -/// Find a peer to route a chunk request to. -/// Tries connected peers first, falls back to reconnecting to a bootstrap peer. -async fn find_peer( - state: &AppState, -) -> Result<(ant_node::core::PeerId, Vec), AntdError> { - if state.bootstrap_peers.is_empty() { - return Err(AntdError::Network("no bootstrap peers available".into())); - } - - // Try connected peers first - let connected_peers = state.node.connected_peers().await; - if let Some(peer_id) = connected_peers.first() { - return Ok((peer_id.clone(), state.bootstrap_peers.clone())); - } - - // No connected peers — reconnect to first bootstrap peer - tracing::info!("no connected peers, reconnecting to bootstrap..."); - let peer_addr = &state.bootstrap_peers[0]; - match state.node.connect_peer(peer_addr).await { - Ok(_channel_id) => { - // Wait briefly for connection to register - tokio::time::sleep(Duration::from_millis(200)).await; - let peers = state.node.connected_peers().await; - if let Some(peer_id) = peers.first() { - Ok((peer_id.clone(), state.bootstrap_peers.clone())) - } else { - Err(AntdError::Network("connected but peer not yet registered".into())) - } - } - Err(e) => Err(AntdError::Network(format!("failed to reconnect: {e}"))), - } -} - pub async fn chunk_get( State(state): State>, Path(addr): Path, @@ -67,41 +20,12 @@ pub async fn chunk_get( .try_into() .map_err(|_| AntdError::BadRequest("address must be 32 bytes".into()))?; - let (peer_id, peer_addrs) = find_peer(&state).await?; - let request_id = rand::random::(); - - let msg = ChunkMessage { - request_id, - body: ChunkMessageBody::GetRequest(ChunkGetRequest::new(address)), - }; - let msg_bytes = msg.encode().map_err(AntdError::from)?; - - let content: Vec = send_and_await_chunk_response( - &state.node, - &peer_id, - msg_bytes, - request_id, - CHUNK_TIMEOUT, - &peer_addrs, - |body| match body { - ChunkMessageBody::GetResponse(ProtoGetResponse::Success { content, .. }) => { - Some(Ok(content)) - } - ChunkMessageBody::GetResponse(ProtoGetResponse::NotFound { .. }) => { - Some(Err(AntdError::NotFound("chunk not found".into()))) - } - ChunkMessageBody::GetResponse(ProtoGetResponse::Error(e)) => { - Some(Err(AntdError::from(e))) - } - _ => None, - }, - |e| AntdError::Network(format!("failed to send get request: {e}")), - || AntdError::Timeout("chunk get timed out".into()), - ) - .await?; + let chunk = state.client.chunk_get(&address).await + .map_err(|e| AntdError::from_core(e))? + .ok_or_else(|| AntdError::NotFound("chunk not found".into()))?; Ok(Json(ChunkGetResponse { - data: BASE64.encode(&content), + data: BASE64.encode(&chunk.content), })) } @@ -109,181 +33,22 @@ pub async fn chunk_put( State(state): State>, Json(req): Json, ) -> Result, AntdError> { - let wallet = state.wallet.as_ref() - .ok_or_else(|| AntdError::Payment("no EVM wallet configured — set AUTONOMI_WALLET_KEY".into()))?; + if state.client.wallet().is_none() { + return Err(AntdError::Payment( + "no EVM wallet configured — set AUTONOMI_WALLET_KEY".into(), + )); + } let data = BASE64 .decode(&req.data) .map_err(|e| AntdError::BadRequest(format!("invalid base64: {e}")))?; - if data.len() > MAX_CHUNK_SIZE { - return Err(AntdError::BadRequest(format!( - "chunk size {} exceeds maximum {}", - data.len(), - MAX_CHUNK_SIZE - ))); - } - - let address = compute_address(&data); - - // ── Step 1: Get quotes from 5 peers ── - tracing::info!(addr = hex::encode(address), "requesting storage quotes from 5 peers..."); - - let connected_peers = state.node.connected_peers().await; - if connected_peers.len() < REQUIRED_QUOTES { - // Try reconnecting - for peer_addr in &state.bootstrap_peers { - if state.node.connected_peers().await.len() >= REQUIRED_QUOTES { - break; - } - let _ = state.node.connect_peer(peer_addr).await; - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - - let peers = state.node.connected_peers().await; - if peers.len() < REQUIRED_QUOTES { - return Err(AntdError::Network(format!( - "need {} connected peers for payment, have {}", - REQUIRED_QUOTES, - peers.len() - ))); - } - - let peer_addrs = state.bootstrap_peers.clone(); - let mut quotes_with_prices: Vec<(ant_evm::PaymentQuote, ant_evm::Amount)> = Vec::new(); - let mut quote_peer_ids: Vec = Vec::new(); - - for peer_id in peers.iter().take(REQUIRED_QUOTES) { - let request_id = rand::random::(); - let msg = ChunkMessage { - request_id, - body: ChunkMessageBody::QuoteRequest(ChunkQuoteRequest::new( - address, - data.len() as u64, - )), - }; - let msg_bytes = msg.encode().map_err(AntdError::from)?; - - let (quote_bytes, already_stored): (Vec, bool) = send_and_await_chunk_response( - &state.node, - peer_id, - msg_bytes, - request_id, - CHUNK_TIMEOUT, - &peer_addrs, - |body| match body { - ChunkMessageBody::QuoteResponse(ProtoQuoteResponse::Success { - quote, - already_stored, - }) => Some(Ok((quote, already_stored))), - ChunkMessageBody::QuoteResponse(ProtoQuoteResponse::Error(e)) => { - Some(Err(AntdError::from(e))) - } - _ => None, - }, - |e| AntdError::Network(format!("failed to send quote request: {e}")), - || AntdError::Timeout("quote request timed out".into()), - ) - .await?; - - if already_stored { - tracing::info!(addr = hex::encode(address), "chunk already stored"); - return Ok(Json(ChunkPutResponse { - cost: "0".to_string(), - address: hex::encode(address), - })); - } - - let payment_quote: ant_evm::PaymentQuote = rmp_serde::from_slice("e_bytes) - .map_err(|e| AntdError::Internal(format!("failed to deserialize quote: {e}")))?; - - let price = ant_node::payment::calculate_price(&payment_quote.quoting_metrics); - quotes_with_prices.push((payment_quote, price)); - quote_peer_ids.push(peer_id.clone()); - } - - tracing::info!(addr = hex::encode(address), "got {} quotes", quotes_with_prices.len()); - - // ── Step 2: Create SingleNode payment and pay on-chain ── - // Save the original quote order before SingleNodePayment sorts them - let original_quotes: Vec = quotes_with_prices.iter().map(|(q, _)| q.clone()).collect(); - - let single_payment = ant_node::payment::SingleNodePayment::from_quotes(quotes_with_prices) - .map_err(|e| AntdError::Payment(format!("failed to create payment: {e}")))?; - - let cost = single_payment.total_amount(); - let cost_str = cost.to_string(); - tracing::info!(addr = hex::encode(address), cost = %cost_str, "paying on-chain..."); - - let tx_hashes = single_payment.pay(wallet).await - .map_err(|e| AntdError::Payment(format!("EVM payment failed: {e}")))?; - - tracing::info!(addr = hex::encode(address), cost = %cost_str, "payment submitted"); - - // ── Step 3: Build proof and store chunk ── - // Build ProofOfPayment with all 5 (peer_id, quote) pairs - let mut peer_quotes = Vec::new(); - for (i, quote) in original_quotes.into_iter().enumerate() { - let encoded_peer_id = ant_node::client::hex_node_id_to_encoded_peer_id( - "e_peer_ids[i].to_hex() - ).map_err(|e| AntdError::Internal(format!("failed to encode peer ID: {e}")))?; - peer_quotes.push((encoded_peer_id, quote)); - } - - let payment_proof = ant_node::payment::PaymentProof { - proof_of_payment: ant_evm::ProofOfPayment { peer_quotes }, - tx_hashes, - }; - let proof_bytes = rmp_serde::to_vec(&payment_proof) - .map_err(|e| AntdError::Internal(format!("failed to serialize proof: {e}")))?; - - tracing::info!(addr = hex::encode(address), "storing chunk with payment proof..."); - - // Send PUT to the first peer (who should be one of the 5 closest) - let (put_peer_id, _) = find_peer(&state).await?; - let put_request_id = rand::random::(); - let put_msg = ChunkMessage { - request_id: put_request_id, - body: ChunkMessageBody::PutRequest(ProtoPutRequest::with_payment( - address, - data, - proof_bytes, - )), - }; - let put_msg_bytes = put_msg.encode().map_err(AntdError::from)?; - - let result_address: [u8; 32] = send_and_await_chunk_response( - &state.node, - &put_peer_id, - put_msg_bytes, - put_request_id, - CHUNK_TIMEOUT, - &peer_addrs, - |body| match body { - ChunkMessageBody::PutResponse(ProtoPutResponse::Success { address }) => { - Some(Ok(address)) - } - ChunkMessageBody::PutResponse(ProtoPutResponse::AlreadyExists { address }) => { - Some(Ok(address)) - } - ChunkMessageBody::PutResponse(ProtoPutResponse::PaymentRequired { message }) => { - Some(Err(AntdError::Payment(message))) - } - ChunkMessageBody::PutResponse(ProtoPutResponse::Error(e)) => { - Some(Err(AntdError::from(e))) - } - _ => None, - }, - |e| AntdError::Network(format!("failed to send put request: {e}")), - || AntdError::Timeout("chunk put timed out".into()), - ) - .await?; - - tracing::info!(addr = hex::encode(result_address), cost = %cost_str, "chunk stored successfully"); + let content = Bytes::from(data); + let address = state.client.chunk_put(content).await + .map_err(|e| AntdError::from_core(e))?; Ok(Json(ChunkPutResponse { - cost: cost_str, - address: hex::encode(result_address), + cost: String::new(), // TODO: Client.chunk_put doesn't return cost yet + address: hex::encode(address), })) } diff --git a/antd/src/rest/wallet.rs b/antd/src/rest/wallet.rs index 420f628..e192d71 100644 --- a/antd/src/rest/wallet.rs +++ b/antd/src/rest/wallet.rs @@ -10,7 +10,7 @@ use crate::types::*; pub async fn wallet_address( State(state): State>, ) -> Result, AntdError> { - let wallet = state.wallet.as_ref() + let wallet = state.client.wallet() .ok_or_else(|| AntdError::BadRequest("no EVM wallet configured".into()))?; Ok(Json(WalletAddressResponse { @@ -21,7 +21,7 @@ pub async fn wallet_address( pub async fn wallet_balance( State(state): State>, ) -> Result, AntdError> { - let wallet = state.wallet.as_ref() + let wallet = state.client.wallet() .ok_or_else(|| AntdError::BadRequest("no EVM wallet configured".into()))?; let balance = wallet.balance_of_tokens().await diff --git a/antd/src/state.rs b/antd/src/state.rs index c2d4601..b5fb1c2 100644 --- a/antd/src/state.rs +++ b/antd/src/state.rs @@ -1,16 +1,11 @@ -use std::sync::Arc; - -use ant_node::core::P2PNode; +use ant_core::data::{Client, MultiAddr}; /// Shared application state passed to all handlers. -#[derive(Clone)] pub struct AppState { - /// The Autonomi P2P node in client mode. - pub node: Arc, + /// High-level Autonomi client (wraps P2P node, wallet, cache). + pub client: Client, /// Network mode label ("local", "default", etc.) pub network: String, - /// Bootstrap peer addresses for chunk routing. - pub bootstrap_peers: Vec, - /// EVM wallet for paying storage quotes (optional — not needed for reads). - pub wallet: Option, + /// Bootstrap peer addresses. + pub bootstrap_peers: Vec, } From 45a979b49da7b06cabc14b533d326655327ae2f0 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Thu, 26 Mar 2026 10:30:00 +0000 Subject: [PATCH 06/10] Add payment_mode parameter for merkle batch payments Adds optional payment_mode ("auto", "merkle", "single") to all data and file upload methods across antd and all 15 SDKs. antd types: - DataPutRequest and FileUploadRequest now accept payment_mode - DataPutPublicResponse returns chunks_stored and payment_mode_used - parse_payment_mode/format_payment_mode helpers added Data and file endpoints remain stubs (501 Not Implemented) due to an upstream lifetime issue in ant-core's stream closures that prevents the Client methods from being called in async axum handlers. The types and parameter plumbing are in place for when ant-core is fixed. AppState.client wrapped in Arc for Send + Sync compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- antd-cpp/include/antd/client.hpp | 8 +- antd-cpp/src/client.cpp | 40 ++++++---- antd-csharp/Antd.Sdk/AntdGrpcClient.cs | 8 +- antd-csharp/Antd.Sdk/AntdRestClient.cs | 28 +++++-- antd-csharp/Antd.Sdk/IAntdClient.cs | 8 +- antd-dart/lib/src/client.dart | 32 +++++--- antd-elixir/lib/antd/client.ex | 60 +++++++++----- antd-go/client.go | 52 +++++++++--- .../java/com/autonomi/antd/AntdClient.java | 32 +++++++- antd-js/src/rest-client.ts | 32 ++++---- .../kotlin/com/autonomi/sdk/AntdGrpcClient.kt | 8 +- .../kotlin/com/autonomi/sdk/AntdRestClient.kt | 28 +++++-- .../kotlin/com/autonomi/sdk/IAntdClient.kt | 8 +- antd-lua/src/antd/client.lua | 44 +++++++--- antd-php/src/AntdClient.php | 80 +++++++++++-------- antd-py/src/antd/_rest.py | 56 +++++++++---- antd-ruby/lib/antd/client.rb | 24 ++++-- antd-rust/src/client.rs | 32 ++++++-- .../Sources/AntdSdk/AntdRestClient.swift | 24 ++++-- antd-zig/src/antd.zig | 42 +++++++--- antd-zig/src/json_helpers.zig | 16 ++++ antd/src/main.rs | 2 +- antd/src/rest/data.rs | 36 ++++----- antd/src/rest/files.rs | 19 +++-- antd/src/state.rs | 5 +- antd/src/types.rs | 33 +++++++- 26 files changed, 511 insertions(+), 246 deletions(-) diff --git a/antd-cpp/include/antd/client.hpp b/antd-cpp/include/antd/client.hpp index 5f71c22..58b78f2 100644 --- a/antd-cpp/include/antd/client.hpp +++ b/antd-cpp/include/antd/client.hpp @@ -51,13 +51,13 @@ class Client { // --- Data (Immutable) --- /// Store public immutable data on the network. - PutResult data_put_public(const std::vector& data); + PutResult data_put_public(const std::vector& data, const std::string& payment_mode = ""); /// Retrieve public data by address. std::vector data_get_public(std::string_view address); /// Store private encrypted data on the network. - PutResult data_put_private(const std::vector& data); + PutResult data_put_private(const std::vector& data, const std::string& payment_mode = ""); /// Retrieve private data using a data map. std::vector data_get_private(std::string_view data_map); @@ -93,13 +93,13 @@ class Client { // --- Files & Directories --- /// Upload a local file to the network. - PutResult file_upload_public(std::string_view path); + PutResult file_upload_public(std::string_view path, const std::string& payment_mode = ""); /// Download a file from the network to a local path. void file_download_public(std::string_view address, std::string_view dest_path); /// Upload a local directory to the network. - PutResult dir_upload_public(std::string_view path); + PutResult dir_upload_public(std::string_view path, const std::string& payment_mode = ""); /// Download a directory from the network to a local path. void dir_download_public(std::string_view address, std::string_view dest_path); diff --git a/antd-cpp/src/client.cpp b/antd-cpp/src/client.cpp index a725d6c..00a46cc 100644 --- a/antd-cpp/src/client.cpp +++ b/antd-cpp/src/client.cpp @@ -107,10 +107,12 @@ HealthStatus Client::health() { // Data (Immutable) // --------------------------------------------------------------------------- -PutResult Client::data_put_public(const std::vector& data) { - auto j = impl_->do_json("POST", "/v1/data/public", json{ - {"data", detail::base64_encode(data)}, - }); +PutResult Client::data_put_public(const std::vector& data, const std::string& payment_mode) { + json body = {{"data", detail::base64_encode(data)}}; + if (!payment_mode.empty()) { + body["payment_mode"] = payment_mode; + } + auto j = impl_->do_json("POST", "/v1/data/public", body); return PutResult{ .cost = j.value("cost", ""), .address = j.value("address", ""), @@ -122,10 +124,12 @@ std::vector Client::data_get_public(std::string_view address) { return detail::base64_decode(j.value("data", "")); } -PutResult Client::data_put_private(const std::vector& data) { - auto j = impl_->do_json("POST", "/v1/data/private", json{ - {"data", detail::base64_encode(data)}, - }); +PutResult Client::data_put_private(const std::vector& data, const std::string& payment_mode) { + json body = {{"data", detail::base64_encode(data)}}; + if (!payment_mode.empty()) { + body["payment_mode"] = payment_mode; + } + auto j = impl_->do_json("POST", "/v1/data/private", body); return PutResult{ .cost = j.value("cost", ""), .address = j.value("data_map", ""), @@ -240,10 +244,12 @@ std::string Client::graph_entry_cost(std::string_view public_key) { // Files & Directories // --------------------------------------------------------------------------- -PutResult Client::file_upload_public(std::string_view path) { - auto j = impl_->do_json("POST", "/v1/files/upload/public", json{ - {"path", std::string(path)}, - }); +PutResult Client::file_upload_public(std::string_view path, const std::string& payment_mode) { + json body = {{"path", std::string(path)}}; + if (!payment_mode.empty()) { + body["payment_mode"] = payment_mode; + } + auto j = impl_->do_json("POST", "/v1/files/upload/public", body); return PutResult{ .cost = j.value("cost", ""), .address = j.value("address", ""), @@ -257,10 +263,12 @@ void Client::file_download_public(std::string_view address, std::string_view des }); } -PutResult Client::dir_upload_public(std::string_view path) { - auto j = impl_->do_json("POST", "/v1/dirs/upload/public", json{ - {"path", std::string(path)}, - }); +PutResult Client::dir_upload_public(std::string_view path, const std::string& payment_mode) { + json body = {{"path", std::string(path)}}; + if (!payment_mode.empty()) { + body["payment_mode"] = payment_mode; + } + auto j = impl_->do_json("POST", "/v1/dirs/upload/public", body); return PutResult{ .cost = j.value("cost", ""), .address = j.value("address", ""), diff --git a/antd-csharp/Antd.Sdk/AntdGrpcClient.cs b/antd-csharp/Antd.Sdk/AntdGrpcClient.cs index 0cae715..1f44f71 100644 --- a/antd-csharp/Antd.Sdk/AntdGrpcClient.cs +++ b/antd-csharp/Antd.Sdk/AntdGrpcClient.cs @@ -63,7 +63,7 @@ public async Task HealthAsync() // ── Data ── - public async Task DataPutPublicAsync(byte[] data) + public async Task DataPutPublicAsync(byte[] data, string? paymentMode = null) { try { @@ -83,7 +83,7 @@ public async Task DataGetPublicAsync(string address) catch (RpcException ex) { throw Wrap(ex); } } - public async Task DataPutPrivateAsync(byte[] data) + public async Task DataPutPrivateAsync(byte[] data, string? paymentMode = null) { try { @@ -195,7 +195,7 @@ public async Task GraphEntryCostAsync(string publicKey) // ── Files ── - public async Task FileUploadPublicAsync(string path) + public async Task FileUploadPublicAsync(string path, string? paymentMode = null) { try { @@ -214,7 +214,7 @@ public async Task FileDownloadPublicAsync(string address, string destPath) catch (RpcException ex) { throw Wrap(ex); } } - public async Task DirUploadPublicAsync(string path) + public async Task DirUploadPublicAsync(string path, string? paymentMode = null) { try { diff --git a/antd-csharp/Antd.Sdk/AntdRestClient.cs b/antd-csharp/Antd.Sdk/AntdRestClient.cs index 61dc465..ac40716 100644 --- a/antd-csharp/Antd.Sdk/AntdRestClient.cs +++ b/antd-csharp/Antd.Sdk/AntdRestClient.cs @@ -91,9 +91,12 @@ public async Task HealthAsync() // ── Data ── - public async Task DataPutPublicAsync(byte[] data) + public async Task DataPutPublicAsync(byte[] data, string? paymentMode = null) { - var resp = await PostJsonAsync("/v1/data/public", new { data = Convert.ToBase64String(data) }); + object body = paymentMode != null + ? new { data = Convert.ToBase64String(data), payment_mode = paymentMode } + : new { data = Convert.ToBase64String(data) }; + var resp = await PostJsonAsync("/v1/data/public", body); return new PutResult(resp.Cost, resp.Address); } @@ -103,9 +106,12 @@ public async Task DataGetPublicAsync(string address) return Convert.FromBase64String(resp.Data); } - public async Task DataPutPrivateAsync(byte[] data) + public async Task DataPutPrivateAsync(byte[] data, string? paymentMode = null) { - var resp = await PostJsonAsync("/v1/data/private", new { data = Convert.ToBase64String(data) }); + object body = paymentMode != null + ? new { data = Convert.ToBase64String(data), payment_mode = paymentMode } + : new { data = Convert.ToBase64String(data) }; + var resp = await PostJsonAsync("/v1/data/private", body); return new PutResult(resp.Cost, resp.DataMap); } @@ -167,9 +173,12 @@ public async Task GraphEntryCostAsync(string publicKey) // ── Files ── - public async Task FileUploadPublicAsync(string path) + public async Task FileUploadPublicAsync(string path, string? paymentMode = null) { - var resp = await PostJsonAsync("/v1/files/upload/public", new { path }); + object body = paymentMode != null + ? new { path, payment_mode = paymentMode } + : (object)new { path }; + var resp = await PostJsonAsync("/v1/files/upload/public", body); return new PutResult(resp.Cost, resp.Address); } @@ -178,9 +187,12 @@ public async Task FileDownloadPublicAsync(string address, string destPath) await PostJsonNoResultAsync("/v1/files/download/public", new { address, dest_path = destPath }); } - public async Task DirUploadPublicAsync(string path) + public async Task DirUploadPublicAsync(string path, string? paymentMode = null) { - var resp = await PostJsonAsync("/v1/dirs/upload/public", new { path }); + object body = paymentMode != null + ? new { path, payment_mode = paymentMode } + : (object)new { path }; + var resp = await PostJsonAsync("/v1/dirs/upload/public", body); return new PutResult(resp.Cost, resp.Address); } diff --git a/antd-csharp/Antd.Sdk/IAntdClient.cs b/antd-csharp/Antd.Sdk/IAntdClient.cs index ef1ffdf..c92147c 100644 --- a/antd-csharp/Antd.Sdk/IAntdClient.cs +++ b/antd-csharp/Antd.Sdk/IAntdClient.cs @@ -6,9 +6,9 @@ public interface IAntdClient : IDisposable Task HealthAsync(); // Data - Task DataPutPublicAsync(byte[] data); + Task DataPutPublicAsync(byte[] data, string? paymentMode = null); Task DataGetPublicAsync(string address); - Task DataPutPrivateAsync(byte[] data); + Task DataPutPrivateAsync(byte[] data, string? paymentMode = null); Task DataGetPrivateAsync(string dataMap); Task DataCostAsync(byte[] data); @@ -23,9 +23,9 @@ public interface IAntdClient : IDisposable Task GraphEntryCostAsync(string publicKey); // Files - Task FileUploadPublicAsync(string path); + Task FileUploadPublicAsync(string path, string? paymentMode = null); Task FileDownloadPublicAsync(string address, string destPath); - Task DirUploadPublicAsync(string path); + Task DirUploadPublicAsync(string path, string? paymentMode = null); Task DirDownloadPublicAsync(string address, string destPath); Task ArchiveGetPublicAsync(string address); Task ArchivePutPublicAsync(Archive archive); diff --git a/antd-dart/lib/src/client.dart b/antd-dart/lib/src/client.dart index f74b619..1fae92a 100644 --- a/antd-dart/lib/src/client.dart +++ b/antd-dart/lib/src/client.dart @@ -143,10 +143,12 @@ class AntdClient { // --- Data --- /// Stores public immutable data on the network. - Future dataPutPublic(Uint8List data) async { - final json = await _doJson('POST', '/v1/data/public', { + Future dataPutPublic(Uint8List data, {String? paymentMode}) async { + final body = { 'data': _b64Encode(data), - }); + }; + if (paymentMode != null) body['payment_mode'] = paymentMode; + final json = await _doJson('POST', '/v1/data/public', body); return PutResult.fromJson(json!); } @@ -157,10 +159,12 @@ class AntdClient { } /// Stores private encrypted data on the network. - Future dataPutPrivate(Uint8List data) async { - final json = await _doJson('POST', '/v1/data/private', { + Future dataPutPrivate(Uint8List data, {String? paymentMode}) async { + final body = { 'data': _b64Encode(data), - }); + }; + if (paymentMode != null) body['payment_mode'] = paymentMode; + final json = await _doJson('POST', '/v1/data/private', body); return PutResult.fromJson(json!, addressKey: 'data_map'); } @@ -242,10 +246,12 @@ class AntdClient { // --- Files --- /// Uploads a local file to the network. - Future fileUploadPublic(String path) async { - final json = await _doJson('POST', '/v1/files/upload/public', { + Future fileUploadPublic(String path, {String? paymentMode}) async { + final body = { 'path': path, - }); + }; + if (paymentMode != null) body['payment_mode'] = paymentMode; + final json = await _doJson('POST', '/v1/files/upload/public', body); return PutResult.fromJson(json!); } @@ -258,10 +264,12 @@ class AntdClient { } /// Uploads a local directory to the network. - Future dirUploadPublic(String path) async { - final json = await _doJson('POST', '/v1/dirs/upload/public', { + Future dirUploadPublic(String path, {String? paymentMode}) async { + final body = { 'path': path, - }); + }; + if (paymentMode != null) body['payment_mode'] = paymentMode; + final json = await _doJson('POST', '/v1/dirs/upload/public', body); return PutResult.fromJson(json!); } diff --git a/antd-elixir/lib/antd/client.ex b/antd-elixir/lib/antd/client.ex index d5eee9a..362688a 100644 --- a/antd-elixir/lib/antd/client.ex +++ b/antd-elixir/lib/antd/client.ex @@ -89,9 +89,14 @@ defmodule Antd.Client do # --------------------------------------------------------------------------- @doc "Stores public immutable data on the network." - @spec data_put_public(t(), binary()) :: {:ok, Antd.PutResult.t()} | {:error, Exception.t()} - def data_put_public(%__MODULE__{} = client, data) when is_binary(data) do - case do_json(client, :post, "/v1/data/public", %{data: Base.encode64(data)}) do + @spec data_put_public(t(), binary(), keyword()) :: {:ok, Antd.PutResult.t()} | {:error, Exception.t()} + def data_put_public(%__MODULE__{} = client, data, opts \\ []) when is_binary(data) do + payload = %{data: Base.encode64(data)} + payload = case Keyword.get(opts, :payment_mode) do + nil -> payload + mode -> Map.put(payload, :payment_mode, mode) + end + case do_json(client, :post, "/v1/data/public", payload) do {:ok, body} -> {:ok, %Antd.PutResult{cost: body["cost"], address: body["address"]}} @@ -101,8 +106,8 @@ defmodule Antd.Client do end @doc "Like `data_put_public/2` but raises on error." - @spec data_put_public!(t(), binary()) :: Antd.PutResult.t() - def data_put_public!(client, data), do: unwrap!(data_put_public(client, data)) + @spec data_put_public!(t(), binary(), keyword()) :: Antd.PutResult.t() + def data_put_public!(client, data, opts \\ []), do: unwrap!(data_put_public(client, data, opts)) @doc "Retrieves public data by address." @spec data_get_public(t(), String.t()) :: {:ok, binary()} | {:error, Exception.t()} @@ -118,9 +123,14 @@ defmodule Antd.Client do def data_get_public!(client, address), do: unwrap!(data_get_public(client, address)) @doc "Stores private encrypted data on the network." - @spec data_put_private(t(), binary()) :: {:ok, Antd.PutResult.t()} | {:error, Exception.t()} - def data_put_private(%__MODULE__{} = client, data) when is_binary(data) do - case do_json(client, :post, "/v1/data/private", %{data: Base.encode64(data)}) do + @spec data_put_private(t(), binary(), keyword()) :: {:ok, Antd.PutResult.t()} | {:error, Exception.t()} + def data_put_private(%__MODULE__{} = client, data, opts \\ []) when is_binary(data) do + payload = %{data: Base.encode64(data)} + payload = case Keyword.get(opts, :payment_mode) do + nil -> payload + mode -> Map.put(payload, :payment_mode, mode) + end + case do_json(client, :post, "/v1/data/private", payload) do {:ok, body} -> {:ok, %Antd.PutResult{cost: body["cost"], address: body["data_map"]}} @@ -130,8 +140,8 @@ defmodule Antd.Client do end @doc "Like `data_put_private/2` but raises on error." - @spec data_put_private!(t(), binary()) :: Antd.PutResult.t() - def data_put_private!(client, data), do: unwrap!(data_put_private(client, data)) + @spec data_put_private!(t(), binary(), keyword()) :: Antd.PutResult.t() + def data_put_private!(client, data, opts \\ []), do: unwrap!(data_put_private(client, data, opts)) @doc "Retrieves private data using a data map." @spec data_get_private(t(), String.t()) :: {:ok, binary()} | {:error, Exception.t()} @@ -291,9 +301,14 @@ defmodule Antd.Client do # --------------------------------------------------------------------------- @doc "Uploads a local file to the network." - @spec file_upload_public(t(), String.t()) :: {:ok, Antd.PutResult.t()} | {:error, Exception.t()} - def file_upload_public(%__MODULE__{} = client, path) do - case do_json(client, :post, "/v1/files/upload/public", %{path: path}) do + @spec file_upload_public(t(), String.t(), keyword()) :: {:ok, Antd.PutResult.t()} | {:error, Exception.t()} + def file_upload_public(%__MODULE__{} = client, path, opts \\ []) do + payload = %{path: path} + payload = case Keyword.get(opts, :payment_mode) do + nil -> payload + mode -> Map.put(payload, :payment_mode, mode) + end + case do_json(client, :post, "/v1/files/upload/public", payload) do {:ok, body} -> {:ok, %Antd.PutResult{cost: body["cost"], address: body["address"]}} @@ -303,8 +318,8 @@ defmodule Antd.Client do end @doc "Like `file_upload_public/2` but raises on error." - @spec file_upload_public!(t(), String.t()) :: Antd.PutResult.t() - def file_upload_public!(client, path), do: unwrap!(file_upload_public(client, path)) + @spec file_upload_public!(t(), String.t(), keyword()) :: Antd.PutResult.t() + def file_upload_public!(client, path, opts \\ []), do: unwrap!(file_upload_public(client, path, opts)) @doc "Downloads a file from the network to a local path." @spec file_download_public(t(), String.t(), String.t()) :: :ok | {:error, Exception.t()} @@ -322,9 +337,14 @@ defmodule Antd.Client do end @doc "Uploads a local directory to the network." - @spec dir_upload_public(t(), String.t()) :: {:ok, Antd.PutResult.t()} | {:error, Exception.t()} - def dir_upload_public(%__MODULE__{} = client, path) do - case do_json(client, :post, "/v1/dirs/upload/public", %{path: path}) do + @spec dir_upload_public(t(), String.t(), keyword()) :: {:ok, Antd.PutResult.t()} | {:error, Exception.t()} + def dir_upload_public(%__MODULE__{} = client, path, opts \\ []) do + payload = %{path: path} + payload = case Keyword.get(opts, :payment_mode) do + nil -> payload + mode -> Map.put(payload, :payment_mode, mode) + end + case do_json(client, :post, "/v1/dirs/upload/public", payload) do {:ok, body} -> {:ok, %Antd.PutResult{cost: body["cost"], address: body["address"]}} @@ -334,8 +354,8 @@ defmodule Antd.Client do end @doc "Like `dir_upload_public/2` but raises on error." - @spec dir_upload_public!(t(), String.t()) :: Antd.PutResult.t() - def dir_upload_public!(client, path), do: unwrap!(dir_upload_public(client, path)) + @spec dir_upload_public!(t(), String.t(), keyword()) :: Antd.PutResult.t() + def dir_upload_public!(client, path, opts \\ []), do: unwrap!(dir_upload_public(client, path, opts)) @doc "Downloads a directory from the network to a local path." @spec dir_download_public(t(), String.t(), String.t()) :: :ok | {:error, Exception.t()} diff --git a/antd-go/client.go b/antd-go/client.go index d425f2a..69c0aa8 100644 --- a/antd-go/client.go +++ b/antd-go/client.go @@ -192,13 +192,29 @@ func (c *Client) Health(ctx context.Context) (*HealthStatus, error) { }, nil } +// PaymentMode controls how payments are made for storage operations. +type PaymentMode string + +const ( + // PaymentModeAuto lets the server choose the best payment strategy. + PaymentModeAuto PaymentMode = "auto" + // PaymentModeMerkle uses Merkle-based batch payments. + PaymentModeMerkle PaymentMode = "merkle" + // PaymentModeSingle uses individual payment per chunk. + PaymentModeSingle PaymentMode = "single" +) + // --- Data --- // DataPutPublic stores public immutable data on the network. -func (c *Client) DataPutPublic(ctx context.Context, data []byte) (*PutResult, error) { - j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/data/public", map[string]any{ +func (c *Client) DataPutPublic(ctx context.Context, data []byte, paymentMode ...PaymentMode) (*PutResult, error) { + body := map[string]any{ "data": b64Encode(data), - }) + } + if len(paymentMode) > 0 && paymentMode[0] != "" { + body["payment_mode"] = string(paymentMode[0]) + } + j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/data/public", body) if err != nil { return nil, err } @@ -215,10 +231,14 @@ func (c *Client) DataGetPublic(ctx context.Context, address string) ([]byte, err } // DataPutPrivate stores private encrypted data on the network. -func (c *Client) DataPutPrivate(ctx context.Context, data []byte) (*PutResult, error) { - j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/data/private", map[string]any{ +func (c *Client) DataPutPrivate(ctx context.Context, data []byte, paymentMode ...PaymentMode) (*PutResult, error) { + body := map[string]any{ "data": b64Encode(data), - }) + } + if len(paymentMode) > 0 && paymentMode[0] != "" { + body["payment_mode"] = string(paymentMode[0]) + } + j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/data/private", body) if err != nil { return nil, err } @@ -336,10 +356,14 @@ func (c *Client) GraphEntryCost(ctx context.Context, publicKey string) (string, // --- Files --- // FileUploadPublic uploads a local file to the network. -func (c *Client) FileUploadPublic(ctx context.Context, path string) (*PutResult, error) { - j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/files/upload/public", map[string]any{ +func (c *Client) FileUploadPublic(ctx context.Context, path string, paymentMode ...PaymentMode) (*PutResult, error) { + body := map[string]any{ "path": path, - }) + } + if len(paymentMode) > 0 && paymentMode[0] != "" { + body["payment_mode"] = string(paymentMode[0]) + } + j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/files/upload/public", body) if err != nil { return nil, err } @@ -356,10 +380,14 @@ func (c *Client) FileDownloadPublic(ctx context.Context, address, destPath strin } // DirUploadPublic uploads a local directory to the network. -func (c *Client) DirUploadPublic(ctx context.Context, path string) (*PutResult, error) { - j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/dirs/upload/public", map[string]any{ +func (c *Client) DirUploadPublic(ctx context.Context, path string, paymentMode ...PaymentMode) (*PutResult, error) { + body := map[string]any{ "path": path, - }) + } + if len(paymentMode) > 0 && paymentMode[0] != "" { + body["payment_mode"] = string(paymentMode[0]) + } + j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/dirs/upload/public", body) if err != nil { return nil, err } diff --git a/antd-java/src/main/java/com/autonomi/antd/AntdClient.java b/antd-java/src/main/java/com/autonomi/antd/AntdClient.java index de41c69..aa5dc53 100644 --- a/antd-java/src/main/java/com/autonomi/antd/AntdClient.java +++ b/antd-java/src/main/java/com/autonomi/antd/AntdClient.java @@ -198,7 +198,13 @@ public HealthStatus health() { // ── Data (Immutable) ── public PutResult dataPutPublic(byte[] data) { - String body = Json.object("data", b64Encode(data)); + return dataPutPublic(data, null); + } + + public PutResult dataPutPublic(byte[] data, String paymentMode) { + String body = paymentMode != null + ? Json.object("data", b64Encode(data), "payment_mode", paymentMode) + : Json.object("data", b64Encode(data)); Map j = doJson("POST", "/v1/data/public", body); return new PutResult(str(j, "cost"), str(j, "address")); } @@ -209,7 +215,13 @@ public byte[] dataGetPublic(String address) { } public PutResult dataPutPrivate(byte[] data) { - String body = Json.object("data", b64Encode(data)); + return dataPutPrivate(data, null); + } + + public PutResult dataPutPrivate(byte[] data, String paymentMode) { + String body = paymentMode != null + ? Json.object("data", b64Encode(data), "payment_mode", paymentMode) + : Json.object("data", b64Encode(data)); Map j = doJson("POST", "/v1/data/private", body); return new PutResult(str(j, "cost"), str(j, "data_map")); } @@ -283,7 +295,13 @@ public String graphEntryCost(String publicKey) { // ── Files & Directories ── public PutResult fileUploadPublic(String path) { - String body = Json.object("path", path); + return fileUploadPublic(path, null); + } + + public PutResult fileUploadPublic(String path, String paymentMode) { + String body = paymentMode != null + ? Json.object("path", path, "payment_mode", paymentMode) + : Json.object("path", path); Map j = doJson("POST", "/v1/files/upload/public", body); return new PutResult(str(j, "cost"), str(j, "address")); } @@ -294,7 +312,13 @@ public void fileDownloadPublic(String address, String destPath) { } public PutResult dirUploadPublic(String path) { - String body = Json.object("path", path); + return dirUploadPublic(path, null); + } + + public PutResult dirUploadPublic(String path, String paymentMode) { + String body = paymentMode != null + ? Json.object("path", path, "payment_mode", paymentMode) + : Json.object("path", path); Map j = doJson("POST", "/v1/dirs/upload/public", body); return new PutResult(str(j, "cost"), str(j, "address")); } diff --git a/antd-js/src/rest-client.ts b/antd-js/src/rest-client.ts index 9006cf7..60e82d1 100644 --- a/antd-js/src/rest-client.ts +++ b/antd-js/src/rest-client.ts @@ -128,10 +128,10 @@ export class RestClient { // ---- Data ---- - async dataPutPublic(data: Buffer): Promise { - const j = await this.postJson<{ cost: string; address: string }>("/v1/data/public", { - data: RestClient.b64(data), - }); + async dataPutPublic(data: Buffer, options?: { paymentMode?: string }): Promise { + const body: Record = { data: RestClient.b64(data) }; + if (options?.paymentMode) body.payment_mode = options.paymentMode; + const j = await this.postJson<{ cost: string; address: string }>("/v1/data/public", body); return { cost: j.cost, address: j.address }; } @@ -140,10 +140,10 @@ export class RestClient { return RestClient.unb64(j.data); } - async dataPutPrivate(data: Buffer): Promise { - const j = await this.postJson<{ cost: string; data_map: string }>("/v1/data/private", { - data: RestClient.b64(data), - }); + async dataPutPrivate(data: Buffer, options?: { paymentMode?: string }): Promise { + const body: Record = { data: RestClient.b64(data) }; + if (options?.paymentMode) body.payment_mode = options.paymentMode; + const j = await this.postJson<{ cost: string; data_map: string }>("/v1/data/private", body); return { cost: j.cost, address: j.data_map }; } @@ -224,10 +224,10 @@ export class RestClient { // ---- Files ---- - async fileUploadPublic(path: string): Promise { - const j = await this.postJson<{ cost: string; address: string }>("/v1/files/upload/public", { - path, - }); + async fileUploadPublic(path: string, options?: { paymentMode?: string }): Promise { + const body: Record = { path }; + if (options?.paymentMode) body.payment_mode = options.paymentMode; + const j = await this.postJson<{ cost: string; address: string }>("/v1/files/upload/public", body); return { cost: j.cost, address: j.address }; } @@ -238,10 +238,10 @@ export class RestClient { }); } - async dirUploadPublic(path: string): Promise { - const j = await this.postJson<{ cost: string; address: string }>("/v1/dirs/upload/public", { - path, - }); + async dirUploadPublic(path: string, options?: { paymentMode?: string }): Promise { + const body: Record = { path }; + if (options?.paymentMode) body.payment_mode = options.paymentMode; + const j = await this.postJson<{ cost: string; address: string }>("/v1/dirs/upload/public", body); return { cost: j.cost, address: j.address }; } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt index 9904687..3219427 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdGrpcClient.kt @@ -49,7 +49,7 @@ class AntdGrpcClient(target: String = "localhost:50051") : IAntdClient { // ── Data ── - override suspend fun dataPutPublic(data: ByteArray): PutResult = try { + override suspend fun dataPutPublic(data: ByteArray, paymentMode: String?): PutResult = try { val resp = dataStub.putPublic(putPublicDataRequest { this.data = ByteString.copyFrom(data) }) PutResult(resp.cost.attoTokens, resp.address) } catch (ex: StatusRuntimeException) { throw wrap(ex) } @@ -59,7 +59,7 @@ class AntdGrpcClient(target: String = "localhost:50051") : IAntdClient { resp.data.toByteArray() } catch (ex: StatusRuntimeException) { throw wrap(ex) } - override suspend fun dataPutPrivate(data: ByteArray): PutResult = try { + override suspend fun dataPutPrivate(data: ByteArray, paymentMode: String?): PutResult = try { val resp = dataStub.putPrivate(putPrivateDataRequest { this.data = ByteString.copyFrom(data) }) PutResult(resp.cost.attoTokens, resp.dataMap) } catch (ex: StatusRuntimeException) { throw wrap(ex) } @@ -125,7 +125,7 @@ class AntdGrpcClient(target: String = "localhost:50051") : IAntdClient { // ── Files ── - override suspend fun fileUploadPublic(path: String): PutResult = try { + override suspend fun fileUploadPublic(path: String, paymentMode: String?): PutResult = try { val resp = fileStub.uploadPublic(uploadFileRequest { this.path = path }) PutResult(resp.cost.attoTokens, resp.address) } catch (ex: StatusRuntimeException) { throw wrap(ex) } @@ -135,7 +135,7 @@ class AntdGrpcClient(target: String = "localhost:50051") : IAntdClient { Unit } catch (ex: StatusRuntimeException) { throw wrap(ex) } - override suspend fun dirUploadPublic(path: String): PutResult = try { + override suspend fun dirUploadPublic(path: String, paymentMode: String?): PutResult = try { val resp = fileStub.dirUploadPublic(uploadFileRequest { this.path = path }) PutResult(resp.cost.attoTokens, resp.address) } catch (ex: StatusRuntimeException) { throw wrap(ex) } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt index afa1039..c151693 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt @@ -103,8 +103,11 @@ class AntdRestClient( // ── Data ── - override suspend fun dataPutPublic(data: ByteArray): PutResult { - val body = buildJsonObject { put("data", b64(data)) }.toString() + override suspend fun dataPutPublic(data: ByteArray, paymentMode: String?): PutResult { + val body = buildJsonObject { + put("data", b64(data)) + if (paymentMode != null) put("payment_mode", paymentMode) + }.toString() val resp = postJson("/v1/data/public", body) return PutResult(resp.cost, resp.address) } @@ -114,8 +117,11 @@ class AntdRestClient( return fromB64(resp.data) } - override suspend fun dataPutPrivate(data: ByteArray): PutResult { - val body = buildJsonObject { put("data", b64(data)) }.toString() + override suspend fun dataPutPrivate(data: ByteArray, paymentMode: String?): PutResult { + val body = buildJsonObject { + put("data", b64(data)) + if (paymentMode != null) put("payment_mode", paymentMode) + }.toString() val resp = postJson("/v1/data/private", body) return PutResult(resp.cost, resp.dataMap) } @@ -185,8 +191,11 @@ class AntdRestClient( // ── Files ── - override suspend fun fileUploadPublic(path: String): PutResult { - val body = buildJsonObject { put("path", path) }.toString() + override suspend fun fileUploadPublic(path: String, paymentMode: String?): PutResult { + val body = buildJsonObject { + put("path", path) + if (paymentMode != null) put("payment_mode", paymentMode) + }.toString() val resp = postJson("/v1/files/upload/public", body) return PutResult(resp.cost, resp.address) } @@ -199,8 +208,11 @@ class AntdRestClient( postJsonNoResult("/v1/files/download/public", body) } - override suspend fun dirUploadPublic(path: String): PutResult { - val body = buildJsonObject { put("path", path) }.toString() + override suspend fun dirUploadPublic(path: String, paymentMode: String?): PutResult { + val body = buildJsonObject { + put("path", path) + if (paymentMode != null) put("payment_mode", paymentMode) + }.toString() val resp = postJson("/v1/dirs/upload/public", body) return PutResult(resp.cost, resp.address) } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt index 8a22f90..e2d50f3 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt @@ -14,9 +14,9 @@ interface IAntdClient : Closeable { suspend fun health(): HealthStatus // Data - suspend fun dataPutPublic(data: ByteArray): PutResult + suspend fun dataPutPublic(data: ByteArray, paymentMode: String? = null): PutResult suspend fun dataGetPublic(address: String): ByteArray - suspend fun dataPutPrivate(data: ByteArray): PutResult + suspend fun dataPutPrivate(data: ByteArray, paymentMode: String? = null): PutResult suspend fun dataGetPrivate(dataMap: String): ByteArray suspend fun dataCost(data: ByteArray): String @@ -31,9 +31,9 @@ interface IAntdClient : Closeable { suspend fun graphEntryCost(publicKey: String): String // Files - suspend fun fileUploadPublic(path: String): PutResult + suspend fun fileUploadPublic(path: String, paymentMode: String? = null): PutResult suspend fun fileDownloadPublic(address: String, destPath: String) - suspend fun dirUploadPublic(path: String): PutResult + suspend fun dirUploadPublic(path: String, paymentMode: String? = null): PutResult suspend fun dirDownloadPublic(address: String, destPath: String) suspend fun archiveGetPublic(address: String): Archive suspend fun archivePutPublic(archive: Archive): PutResult diff --git a/antd-lua/src/antd/client.lua b/antd-lua/src/antd/client.lua index bb0eb6a..da4644b 100644 --- a/antd-lua/src/antd/client.lua +++ b/antd-lua/src/antd/client.lua @@ -147,11 +147,16 @@ end --- Store public immutable data. -- @param data string raw bytes to store +-- @param opts table optional settings: { payment_mode = string } -- @return PutResult|nil, error|nil -function Client:data_put_public(data) - local j, _, err = self:_do_json("POST", "/v1/data/public", { +function Client:data_put_public(data, opts) + local body = { data = base64.encode(data), - }) + } + if opts and opts.payment_mode then + body.payment_mode = opts.payment_mode + end + local j, _, err = self:_do_json("POST", "/v1/data/public", body) if err then return nil, err end return models.new_put_result(str(j, "cost"), str(j, "address")), nil end @@ -167,11 +172,16 @@ end --- Store private encrypted data. -- @param data string raw bytes to store +-- @param opts table optional settings: { payment_mode = string } -- @return PutResult|nil, error|nil -function Client:data_put_private(data) - local j, _, err = self:_do_json("POST", "/v1/data/private", { +function Client:data_put_private(data, opts) + local body = { data = base64.encode(data), - }) + } + if opts and opts.payment_mode then + body.payment_mode = opts.payment_mode + end + local j, _, err = self:_do_json("POST", "/v1/data/private", body) if err then return nil, err end return models.new_put_result(str(j, "cost"), str(j, "data_map")), nil end @@ -297,11 +307,16 @@ end --- Upload a file to the network. -- @param path string local file path +-- @param opts table optional settings: { payment_mode = string } -- @return PutResult|nil, error|nil -function Client:file_upload_public(path) - local j, _, err = self:_do_json("POST", "/v1/files/upload/public", { +function Client:file_upload_public(path, opts) + local body = { path = path, - }) + } + if opts and opts.payment_mode then + body.payment_mode = opts.payment_mode + end + local j, _, err = self:_do_json("POST", "/v1/files/upload/public", body) if err then return nil, err end return models.new_put_result(str(j, "cost"), str(j, "address")), nil end @@ -320,11 +335,16 @@ end --- Upload a directory to the network. -- @param path string local directory path +-- @param opts table optional settings: { payment_mode = string } -- @return PutResult|nil, error|nil -function Client:dir_upload_public(path) - local j, _, err = self:_do_json("POST", "/v1/dirs/upload/public", { +function Client:dir_upload_public(path, opts) + local body = { path = path, - }) + } + if opts and opts.payment_mode then + body.payment_mode = opts.payment_mode + end + local j, _, err = self:_do_json("POST", "/v1/dirs/upload/public", body) if err then return nil, err end return models.new_put_result(str(j, "cost"), str(j, "address")), nil end diff --git a/antd-php/src/AntdClient.php b/antd-php/src/AntdClient.php index e70776d..65e795d 100644 --- a/antd-php/src/AntdClient.php +++ b/antd-php/src/AntdClient.php @@ -211,11 +211,13 @@ public function healthAsync(): PromiseInterface /** * Store public immutable data on the network. */ - public function dataPutPublic(string $data): PutResult + public function dataPutPublic(string $data, ?string $paymentMode = null): PutResult { - $json = $this->doJson('POST', '/v1/data/public', [ - 'data' => $this->b64Encode($data), - ]); + $body = ['data' => $this->b64Encode($data)]; + if ($paymentMode !== null) { + $body['payment_mode'] = $paymentMode; + } + $json = $this->doJson('POST', '/v1/data/public', $body); return new PutResult( cost: $json['cost'] ?? '', address: $json['address'] ?? '', @@ -227,11 +229,13 @@ public function dataPutPublic(string $data): PutResult * * @return PromiseInterface */ - public function dataPutPublicAsync(string $data): PromiseInterface + public function dataPutPublicAsync(string $data, ?string $paymentMode = null): PromiseInterface { - return $this->doJsonAsync('POST', '/v1/data/public', [ - 'data' => $this->b64Encode($data), - ])->then( + $body = ['data' => $this->b64Encode($data)]; + if ($paymentMode !== null) { + $body['payment_mode'] = $paymentMode; + } + return $this->doJsonAsync('POST', '/v1/data/public', $body)->then( fn(?array $json) => new PutResult( cost: $json['cost'] ?? '', address: $json['address'] ?? '', @@ -263,11 +267,13 @@ public function dataGetPublicAsync(string $address): PromiseInterface /** * Store private encrypted data on the network. */ - public function dataPutPrivate(string $data): PutResult + public function dataPutPrivate(string $data, ?string $paymentMode = null): PutResult { - $json = $this->doJson('POST', '/v1/data/private', [ - 'data' => $this->b64Encode($data), - ]); + $body = ['data' => $this->b64Encode($data)]; + if ($paymentMode !== null) { + $body['payment_mode'] = $paymentMode; + } + $json = $this->doJson('POST', '/v1/data/private', $body); return new PutResult( cost: $json['cost'] ?? '', address: $json['data_map'] ?? '', @@ -279,11 +285,13 @@ public function dataPutPrivate(string $data): PutResult * * @return PromiseInterface */ - public function dataPutPrivateAsync(string $data): PromiseInterface + public function dataPutPrivateAsync(string $data, ?string $paymentMode = null): PromiseInterface { - return $this->doJsonAsync('POST', '/v1/data/private', [ - 'data' => $this->b64Encode($data), - ])->then( + $body = ['data' => $this->b64Encode($data)]; + if ($paymentMode !== null) { + $body['payment_mode'] = $paymentMode; + } + return $this->doJsonAsync('POST', '/v1/data/private', $body)->then( fn(?array $json) => new PutResult( cost: $json['cost'] ?? '', address: $json['data_map'] ?? '', @@ -567,11 +575,13 @@ public function graphEntryCostAsync(string $publicKey): PromiseInterface /** * Upload a local file to the network. */ - public function fileUploadPublic(string $path): PutResult + public function fileUploadPublic(string $path, ?string $paymentMode = null): PutResult { - $json = $this->doJson('POST', '/v1/files/upload/public', [ - 'path' => $path, - ]); + $body = ['path' => $path]; + if ($paymentMode !== null) { + $body['payment_mode'] = $paymentMode; + } + $json = $this->doJson('POST', '/v1/files/upload/public', $body); return new PutResult( cost: $json['cost'] ?? '', address: $json['address'] ?? '', @@ -583,11 +593,13 @@ public function fileUploadPublic(string $path): PutResult * * @return PromiseInterface */ - public function fileUploadPublicAsync(string $path): PromiseInterface + public function fileUploadPublicAsync(string $path, ?string $paymentMode = null): PromiseInterface { - return $this->doJsonAsync('POST', '/v1/files/upload/public', [ - 'path' => $path, - ])->then( + $body = ['path' => $path]; + if ($paymentMode !== null) { + $body['payment_mode'] = $paymentMode; + } + return $this->doJsonAsync('POST', '/v1/files/upload/public', $body)->then( fn(?array $json) => new PutResult( cost: $json['cost'] ?? '', address: $json['address'] ?? '', @@ -622,11 +634,13 @@ public function fileDownloadPublicAsync(string $address, string $destPath): Prom /** * Upload a local directory to the network. */ - public function dirUploadPublic(string $path): PutResult + public function dirUploadPublic(string $path, ?string $paymentMode = null): PutResult { - $json = $this->doJson('POST', '/v1/dirs/upload/public', [ - 'path' => $path, - ]); + $body = ['path' => $path]; + if ($paymentMode !== null) { + $body['payment_mode'] = $paymentMode; + } + $json = $this->doJson('POST', '/v1/dirs/upload/public', $body); return new PutResult( cost: $json['cost'] ?? '', address: $json['address'] ?? '', @@ -638,11 +652,13 @@ public function dirUploadPublic(string $path): PutResult * * @return PromiseInterface */ - public function dirUploadPublicAsync(string $path): PromiseInterface + public function dirUploadPublicAsync(string $path, ?string $paymentMode = null): PromiseInterface { - return $this->doJsonAsync('POST', '/v1/dirs/upload/public', [ - 'path' => $path, - ])->then( + $body = ['path' => $path]; + if ($paymentMode !== null) { + $body['payment_mode'] = $paymentMode; + } + return $this->doJsonAsync('POST', '/v1/dirs/upload/public', $body)->then( fn(?array $json) => new PutResult( cost: $json['cost'] ?? '', address: $json['address'] ?? '', diff --git a/antd-py/src/antd/_rest.py b/antd-py/src/antd/_rest.py index 19f64f7..422022d 100644 --- a/antd-py/src/antd/_rest.py +++ b/antd-py/src/antd/_rest.py @@ -83,8 +83,11 @@ def health(self) -> HealthStatus: # --- Data --- - def data_put_public(self, data: bytes) -> PutResult: - resp = self._http.post("/v1/data/public", json={"data": _b64(data)}) + def data_put_public(self, data: bytes, payment_mode: str | None = None) -> PutResult: + body: dict = {"data": _b64(data)} + if payment_mode is not None: + body["payment_mode"] = payment_mode + resp = self._http.post("/v1/data/public", json=body) _check(resp) j = resp.json() return PutResult(cost=j["cost"], address=j["address"]) @@ -94,8 +97,11 @@ def data_get_public(self, address: str) -> bytes: _check(resp) return _unb64(resp.json()["data"]) - def data_put_private(self, data: bytes) -> PutResult: - resp = self._http.post("/v1/data/private", json={"data": _b64(data)}) + def data_put_private(self, data: bytes, payment_mode: str | None = None) -> PutResult: + body: dict = {"data": _b64(data)} + if payment_mode is not None: + body["payment_mode"] = payment_mode + resp = self._http.post("/v1/data/private", json=body) _check(resp) j = resp.json() return PutResult(cost=j["cost"], address=j["data_map"]) @@ -163,8 +169,11 @@ def graph_entry_cost(self, public_key: str) -> str: # --- Files --- - def file_upload_public(self, path: str) -> PutResult: - resp = self._http.post("/v1/files/upload/public", json={"path": path}) + def file_upload_public(self, path: str, payment_mode: str | None = None) -> PutResult: + body: dict = {"path": path} + if payment_mode is not None: + body["payment_mode"] = payment_mode + resp = self._http.post("/v1/files/upload/public", json=body) _check(resp) j = resp.json() return PutResult(cost=j["cost"], address=j["address"]) @@ -176,8 +185,11 @@ def file_download_public(self, address: str, dest_path: str) -> None: }) _check(resp) - def dir_upload_public(self, path: str) -> PutResult: - resp = self._http.post("/v1/dirs/upload/public", json={"path": path}) + def dir_upload_public(self, path: str, payment_mode: str | None = None) -> PutResult: + body: dict = {"path": path} + if payment_mode is not None: + body["payment_mode"] = payment_mode + resp = self._http.post("/v1/dirs/upload/public", json=body) _check(resp) j = resp.json() return PutResult(cost=j["cost"], address=j["address"]) @@ -279,8 +291,11 @@ async def health(self) -> HealthStatus: # --- Data --- - async def data_put_public(self, data: bytes) -> PutResult: - resp = await self._http.post("/v1/data/public", json={"data": _b64(data)}) + async def data_put_public(self, data: bytes, payment_mode: str | None = None) -> PutResult: + body: dict = {"data": _b64(data)} + if payment_mode is not None: + body["payment_mode"] = payment_mode + resp = await self._http.post("/v1/data/public", json=body) _check(resp) j = resp.json() return PutResult(cost=j["cost"], address=j["address"]) @@ -290,8 +305,11 @@ async def data_get_public(self, address: str) -> bytes: _check(resp) return _unb64(resp.json()["data"]) - async def data_put_private(self, data: bytes) -> PutResult: - resp = await self._http.post("/v1/data/private", json={"data": _b64(data)}) + async def data_put_private(self, data: bytes, payment_mode: str | None = None) -> PutResult: + body: dict = {"data": _b64(data)} + if payment_mode is not None: + body["payment_mode"] = payment_mode + resp = await self._http.post("/v1/data/private", json=body) _check(resp) j = resp.json() return PutResult(cost=j["cost"], address=j["data_map"]) @@ -359,8 +377,11 @@ async def graph_entry_cost(self, public_key: str) -> str: # --- Files --- - async def file_upload_public(self, path: str) -> PutResult: - resp = await self._http.post("/v1/files/upload/public", json={"path": path}) + async def file_upload_public(self, path: str, payment_mode: str | None = None) -> PutResult: + body: dict = {"path": path} + if payment_mode is not None: + body["payment_mode"] = payment_mode + resp = await self._http.post("/v1/files/upload/public", json=body) _check(resp) j = resp.json() return PutResult(cost=j["cost"], address=j["address"]) @@ -372,8 +393,11 @@ async def file_download_public(self, address: str, dest_path: str) -> None: }) _check(resp) - async def dir_upload_public(self, path: str) -> PutResult: - resp = await self._http.post("/v1/dirs/upload/public", json={"path": path}) + async def dir_upload_public(self, path: str, payment_mode: str | None = None) -> PutResult: + body: dict = {"path": path} + if payment_mode is not None: + body["payment_mode"] = payment_mode + resp = await self._http.post("/v1/dirs/upload/public", json=body) _check(resp) j = resp.json() return PutResult(cost=j["cost"], address=j["address"]) diff --git a/antd-ruby/lib/antd/client.rb b/antd-ruby/lib/antd/client.rb index b10141a..d65bdd3 100644 --- a/antd-ruby/lib/antd/client.rb +++ b/antd-ruby/lib/antd/client.rb @@ -45,8 +45,10 @@ def health # Store public immutable data on the network. # @param data [String] raw bytes # @return [PutResult] - def data_put_public(data) - j = do_json(:post, "/v1/data/public", { data: b64_encode(data) }) + def data_put_public(data, payment_mode: nil) + body = { data: b64_encode(data) } + body[:payment_mode] = payment_mode if payment_mode + j = do_json(:post, "/v1/data/public", body) PutResult.new(cost: j["cost"], address: j["address"]) end @@ -61,8 +63,10 @@ def data_get_public(address) # Store private encrypted data on the network. # @param data [String] raw bytes # @return [PutResult] - def data_put_private(data) - j = do_json(:post, "/v1/data/private", { data: b64_encode(data) }) + def data_put_private(data, payment_mode: nil) + body = { data: b64_encode(data) } + body[:payment_mode] = payment_mode if payment_mode + j = do_json(:post, "/v1/data/private", body) PutResult.new(cost: j["cost"], address: j["data_map"]) end @@ -159,8 +163,10 @@ def graph_entry_cost(public_key) # Upload a local file to the network. # @param path [String] local file path # @return [PutResult] - def file_upload_public(path) - j = do_json(:post, "/v1/files/upload/public", { path: path }) + def file_upload_public(path, payment_mode: nil) + body = { path: path } + body[:payment_mode] = payment_mode if payment_mode + j = do_json(:post, "/v1/files/upload/public", body) PutResult.new(cost: j["cost"], address: j["address"]) end @@ -176,8 +182,10 @@ def file_download_public(address, dest_path) # Upload a local directory to the network. # @param path [String] local directory path # @return [PutResult] - def dir_upload_public(path) - j = do_json(:post, "/v1/dirs/upload/public", { path: path }) + def dir_upload_public(path, payment_mode: nil) + body = { path: path } + body[:payment_mode] = payment_mode if payment_mode + j = do_json(:post, "/v1/dirs/upload/public", body) PutResult.new(cost: j["cost"], address: j["address"]) end diff --git a/antd-rust/src/client.rs b/antd-rust/src/client.rs index e5b316d..81f73c6 100644 --- a/antd-rust/src/client.rs +++ b/antd-rust/src/client.rs @@ -162,12 +162,16 @@ impl Client { // --- Data --- /// Stores public immutable data on the network. - pub async fn data_put_public(&self, data: &[u8]) -> Result { + pub async fn data_put_public(&self, data: &[u8], payment_mode: Option<&str>) -> Result { + let mut body = json!({ "data": Self::b64_encode(data) }); + if let Some(mode) = payment_mode { + body["payment_mode"] = json!(mode); + } let (j, _) = self .do_json( reqwest::Method::POST, "/v1/data/public", - Some(json!({ "data": Self::b64_encode(data) })), + Some(body), ) .await?; let j = j.unwrap_or_default(); @@ -191,12 +195,16 @@ impl Client { } /// Stores private encrypted data on the network. - pub async fn data_put_private(&self, data: &[u8]) -> Result { + pub async fn data_put_private(&self, data: &[u8], payment_mode: Option<&str>) -> Result { + let mut body = json!({ "data": Self::b64_encode(data) }); + if let Some(mode) = payment_mode { + body["payment_mode"] = json!(mode); + } let (j, _) = self .do_json( reqwest::Method::POST, "/v1/data/private", - Some(json!({ "data": Self::b64_encode(data) })), + Some(body), ) .await?; let j = j.unwrap_or_default(); @@ -367,12 +375,16 @@ impl Client { // --- Files --- /// Uploads a local file to the network. - pub async fn file_upload_public(&self, path: &str) -> Result { + pub async fn file_upload_public(&self, path: &str, payment_mode: Option<&str>) -> Result { + let mut body = json!({ "path": path }); + if let Some(mode) = payment_mode { + body["payment_mode"] = json!(mode); + } let (j, _) = self .do_json( reqwest::Method::POST, "/v1/files/upload/public", - Some(json!({ "path": path })), + Some(body), ) .await?; let j = j.unwrap_or_default(); @@ -398,12 +410,16 @@ impl Client { } /// Uploads a local directory to the network. - pub async fn dir_upload_public(&self, path: &str) -> Result { + pub async fn dir_upload_public(&self, path: &str, payment_mode: Option<&str>) -> Result { + let mut body = json!({ "path": path }); + if let Some(mode) = payment_mode { + body["payment_mode"] = json!(mode); + } let (j, _) = self .do_json( reqwest::Method::POST, "/v1/dirs/upload/public", - Some(json!({ "path": path })), + Some(body), ) .await?; let j = j.unwrap_or_default(); diff --git a/antd-swift/Sources/AntdSdk/AntdRestClient.swift b/antd-swift/Sources/AntdSdk/AntdRestClient.swift index bf50e1e..1122d89 100644 --- a/antd-swift/Sources/AntdSdk/AntdRestClient.swift +++ b/antd-swift/Sources/AntdSdk/AntdRestClient.swift @@ -75,8 +75,10 @@ public final class AntdRestClient: AntdClientProtocol, @unchecked Sendable { // MARK: - Data - public func dataPutPublic(_ data: Data) async throws -> PutResult { - let resp: CostAddressDTO = try await postJSON("/v1/data/public", body: ["data": data.base64EncodedString()]) + public func dataPutPublic(_ data: Data, paymentMode: String? = nil) async throws -> PutResult { + var body: [String: Any] = ["data": data.base64EncodedString()] + if let mode = paymentMode { body["payment_mode"] = mode } + let resp: CostAddressDTO = try await postJSON("/v1/data/public", body: body) return PutResult(cost: resp.cost, address: resp.address) } @@ -86,8 +88,10 @@ public final class AntdRestClient: AntdClientProtocol, @unchecked Sendable { return decoded } - public func dataPutPrivate(_ data: Data) async throws -> PutResult { - let resp: CostDataMapDTO = try await postJSON("/v1/data/private", body: ["data": data.base64EncodedString()]) + public func dataPutPrivate(_ data: Data, paymentMode: String? = nil) async throws -> PutResult { + var body: [String: Any] = ["data": data.base64EncodedString()] + if let mode = paymentMode { body["payment_mode"] = mode } + let resp: CostDataMapDTO = try await postJSON("/v1/data/private", body: body) return PutResult(cost: resp.cost, address: resp.dataMap) } @@ -145,8 +149,10 @@ public final class AntdRestClient: AntdClientProtocol, @unchecked Sendable { // MARK: - Files - public func fileUploadPublic(path: String) async throws -> PutResult { - let resp: CostAddressDTO = try await postJSON("/v1/files/upload/public", body: ["path": path]) + public func fileUploadPublic(path: String, paymentMode: String? = nil) async throws -> PutResult { + var body: [String: Any] = ["path": path] + if let mode = paymentMode { body["payment_mode"] = mode } + let resp: CostAddressDTO = try await postJSON("/v1/files/upload/public", body: body) return PutResult(cost: resp.cost, address: resp.address) } @@ -154,8 +160,10 @@ public final class AntdRestClient: AntdClientProtocol, @unchecked Sendable { try await postJSONNoResult("/v1/files/download/public", body: ["address": address, "dest_path": destPath]) } - public func dirUploadPublic(path: String) async throws -> PutResult { - let resp: CostAddressDTO = try await postJSON("/v1/dirs/upload/public", body: ["path": path]) + public func dirUploadPublic(path: String, paymentMode: String? = nil) async throws -> PutResult { + var body: [String: Any] = ["path": path] + if let mode = paymentMode { body["payment_mode"] = mode } + let resp: CostAddressDTO = try await postJSON("/v1/dirs/upload/public", body: body) return PutResult(cost: resp.cost, address: resp.address) } diff --git a/antd-zig/src/antd.zig b/antd-zig/src/antd.zig index 4151593..09a88cc 100644 --- a/antd-zig/src/antd.zig +++ b/antd-zig/src/antd.zig @@ -180,8 +180,11 @@ pub const Client = struct { // --- Data --- /// Store public immutable data on the network. - pub fn dataPutPublic(self: *Client, data: []const u8) !PutResult { - const req_body = try json_helpers.buildDataBody(self.allocator, data); + pub fn dataPutPublic(self: *Client, data: []const u8, payment_mode: ?[]const u8) !PutResult { + const req_body = if (payment_mode) |mode| + try json_helpers.buildDataBodyWithPaymentMode(self.allocator, data, mode) + else + try json_helpers.buildDataBody(self.allocator, data); defer self.allocator.free(req_body); const resp = try self.doRequest(.POST, "/v1/data/public", req_body) orelse return error.JsonError; defer self.allocator.free(resp); @@ -198,8 +201,11 @@ pub const Client = struct { } /// Store private encrypted data on the network. - pub fn dataPutPrivate(self: *Client, data: []const u8) !PutResult { - const req_body = try json_helpers.buildDataBody(self.allocator, data); + pub fn dataPutPrivate(self: *Client, data: []const u8, payment_mode: ?[]const u8) !PutResult { + const req_body = if (payment_mode) |mode| + try json_helpers.buildDataBodyWithPaymentMode(self.allocator, data, mode) + else + try json_helpers.buildDataBody(self.allocator, data); defer self.allocator.free(req_body); const resp = try self.doRequest(.POST, "/v1/data/private", req_body) orelse return error.JsonError; defer self.allocator.free(resp); @@ -316,10 +322,16 @@ pub const Client = struct { // --- Files --- /// Upload a local file to the network. - pub fn fileUploadPublic(self: *Client, path: []const u8) !PutResult { - const req_body = try json_helpers.buildJsonBody(self.allocator, &.{ - .{ .key = "path", .value = .{ .string = path } }, - }); + pub fn fileUploadPublic(self: *Client, path: []const u8, payment_mode: ?[]const u8) !PutResult { + const req_body = if (payment_mode) |mode| + try json_helpers.buildJsonBody(self.allocator, &.{ + .{ .key = "path", .value = .{ .string = path } }, + .{ .key = "payment_mode", .value = .{ .string = mode } }, + }) + else + try json_helpers.buildJsonBody(self.allocator, &.{ + .{ .key = "path", .value = .{ .string = path } }, + }); defer self.allocator.free(req_body); const resp = try self.doRequest(.POST, "/v1/files/upload/public", req_body) orelse return error.JsonError; defer self.allocator.free(resp); @@ -337,10 +349,16 @@ pub const Client = struct { } /// Upload a local directory to the network. - pub fn dirUploadPublic(self: *Client, path: []const u8) !PutResult { - const req_body = try json_helpers.buildJsonBody(self.allocator, &.{ - .{ .key = "path", .value = .{ .string = path } }, - }); + pub fn dirUploadPublic(self: *Client, path: []const u8, payment_mode: ?[]const u8) !PutResult { + const req_body = if (payment_mode) |mode| + try json_helpers.buildJsonBody(self.allocator, &.{ + .{ .key = "path", .value = .{ .string = path } }, + .{ .key = "payment_mode", .value = .{ .string = mode } }, + }) + else + try json_helpers.buildJsonBody(self.allocator, &.{ + .{ .key = "path", .value = .{ .string = path } }, + }); defer self.allocator.free(req_body); const resp = try self.doRequest(.POST, "/v1/dirs/upload/public", req_body) orelse return error.JsonError; defer self.allocator.free(resp); diff --git a/antd-zig/src/json_helpers.zig b/antd-zig/src/json_helpers.zig index 7bfa235..0ef1a69 100644 --- a/antd-zig/src/json_helpers.zig +++ b/antd-zig/src/json_helpers.zig @@ -290,6 +290,22 @@ pub fn buildDataBody(allocator: Allocator, data: []const u8) ![]const u8 { return std.fmt.allocPrint(allocator, "{{\"data\":{s}}}", .{escaped}) catch return error.JsonError; } +/// Build a JSON body for a data upload with an optional payment_mode field. +pub fn buildDataBodyWithPaymentMode(allocator: Allocator, data: []const u8, payment_mode: []const u8) ![]const u8 { + const encoded_len = std.base64.standard.Encoder.calcSize(data.len); + const encoded = allocator.alloc(u8, encoded_len) catch return error.JsonError; + defer allocator.free(encoded); + _ = std.base64.standard.Encoder.encode(encoded, data); + + const escaped_data = jsonEscapeString(allocator, encoded) catch return error.JsonError; + defer allocator.free(escaped_data); + + const escaped_mode = jsonEscapeString(allocator, payment_mode) catch return error.JsonError; + defer allocator.free(escaped_mode); + + return std.fmt.allocPrint(allocator, "{{\"data\":{s},\"payment_mode\":{s}}}", .{ escaped_data, escaped_mode }) catch return error.JsonError; +} + /// Values supported in JSON body construction. pub const JsonValue = union(enum) { string: []const u8, diff --git a/antd/src/main.rs b/antd/src/main.rs index 314d2d0..24e9604 100644 --- a/antd/src/main.rs +++ b/antd/src/main.rs @@ -161,7 +161,7 @@ async fn main() -> Result<(), Box> { } let state = Arc::new(AppState { - client, + client: Arc::new(client), network: config.network.clone(), bootstrap_peers, }); diff --git a/antd/src/rest/data.rs b/antd/src/rest/data.rs index dd6db95..242fd9a 100644 --- a/antd/src/rest/data.rs +++ b/antd/src/rest/data.rs @@ -3,57 +3,55 @@ use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::Json; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; use crate::error::AntdError; use crate::state::AppState; use crate::types::*; -// TODO: Implement data operations on top of ant-node chunk protocol. -// Data operations require multi-chunk handling (chunking, self-encryption) -// which is not yet available in the ant-node client. - -pub async fn data_get_public( - State(_state): State>, - Path(_addr): Path, -) -> Result, AntdError> { - Err(AntdError::Internal("data operations not yet implemented yet".into())) -} +// Data operations are blocked on an upstream lifetime issue in ant-core's +// stream closures (data_upload_with_mode, data_download). The types and +// payment_mode parameter are in place — implementations will land once +// ant-core is fixed. pub async fn data_put_public( State(_state): State>, Json(_req): Json, ) -> Result, AntdError> { - Err(AntdError::Internal("data operations not yet implemented yet".into())) + Err(AntdError::NotImplemented("data put public pending ant-core fix".into())) } -pub async fn data_get_private( +pub async fn data_get_public( State(_state): State>, - Query(_query): Query, + Path(_addr): Path, ) -> Result, AntdError> { - Err(AntdError::Internal("private data operations not yet implemented yet".into())) + Err(AntdError::NotImplemented("data get public pending ant-core fix".into())) } pub async fn data_put_private( State(_state): State>, Json(_req): Json, ) -> Result, AntdError> { - Err(AntdError::Internal("private data operations not yet implemented yet".into())) + Err(AntdError::NotImplemented("data put private pending ant-core fix".into())) +} + +pub async fn data_get_private( + State(_state): State>, + Query(_query): Query, +) -> Result, AntdError> { + Err(AntdError::NotImplemented("data get private pending ant-core fix".into())) } pub async fn data_cost( State(_state): State>, Json(_req): Json, ) -> Result, AntdError> { - Err(AntdError::Internal("data cost not yet implemented yet".into())) + Err(AntdError::NotImplemented("data cost estimation not yet available".into())) } pub async fn data_stream_public( State(_state): State>, Path(_addr): Path, ) -> Result>>, AntdError> { - // Return an empty stream for now let stream = futures::stream::empty(); Ok(Sse::new(stream).keep_alive(KeepAlive::default())) } diff --git a/antd/src/rest/files.rs b/antd/src/rest/files.rs index 2b8ae9b..c2e4a46 100644 --- a/antd/src/rest/files.rs +++ b/antd/src/rest/files.rs @@ -7,55 +7,54 @@ use crate::error::AntdError; use crate::state::AppState; use crate::types::*; -// TODO: Implement file operations on top of ant-node chunk protocol. -// File operations require chunking, FEC encoding, and archive manifests -// which need to be built on top of the raw chunk layer. +// File operations are blocked on the same ant-core lifetime issue as data ops. +// The payment_mode parameter is in place on FileUploadRequest. pub async fn file_upload_public( State(_state): State>, Json(_req): Json, ) -> Result, AntdError> { - Err(AntdError::Internal("file operations not yet implemented yet".into())) + Err(AntdError::NotImplemented("file upload pending ant-core fix".into())) } pub async fn file_download_public( State(_state): State>, Json(_req): Json, ) -> Result { - Err(AntdError::Internal("file operations not yet implemented yet".into())) + Err(AntdError::NotImplemented("file download pending ant-core fix".into())) } pub async fn dir_upload_public( State(_state): State>, Json(_req): Json, ) -> Result, AntdError> { - Err(AntdError::Internal("directory operations not yet implemented yet".into())) + Err(AntdError::NotImplemented("directory upload pending ant-core fix".into())) } pub async fn dir_download_public( State(_state): State>, Json(_req): Json, ) -> Result { - Err(AntdError::Internal("directory operations not yet implemented yet".into())) + Err(AntdError::NotImplemented("directory download pending ant-core fix".into())) } pub async fn archive_get_public( State(_state): State>, axum::extract::Path(_addr): axum::extract::Path, ) -> Result, AntdError> { - Err(AntdError::Internal("archive operations not yet implemented yet".into())) + Err(AntdError::NotImplemented("archive operations not yet available".into())) } pub async fn archive_put_public( State(_state): State>, Json(_req): Json, ) -> Result, AntdError> { - Err(AntdError::Internal("archive operations not yet implemented yet".into())) + Err(AntdError::NotImplemented("archive operations not yet available".into())) } pub async fn file_cost( State(_state): State>, Json(_req): Json, ) -> Result, AntdError> { - Err(AntdError::Internal("file cost not yet implemented yet".into())) + Err(AntdError::NotImplemented("file cost estimation not yet available".into())) } diff --git a/antd/src/state.rs b/antd/src/state.rs index b5fb1c2..e755d4a 100644 --- a/antd/src/state.rs +++ b/antd/src/state.rs @@ -1,9 +1,12 @@ +use std::sync::Arc; + use ant_core::data::{Client, MultiAddr}; /// Shared application state passed to all handlers. +#[derive(Clone)] pub struct AppState { /// High-level Autonomi client (wraps P2P node, wallet, cache). - pub client: Client, + pub client: Arc, /// Network mode label ("local", "default", etc.) pub network: String, /// Bootstrap peer addresses. diff --git a/antd/src/types.rs b/antd/src/types.rs index c40dde6..9471866 100644 --- a/antd/src/types.rs +++ b/antd/src/types.rs @@ -5,12 +5,16 @@ use serde::{Deserialize, Serialize}; #[derive(Deserialize)] pub struct DataPutRequest { pub data: String, // base64 + /// Payment mode: "auto" (default), "merkle", or "single". + #[serde(default)] + pub payment_mode: Option, } #[derive(Serialize)] pub struct DataPutPublicResponse { - pub cost: String, pub address: String, + pub chunks_stored: usize, + pub payment_mode_used: String, } #[derive(Serialize)] @@ -25,8 +29,9 @@ pub struct DataCostRequest { #[derive(Serialize)] pub struct DataPutPrivateResponse { - pub cost: String, - pub data_map: String, // hex + pub data_map: String, // hex-encoded serialized data map + pub chunks_stored: usize, + pub payment_mode_used: String, } #[derive(Deserialize)] @@ -92,6 +97,9 @@ pub struct GraphEntryCostRequest { #[derive(Deserialize)] pub struct FileUploadRequest { pub path: String, + /// Payment mode: "auto" (default), "merkle", or "single". + #[serde(default)] + pub payment_mode: Option, } #[derive(Serialize)] @@ -167,6 +175,25 @@ fn default_true() -> bool { true } +/// Parse a payment mode string into ant-core's PaymentMode. +pub fn parse_payment_mode(mode: Option<&str>) -> Result { + match mode { + None | Some("auto") => Ok(ant_core::data::PaymentMode::Auto), + Some("merkle") => Ok(ant_core::data::PaymentMode::Merkle), + Some("single") => Ok(ant_core::data::PaymentMode::Single), + Some(other) => Err(format!("invalid payment_mode: {other:?}. Use \"auto\", \"merkle\", or \"single\"")), + } +} + +/// Format a PaymentMode for JSON responses. +pub fn format_payment_mode(mode: ant_core::data::PaymentMode) -> String { + match mode { + ant_core::data::PaymentMode::Auto => "auto".into(), + ant_core::data::PaymentMode::Merkle => "merkle".into(), + ant_core::data::PaymentMode::Single => "single".into(), + } +} + // ── Wallet ── #[derive(Serialize)] From 40827f0e2e01f1c66c1576d1c534bde0cdc495cf Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Thu, 26 Mar 2026 13:58:47 +0000 Subject: [PATCH 07/10] Implement data and file endpoints using ant-core Client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the upstream HRTB lifetime fix (ant-client#8), ant-core's Client methods can now be called from async axum handlers via tokio::spawn. Implemented endpoints: - POST /v1/data/public — upload with self-encryption + merkle payments - GET /v1/data/public/{addr} — download via data map - POST /v1/data/private — upload, return data map to caller - GET /v1/data/private — download using caller-provided data map - POST /v1/files/upload/public — stream-encrypt file from disk - POST /v1/files/download/public — download file to disk - POST /v1/dirs/upload/public — upload directory - POST /v1/dirs/download/public — download directory All upload endpoints accept optional payment_mode parameter ("auto"/"merkle"/"single"). Default is "auto" which uses merkle batch payments for 64+ chunks. Still stubbed (501): archives, cost estimation, data streaming. Co-Authored-By: Claude Opus 4.6 (1M context) --- antd/Cargo.lock | 2 +- antd/src/rest/data.rs | 108 ++++++++++++++++++++++++++++++++++------- antd/src/rest/files.rs | 108 +++++++++++++++++++++++++++++++++++------ 3 files changed, 185 insertions(+), 33 deletions(-) diff --git a/antd/Cargo.lock b/antd/Cargo.lock index 6e739c4..f4e304e 100644 --- a/antd/Cargo.lock +++ b/antd/Cargo.lock @@ -829,7 +829,7 @@ dependencies = [ [[package]] name = "ant-core" version = "0.1.0" -source = "git+https://github.com/WithAutonomi/ant-client#1a90ca8edd66b22b35526f8575440d1e2f10554a" +source = "git+https://github.com/WithAutonomi/ant-client#c979f145a15c310693bfbb801e1500252af36cad" dependencies = [ "ant-evm", "ant-node", diff --git a/antd/src/rest/data.rs b/antd/src/rest/data.rs index 242fd9a..c88456a 100644 --- a/antd/src/rest/data.rs +++ b/antd/src/rest/data.rs @@ -3,42 +3,116 @@ use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::Json; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use bytes::Bytes; use crate::error::AntdError; use crate::state::AppState; use crate::types::*; -// Data operations are blocked on an upstream lifetime issue in ant-core's -// stream closures (data_upload_with_mode, data_download). The types and -// payment_mode parameter are in place — implementations will land once -// ant-core is fixed. - pub async fn data_put_public( - State(_state): State>, - Json(_req): Json, + State(state): State>, + Json(req): Json, ) -> Result, AntdError> { - Err(AntdError::NotImplemented("data put public pending ant-core fix".into())) + if state.client.wallet().is_none() { + return Err(AntdError::Payment("no EVM wallet configured — set AUTONOMI_WALLET_KEY".into())); + } + + let data = BASE64.decode(&req.data) + .map_err(|e| AntdError::BadRequest(format!("invalid base64: {e}")))?; + + let mode = parse_payment_mode(req.payment_mode.as_deref()) + .map_err(AntdError::BadRequest)?; + + let client = state.client.clone(); + let (address, chunks_stored, payment_mode_used) = tokio::spawn(async move { + let result = client.data_upload_with_mode(Bytes::from(data), mode).await + .map_err(AntdError::from_core)?; + let address = client.data_map_store(&result.data_map).await + .map_err(AntdError::from_core)?; + Ok::<_, AntdError>((address, result.chunks_stored, result.payment_mode_used)) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(Json(DataPutPublicResponse { + address: hex::encode(address), + chunks_stored, + payment_mode_used: format_payment_mode(payment_mode_used), + })) } pub async fn data_get_public( - State(_state): State>, - Path(_addr): Path, + State(state): State>, + Path(addr): Path, ) -> Result, AntdError> { - Err(AntdError::NotImplemented("data get public pending ant-core fix".into())) + let address_bytes = hex::decode(&addr) + .map_err(|e| AntdError::BadRequest(format!("invalid hex address: {e}")))?; + let address: [u8; 32] = address_bytes + .try_into() + .map_err(|_| AntdError::BadRequest("address must be 32 bytes".into()))?; + + let client = state.client.clone(); + let content = tokio::spawn(async move { + let data_map = client.data_map_fetch(&address).await + .map_err(AntdError::from_core)?; + client.data_download(&data_map).await + .map_err(AntdError::from_core) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(Json(DataGetResponse { + data: BASE64.encode(&content), + })) } pub async fn data_put_private( - State(_state): State>, - Json(_req): Json, + State(state): State>, + Json(req): Json, ) -> Result, AntdError> { - Err(AntdError::NotImplemented("data put private pending ant-core fix".into())) + if state.client.wallet().is_none() { + return Err(AntdError::Payment("no EVM wallet configured — set AUTONOMI_WALLET_KEY".into())); + } + + let data = BASE64.decode(&req.data) + .map_err(|e| AntdError::BadRequest(format!("invalid base64: {e}")))?; + + let mode = parse_payment_mode(req.payment_mode.as_deref()) + .map_err(AntdError::BadRequest)?; + + let client = state.client.clone(); + let (data_map_hex, chunks_stored, payment_mode_used) = tokio::spawn(async move { + let result = client.data_upload_with_mode(Bytes::from(data), mode).await + .map_err(AntdError::from_core)?; + let data_map_bytes = rmp_serde::to_vec(&result.data_map) + .map_err(|e| AntdError::Internal(format!("failed to serialize data map: {e}")))?; + Ok::<_, AntdError>((hex::encode(data_map_bytes), result.chunks_stored, result.payment_mode_used)) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(Json(DataPutPrivateResponse { + data_map: data_map_hex, + chunks_stored, + payment_mode_used: format_payment_mode(payment_mode_used), + })) } pub async fn data_get_private( - State(_state): State>, - Query(_query): Query, + State(state): State>, + Query(query): Query, ) -> Result, AntdError> { - Err(AntdError::NotImplemented("data get private pending ant-core fix".into())) + let data_map_bytes = hex::decode(&query.data_map) + .map_err(|e| AntdError::BadRequest(format!("invalid hex data_map: {e}")))?; + + let data_map: ant_core::data::DataMap = rmp_serde::from_slice(&data_map_bytes) + .map_err(|e| AntdError::BadRequest(format!("invalid data map: {e}")))?; + + let client = state.client.clone(); + let content = tokio::spawn(async move { + client.data_download(&data_map).await + .map_err(AntdError::from_core) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(Json(DataGetResponse { + data: BASE64.encode(&content), + })) } pub async fn data_cost( diff --git a/antd/src/rest/files.rs b/antd/src/rest/files.rs index c2e4a46..a4a30b9 100644 --- a/antd/src/rest/files.rs +++ b/antd/src/rest/files.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::sync::Arc; use axum::extract::State; @@ -7,35 +8,112 @@ use crate::error::AntdError; use crate::state::AppState; use crate::types::*; -// File operations are blocked on the same ant-core lifetime issue as data ops. -// The payment_mode parameter is in place on FileUploadRequest. - pub async fn file_upload_public( - State(_state): State>, - Json(_req): Json, + State(state): State>, + Json(req): Json, ) -> Result, AntdError> { - Err(AntdError::NotImplemented("file upload pending ant-core fix".into())) + if state.client.wallet().is_none() { + return Err(AntdError::Payment("no EVM wallet configured — set AUTONOMI_WALLET_KEY".into())); + } + + let path = PathBuf::from(&req.path); + if !path.exists() { + return Err(AntdError::BadRequest(format!("file not found: {}", req.path))); + } + + let mode = parse_payment_mode(req.payment_mode.as_deref()) + .map_err(AntdError::BadRequest)?; + + let client = state.client.clone(); + let address = tokio::spawn(async move { + let result = client.file_upload_with_mode(&path, mode).await + .map_err(AntdError::from_core)?; + let address = client.data_map_store(&result.data_map).await + .map_err(AntdError::from_core)?; + Ok::<_, AntdError>(address) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(Json(FileUploadPublicResponse { + cost: String::new(), + address: hex::encode(address), + })) } pub async fn file_download_public( - State(_state): State>, - Json(_req): Json, + State(state): State>, + Json(req): Json, ) -> Result { - Err(AntdError::NotImplemented("file download pending ant-core fix".into())) + let address_bytes = hex::decode(&req.address) + .map_err(|e| AntdError::BadRequest(format!("invalid hex address: {e}")))?; + let address: [u8; 32] = address_bytes + .try_into() + .map_err(|_| AntdError::BadRequest("address must be 32 bytes".into()))?; + + let dest = PathBuf::from(&req.dest_path); + let client = state.client.clone(); + tokio::spawn(async move { + let data_map = client.data_map_fetch(&address).await + .map_err(AntdError::from_core)?; + client.file_download(&data_map, &dest).await + .map_err(AntdError::from_core)?; + Ok::<_, AntdError>(()) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(axum::http::StatusCode::OK) } pub async fn dir_upload_public( - State(_state): State>, - Json(_req): Json, + State(state): State>, + Json(req): Json, ) -> Result, AntdError> { - Err(AntdError::NotImplemented("directory upload pending ant-core fix".into())) + if state.client.wallet().is_none() { + return Err(AntdError::Payment("no EVM wallet configured — set AUTONOMI_WALLET_KEY".into())); + } + + let path = PathBuf::from(&req.path); + if !path.is_dir() { + return Err(AntdError::BadRequest(format!("not a directory: {}", req.path))); + } + + let mode = parse_payment_mode(req.payment_mode.as_deref()) + .map_err(AntdError::BadRequest)?; + + let client = state.client.clone(); + let address = tokio::spawn(async move { + let result = client.file_upload_with_mode(&path, mode).await + .map_err(AntdError::from_core)?; + let address = client.data_map_store(&result.data_map).await + .map_err(AntdError::from_core)?; + Ok::<_, AntdError>(address) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(Json(DirUploadPublicResponse { + cost: String::new(), + address: hex::encode(address), + })) } pub async fn dir_download_public( - State(_state): State>, - Json(_req): Json, + State(state): State>, + Json(req): Json, ) -> Result { - Err(AntdError::NotImplemented("directory download pending ant-core fix".into())) + let address_bytes = hex::decode(&req.address) + .map_err(|e| AntdError::BadRequest(format!("invalid hex address: {e}")))?; + let address: [u8; 32] = address_bytes + .try_into() + .map_err(|_| AntdError::BadRequest("address must be 32 bytes".into()))?; + + let dest = PathBuf::from(&req.dest_path); + let client = state.client.clone(); + tokio::spawn(async move { + let data_map = client.data_map_fetch(&address).await + .map_err(AntdError::from_core)?; + client.file_download(&data_map, &dest).await + .map_err(AntdError::from_core)?; + Ok::<_, AntdError>(()) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(axum::http::StatusCode::OK) } pub async fn archive_get_public( From 75ac06aab45d72e2d99d50d329242a74d76c38b8 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Thu, 26 Mar 2026 14:22:19 +0000 Subject: [PATCH 08/10] Update docs, MCP server, and READMEs for payment modes and implemented endpoints - MCP: store_data and upload_file tools now accept payment_mode parameter - README: Add Payment Modes section explaining auto/merkle/single - llms.txt: Document payment_mode on data/file PUT endpoints - llms-full.txt: Add Payment Modes section with SDK examples - skill.md: Add merkle guidance to data storage pattern - antd-go/README.md: Add payment mode configuration examples - antd-mcp/README.md: Update tool table and add Payment Modes section Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 13 +++++++++- antd-go/README.md | 6 +++++ antd-mcp/README.md | 17 ++++++++++--- antd-mcp/src/antd_mcp/server.py | 16 +++++++++--- llms-full.txt | 43 +++++++++++++++++++++++++++++---- llms.txt | 22 ++++++++++++++--- skill.md | 7 +++++- 7 files changed, 106 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9a054a1..9d405cc 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,14 @@ This is especially useful in managed mode, where a parent process (e.g. indelibl If no port file is found, all SDKs fall back to the default REST endpoint (`http://localhost:8082`) or gRPC target (`localhost:50051`). +### Payment Modes + +All data and file upload operations accept an optional `payment_mode` parameter (defaults to `"auto"`): + +- **`auto`** — Uses merkle batch payments for uploads of 64+ chunks, single payments otherwise. Recommended for most use cases. +- **`merkle`** — Forces merkle batch payments regardless of chunk count (minimum 2 chunks). Saves gas on larger uploads. +- **`single`** — Forces per-chunk payments. Useful for small data or debugging. + ## Components ### Infrastructure @@ -162,7 +170,7 @@ client = AntdClient() status = client.health() print(f"Network: {status.network}") -# Store data on the network +# Store data on the network (payment_mode defaults to "auto") result = client.data_put_public(b"Hello, Autonomi!") print(f"Address: {result.address}") print(f"Cost: {result.cost} atto tokens") @@ -170,6 +178,9 @@ print(f"Cost: {result.cost} atto tokens") # Retrieve it back data = client.data_get_public(result.address) print(data.decode()) # "Hello, Autonomi!" + +# For large uploads, you can explicitly set payment_mode: +# result = client.data_put_public(large_data, payment_mode="merkle") ``` ### Write your first app (JavaScript/TypeScript) diff --git a/antd-go/README.md b/antd-go/README.md index 05c14e0..ac5526b 100644 --- a/antd-go/README.md +++ b/antd-go/README.md @@ -73,6 +73,12 @@ client := antd.NewClient(antd.DefaultBaseURL, antd.WithTimeout(30 * time.Second) // Custom HTTP client client := antd.NewClient(antd.DefaultBaseURL, antd.WithHTTPClient(myHTTPClient)) + +// Payment mode for uploads (defaults to "auto") +result, _ := client.DataPutPublic(ctx, data, antd.WithPaymentMode("merkle")) +// "auto" = merkle for 64+ chunks, single otherwise +// "merkle" = force batch payments (saves gas, min 2 chunks) +// "single" = per-chunk payments ``` ## API Reference diff --git a/antd-mcp/README.md b/antd-mcp/README.md index 56f514f..8c71c47 100644 --- a/antd-mcp/README.md +++ b/antd-mcp/README.md @@ -50,9 +50,9 @@ The server will auto-discover the daemon via the port file. Add `"env": {"ANTD_B | # | Tool | Description | |---|------|-------------| -| 1 | `store_data(text, private?)` | Store text on the network (public or encrypted) | +| 1 | `store_data(text, private?, payment_mode?)` | Store text on the network (public or encrypted) | | 2 | `retrieve_data(address, private?)` | Retrieve text by address | -| 3 | `upload_file(path, is_directory?)` | Upload a local file or directory | +| 3 | `upload_file(path, is_directory?, payment_mode?)` | Upload a local file or directory | | 4 | `download_file(address, dest_path, is_directory?)` | Download to local path | | 5 | `get_cost(text?, file_path?)` | Estimate storage cost | | 6 | `check_balance()` | Check daemon health and network status | @@ -80,6 +80,16 @@ The server will auto-discover the daemon via the port file. Add `"env": {"ANTD_B | 13 | `archive_get(address)` | List files in an archive | | 14 | `archive_put(entries)` | Create an archive manifest | +### Payment Modes + +The `store_data` and `upload_file` tools accept an optional `payment_mode` parameter: + +| Mode | Behavior | +|------|----------| +| `"auto"` (default) | Uses merkle batch payments for 64+ chunks, single payments otherwise. Recommended for most use cases. | +| `"merkle"` | Forces merkle batch payments regardless of chunk count (minimum 2 chunks). Saves gas on larger uploads. | +| `"single"` | Forces per-chunk payments. Useful for small data or debugging. | + ## Response Format All tools return JSON with a `network` field indicating the connected network: @@ -110,6 +120,7 @@ antd-mcp/ ├── pyproject.toml └── src/antd_mcp/ ├── __init__.py - ├── server.py # 14 MCP tool definitions + ├── server.py # 16 MCP tool definitions + ├── discover.py # Daemon port-file discovery └── errors.py # Error formatting ``` diff --git a/antd-mcp/src/antd_mcp/server.py b/antd-mcp/src/antd_mcp/server.py index 9a71705..c87a42b 100644 --- a/antd-mcp/src/antd_mcp/server.py +++ b/antd-mcp/src/antd_mcp/server.py @@ -87,12 +87,16 @@ def _err(exc: Exception, network: str) -> str: async def store_data( text: str, private: bool = False, + payment_mode: str = "auto", ) -> str: """Store text on the Autonomi network. Args: text: The text content to store. private: If True, store as private (encrypted). Default: public. + payment_mode: Payment strategy — "auto" (default, uses merkle for 64+ + chunks), "merkle" (force batch payments, min 2 chunks), or "single" + (per-chunk payments). Returns: JSON with address and cost, or error details. @@ -101,9 +105,9 @@ async def store_data( data = text.encode("utf-8") try: if private: - result = await client.data_put_private(data) + result = await client.data_put_private(data, payment_mode=payment_mode) else: - result = await client.data_put_public(data) + result = await client.data_put_public(data, payment_mode=payment_mode) return _ok({"address": result.address, "cost": result.cost}, network) except AntdError as exc: return _err_antd(exc, network) @@ -152,12 +156,16 @@ async def retrieve_data( async def upload_file( path: str, is_directory: bool = False, + payment_mode: str = "auto", ) -> str: """Upload a local file or directory to the Autonomi network (public). Args: path: Absolute path to the local file or directory. is_directory: Set True if path is a directory. + payment_mode: Payment strategy — "auto" (default, uses merkle for 64+ + chunks), "merkle" (force batch payments, min 2 chunks), or "single" + (per-chunk payments). Returns: JSON with address and cost, or error details. @@ -165,9 +173,9 @@ async def upload_file( client, network = _get_ctx() try: if is_directory: - result = await client.dir_upload_public(path) + result = await client.dir_upload_public(path, payment_mode=payment_mode) else: - result = await client.file_upload_public(path) + result = await client.file_upload_public(path, payment_mode=payment_mode) return _ok({"address": result.address, "cost": result.cost}, network) except AntdError as exc: return _err_antd(exc, network) diff --git a/llms-full.txt b/llms-full.txt index fc04fca..52fa3b2 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -41,10 +41,13 @@ client = AntdClient() # REST, localhost:8082 client = AntdClient(transport="grpc") # gRPC, localhost:50051 client = AntdClient(base_url="http://remote:8082") -# Store and retrieve data +# Store and retrieve data (payment_mode defaults to "auto") result = client.data_put_public(b"hello world") print(result.address, result.cost) data = client.data_get_public(result.address) # b"hello world" + +# Explicit payment mode for large uploads +result = client.data_put_public(large_data, payment_mode="merkle") ``` ```typescript @@ -219,7 +222,7 @@ Response: `{"status": "ok", "network": "local"}` #### `POST /v1/data/public` Store public (unencrypted) data on the network. -Request: `{"data": ""}` +Request: `{"data": "", "payment_mode": "auto"}` (payment_mode is optional, defaults to "auto") Response: `{"cost": "", "address": ""}` #### `GET /v1/data/public/{addr}` @@ -233,7 +236,7 @@ Stream public data as chunked response. Same path params as above. #### `POST /v1/data/private` Store private (encrypted) data. Returns a data map instead of address. -Request: `{"data": ""}` +Request: `{"data": "", "payment_mode": "auto"}` (payment_mode is optional, defaults to "auto") Response: `{"cost": "", "data_map": ""}` #### `GET /v1/data/private` @@ -308,7 +311,7 @@ Upload and download files/directories from the local filesystem. #### `POST /v1/files/upload/public` Upload a local file. -Request: `{"path": "/absolute/path/to/file"}` +Request: `{"path": "/absolute/path/to/file", "payment_mode": "auto"}` (payment_mode is optional, defaults to "auto") Response: `{"cost": "", "address": ""}` #### `POST /v1/files/download/public` @@ -319,7 +322,7 @@ Request: `{"address": "", "dest_path": "/absolute/path"}` #### `POST /v1/dirs/upload/public` Upload a local directory (recursively). -Request: `{"path": "/absolute/path/to/dir"}` +Request: `{"path": "/absolute/path/to/dir", "payment_mode": "auto"}` (payment_mode is optional, defaults to "auto") Response: `{"cost": "", "address": ""}` #### `POST /v1/dirs/download/public` @@ -411,6 +414,36 @@ JS/TS, PHP, Lua, and Zig are REST-only. --- +## Payment Modes + +All data and file upload operations accept an optional `payment_mode` parameter that controls how storage payments are batched: + +| Mode | Behavior | +|------|----------| +| `"auto"` (default) | Uses merkle batch payments for uploads of 64+ chunks, single payments otherwise. Best for general use. | +| `"merkle"` | Forces merkle batch payments regardless of chunk count (minimum 2 chunks). Saves gas on larger uploads. | +| `"single"` | Forces per-chunk payments. Useful for small data or debugging. | + +This applies to all PUT endpoints (`data_put_public`, `data_put_private`, `file_upload_public`, `dir_upload_public`) across all SDKs. The parameter is always optional and defaults to `"auto"`. + +**REST:** Include `"payment_mode": "merkle"` in the JSON request body. + +**SDK examples:** +```python +# Python +result = client.data_put_public(data, payment_mode="merkle") +``` +```go +// Go +result, err := client.DataPutPublic(ctx, data, antd.WithPaymentMode("merkle")) +``` +```typescript +// TypeScript +const result = await client.dataPutPublic(data, { paymentMode: "merkle" }); +``` + +--- + ## JS/TS SDK Method Signatures ```typescript diff --git a/llms.txt b/llms.txt index 76e1e3f..e4ca5d3 100644 --- a/llms.txt +++ b/llms.txt @@ -43,9 +43,9 @@ data = client.data_get_public(result.address) | GET | `/health` | Health check and network status | | GET | `/v1/data/public/{addr}` | Retrieve public data by address | | GET | `/v1/data/public/{addr}/stream` | Stream public data by address | -| POST | `/v1/data/public` | Store public data | +| POST | `/v1/data/public` | Store public data (accepts optional `payment_mode`) | | GET | `/v1/data/private` | Retrieve private (encrypted) data | -| POST | `/v1/data/private` | Store private (encrypted) data | +| POST | `/v1/data/private` | Store private (encrypted) data (accepts optional `payment_mode`) | | POST | `/v1/data/cost` | Estimate data storage cost | | GET | `/v1/chunks/{addr}` | Get raw chunk by address | | POST | `/v1/chunks` | Store raw chunk | @@ -53,9 +53,9 @@ data = client.data_get_public(result.address) | HEAD | `/v1/graph/{addr}` | Check graph entry existence | | POST | `/v1/graph` | Create graph entry | | POST | `/v1/graph/cost` | Estimate graph entry cost | -| POST | `/v1/files/upload/public` | Upload file to network | +| POST | `/v1/files/upload/public` | Upload file to network (accepts optional `payment_mode`) | | POST | `/v1/files/download/public` | Download file from network | -| POST | `/v1/dirs/upload/public` | Upload directory to network | +| POST | `/v1/dirs/upload/public` | Upload directory to network (accepts optional `payment_mode`) | | POST | `/v1/dirs/download/public` | Download directory from network | | GET | `/v1/archives/public/{addr}` | List archive entries | | POST | `/v1/archives/public` | Create archive manifest | @@ -122,3 +122,17 @@ Port file locations: `%APPDATA%\ant\daemon.port` (Windows), `~/.local/share/ant/ - REST: `http://localhost:8082` - gRPC: `localhost:50051` + +## Payment Modes + +All data and file PUT/upload endpoints accept an optional `payment_mode` field in the request body: + +| Mode | Behavior | +|------|----------| +| `"auto"` (default) | Uses merkle batch payments for 64+ chunks, single payments otherwise | +| `"merkle"` | Forces merkle batch payments regardless of chunk count (minimum 2 chunks). Saves gas on larger uploads | +| `"single"` | Forces per-chunk payments. Useful for small data or debugging | + +REST example: `POST /v1/data/public` with `{"data": "", "payment_mode": "merkle"}` + +All SDK `*_put_public`, `*_put_private`, `file_upload_public`, and `dir_upload_public` methods accept a `payment_mode` parameter (defaults to `"auto"`). diff --git a/skill.md b/skill.md index 54eb41f..4e0f0d4 100644 --- a/skill.md +++ b/skill.md @@ -68,14 +68,19 @@ This is the most important decision. Match the developer's use case to the right Store data permanently on the network. Content-addressed, so duplicate data is free. ```python -# Store public data +# Store public data (payment_mode defaults to "auto") result = client.data_put_public(b"Hello, Autonomi!") print(f"Address: {result.address}") # Retrieve it back data = client.data_get_public(result.address) + +# For large uploads, explicitly use merkle batch payments to save gas +result = client.data_put_public(large_data, payment_mode="merkle") ``` +All write operations accept an optional `payment_mode` parameter: `"auto"` (default — uses merkle for 64+ chunks), `"merkle"` (force batch payments, min 2 chunks), or `"single"` (per-chunk payments). The `"auto"` mode is recommended for most use cases. + **When to suggest this:** Developer wants permanent, immutable content storage with public readability. ### Pattern 2: Private Data Storage From 06674f0ba656312edb5d585a2adce0fb57d1fbf7 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Thu, 26 Mar 2026 14:43:52 +0000 Subject: [PATCH 09/10] Implement cost estimation and wallet approve across antd and all SDKs antd: - Data cost: encrypts data to determine chunks, queries network for storage quotes, sums prices (skips already-stored chunks) - File cost: same approach for files on disk - Wallet approve: POST /v1/wallet/approve calls approve_token_spend() to authorize payment contracts (one-time before storage) - Added self_encryption dependency for cost estimation chunking SDK bindings added for wallet_approve() across all 15 languages + MCP. Documentation updated (llms.txt, llms-full.txt, skill.md, READMEs). Co-Authored-By: Claude Opus 4.6 (1M context) --- antd-cpp/include/antd/client.hpp | 3 + antd-cpp/src/client.cpp | 5 + antd-csharp/Antd.Sdk/AntdRestClient.cs | 12 +++ antd-csharp/Antd.Sdk/IAntdClient.cs | 1 + antd-dart/lib/src/client.dart | 6 ++ antd-elixir/lib/antd/client.ex | 16 +++ antd-go/client.go | 11 +++ .../java/com/autonomi/antd/AntdClient.java | 13 +++ antd-js/src/rest-client.ts | 6 ++ .../kotlin/com/autonomi/sdk/AntdRestClient.kt | 7 ++ .../kotlin/com/autonomi/sdk/IAntdClient.kt | 1 + .../main/kotlin/com/autonomi/sdk/Models.kt | 5 + antd-lua/src/antd/client.lua | 8 ++ antd-mcp/README.md | 28 ++++-- antd-mcp/src/antd_mcp/server.py | 27 +++++- antd-php/src/AntdClient.php | 24 +++++ antd-py/src/antd/_rest.py | 14 +++ antd-ruby/lib/antd/client.rb | 7 ++ antd-rust/src/client.rs | 14 +++ .../Sources/AntdSdk/AntdClientProtocol.swift | 1 + .../Sources/AntdSdk/AntdRestClient.swift | 10 ++ antd-zig/src/antd.zig | 7 ++ antd-zig/src/json_helpers.zig | 16 +++ antd/Cargo.lock | 1 + antd/Cargo.toml | 1 + antd/src/rest/data.rs | 40 +++++++- antd/src/rest/files.rs | 44 ++++++++- antd/src/rest/mod.rs | 1 + antd/src/rest/wallet.rs | 18 ++++ antd/src/types.rs | 6 ++ llms-full.txt | 97 +++++++++++++++++++ llms.txt | 7 +- skill.md | 2 +- 33 files changed, 439 insertions(+), 20 deletions(-) diff --git a/antd-cpp/include/antd/client.hpp b/antd-cpp/include/antd/client.hpp index 58b78f2..ad3b7ee 100644 --- a/antd-cpp/include/antd/client.hpp +++ b/antd-cpp/include/antd/client.hpp @@ -121,6 +121,9 @@ class Client { /// Get the wallet balance (tokens and gas). WalletBalance wallet_balance(); + /// Approve the wallet to spend tokens on payment contracts (one-time operation). + bool wallet_approve(); + private: struct Impl; std::unique_ptr impl_; diff --git a/antd-cpp/src/client.cpp b/antd-cpp/src/client.cpp index 00a46cc..a9671f7 100644 --- a/antd-cpp/src/client.cpp +++ b/antd-cpp/src/client.cpp @@ -352,4 +352,9 @@ WalletBalance Client::wallet_balance() { }; } +bool Client::wallet_approve() { + auto j = impl_->do_json("POST", "/v1/wallet/approve", json::object()); + return j.value("approved", false); +} + } // namespace antd diff --git a/antd-csharp/Antd.Sdk/AntdRestClient.cs b/antd-csharp/Antd.Sdk/AntdRestClient.cs index ac40716..6030f80 100644 --- a/antd-csharp/Antd.Sdk/AntdRestClient.cs +++ b/antd-csharp/Antd.Sdk/AntdRestClient.cs @@ -246,6 +246,15 @@ public async Task WalletBalanceAsync() return new WalletBalance(resp.Balance, resp.GasBalance); } + /// + /// Approves the wallet to spend tokens on payment contracts (one-time operation). + /// + public async Task WalletApproveAsync() + { + var resp = await PostJsonAsync("/v1/wallet/approve", new { }); + return resp.Approved; + } + // ── Internal DTOs for JSON deserialization ── private sealed record HealthResponseDto( @@ -292,4 +301,7 @@ private sealed record WalletAddressDto( private sealed record WalletBalanceDto( [property: JsonPropertyName("balance")] string Balance, [property: JsonPropertyName("gas_balance")] string GasBalance); + + private sealed record WalletApproveDto( + [property: JsonPropertyName("approved")] bool Approved); } diff --git a/antd-csharp/Antd.Sdk/IAntdClient.cs b/antd-csharp/Antd.Sdk/IAntdClient.cs index c92147c..c63b1a4 100644 --- a/antd-csharp/Antd.Sdk/IAntdClient.cs +++ b/antd-csharp/Antd.Sdk/IAntdClient.cs @@ -34,4 +34,5 @@ public interface IAntdClient : IDisposable // Wallet Task WalletAddressAsync(); Task WalletBalanceAsync(); + Task WalletApproveAsync(); } diff --git a/antd-dart/lib/src/client.dart b/antd-dart/lib/src/client.dart index 1fae92a..a19e741 100644 --- a/antd-dart/lib/src/client.dart +++ b/antd-dart/lib/src/client.dart @@ -322,4 +322,10 @@ class AntdClient { final json = await _doJson('GET', '/v1/wallet/balance'); return WalletBalance.fromJson(json!); } + + /// Approves the wallet to spend tokens on payment contracts (one-time operation). + Future walletApprove() async { + final json = await _doJson('POST', '/v1/wallet/approve', {}); + return json!['approved'] as bool; + } } diff --git a/antd-elixir/lib/antd/client.ex b/antd-elixir/lib/antd/client.ex index 362688a..01d5b7f 100644 --- a/antd-elixir/lib/antd/client.ex +++ b/antd-elixir/lib/antd/client.ex @@ -480,6 +480,22 @@ defmodule Antd.Client do @spec wallet_balance!(t()) :: Antd.WalletBalance.t() def wallet_balance!(client), do: unwrap!(wallet_balance(client)) + @doc "Approves the wallet to spend tokens on payment contracts (one-time operation)." + @spec wallet_approve(t()) :: {:ok, boolean()} | {:error, Exception.t()} + def wallet_approve(%__MODULE__{} = client) do + case do_json(client, :post, "/v1/wallet/approve", %{}) do + {:ok, body} -> + {:ok, body["approved"] == true} + + {:error, _} = err -> + err + end + end + + @doc "Like `wallet_approve/1` but raises on error." + @spec wallet_approve!(t()) :: boolean() + def wallet_approve!(client), do: unwrap!(wallet_approve(client)) + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- diff --git a/antd-go/client.go b/antd-go/client.go index 69c0aa8..d819296 100644 --- a/antd-go/client.go +++ b/antd-go/client.go @@ -477,3 +477,14 @@ func (c *Client) WalletBalance(ctx context.Context) (*WalletBalance, error) { GasBalance: str(j, "gas_balance"), }, nil } + +// WalletApprove approves the wallet to spend tokens on payment contracts. +// This is a one-time operation required before any storage operations. +func (c *Client) WalletApprove(ctx context.Context) error { + j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/wallet/approve", map[string]any{}) + if err != nil { + return err + } + _ = j + return nil +} diff --git a/antd-java/src/main/java/com/autonomi/antd/AntdClient.java b/antd-java/src/main/java/com/autonomi/antd/AntdClient.java index aa5dc53..1d27156 100644 --- a/antd-java/src/main/java/com/autonomi/antd/AntdClient.java +++ b/antd-java/src/main/java/com/autonomi/antd/AntdClient.java @@ -368,4 +368,17 @@ public WalletBalance walletBalance() { Map j = doJson("GET", "/v1/wallet/balance", null); return new WalletBalance(str(j, "balance"), str(j, "gas_balance")); } + + /** + * Approves the wallet to spend tokens on payment contracts. + * This is a one-time operation required before any storage operations. + * + * @return true if the wallet was approved + * @throws AntdException if no wallet is configured (HTTP 400) or on other errors + */ + public boolean walletApprove() { + Map j = doJson("POST", "/v1/wallet/approve", "{}"); + Object approved = j.get("approved"); + return approved instanceof Boolean b && b; + } } diff --git a/antd-js/src/rest-client.ts b/antd-js/src/rest-client.ts index 60e82d1..895e550 100644 --- a/antd-js/src/rest-client.ts +++ b/antd-js/src/rest-client.ts @@ -303,4 +303,10 @@ export class RestClient { const j = await this.getJson<{ balance: string; gas_balance: string }>("/v1/wallet/balance"); return { balance: j.balance, gasBalance: j.gas_balance }; } + + /** Approve the wallet to spend tokens on payment contracts (one-time operation). */ + async walletApprove(): Promise { + const j = await this.postJson<{ approved: boolean }>("/v1/wallet/approve", {}); + return j.approved; + } } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt index c151693..4651805 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/AntdRestClient.kt @@ -270,4 +270,11 @@ class AntdRestClient( val resp = getJson("/v1/wallet/balance") return WalletBalance(resp.balance, resp.gasBalance) } + + /** Approves the wallet to spend tokens on payment contracts (one-time operation). */ + override suspend fun walletApprove(): Boolean { + val body = buildJsonObject {}.toString() + val resp = postJson("/v1/wallet/approve", body) + return resp.approved + } } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt index e2d50f3..57d2ef9 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/IAntdClient.kt @@ -42,4 +42,5 @@ interface IAntdClient : Closeable { // Wallet suspend fun walletAddress(): WalletAddress suspend fun walletBalance(): WalletBalance + suspend fun walletApprove(): Boolean } diff --git a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/Models.kt b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/Models.kt index 95700a3..a0271cf 100644 --- a/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/Models.kt +++ b/antd-kotlin/lib/src/main/kotlin/com/autonomi/sdk/Models.kt @@ -95,3 +95,8 @@ internal data class WalletBalanceDto( val balance: String, @SerialName("gas_balance") val gasBalance: String, ) + +@Serializable +internal data class WalletApproveDto( + val approved: Boolean, +) diff --git a/antd-lua/src/antd/client.lua b/antd-lua/src/antd/client.lua index da4644b..46c779d 100644 --- a/antd-lua/src/antd/client.lua +++ b/antd-lua/src/antd/client.lua @@ -440,6 +440,14 @@ function Client:wallet_balance() return { balance = str(j, "balance"), gas_balance = str(j, "gas_balance") }, nil end +--- Approve the wallet to spend tokens on payment contracts (one-time operation). +-- @return boolean|nil, error|nil +function Client:wallet_approve() + local j, _, err = self:_do_json("POST", "/v1/wallet/approve", {}) + if err then return nil, err end + return j.approved == true, nil +end + --- Create a client using daemon port discovery. -- Falls back to the default base URL if discovery fails. -- @param opts table optional settings: { timeout = number } diff --git a/antd-mcp/README.md b/antd-mcp/README.md index 8c71c47..0b6b7e2 100644 --- a/antd-mcp/README.md +++ b/antd-mcp/README.md @@ -1,6 +1,6 @@ # antd-mcp — MCP Server for Autonomi -An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that exposes the Autonomi network as 14 tools for AI agents. Works with Claude Desktop, Claude Code, and any MCP-compatible client. +An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that exposes the Autonomi network as 17 tools for AI agents. Works with Claude Desktop, Claude Code, and any MCP-compatible client. ## Installation @@ -57,28 +57,36 @@ The server will auto-discover the daemon via the port file. Add `"env": {"ANTD_B | 5 | `get_cost(text?, file_path?)` | Estimate storage cost | | 6 | `check_balance()` | Check daemon health and network status | +### Wallet Operations + +| # | Tool | Description | +|---|------|-------------| +| 7 | `wallet_address()` | Get wallet public address | +| 8 | `wallet_balance()` | Get wallet token and gas balances | +| 16 | `wallet_approve()` | Approve wallet to spend tokens on payment contracts (one-time) | + ### Chunk Operations | # | Tool | Description | |---|------|-------------| -| 7 | `chunk_put(data)` | Store a raw chunk (base64 input) | -| 8 | `chunk_get(address)` | Retrieve a chunk (base64 output) | +| 9 | `chunk_put(data)` | Store a raw chunk (base64 input) | +| 10 | `chunk_get(address)` | Retrieve a chunk (base64 output) | ### Graph Operations | # | Tool | Description | |---|------|-------------| -| 9 | `create_graph_entry(owner_secret_key, content, parents?, descendants?)` | Create DAG node | -| 10 | `get_graph_entry(address)` | Read graph entry | -| 11 | `graph_entry_exists(address)` | Check if entry exists | -| 12 | `graph_entry_cost(public_key)` | Estimate creation cost | +| 11 | `create_graph_entry(owner_secret_key, content, parents?, descendants?)` | Create DAG node | +| 12 | `get_graph_entry(address)` | Read graph entry | +| 13 | `graph_entry_exists(address)` | Check if entry exists | +| 14 | `graph_entry_cost(public_key)` | Estimate creation cost | ### Archive Operations | # | Tool | Description | |---|------|-------------| -| 13 | `archive_get(address)` | List files in an archive | -| 14 | `archive_put(entries)` | Create an archive manifest | +| 15 | `archive_get(address)` | List files in an archive | +| 17 | `archive_put(entries)` | Create an archive manifest | ### Payment Modes @@ -120,7 +128,7 @@ antd-mcp/ ├── pyproject.toml └── src/antd_mcp/ ├── __init__.py - ├── server.py # 16 MCP tool definitions + ├── server.py # 17 MCP tool definitions ├── discover.py # Daemon port-file discovery └── errors.py # Error formatting ``` diff --git a/antd-mcp/src/antd_mcp/server.py b/antd-mcp/src/antd_mcp/server.py index c87a42b..c9db591 100644 --- a/antd-mcp/src/antd_mcp/server.py +++ b/antd-mcp/src/antd_mcp/server.py @@ -547,7 +547,32 @@ async def archive_get( # --------------------------------------------------------------------------- -# Tool 16: archive_put +# Tool 16: wallet_approve +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def wallet_approve() -> str: + """Approve the wallet to spend tokens on payment contracts. + + This is a one-time operation required before any storage operations. + Must be called after configuring a wallet but before storing data. + + Returns: + JSON with approved boolean, or error details. + """ + client, network = _get_ctx() + try: + result = await client.wallet_approve() + return _ok({"approved": result}, network) + except AntdError as exc: + return _err_antd(exc, network) + except Exception as exc: + return _err(exc, network) + + +# --------------------------------------------------------------------------- +# Tool 17: archive_put # --------------------------------------------------------------------------- diff --git a/antd-php/src/AntdClient.php b/antd-php/src/AntdClient.php index 65e795d..0d50547 100644 --- a/antd-php/src/AntdClient.php +++ b/antd-php/src/AntdClient.php @@ -840,6 +840,30 @@ public function walletBalanceAsync(): PromiseInterface ); } + /** + * Approve the wallet to spend tokens on payment contracts (one-time operation). + * + * @return bool + * @throws AntdError if no wallet is configured (HTTP 400) + */ + public function walletApprove(): bool + { + $json = $this->doJson('POST', '/v1/wallet/approve', []); + return $json['approved'] ?? false; + } + + /** + * Async: Approve the wallet to spend tokens on payment contracts (one-time operation). + * + * @return PromiseInterface + */ + public function walletApproveAsync(): PromiseInterface + { + return $this->doJsonAsync('POST', '/v1/wallet/approve', [])->then( + fn(?array $json) => $json['approved'] ?? false, + ); + } + /** * Estimate the cost of uploading a file. */ diff --git a/antd-py/src/antd/_rest.py b/antd-py/src/antd/_rest.py index 422022d..65bc11f 100644 --- a/antd-py/src/antd/_rest.py +++ b/antd-py/src/antd/_rest.py @@ -249,6 +249,13 @@ def wallet_balance(self) -> WalletBalance: j = resp.json() return WalletBalance(balance=j["balance"], gas_balance=j["gas_balance"]) + def wallet_approve(self) -> bool: + """Approve the wallet to spend tokens on payment contracts (one-time operation).""" + resp = self._http.post("/v1/wallet/approve", json={}) + _check(resp) + j = resp.json() + return j.get("approved", False) + class AsyncRestClient: """Asynchronous REST client for the antd daemon.""" @@ -456,3 +463,10 @@ async def wallet_balance(self) -> WalletBalance: _check(resp) j = resp.json() return WalletBalance(balance=j["balance"], gas_balance=j["gas_balance"]) + + async def wallet_approve(self) -> bool: + """Approve the wallet to spend tokens on payment contracts (one-time operation).""" + resp = await self._http.post("/v1/wallet/approve", json={}) + _check(resp) + j = resp.json() + return j.get("approved", False) diff --git a/antd-ruby/lib/antd/client.rb b/antd-ruby/lib/antd/client.rb index d65bdd3..6b4694f 100644 --- a/antd-ruby/lib/antd/client.rb +++ b/antd-ruby/lib/antd/client.rb @@ -256,6 +256,13 @@ def wallet_balance WalletBalance.new(balance: j["balance"], gas_balance: j["gas_balance"]) end + # Approve the wallet to spend tokens on payment contracts (one-time operation). + # @return [Boolean] + def wallet_approve + j = do_json(:post, "/v1/wallet/approve", {}) + j["approved"] == true + end + private def b64_encode(data) diff --git a/antd-rust/src/client.rs b/antd-rust/src/client.rs index 81f73c6..e13ba17 100644 --- a/antd-rust/src/client.rs +++ b/antd-rust/src/client.rs @@ -547,4 +547,18 @@ impl Client { gas_balance: Self::str_field(&j, "gas_balance"), }) } + + /// Approves the wallet to spend tokens on payment contracts. + /// This is a one-time operation required before any storage operations. + pub async fn wallet_approve(&self) -> Result { + let (j, _) = self + .do_json( + reqwest::Method::POST, + "/v1/wallet/approve", + Some(json!({})), + ) + .await?; + let j = j.unwrap_or_default(); + Ok(j.get("approved").and_then(|v| v.as_bool()).unwrap_or(false)) + } } diff --git a/antd-swift/Sources/AntdSdk/AntdClientProtocol.swift b/antd-swift/Sources/AntdSdk/AntdClientProtocol.swift index a12d48d..6b232cb 100644 --- a/antd-swift/Sources/AntdSdk/AntdClientProtocol.swift +++ b/antd-swift/Sources/AntdSdk/AntdClientProtocol.swift @@ -38,4 +38,5 @@ public protocol AntdClientProtocol: Sendable { // Wallet func walletAddress() async throws -> WalletAddress func walletBalance() async throws -> WalletBalance + func walletApprove() async throws -> Bool } diff --git a/antd-swift/Sources/AntdSdk/AntdRestClient.swift b/antd-swift/Sources/AntdSdk/AntdRestClient.swift index 1122d89..b7cae2e 100644 --- a/antd-swift/Sources/AntdSdk/AntdRestClient.swift +++ b/antd-swift/Sources/AntdSdk/AntdRestClient.swift @@ -205,6 +205,12 @@ public final class AntdRestClient: AntdClientProtocol, @unchecked Sendable { let resp: WalletBalanceDTO = try await getJSON("/v1/wallet/balance") return WalletBalance(balance: resp.balance, gasBalance: resp.gasBalance) } + + /// Approves the wallet to spend tokens on payment contracts (one-time operation). + public func walletApprove() async throws -> Bool { + let resp: WalletApproveDTO = try await postJSON("/v1/wallet/approve", body: [:] as [String: Any]) + return resp.approved + } } // MARK: - Internal DTOs @@ -265,6 +271,10 @@ private struct WalletBalanceDTO: Decodable { let gasBalance: String } +private struct WalletApproveDTO: Decodable { + let approved: Bool +} + extension JSONDecoder { static let snakeCase: JSONDecoder = { let decoder = JSONDecoder() diff --git a/antd-zig/src/antd.zig b/antd-zig/src/antd.zig index 09a88cc..9bee1af 100644 --- a/antd-zig/src/antd.zig +++ b/antd-zig/src/antd.zig @@ -319,6 +319,13 @@ pub const Client = struct { return json_helpers.parseWalletBalance(self.allocator, resp); } + /// Approve the wallet to spend tokens on payment contracts (one-time operation). + pub fn walletApprove(self: *Client) !bool { + const resp = try self.doRequest(.POST, "/v1/wallet/approve", "{}") orelse return error.JsonError; + defer self.allocator.free(resp); + return json_helpers.parseBoolField(self.allocator, resp, "approved"); + } + // --- Files --- /// Upload a local file to the network. diff --git a/antd-zig/src/json_helpers.zig b/antd-zig/src/json_helpers.zig index 0ef1a69..7d0dee7 100644 --- a/antd-zig/src/json_helpers.zig +++ b/antd-zig/src/json_helpers.zig @@ -428,6 +428,22 @@ pub fn parseWalletBalance(allocator: Allocator, body: []const u8) !models.Wallet }; } +/// Extract a boolean field from a JSON response body. +pub fn parseBoolField(allocator: Allocator, body: []const u8, key: []const u8) !bool { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return error.JsonError; + defer parsed.deinit(); + const obj = switch (parsed.value) { + .object => |o| o, + else => return error.JsonError, + }; + const val = obj.get(key) orelse return false; + return switch (val) { + .bool => |b| b, + else => false, + }; +} + /// Extract the "error" message from a JSON error response body. pub fn parseErrorMessage(allocator: Allocator, body: []const u8) ?[]const u8 { const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return null; diff --git a/antd/Cargo.lock b/antd/Cargo.lock index f4e304e..76b5889 100644 --- a/antd/Cargo.lock +++ b/antd/Cargo.lock @@ -971,6 +971,7 @@ dependencies = [ "prost", "rand 0.8.5", "rmp-serde", + "self_encryption", "serde", "serde_json", "thiserror 2.0.18", diff --git a/antd/Cargo.toml b/antd/Cargo.toml index eed9b6f..1e410a0 100644 --- a/antd/Cargo.toml +++ b/antd/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] ant-core = { git = "https://github.com/WithAutonomi/ant-client" } ant-evm = "0.1.19" +self_encryption = "0.35.0" evmlib = "0.4.9" axum = { version = "0.8", features = ["macros"] } tower-http = { version = "0.6", features = ["cors", "trace"] } diff --git a/antd/src/rest/data.rs b/antd/src/rest/data.rs index c88456a..67e58cd 100644 --- a/antd/src/rest/data.rs +++ b/antd/src/rest/data.rs @@ -116,10 +116,44 @@ pub async fn data_get_private( } pub async fn data_cost( - State(_state): State>, - Json(_req): Json, + State(state): State>, + Json(req): Json, ) -> Result, AntdError> { - Err(AntdError::NotImplemented("data cost estimation not yet available".into())) + let data = BASE64.decode(&req.data) + .map_err(|e| AntdError::BadRequest(format!("invalid base64: {e}")))?; + + // Encrypt to determine chunk count and addresses, then quote each + let client = state.client.clone(); + let total_cost = tokio::spawn(async move { + use self_encryption::encrypt; + let (_data_map, encrypted_chunks) = encrypt(Bytes::from(data)) + .map_err(|e| AntdError::Internal(format!("encryption failed: {e}")))?; + + let mut total = ant_core::data::U256::ZERO; + for chunk in &encrypted_chunks { + let address = ant_core::data::compute_address(&chunk.content); + let data_size = chunk.content.len() as u64; + match client.get_store_quotes(&address, data_size, 0).await { + Ok(quotes) => { + for (_, _, _, price) in "es { + total = total.saturating_add(*price); + } + } + Err(e) => { + // AlreadyStored means no cost for this chunk + let core_err_str = format!("{e}"); + if !core_err_str.contains("AlreadyStored") { + return Err(AntdError::from_core(e)); + } + } + } + } + Ok::<_, AntdError>(total) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(Json(CostResponse { + cost: total_cost.to_string(), + })) } pub async fn data_stream_public( diff --git a/antd/src/rest/files.rs b/antd/src/rest/files.rs index a4a30b9..b66e148 100644 --- a/antd/src/rest/files.rs +++ b/antd/src/rest/files.rs @@ -131,8 +131,46 @@ pub async fn archive_put_public( } pub async fn file_cost( - State(_state): State>, - Json(_req): Json, + State(state): State>, + Json(req): Json, ) -> Result, AntdError> { - Err(AntdError::NotImplemented("file cost estimation not yet available".into())) + let path = PathBuf::from(&req.path); + if !path.exists() { + return Err(AntdError::BadRequest(format!("path not found: {}", req.path))); + } + + // Read file, encrypt to get chunks, then quote each + let client = state.client.clone(); + let total_cost = tokio::spawn(async move { + use self_encryption::encrypt; + let file_data = tokio::fs::read(&path).await + .map_err(|e| AntdError::Internal(format!("failed to read file: {e}")))?; + + let (_data_map, encrypted_chunks) = encrypt(bytes::Bytes::from(file_data)) + .map_err(|e| AntdError::Internal(format!("encryption failed: {e}")))?; + + let mut total = ant_core::data::U256::ZERO; + for chunk in &encrypted_chunks { + let address = ant_core::data::compute_address(&chunk.content); + let data_size = chunk.content.len() as u64; + match client.get_store_quotes(&address, data_size, 0).await { + Ok(quotes) => { + for (_, _, _, price) in "es { + total = total.saturating_add(*price); + } + } + Err(e) => { + let core_err_str = format!("{e}"); + if !core_err_str.contains("AlreadyStored") { + return Err(AntdError::from_core(e)); + } + } + } + } + Ok::<_, AntdError>(total) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(Json(CostResponse { + cost: total_cost.to_string(), + })) } diff --git a/antd/src/rest/mod.rs b/antd/src/rest/mod.rs index d5db591..469e834 100644 --- a/antd/src/rest/mod.rs +++ b/antd/src/rest/mod.rs @@ -47,6 +47,7 @@ pub fn router(state: Arc, enable_cors: bool, rest_port: u16) -> Router // Wallet .route("/v1/wallet/address", get(wallet::wallet_address)) .route("/v1/wallet/balance", get(wallet::wallet_balance)) + .route("/v1/wallet/approve", post(wallet::wallet_approve)) .with_state(state); if enable_cors { diff --git a/antd/src/rest/wallet.rs b/antd/src/rest/wallet.rs index e192d71..bf53308 100644 --- a/antd/src/rest/wallet.rs +++ b/antd/src/rest/wallet.rs @@ -35,3 +35,21 @@ pub async fn wallet_balance( gas_balance: gas_balance.to_string(), })) } + +pub async fn wallet_approve( + State(state): State>, +) -> Result, AntdError> { + if state.client.wallet().is_none() { + return Err(AntdError::BadRequest("no EVM wallet configured".into())); + } + + let client = state.client.clone(); + tokio::spawn(async move { + client.approve_token_spend().await + .map_err(AntdError::from_core) + }).await.map_err(|e| AntdError::Internal(format!("task failed: {e}")))??; + + Ok(Json(WalletApproveResponse { + approved: true, + })) +} diff --git a/antd/src/types.rs b/antd/src/types.rs index 9471866..3001628 100644 --- a/antd/src/types.rs +++ b/antd/src/types.rs @@ -210,6 +210,12 @@ pub struct WalletAddressResponse { pub address: String, } +#[derive(Serialize)] +pub struct WalletApproveResponse { + /// Whether the token spend was approved. + pub approved: bool, +} + // ── Health ── #[derive(Serialize)] diff --git a/llms-full.txt b/llms-full.txt index 52fa3b2..4701a18 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -354,6 +354,25 @@ Estimate file upload cost. Request: `{"path": "/path/to/file", "is_public": true, "include_archive": false}` Response: `{"cost": ""}` +#### `GET /v1/wallet/address` +Get the wallet's public address. + +Response: `{"address": "0x..."}` +Returns 400 if no wallet is configured. + +#### `GET /v1/wallet/balance` +Get the wallet's token and gas balances. + +Response: `{"balance": "", "gas_balance": ""}` +Returns 400 if no wallet is configured. + +#### `POST /v1/wallet/approve` +Approve the wallet to spend tokens on payment contracts (one-time operation before any storage). + +Request: `{}` (empty body) +Response: `{"approved": true}` +Returns 400 if no wallet is configured. + --- ## gRPC API — All Services @@ -481,6 +500,11 @@ client.dirDownloadPublic(address: string, destPath: string): Promise client.archiveGetPublic(address: string): Promise client.archivePutPublic(archive: Archive): Promise client.fileCost(path: string, isPublic?: boolean, includeArchive?: boolean): Promise + +// Wallet +client.walletAddress(): Promise +client.walletBalance(): Promise +client.walletApprove(): Promise ``` --- @@ -521,6 +545,11 @@ client.dir_download_public(address: str, dest_path: str) → None client.archive_get_public(address: str) → Archive client.archive_put_public(archive: Archive) → PutResult client.file_cost(path: str, is_public: bool = True, include_archive: bool = False) → str + +# Wallet +client.wallet_address() → WalletAddress +client.wallet_balance() → WalletBalance +client.wallet_approve() → bool ``` --- @@ -561,6 +590,11 @@ Task DirDownloadPublicAsync(string address, string destPath); Task ArchiveGetPublicAsync(string address); Task ArchivePutPublicAsync(Archive archive); Task FileCostAsync(string path, bool isPublic = true, bool includeArchive = false); + +// Wallet +Task WalletAddressAsync(); +Task WalletBalanceAsync(); +Task WalletApproveAsync(); ``` --- @@ -601,6 +635,11 @@ suspend fun dirDownloadPublic(address: String, destPath: String) suspend fun archiveGetPublic(address: String): Archive suspend fun archivePutPublic(archive: Archive): PutResult suspend fun fileCost(path: String, isPublic: Boolean = true, includeArchive: Boolean = false): String + +// Wallet +suspend fun walletAddress(): WalletAddress +suspend fun walletBalance(): WalletBalance +suspend fun walletApprove(): Boolean ``` --- @@ -643,6 +682,11 @@ func dirDownloadPublic(address: String, destPath: String) async throws func archiveGetPublic(address: String) async throws -> Archive func archivePutPublic(archive: Archive) async throws -> PutResult func fileCost(path: String, isPublic: Bool, includeArchive: Bool) async throws -> String + +// Wallet +func walletAddress() async throws -> WalletAddress +func walletBalance() async throws -> WalletBalance +func walletApprove() async throws -> Bool ``` --- @@ -682,6 +726,11 @@ func (c *Client) DirDownloadPublic(ctx context.Context, address string, destPath func (c *Client) ArchiveGetPublic(ctx context.Context, address string) (*Archive, error) func (c *Client) ArchivePutPublic(ctx context.Context, archive *Archive) (*PutResult, error) func (c *Client) FileCost(ctx context.Context, path string, isPublic bool, includeArchive bool) (string, error) + +// Wallet +func (c *Client) WalletAddress(ctx context.Context) (*WalletAddress, error) +func (c *Client) WalletBalance(ctx context.Context) (*WalletBalance, error) +func (c *Client) WalletApprove(ctx context.Context) error ``` --- @@ -725,6 +774,11 @@ Archive archiveGetPublic(String address) throws AntdException PutResult archivePutPublic(Archive archive) throws AntdException String fileCost(String path, boolean isPublic, boolean includeArchive) throws AntdException +// Wallet +WalletAddress walletAddress() throws AntdException +WalletBalance walletBalance() throws AntdException +boolean walletApprove() throws AntdException + // AntdClient implements AutoCloseable — use try-with-resources ``` @@ -767,6 +821,11 @@ async fn archive_get_public(&self, address: &str) -> Result async fn archive_put_public(&self, archive: &Archive) -> Result async fn file_cost(&self, path: &str, is_public: bool, include_archive: bool) -> Result +// Wallet +async fn wallet_address(&self) -> Result +async fn wallet_balance(&self) -> Result +async fn wallet_approve(&self) -> Result + // AntdError variants: BadRequest, Payment, NotFound, AlreadyExists, Fork, TooLarge, Internal, Network ``` @@ -810,6 +869,11 @@ antd::Archive archiveGetPublic(const std::string& address) antd::PutResult archivePutPublic(const antd::Archive& archive) std::string fileCost(const std::string& path, bool isPublic = true, bool includeArchive = false) +// Wallet +antd::WalletAddress walletAddress() +antd::WalletBalance walletBalance() +bool walletApprove() + // All methods throw antd::AntdError or subclasses: BadRequestError, PaymentError, NotFoundError, // AlreadyExistsError, ForkError, TooLargeError, InternalError, NetworkError ``` @@ -852,6 +916,11 @@ client.dir_download_public(address, dest_path) → nil client.archive_get_public(address) → Archive client.archive_put_public(archive) → PutResult client.file_cost(path, is_public: true, include_archive: false) → String + +# Wallet +client.wallet_address → WalletAddress +client.wallet_balance → WalletBalance +client.wallet_approve → Boolean ``` --- @@ -892,6 +961,11 @@ $client->dirDownloadPublic(string $address, string $destPath): void $client->archiveGetPublic(string $address): Archive $client->archivePutPublic(Archive $archive): PutResult $client->fileCost(string $path, bool $isPublic = true, bool $includeArchive = false): string + +// Wallet +$client->walletAddress(): array{address: string} +$client->walletBalance(): array{balance: string, gas_balance: string} +$client->walletApprove(): bool ``` --- @@ -932,6 +1006,11 @@ Future dirDownloadPublic(String address, String destPath) Future archiveGetPublic(String address) Future archivePutPublic(Archive archive) Future fileCost(String path, {bool isPublic = true, bool includeArchive = false}) + +// Wallet +Future walletAddress() +Future walletBalance() +Future walletApprove() ``` --- @@ -972,6 +1051,11 @@ client:dir_download_public(address, dest_path) → nil client:archive_get_public(address) → Archive client:archive_put_public(archive) → PutResult client:file_cost(path, is_public, include_archive) → string + +-- Wallet +client:wallet_address() → {address=string} +client:wallet_balance() → {balance=string, gas_balance=string} +client:wallet_approve() → boolean ``` --- @@ -1010,6 +1094,11 @@ Antd.Client.dir_download_public(client, address, dest_path) :: :ok | {:error, te Antd.Client.archive_get_public(client, address) :: {:ok, Archive.t()} | {:error, term()} Antd.Client.archive_put_public(client, archive) :: {:ok, PutResult.t()} | {:error, term()} Antd.Client.file_cost(client, path, is_public \\ true, include_archive \\ false) :: {:ok, String.t()} | {:error, term()} + +# Wallet +Antd.Client.wallet_address(client) :: {:ok, WalletAddress.t()} | {:error, term()} +Antd.Client.wallet_balance(client) :: {:ok, WalletBalance.t()} | {:error, term()} +Antd.Client.wallet_approve(client) :: {:ok, boolean()} | {:error, term()} ``` --- @@ -1051,6 +1140,11 @@ fn dirDownloadPublic(self: *Client, address: []const u8, dest_path: []const u8) fn archiveGetPublic(self: *Client, address: []const u8) !Archive fn archivePutPublic(self: *Client, archive: Archive) !PutResult fn fileCost(self: *Client, path: []const u8, is_public: bool, include_archive: bool) ![]u8 + +// Wallet +fn walletAddress(self: *Client) !WalletAddress +fn walletBalance(self: *Client) !WalletBalance +fn walletApprove(self: *Client) !bool ``` --- @@ -1073,6 +1167,9 @@ fn fileCost(self: *Client, path: []const u8, is_public: bool, include_archive: b | `archive_put` | `archive_put_public` | Create archive | | `get_cost` | `data_cost` / `file_cost` | Estimate storage cost | | `check_balance` | `health` | Check daemon health | +| `wallet_address` | `wallet_address` | Get wallet public address | +| `wallet_balance` | `wallet_balance` | Get wallet balances | +| `wallet_approve` | `wallet_approve` | Approve wallet for payments | --- diff --git a/llms.txt b/llms.txt index e4ca5d3..16886a9 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # antd SDK -> Go, JavaScript/TypeScript, Python, C#, Kotlin, Swift, Ruby, PHP, Dart, Lua, Elixir, Zig, Rust, C++, and Java SDKs for the Autonomi network via the antd daemon. Provides REST and gRPC transports, an MCP server (14 tools), and a local daemon that manages wallet, payments, and network connectivity. +> Go, JavaScript/TypeScript, Python, C#, Kotlin, Swift, Ruby, PHP, Dart, Lua, Elixir, Zig, Rust, C++, and Java SDKs for the Autonomi network via the antd daemon. Provides REST and gRPC transports, an MCP server (17 tools), and a local daemon that manages wallet, payments, and network connectivity. ## Quick Start @@ -28,7 +28,7 @@ data = client.data_get_public(result.address) - [Rust SDK](antd-rust/) — `cargo add antd-client` — REST + gRPC, async/tokio - [C++ SDK](antd-cpp/) — CMake FetchContent — REST + gRPC, sync + async (C++20) - [Java SDK](antd-java/) — Gradle/Maven — REST + gRPC, sync + async (Java 17+) -- [MCP Server](antd-mcp/) — 14 tools for AI agent integration +- [MCP Server](antd-mcp/) — 17 tools for AI agent integration ## API Reference @@ -60,6 +60,9 @@ data = client.data_get_public(result.address) | GET | `/v1/archives/public/{addr}` | List archive entries | | POST | `/v1/archives/public` | Create archive manifest | | POST | `/v1/cost/file` | Estimate file upload cost | +| GET | `/v1/wallet/address` | Get wallet public address | +| GET | `/v1/wallet/balance` | Get wallet token and gas balances | +| POST | `/v1/wallet/approve` | Approve wallet to spend tokens on payment contracts | ## gRPC Services diff --git a/skill.md b/skill.md index 4e0f0d4..855c462 100644 --- a/skill.md +++ b/skill.md @@ -109,7 +109,7 @@ entry2 = client.graph_entry_put(key2, parents=[entry1.address], content=content2 ## Key Rules -1. **Every write costs tokens.** Always offer to estimate cost first with the `*_cost` methods. Reads are free. +1. **Every write costs tokens.** Always offer to estimate cost first with the `*_cost` methods (now fully implemented for both data and files). Before the first storage operation, the wallet must be approved via `wallet_approve()`. Reads are free. 2. **Data is permanent.** Once stored, it cannot be deleted. Warn developers about storing sensitive data publicly. 3. **No access revocation.** Once data is public, it stays public. 4. **Content-addressed = deduplication.** Storing the same bytes twice produces the same address and doesn't cost extra. From ecc83364b957582f6542502a1874e7228a68cb61 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Thu, 26 Mar 2026 14:51:06 +0000 Subject: [PATCH 10/10] Use HTTP 503 for missing wallet instead of 400/402 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When antd is started without AUTONOMI_WALLET_KEY, write operations (chunk put, data put, file upload, wallet endpoints) now return 503 Service Unavailable instead of 400 Bad Request or 402 Payment Required. This correctly signals a server configuration issue rather than a client error — the request is valid but the server can't fulfil it without a wallet. - antd: Added ServiceUnavailable error variant (503 / gRPC UNAVAILABLE) - All 15 SDKs: Added ServiceUnavailableError exception type, mapped 503 - Docs: Updated error tables in llms.txt, llms-full.txt, skill.md Co-Authored-By: Claude Opus 4.6 (1M context) --- antd-cpp/include/antd/errors.hpp | 7 +++++++ antd-csharp/Antd.Sdk/Exceptions.cs | 7 +++++++ antd-dart/lib/src/errors.dart | 7 +++++++ antd-elixir/lib/antd/errors.ex | 12 ++++++++++++ antd-go/errors.go | 6 ++++++ .../com/autonomi/antd/errors/ExceptionFactory.java | 1 + .../antd/errors/ServiceUnavailableException.java | 8 ++++++++ antd-js/src/errors.ts | 9 +++++++++ .../src/main/kotlin/com/autonomi/sdk/Exceptions.kt | 2 ++ antd-lua/src/antd/errors.lua | 8 ++++++++ antd-php/src/Errors/ErrorFactory.php | 1 + antd-php/src/Errors/ServiceUnavailableError.php | 14 ++++++++++++++ antd-py/src/antd/exceptions.py | 6 ++++++ antd-ruby/lib/antd/errors.rb | 6 ++++++ antd-rust/src/errors.rs | 5 +++++ antd-swift/Sources/AntdSdk/Errors.swift | 7 +++++++ antd-zig/src/errors.zig | 2 ++ antd/src/error.rs | 5 +++++ antd/src/grpc/service.rs | 4 ++-- antd/src/rest/chunks.rs | 4 ++-- antd/src/rest/data.rs | 4 ++-- antd/src/rest/files.rs | 4 ++-- antd/src/rest/wallet.rs | 6 +++--- llms-full.txt | 1 + llms.txt | 1 + skill.md | 1 + 26 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 antd-java/src/main/java/com/autonomi/antd/errors/ServiceUnavailableException.java create mode 100644 antd-php/src/Errors/ServiceUnavailableError.php diff --git a/antd-cpp/include/antd/errors.hpp b/antd-cpp/include/antd/errors.hpp index 6978d46..ee4d84c 100644 --- a/antd-cpp/include/antd/errors.hpp +++ b/antd-cpp/include/antd/errors.hpp @@ -63,6 +63,12 @@ class NetworkError : public AntdError { NetworkError(const std::string& msg) : AntdError(502, msg) {} }; +/// Service unavailable, e.g. wallet not configured (HTTP 503). +class ServiceUnavailableError : public AntdError { +public: + ServiceUnavailableError(const std::string& msg) : AntdError(503, msg) {} +}; + /// Throw the appropriate AntdError subclass for an HTTP status code. [[noreturn]] inline void error_for_status(int code, const std::string& message) { switch (code) { @@ -73,6 +79,7 @@ class NetworkError : public AntdError { case 413: throw TooLargeError(message); case 500: throw InternalError(message); case 502: throw NetworkError(message); + case 503: throw ServiceUnavailableError(message); default: throw AntdError(code, message); } } diff --git a/antd-csharp/Antd.Sdk/Exceptions.cs b/antd-csharp/Antd.Sdk/Exceptions.cs index 1f17f41..c1ea8d2 100644 --- a/antd-csharp/Antd.Sdk/Exceptions.cs +++ b/antd-csharp/Antd.Sdk/Exceptions.cs @@ -47,6 +47,12 @@ public NetworkException(string message, int statusCode = 502) : base(message, statusCode) { } } +public class ServiceUnavailableException : AntdException +{ + public ServiceUnavailableException(string message, int statusCode = 503) + : base(message, statusCode) { } +} + public class TooLargeException : AntdException { public TooLargeException(string message, int statusCode = 413) @@ -73,6 +79,7 @@ public static AntdException FromHttpStatus(HttpStatusCode status, string body) 413 => new TooLargeException(body, code), 500 => new InternalException(body, code), 502 => new NetworkException(body, code), + 503 => new ServiceUnavailableException(body, code), _ => new AntdException(body, code), }; } diff --git a/antd-dart/lib/src/errors.dart b/antd-dart/lib/src/errors.dart index c9e73eb..1f6cde3 100644 --- a/antd-dart/lib/src/errors.dart +++ b/antd-dart/lib/src/errors.dart @@ -52,6 +52,11 @@ class NetworkError extends AntdError { const NetworkError(String message) : super(502, message); } +/// Service unavailable, e.g. wallet not configured (HTTP 503). +class ServiceUnavailableError extends AntdError { + const ServiceUnavailableError(String message) : super(503, message); +} + /// Returns the appropriate error type for an HTTP status code. AntdError errorForStatus(int statusCode, String message) { switch (statusCode) { @@ -69,6 +74,8 @@ AntdError errorForStatus(int statusCode, String message) { return InternalError(message); case 502: return NetworkError(message); + case 503: + return ServiceUnavailableError(message); default: return AntdError(statusCode, message); } diff --git a/antd-elixir/lib/antd/errors.ex b/antd-elixir/lib/antd/errors.ex index fae41a0..1b1834b 100644 --- a/antd-elixir/lib/antd/errors.ex +++ b/antd-elixir/lib/antd/errors.ex @@ -97,6 +97,17 @@ defmodule Antd.NetworkError do } end +defmodule Antd.ServiceUnavailableError do + @moduledoc "Service unavailable, e.g. wallet not configured (HTTP 503)." + + defexception [:message, :status_code] + + @type t :: %__MODULE__{ + message: String.t(), + status_code: integer() + } +end + defmodule Antd.Errors do @moduledoc false @@ -111,6 +122,7 @@ defmodule Antd.Errors do 413 -> %Antd.TooLargeError{message: message, status_code: 413} 500 -> %Antd.InternalError{message: message, status_code: 500} 502 -> %Antd.NetworkError{message: message, status_code: 502} + 503 -> %Antd.ServiceUnavailableError{message: message, status_code: 503} _ -> %Antd.AntdError{message: message, status_code: status_code} end end diff --git a/antd-go/errors.go b/antd-go/errors.go index 501cde5..ee53fe1 100644 --- a/antd-go/errors.go +++ b/antd-go/errors.go @@ -37,6 +37,10 @@ type InternalError struct{ AntdError } // NetworkError indicates the daemon cannot reach the network (HTTP 502). type NetworkError struct{ AntdError } +// ServiceUnavailableError indicates the daemon is missing a required +// dependency such as a wallet (HTTP 503). +type ServiceUnavailableError struct{ AntdError } + // errorForStatus returns the appropriate error type for an HTTP status code. func errorForStatus(statusCode int, message string) error { base := AntdError{StatusCode: statusCode, Message: message} @@ -55,6 +59,8 @@ func errorForStatus(statusCode int, message string) error { return &InternalError{base} case 502: return &NetworkError{base} + case 503: + return &ServiceUnavailableError{base} default: return &base } diff --git a/antd-java/src/main/java/com/autonomi/antd/errors/ExceptionFactory.java b/antd-java/src/main/java/com/autonomi/antd/errors/ExceptionFactory.java index 1153ee6..1567848 100644 --- a/antd-java/src/main/java/com/autonomi/antd/errors/ExceptionFactory.java +++ b/antd-java/src/main/java/com/autonomi/antd/errors/ExceptionFactory.java @@ -23,6 +23,7 @@ public static AntdException fromHttpStatus(int statusCode, String message) { case 413 -> new TooLargeException(message); case 500 -> new InternalException(message); case 502 -> new NetworkException(message); + case 503 -> new ServiceUnavailableException(message); default -> new AntdException(statusCode, message); }; } diff --git a/antd-java/src/main/java/com/autonomi/antd/errors/ServiceUnavailableException.java b/antd-java/src/main/java/com/autonomi/antd/errors/ServiceUnavailableException.java new file mode 100644 index 0000000..aea2860 --- /dev/null +++ b/antd-java/src/main/java/com/autonomi/antd/errors/ServiceUnavailableException.java @@ -0,0 +1,8 @@ +package com.autonomi.antd.errors; + +/** Service unavailable, e.g. wallet not configured (HTTP 503). */ +public class ServiceUnavailableException extends AntdException { + public ServiceUnavailableException(String message) { + super(503, message); + } +} diff --git a/antd-js/src/errors.ts b/antd-js/src/errors.ts index 6d94f65..0bb2d03 100644 --- a/antd-js/src/errors.ts +++ b/antd-js/src/errors.ts @@ -65,6 +65,14 @@ export class TooLargeError extends AntdError { } } +/** Service unavailable, e.g. wallet not configured (HTTP 503). */ +export class ServiceUnavailableError extends AntdError { + constructor(message: string, statusCode: number = 503) { + super(message, statusCode); + this.name = "ServiceUnavailableError"; + } +} + /** Internal server error (HTTP 500). */ export class InternalError extends AntdError { constructor(message: string, statusCode: number = 500) { @@ -82,6 +90,7 @@ const HTTP_STATUS_MAP: Record TooLargeException(body, statusCode) 500 -> InternalException(body, statusCode) 502 -> NetworkException(body, statusCode) + 503 -> ServiceUnavailableException(body, statusCode) else -> AntdException(body, statusCode) } diff --git a/antd-lua/src/antd/errors.lua b/antd-lua/src/antd/errors.lua index 5cf8f70..51b203b 100644 --- a/antd-lua/src/antd/errors.lua +++ b/antd-lua/src/antd/errors.lua @@ -74,6 +74,13 @@ function M.network(message) return new_error("network", 502, message) end +--- Create a service_unavailable error (HTTP 503). +-- @param message string +-- @return table +function M.service_unavailable(message) + return new_error("service_unavailable", 503, message) +end + --- Return the appropriate error for an HTTP status code. -- @param code number HTTP status code -- @param message string error message @@ -86,6 +93,7 @@ function M.error_for_status(code, message) if code == 413 then return M.too_large(message) end if code == 500 then return M.internal(message) end if code == 502 then return M.network(message) end + if code == 503 then return M.service_unavailable(message) end return new_error("unknown", code, message) end diff --git a/antd-php/src/Errors/ErrorFactory.php b/antd-php/src/Errors/ErrorFactory.php index 2a7190f..476bb10 100644 --- a/antd-php/src/Errors/ErrorFactory.php +++ b/antd-php/src/Errors/ErrorFactory.php @@ -19,6 +19,7 @@ public static function fromHttpStatus(int $code, string $message): AntdError 413 => new TooLargeError($message), 500 => new InternalError($message), 502 => new NetworkError($message), + 503 => new ServiceUnavailableError($message), default => new AntdError($code, $message), }; } diff --git a/antd-php/src/Errors/ServiceUnavailableError.php b/antd-php/src/Errors/ServiceUnavailableError.php new file mode 100644 index 0000000..42b7adf --- /dev/null +++ b/antd-php/src/Errors/ServiceUnavailableError.php @@ -0,0 +1,14 @@ + AntdError { 413 => AntdError::TooLarge(message), 500 => AntdError::Internal(message), 502 => AntdError::Network(message), + 503 => AntdError::ServiceUnavailable(message), _ => AntdError::Internal(format!("unexpected status {code}: {message}")), } } diff --git a/antd-swift/Sources/AntdSdk/Errors.swift b/antd-swift/Sources/AntdSdk/Errors.swift index dad9977..de09bbc 100644 --- a/antd-swift/Sources/AntdSdk/Errors.swift +++ b/antd-swift/Sources/AntdSdk/Errors.swift @@ -61,6 +61,12 @@ public final class InternalError: AntdError { } } +public final class ServiceUnavailableError: AntdError { + public init(_ message: String, statusCode: Int = 503) { + super.init(message, statusCode: statusCode) + } +} + enum ErrorMapping { static func fromHTTPStatus(_ statusCode: Int, body: String) -> AntdError { @@ -72,6 +78,7 @@ enum ErrorMapping { case 413: return TooLargeError(body, statusCode: statusCode) case 500: return InternalError(body, statusCode: statusCode) case 502: return NetworkError(body, statusCode: statusCode) + case 503: return ServiceUnavailableError(body, statusCode: statusCode) default: return AntdError(body, statusCode: statusCode) } } diff --git a/antd-zig/src/errors.zig b/antd-zig/src/errors.zig index e21f4ac..4e842d9 100644 --- a/antd-zig/src/errors.zig +++ b/antd-zig/src/errors.zig @@ -8,6 +8,7 @@ pub const AntdError = error{ TooLarge, Internal, Network, + ServiceUnavailable, UnexpectedStatus, HttpError, JsonError, @@ -29,6 +30,7 @@ pub fn errorForStatus(code: u16) AntdError { 413 => error.TooLarge, 500 => error.Internal, 502 => error.Network, + 503 => error.ServiceUnavailable, else => error.UnexpectedStatus, }; } diff --git a/antd/src/error.rs b/antd/src/error.rs index 9ca332a..16809be 100644 --- a/antd/src/error.rs +++ b/antd/src/error.rs @@ -25,6 +25,9 @@ pub enum AntdError { #[error("Timeout: {0}")] Timeout(String), + #[error("Service unavailable: {0}")] + ServiceUnavailable(String), + #[error("Not implemented: {0}")] NotImplemented(String), @@ -66,6 +69,7 @@ impl IntoResponse for AntdError { AntdError::Network(_) => StatusCode::BAD_GATEWAY, AntdError::TooLarge => StatusCode::PAYLOAD_TOO_LARGE, AntdError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT, + AntdError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, AntdError::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED, AntdError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, }; @@ -87,6 +91,7 @@ impl From for tonic::Status { AntdError::Network(msg) => tonic::Status::unavailable(msg), AntdError::TooLarge => tonic::Status::resource_exhausted("too large for memory"), AntdError::Timeout(msg) => tonic::Status::deadline_exceeded(msg), + AntdError::ServiceUnavailable(msg) => tonic::Status::unavailable(msg), AntdError::NotImplemented(msg) => tonic::Status::unimplemented(msg), AntdError::Internal(msg) => tonic::Status::internal(msg), } diff --git a/antd/src/grpc/service.rs b/antd/src/grpc/service.rs index a9723f9..360adf4 100644 --- a/antd/src/grpc/service.rs +++ b/antd/src/grpc/service.rs @@ -101,8 +101,8 @@ impl pb::chunk_service_server::ChunkService for ChunkServiceImpl { let data = request.into_inner().data; if self.state.client.wallet().is_none() { - return Err(Status::failed_precondition( - "no EVM wallet configured — set AUTONOMI_WALLET_KEY", + return Err(Status::unavailable( + "wallet not configured — set AUTONOMI_WALLET_KEY", )); } diff --git a/antd/src/rest/chunks.rs b/antd/src/rest/chunks.rs index 8560869..e03c9cd 100644 --- a/antd/src/rest/chunks.rs +++ b/antd/src/rest/chunks.rs @@ -34,8 +34,8 @@ pub async fn chunk_put( Json(req): Json, ) -> Result, AntdError> { if state.client.wallet().is_none() { - return Err(AntdError::Payment( - "no EVM wallet configured — set AUTONOMI_WALLET_KEY".into(), + return Err(AntdError::ServiceUnavailable( + "wallet not configured — set AUTONOMI_WALLET_KEY".into(), )); } diff --git a/antd/src/rest/data.rs b/antd/src/rest/data.rs index 67e58cd..3b6dde7 100644 --- a/antd/src/rest/data.rs +++ b/antd/src/rest/data.rs @@ -16,7 +16,7 @@ pub async fn data_put_public( Json(req): Json, ) -> Result, AntdError> { if state.client.wallet().is_none() { - return Err(AntdError::Payment("no EVM wallet configured — set AUTONOMI_WALLET_KEY".into())); + return Err(AntdError::ServiceUnavailable("wallet not configured — set AUTONOMI_WALLET_KEY".into())); } let data = BASE64.decode(&req.data) @@ -69,7 +69,7 @@ pub async fn data_put_private( Json(req): Json, ) -> Result, AntdError> { if state.client.wallet().is_none() { - return Err(AntdError::Payment("no EVM wallet configured — set AUTONOMI_WALLET_KEY".into())); + return Err(AntdError::ServiceUnavailable("wallet not configured — set AUTONOMI_WALLET_KEY".into())); } let data = BASE64.decode(&req.data) diff --git a/antd/src/rest/files.rs b/antd/src/rest/files.rs index b66e148..428c820 100644 --- a/antd/src/rest/files.rs +++ b/antd/src/rest/files.rs @@ -13,7 +13,7 @@ pub async fn file_upload_public( Json(req): Json, ) -> Result, AntdError> { if state.client.wallet().is_none() { - return Err(AntdError::Payment("no EVM wallet configured — set AUTONOMI_WALLET_KEY".into())); + return Err(AntdError::ServiceUnavailable("wallet not configured — set AUTONOMI_WALLET_KEY".into())); } let path = PathBuf::from(&req.path); @@ -67,7 +67,7 @@ pub async fn dir_upload_public( Json(req): Json, ) -> Result, AntdError> { if state.client.wallet().is_none() { - return Err(AntdError::Payment("no EVM wallet configured — set AUTONOMI_WALLET_KEY".into())); + return Err(AntdError::ServiceUnavailable("wallet not configured — set AUTONOMI_WALLET_KEY".into())); } let path = PathBuf::from(&req.path); diff --git a/antd/src/rest/wallet.rs b/antd/src/rest/wallet.rs index bf53308..0a1092e 100644 --- a/antd/src/rest/wallet.rs +++ b/antd/src/rest/wallet.rs @@ -11,7 +11,7 @@ pub async fn wallet_address( State(state): State>, ) -> Result, AntdError> { let wallet = state.client.wallet() - .ok_or_else(|| AntdError::BadRequest("no EVM wallet configured".into()))?; + .ok_or_else(|| AntdError::ServiceUnavailable("wallet not configured — set AUTONOMI_WALLET_KEY".into()))?; Ok(Json(WalletAddressResponse { address: format!("{:#x}", wallet.address()), @@ -22,7 +22,7 @@ pub async fn wallet_balance( State(state): State>, ) -> Result, AntdError> { let wallet = state.client.wallet() - .ok_or_else(|| AntdError::BadRequest("no EVM wallet configured".into()))?; + .ok_or_else(|| AntdError::ServiceUnavailable("wallet not configured — set AUTONOMI_WALLET_KEY".into()))?; let balance = wallet.balance_of_tokens().await .map_err(|e| AntdError::Internal(format!("failed to get token balance: {e}")))?; @@ -40,7 +40,7 @@ pub async fn wallet_approve( State(state): State>, ) -> Result, AntdError> { if state.client.wallet().is_none() { - return Err(AntdError::BadRequest("no EVM wallet configured".into())); + return Err(AntdError::ServiceUnavailable("wallet not configured — set AUTONOMI_WALLET_KEY".into())); } let client = state.client.clone(); diff --git a/llms-full.txt b/llms-full.txt index 4701a18..5e89218 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1185,6 +1185,7 @@ fn walletApprove(self: *Client) !bool | 413 | RESOURCE_EXHAUSTED | `TooLargeError` | `TooLargeError` | `TooLargeException` | `TooLargeException` | `TooLargeError` | `TooLargeException` | `AntdError::TooLarge` | `TooLargeError` | Data exceeds size limit | | 500 | INTERNAL | `InternalError` | `InternalError` | `InternalException` | `InternalException` | `InternalError` | `InternalException` | `AntdError::Internal` | `InternalError` | Internal server error | | 502 | UNAVAILABLE | `NetworkError` | `NetworkError` | `NetworkException` | `NetworkException` | `NetworkError` | `NetworkException` | `AntdError::Network` | `NetworkError` | Cannot reach network | +| 503 | UNAVAILABLE | `ServiceUnavailableError` | `ServiceUnavailableError` | `ServiceUnavailableException` | `ServiceUnavailableException` | `ServiceUnavailableError` | `ServiceUnavailableException` | `AntdError::ServiceUnavailable` | `ServiceUnavailableError` | Wallet not configured | All exceptions inherit from `AntdError` (JS/TS, Python, Swift, Ruby, Lua, Zig, C++) / `AntdException` (C#, Kotlin, Java, PHP, Dart, Elixir uses `{:error, reason}` tuples). Rust uses `Result` with `match` on enum variants. diff --git a/llms.txt b/llms.txt index 16886a9..0237bff 100644 --- a/llms.txt +++ b/llms.txt @@ -86,6 +86,7 @@ data = client.data_get_public(result.address) | 413 | RESOURCE_EXHAUSTED | TooLargeError | Payload too large | | 500 | INTERNAL | InternalError | Server error | | 502 | UNAVAILABLE | NetworkError | Network unreachable | +| 503 | UNAVAILABLE | ServiceUnavailableError | Wallet not configured | ## Examples diff --git a/skill.md b/skill.md index 855c462..739bca9 100644 --- a/skill.md +++ b/skill.md @@ -125,6 +125,7 @@ All SDKs use the same error hierarchy. Always generate code with proper error ha | `PaymentError` / `PaymentException` | 402 | Wallet has insufficient funds | | `AlreadyExistsError` / `AlreadyExistsException` | 409 | Trying to create something that exists | | `NetworkError` / `NetworkException` | 502 | Daemon can't reach the network | +| `ServiceUnavailableError` / `ServiceUnavailableException` | 503 | Wallet not configured | | `BadRequestError` / `BadRequestException` | 400 | Invalid parameters | Python/JS/Swift use `Error` suffix. C#/Kotlin use `Exception` suffix. All inherit from a base `AntdError`/`AntdException`.