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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions antd-swift/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let package = Package(
.target(
name: "AntdSdk",
dependencies: [
.product(name: "GRPCCore", package: "grpc-swift"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
.product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"),
.product(name: "SwiftProtobuf", package: "swift-protobuf"),
Expand Down
14 changes: 13 additions & 1 deletion antd-swift/Sources/AntdSdk/AntdClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,20 @@ public enum AntdClient {
}

/// Create a gRPC client connecting to the antd daemon.
///
/// > Requires macOS 15+ / iOS 18+ / tvOS 18+ / watchOS 11+ (grpc-swift 2.x).
/// > Use ``createRest(baseURL:timeout:)`` on older platforms.
///
/// - Parameter target: gRPC target address (default: localhost:50051)
@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *)
public static func createGrpc(target: String = "localhost:50051") -> AntdClientProtocol {
AntdGrpcClient(target: target)
}

/// Create a client using the specified transport.
///
/// > `transport: "grpc"` requires macOS 15+ / iOS 18+ / tvOS 18+ / watchOS 11+.
///
/// - Parameters:
/// - transport: "rest" or "grpc"
/// - endpoint: Optional custom endpoint override
Expand All @@ -34,7 +42,11 @@ public enum AntdClient {
case "rest":
return createRest(baseURL: endpoint ?? "http://localhost:8082")
case "grpc":
return createGrpc(target: endpoint ?? "localhost:50051")
if #available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) {
return createGrpc(target: endpoint ?? "localhost:50051")
} else {
fatalError("gRPC transport requires macOS 15+ / iOS 18+. Use 'rest' on older platforms.")
}
default:
fatalError("Unknown transport: \(transport). Use 'rest' or 'grpc'.")
}
Expand Down
199 changes: 181 additions & 18 deletions antd-swift/Sources/AntdSdk/AntdGrpcClient.swift
Original file line number Diff line number Diff line change
@@ -1,27 +1,62 @@
import Foundation
import GRPCCore
import GRPCNIOTransportHTTP2
import GRPCProtobuf

/// gRPC client for the antd daemon.
/// gRPC client for the antd daemon (grpc-swift 2.x).
///
/// > Note: The gRPC client requires the generated protobuf stubs from `antd/proto/antd/v1/`.
/// > Run `scripts/generate-protos.sh` to generate them into `Sources/AntdSdk/Proto/`.
/// > Until proto generation is set up, use ``AntdRestClient`` instead.
/// > Note: grpc-swift 2.x requires macOS 15+ / iOS 18+ / tvOS 18+ / watchOS 11+.
/// > Use ``AntdRestClient`` via ``AntdClient/createRest(baseURL:timeout:)`` on
/// > older platforms.
///
/// Currently implements the V2-284 external-signer prepare/finalize surface
/// (file + data + chunks). Other RPCs throw until subsequent gRPC fan-out work
/// lands; ``AntdRestClient`` is the recommended default for those.
@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *)
public final class AntdGrpcClient: AntdClientProtocol, @unchecked Sendable {

private let target: String
private let host: String
private let port: Int

public init(target: String = "localhost:50051") {
self.target = target
let (h, p) = Self.parseTarget(target)
self.host = h
self.port = p
}

/// Accepts `"host:port"` or `"host"` (default port 50051).
private static func parseTarget(_ target: String) -> (String, Int) {
if let colon = target.lastIndex(of: ":"),
let p = Int(target[target.index(after: colon)...]) {
return (String(target[..<colon]), p)
}
return (target, 50051)
}

// MARK: - Connection helper

/// Opens a one-shot grpc-swift connection to the daemon for the duration of
/// `body`. Per-call connection setup keeps the SDK surface simple; the cost
/// is one TCP handshake per RPC, which is acceptable for the external-signer
/// flow (caller makes one prepare + one finalize per upload).
private func withGRPC<T: Sendable>(
_ body: @Sendable (GRPCClient<HTTP2ClientTransport.Posix>) async throws -> T
) async throws -> T {
do {
let transport = try HTTP2ClientTransport.Posix(
target: .dns(host: host, port: port),
transportSecurity: .plaintext
)
return try await withGRPCClient(transport: transport, handleClient: body)
} catch let rpcError as RPCError {
throw ErrorMapping.fromGRPCStatus(code: rpcError.code.rawValue, detail: rpcError.message)
}
}

// MARK: - Placeholder implementations
// Full gRPC implementation requires generated proto stubs.
// The REST client is the recommended default. These methods
// throw an error until proto generation is configured.
// MARK: - Out-of-scope (later gRPC tickets)

private func notImplemented() -> AntdError {
InternalError("gRPC client requires generated proto stubs. Use AntdClient.createRest() or run scripts/generate-protos.sh first.")
InternalError("not yet implemented on the gRPC client; use AntdClient.createRest()")
}

public func health() async throws -> HealthStatus { throw notImplemented() }
Expand All @@ -32,8 +67,6 @@ public final class AntdGrpcClient: AntdClientProtocol, @unchecked Sendable {
public func dataCost(_ data: Data, paymentMode: PaymentMode = .auto) async throws -> UploadCostEstimate { throw notImplemented() }
public func chunkPut(_ data: Data) async throws -> PutResult { throw notImplemented() }
public func chunkGet(address: String) async throws -> Data { throw notImplemented() }
public func prepareChunkUpload(_ data: Data) async throws -> PrepareChunkResult { throw notImplemented() }
public func finalizeChunkUpload(uploadId: String, txHashes: [String: String]) async throws -> String { throw notImplemented() }
public func filePut(path: String, paymentMode: PaymentMode = .auto) async throws -> FilePutResult { throw notImplemented() }
public func fileGet(dataMap: String, destPath: String) async throws { throw notImplemented() }
public func filePutPublic(path: String, paymentMode: PaymentMode = .auto) async throws -> FilePutPublicResult { throw notImplemented() }
Expand All @@ -42,9 +75,139 @@ public final class AntdGrpcClient: AntdClientProtocol, @unchecked Sendable {
public func walletAddress() async throws -> WalletAddress { throw notImplemented() }
public func walletBalance() async throws -> WalletBalance { throw notImplemented() }
public func walletApprove() async throws -> Bool { throw notImplemented() }
public func prepareUpload(path: String, visibility: String? = nil) async throws -> PrepareUploadResult { throw notImplemented() }
public func prepareUploadPublic(path: String) async throws -> PrepareUploadResult { throw notImplemented() }
public func prepareDataUpload(_ data: Data) async throws -> PrepareUploadResult { throw notImplemented() }
public func finalizeUpload(uploadId: String, txHashes: [String: String]) async throws -> FinalizeUploadResult { throw notImplemented() }
public func finalizeMerkleUpload(uploadId: String, winnerPoolHash: String) async throws -> FinalizeMerkleUploadResult { throw notImplemented() }

// MARK: - External signer (V2-284)

/// Prepares a file upload for external signing.
///
/// `visibility == "public"` bundles the DataMap chunk into the same payment
/// batch, so finalize can return its on-network address via
/// ``FinalizeUploadResult/dataMapAddress``. `nil` leaves the proto3 default
/// of `""`, preserving the wire shape for daemons predating the
/// public-prepare addition.
public func prepareUpload(path: String, visibility: String? = nil) async throws -> PrepareUploadResult {
try await withGRPC { client in
var req = Antd_V1_PrepareFileUploadRequest()
req.path = path
if let visibility = visibility { req.visibility = visibility }
let resp = try await Antd_V1_UploadService.Client(wrapping: client).prepareFileUpload(req)
return Self.mapPrepare(resp)
}
}

public func prepareUploadPublic(path: String) async throws -> PrepareUploadResult {
try await prepareUpload(path: path, visibility: "public")
}

public func prepareDataUpload(_ data: Data) async throws -> PrepareUploadResult {
try await withGRPC { client in
var req = Antd_V1_PrepareDataUploadRequest()
req.data = data
let resp = try await Antd_V1_UploadService.Client(wrapping: client).prepareDataUpload(req)
return Self.mapPrepare(resp)
}
}

public func finalizeUpload(uploadId: String, txHashes: [String: String]) async throws -> FinalizeUploadResult {
try await withGRPC { client in
var req = Antd_V1_FinalizeUploadRequest()
req.uploadID = uploadId
req.txHashes = txHashes
let resp = try await Antd_V1_UploadService.Client(wrapping: client).finalizeUpload(req)
return FinalizeUploadResult(
address: resp.address,
chunksStored: Int64(resp.chunksStored),
dataMap: resp.dataMap,
dataMapAddress: resp.dataMapAddress
)
}
}

/// Merkle finalize uses the same FinalizeUpload RPC; `winnerPoolHash` is
/// populated and `txHashes` left empty. Mirrors the REST surface, which
/// also does not expose the legacy `store_data_map` daemon-wallet path
/// (use `visibility = "public"` on prepare for the public-DataMap case).
public func finalizeMerkleUpload(uploadId: String, winnerPoolHash: String) async throws -> FinalizeMerkleUploadResult {
try await withGRPC { client in
var req = Antd_V1_FinalizeUploadRequest()
req.uploadID = uploadId
req.winnerPoolHash = winnerPoolHash
let resp = try await Antd_V1_UploadService.Client(wrapping: client).finalizeUpload(req)
return FinalizeMerkleUploadResult(
address: resp.address,
chunksStored: Int64(resp.chunksStored),
dataMap: resp.dataMap,
dataMapAddress: resp.dataMapAddress
)
}
}

public func prepareChunkUpload(_ data: Data) async throws -> PrepareChunkResult {
try await withGRPC { client in
var req = Antd_V1_PrepareChunkRequest()
req.data = data
let resp = try await Antd_V1_ChunkService.Client(wrapping: client).prepareChunk(req)
let payments = resp.payments.map {
PaymentInfo(quoteHash: $0.quoteHash, rewardsAddress: $0.rewardsAddress, amount: $0.amount)
}
return PrepareChunkResult(
address: resp.address,
alreadyStored: resp.alreadyStored,
uploadId: resp.uploadID,
paymentType: resp.paymentType,
payments: payments,
totalAmount: resp.totalAmount,
paymentVaultAddress: resp.paymentVaultAddress,
paymentTokenAddress: resp.paymentTokenAddress,
rpcUrl: resp.rpcURL
)
}
}

public func finalizeChunkUpload(uploadId: String, txHashes: [String: String]) async throws -> String {
try await withGRPC { client in
var req = Antd_V1_FinalizeChunkRequest()
req.uploadID = uploadId
req.txHashes = txHashes
let resp = try await Antd_V1_ChunkService.Client(wrapping: client).finalizeChunk(req)
return resp.address
}
}

// MARK: - Mapping

/// Merkle-only fields (`depth`, `poolCommitments`, `merklePaymentTimestamp`)
/// are gated on `payment_type == "merkle"`. proto3 scalar defaults are not
/// enough — REST omits these fields entirely on wave-batch, and the model
/// layer expects `nil` there.
private static func mapPrepare(_ resp: Antd_V1_PrepareUploadResponse) -> PrepareUploadResult {
let payments = resp.payments.map {
PaymentInfo(quoteHash: $0.quoteHash, rewardsAddress: $0.rewardsAddress, amount: $0.amount)
}
let isMerkle = resp.paymentType == "merkle"
let depth: Int? = isMerkle ? Int(resp.depth) : nil
let merkleTs: UInt64? = isMerkle ? resp.merklePaymentTimestamp : nil
let pools: [PoolCommitmentEntry]? = isMerkle
? resp.poolCommitments.map { pc in
PoolCommitmentEntry(
poolHash: pc.poolHash,
candidates: pc.candidates.map {
CandidateNodeEntry(rewardsAddress: $0.rewardsAddress, amount: $0.amount)
}
)
}
: nil
return PrepareUploadResult(
uploadId: resp.uploadID,
payments: payments,
totalAmount: resp.totalAmount,
paymentVaultAddress: resp.paymentVaultAddress,
paymentTokenAddress: resp.paymentTokenAddress,
rpcUrl: resp.rpcURL,
paymentType: resp.paymentType.isEmpty ? "wave_batch" : resp.paymentType,
depth: depth,
poolCommitments: pools,
merklePaymentTimestamp: merkleTs
)
}
}
Loading