- Implement SynorStorage class for decentralized storage operations including upload, download, pinning, and CAR file management. - Create supporting types and models for storage operations such as UploadOptions, Pin, and StorageConfig. - Implement SynorWallet class for wallet operations including wallet creation, address generation, transaction signing, and balance queries. - Create supporting types and models for wallet operations such as Wallet, Address, and Transaction. - Introduce error handling for both storage and wallet operations.
343 lines
11 KiB
Swift
343 lines
11 KiB
Swift
import Foundation
|
|
|
|
/// Synor RPC SDK client for Swift.
|
|
///
|
|
/// Provides blockchain queries, transaction submission, and real-time
|
|
/// subscriptions via WebSocket.
|
|
///
|
|
/// Example:
|
|
/// ```swift
|
|
/// let rpc = SynorRpc(config: RpcConfig(apiKey: "your-api-key"))
|
|
///
|
|
/// // Get latest block
|
|
/// let block = try await rpc.getLatestBlock()
|
|
/// print("Latest block: \(block.height)")
|
|
///
|
|
/// // Subscribe to new blocks
|
|
/// let subscription = try await rpc.subscribeBlocks { block in
|
|
/// print("New block: \(block.height)")
|
|
/// }
|
|
///
|
|
/// // Later: cancel subscription
|
|
/// subscription.cancel()
|
|
/// ```
|
|
public class SynorRpc {
|
|
private let config: RpcConfig
|
|
private let session: URLSession
|
|
private let decoder: JSONDecoder
|
|
private let encoder: JSONEncoder
|
|
|
|
private var wsTask: URLSessionWebSocketTask?
|
|
private var subscriptions: [String: (String) -> Void] = [:]
|
|
private let subscriptionLock = NSLock()
|
|
|
|
public init(config: RpcConfig) {
|
|
self.config = config
|
|
|
|
let sessionConfig = URLSessionConfiguration.default
|
|
sessionConfig.timeoutIntervalForRequest = config.timeout
|
|
sessionConfig.timeoutIntervalForResource = config.timeout * 2
|
|
self.session = URLSession(configuration: sessionConfig)
|
|
|
|
self.decoder = JSONDecoder()
|
|
self.encoder = JSONEncoder()
|
|
}
|
|
|
|
deinit {
|
|
wsTask?.cancel(with: .goingAway, reason: nil)
|
|
}
|
|
|
|
// MARK: - Block Operations
|
|
|
|
/// Get the latest block.
|
|
public func getLatestBlock() async throws -> Block {
|
|
return try await get(path: "/blocks/latest")
|
|
}
|
|
|
|
/// Get a block by hash or height.
|
|
public func getBlock(hashOrHeight: String) async throws -> Block {
|
|
return try await get(path: "/blocks/\(hashOrHeight)")
|
|
}
|
|
|
|
/// Get a block header by hash or height.
|
|
public func getBlockHeader(hashOrHeight: String) async throws -> BlockHeader {
|
|
return try await get(path: "/blocks/\(hashOrHeight)/header")
|
|
}
|
|
|
|
/// Get blocks in a range.
|
|
public func getBlocks(startHeight: Int64, endHeight: Int64) async throws -> [Block] {
|
|
return try await get(path: "/blocks?start=\(startHeight)&end=\(endHeight)")
|
|
}
|
|
|
|
// MARK: - Transaction Operations
|
|
|
|
/// Get a transaction by ID.
|
|
public func getTransaction(txid: String) async throws -> RpcTransaction {
|
|
return try await get(path: "/transactions/\(txid)")
|
|
}
|
|
|
|
/// Get raw transaction hex.
|
|
public func getRawTransaction(txid: String) async throws -> String {
|
|
let response: RawTxResponse = try await get(path: "/transactions/\(txid)/raw")
|
|
return response.hex
|
|
}
|
|
|
|
/// Send a raw transaction.
|
|
public func sendRawTransaction(hex: String) async throws -> SubmitResult {
|
|
let request = SendRawTxRequest(hex: hex)
|
|
return try await post(path: "/transactions/send", body: request)
|
|
}
|
|
|
|
/// Decode a raw transaction without broadcasting.
|
|
public func decodeRawTransaction(hex: String) async throws -> RpcTransaction {
|
|
let request = DecodeRawTxRequest(hex: hex)
|
|
return try await post(path: "/transactions/decode", body: request)
|
|
}
|
|
|
|
/// Get transactions for an address.
|
|
public func getAddressTransactions(
|
|
address: String,
|
|
limit: Int = 50,
|
|
offset: Int = 0
|
|
) async throws -> [RpcTransaction] {
|
|
return try await get(path: "/addresses/\(address)/transactions?limit=\(limit)&offset=\(offset)")
|
|
}
|
|
|
|
// MARK: - Fee Estimation
|
|
|
|
/// Estimate fee for a given priority.
|
|
public func estimateFee(priority: RpcPriority = .medium) async throws -> FeeEstimate {
|
|
return try await get(path: "/fees/estimate?priority=\(priority.rawValue)")
|
|
}
|
|
|
|
/// Get all fee estimates.
|
|
public func getAllFeeEstimates() async throws -> [RpcPriority: FeeEstimate] {
|
|
let estimates: [FeeEstimate] = try await get(path: "/fees/estimates")
|
|
return Dictionary(uniqueKeysWithValues: estimates.map { ($0.priority, $0) })
|
|
}
|
|
|
|
// MARK: - Chain Information
|
|
|
|
/// Get chain information.
|
|
public func getChainInfo() async throws -> ChainInfo {
|
|
return try await get(path: "/chain/info")
|
|
}
|
|
|
|
/// Get mempool information.
|
|
public func getMempoolInfo() async throws -> MempoolInfo {
|
|
return try await get(path: "/mempool/info")
|
|
}
|
|
|
|
/// Get mempool transactions.
|
|
public func getMempoolTransactions(limit: Int = 100) async throws -> [String] {
|
|
return try await get(path: "/mempool/transactions?limit=\(limit)")
|
|
}
|
|
|
|
// MARK: - WebSocket Subscriptions
|
|
|
|
/// Subscribe to new blocks.
|
|
public func subscribeBlocks(callback: @escaping (Block) -> Void) async throws -> Subscription {
|
|
return try await subscribe(channel: "blocks", filter: nil) { [weak self] data in
|
|
guard let self = self else { return }
|
|
if let block = try? self.decoder.decode(Block.self, from: Data(data.utf8)) {
|
|
callback(block)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Subscribe to transactions for a specific address.
|
|
public func subscribeAddress(
|
|
address: String,
|
|
callback: @escaping (RpcTransaction) -> Void
|
|
) async throws -> Subscription {
|
|
return try await subscribe(channel: "address", filter: address) { [weak self] data in
|
|
guard let self = self else { return }
|
|
if let tx = try? self.decoder.decode(RpcTransaction.self, from: Data(data.utf8)) {
|
|
callback(tx)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Subscribe to mempool transactions.
|
|
public func subscribeMempool(callback: @escaping (RpcTransaction) -> Void) async throws -> Subscription {
|
|
return try await subscribe(channel: "mempool", filter: nil) { [weak self] data in
|
|
guard let self = self else { return }
|
|
if let tx = try? self.decoder.decode(RpcTransaction.self, from: Data(data.utf8)) {
|
|
callback(tx)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func subscribe(
|
|
channel: String,
|
|
filter: String?,
|
|
callback: @escaping (String) -> Void
|
|
) async throws -> Subscription {
|
|
try await ensureWebSocketConnection()
|
|
|
|
let subscriptionId = UUID().uuidString
|
|
subscriptionLock.lock()
|
|
subscriptions[subscriptionId] = callback
|
|
subscriptionLock.unlock()
|
|
|
|
let message = WsMessage(type: "subscribe", channel: channel, data: nil, filter: filter)
|
|
let messageData = try encoder.encode(message)
|
|
let messageString = String(data: messageData, encoding: .utf8)!
|
|
|
|
try await wsTask?.send(.string(messageString))
|
|
|
|
return Subscription(id: subscriptionId, channel: channel) { [weak self] in
|
|
self?.subscriptionLock.lock()
|
|
self?.subscriptions.removeValue(forKey: subscriptionId)
|
|
self?.subscriptionLock.unlock()
|
|
|
|
Task {
|
|
let unsubMessage = WsMessage(type: "unsubscribe", channel: channel, data: nil, filter: nil)
|
|
if let data = try? self?.encoder.encode(unsubMessage),
|
|
let str = String(data: data, encoding: .utf8) {
|
|
try? await self?.wsTask?.send(.string(str))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func ensureWebSocketConnection() async throws {
|
|
guard wsTask == nil || wsTask?.state != .running else { return }
|
|
|
|
guard let url = URL(string: "\(config.wsEndpoint)?token=\(config.apiKey)") else {
|
|
throw RpcError.invalidResponse
|
|
}
|
|
|
|
wsTask = session.webSocketTask(with: url)
|
|
wsTask?.resume()
|
|
|
|
// Start receiving messages
|
|
receiveMessages()
|
|
}
|
|
|
|
private func receiveMessages() {
|
|
wsTask?.receive { [weak self] result in
|
|
guard let self = self else { return }
|
|
|
|
switch result {
|
|
case .success(let message):
|
|
switch message {
|
|
case .string(let text):
|
|
self.handleWebSocketMessage(text)
|
|
case .data(let data):
|
|
if let text = String(data: data, encoding: .utf8) {
|
|
self.handleWebSocketMessage(text)
|
|
}
|
|
@unknown default:
|
|
break
|
|
}
|
|
// Continue receiving
|
|
self.receiveMessages()
|
|
|
|
case .failure(let error):
|
|
if self.config.debug {
|
|
print("WebSocket error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleWebSocketMessage(_ text: String) {
|
|
guard let data = text.data(using: .utf8),
|
|
let message = try? decoder.decode(WsMessage.self, from: data),
|
|
message.type == "data",
|
|
let messageData = message.data else {
|
|
return
|
|
}
|
|
|
|
subscriptionLock.lock()
|
|
let callbacks = Array(subscriptions.values)
|
|
subscriptionLock.unlock()
|
|
|
|
for callback in callbacks {
|
|
callback(messageData)
|
|
}
|
|
}
|
|
|
|
// MARK: - HTTP Methods
|
|
|
|
private func get<T: Decodable>(path: String) async throws -> T {
|
|
return try await executeWithRetry {
|
|
try await self.performRequest(method: "GET", path: path, body: nil as Empty?)
|
|
}
|
|
}
|
|
|
|
private func post<T: Decodable, R: Encodable>(path: String, body: R) async throws -> T {
|
|
return try await executeWithRetry {
|
|
try await self.performRequest(method: "POST", path: path, body: body)
|
|
}
|
|
}
|
|
|
|
private func performRequest<T: Decodable, R: Encodable>(
|
|
method: String,
|
|
path: String,
|
|
body: R?
|
|
) async throws -> T {
|
|
guard let url = URL(string: "\(config.endpoint)\(path)") else {
|
|
throw RpcError.invalidResponse
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = method
|
|
request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
if let body = body {
|
|
do {
|
|
request.httpBody = try encoder.encode(body)
|
|
} catch {
|
|
throw RpcError.encodingError(error)
|
|
}
|
|
}
|
|
|
|
let (data, response): (Data, URLResponse)
|
|
do {
|
|
(data, response) = try await session.data(for: request)
|
|
} catch {
|
|
throw RpcError.networkError(error)
|
|
}
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw RpcError.invalidResponse
|
|
}
|
|
|
|
if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 {
|
|
do {
|
|
return try decoder.decode(T.self, from: data)
|
|
} catch {
|
|
throw RpcError.decodingError(error)
|
|
}
|
|
}
|
|
|
|
let message = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
throw RpcError.httpError(statusCode: httpResponse.statusCode, message: message)
|
|
}
|
|
|
|
private func executeWithRetry<T>(_ operation: () async throws -> T) async throws -> T {
|
|
var lastError: Error?
|
|
|
|
for attempt in 0..<config.retries {
|
|
do {
|
|
return try await operation()
|
|
} catch {
|
|
lastError = error
|
|
if config.debug {
|
|
print("Attempt \(attempt + 1) failed: \(error)")
|
|
}
|
|
if attempt < config.retries - 1 {
|
|
try await Task.sleep(nanoseconds: UInt64(1_000_000_000 * (attempt + 1)))
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError ?? RpcError.invalidResponse
|
|
}
|
|
}
|
|
|
|
// Helper type for empty body
|
|
private struct Empty: Encodable {}
|