diff --git a/.gitignore b/.gitignore index 7488afa..ab8837e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,12 @@ .env .env.* *.xcuserstate + +# Local signing material and exported credentials. +*.p8 +*.p12 +*.pem +*.key +*.keystore +*.keychain +*.keychain-db diff --git a/Package.resolved b/Package.resolved index b1c35a7..fc691d8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d030595d2a739a952efda38a5319f65babd205eda1331a1160b261f0338ceac8", + "originHash" : "e4f364a7d2fc8e6a85eebe10d8896de8ff6c68242292b13b789ace862d4e1645", "pins" : [ { "identity" : "async-http-client", @@ -10,6 +10,33 @@ "version" : "1.36.2" } }, + { + "identity" : "grpc-swift-2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-2.git", + "state" : { + "revision" : "e31eb485c4575e0dbed3d1925e24084f755ac6e8", + "version" : "2.4.4" + } + }, + { + "identity" : "grpc-swift-nio-transport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", + "state" : { + "revision" : "9765a256477383b9d441f20952118b75104304e8", + "version" : "2.11.0" + } + }, + { + "identity" : "grpc-swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-protobuf.git", + "state" : { + "revision" : "83f5cdb2f8b4c7b57ed9419c6f4f6380b683500e", + "version" : "2.5.0" + } + }, { "identity" : "swift-algorithms", "kind" : "remoteSourceControl", @@ -123,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", - "version" : "2.103.0" + "revision" : "6db9d9abd92ad7399b8a564cafbb87d3507245fa", + "version" : "2.104.0" } }, { @@ -172,6 +199,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "3d537cb81aef272a03cb3be815439e9bf0eb3bd3", + "version" : "1.39.0" + } + }, { "identity" : "swift-secp256k1", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index b288b70..c148d1f 100644 --- a/Package.swift +++ b/Package.swift @@ -9,9 +9,13 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", exact: "0.23.2"), - .package(url: "https://github.com/apple/swift-nio.git", exact: "2.103.0"), + .package(url: "https://github.com/apple/swift-nio.git", exact: "2.104.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", exact: "2.37.5"), - .package(url: "https://github.com/swift-server/async-http-client.git", exact: "1.36.2") + .package(url: "https://github.com/swift-server/async-http-client.git", exact: "1.36.2"), + .package(url: "https://github.com/grpc/grpc-swift-2.git", exact: "2.4.4"), + .package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", exact: "2.11.0"), + .package(url: "https://github.com/grpc/grpc-swift-protobuf.git", exact: "2.5.0"), + .package(url: "https://github.com/apple/swift-protobuf.git", exact: "1.39.0") ], targets: [ .target( @@ -24,8 +28,19 @@ let package = Package( .product(name: "NIOPosix", package: "swift-nio"), .product(name: "NIOHTTP1", package: "swift-nio"), .product(name: "NIOWebSocket", package: "swift-nio"), - .product(name: "NIOSSL", package: "swift-nio-ssl") + .product(name: "NIOSSL", package: "swift-nio-ssl"), + .product(name: "GRPCCore", package: "grpc-swift-2"), + .product(name: "GRPCNIOTransportHTTP2Posix", package: "grpc-swift-nio-transport"), + .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), + .product(name: "SwiftProtobuf", package: "swift-protobuf") ]), - .testTarget(name: "HyperliquidCoreTests", dependencies: ["HyperliquidCore"]) + .testTarget(name: "HyperliquidCoreTests", dependencies: [ + "HyperliquidCore", + .product(name: "GRPCCore", package: "grpc-swift-2"), + .product(name: "GRPCInProcessTransport", package: "grpc-swift-2"), + .product(name: "GRPCNIOTransportHTTP2Posix", package: "grpc-swift-nio-transport"), + .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), + .product(name: "SwiftProtobuf", package: "swift-protobuf") + ]) ] ) diff --git a/README.md b/README.md index 246848f..10b4070 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,22 @@ Swift protocol and domain library extracted from HyperliquidX. This is a communi ## Requirements -Xcode 26.3 or later (Swift 6.2+) to build; macOS 14 or later to run. Other platforms are not currently validated; the implementation uses Apple Security, CryptoKit, Network and Darwin APIs. +Xcode 26.3 or later (Swift 6.2+) to build; macOS 14 or later to run. The optional gRPC L4 client requires macOS 15 or later; reducers and existing REST/WS APIs retain macOS 14 support. Other platforms are not currently validated; the implementation uses Apple Security, CryptoKit, Network and Darwin APIs. ## Scope - REST and WebSocket transports, market catalogs (including HIP-3), candles and order books. +- Full per-order L4 reconstruction and a configurable Quicknode typed gRPC client; see [L4 coverage and contracts](docs/L4_BOOK.md). - Account streams, history, order validation, slippage and position risk calculations. - Ethereum signing, temporary trading sessions, wallet connection models and a local browser-wallet bridge. - Transfers and deposit routing, including CCTP and Unit-related models and clients. The package contains no app screens, chart rendering, layout models, Touch ID workflow, Reown dependency, or bundled wallet web page. Applications provide their own UI and wallet integration. APIs are initially experimental; releases before 1.0 may change them. +## Module organization + +Implementation and tests are grouped by Hyperliquid domain: types, networking, market data, accounts, trading, signing, wallet, funding and analytics. See [module ownership and fixture paths](docs/MODULES.md). Public imports remain `import HyperliquidCore`. + ## Swift Package Manager Add the package and product to your manifest: diff --git a/Sources/HyperliquidCore/AccountHistory.swift b/Sources/HyperliquidCore/Accounts/AccountHistory.swift similarity index 100% rename from Sources/HyperliquidCore/AccountHistory.swift rename to Sources/HyperliquidCore/Accounts/AccountHistory.swift diff --git a/Sources/HyperliquidCore/AccountStream.swift b/Sources/HyperliquidCore/Accounts/AccountStream.swift similarity index 100% rename from Sources/HyperliquidCore/AccountStream.swift rename to Sources/HyperliquidCore/Accounts/AccountStream.swift diff --git a/Sources/HyperliquidCore/AgentAnalytics.swift b/Sources/HyperliquidCore/Analytics/AgentAnalytics.swift similarity index 100% rename from Sources/HyperliquidCore/AgentAnalytics.swift rename to Sources/HyperliquidCore/Analytics/AgentAnalytics.swift diff --git a/Sources/HyperliquidCore/AgentQuery.swift b/Sources/HyperliquidCore/Analytics/AgentQuery.swift similarity index 100% rename from Sources/HyperliquidCore/AgentQuery.swift rename to Sources/HyperliquidCore/Analytics/AgentQuery.swift diff --git a/Sources/HyperliquidCore/AgentReadClient.swift b/Sources/HyperliquidCore/Analytics/AgentReadClient.swift similarity index 100% rename from Sources/HyperliquidCore/AgentReadClient.swift rename to Sources/HyperliquidCore/Analytics/AgentReadClient.swift diff --git a/Sources/HyperliquidCore/BalanceTransfer.swift b/Sources/HyperliquidCore/Funding/BalanceTransfer.swift similarity index 100% rename from Sources/HyperliquidCore/BalanceTransfer.swift rename to Sources/HyperliquidCore/Funding/BalanceTransfer.swift diff --git a/Sources/HyperliquidCore/BridgeDeposit.swift b/Sources/HyperliquidCore/Funding/BridgeDeposit.swift similarity index 100% rename from Sources/HyperliquidCore/BridgeDeposit.swift rename to Sources/HyperliquidCore/Funding/BridgeDeposit.swift diff --git a/Sources/HyperliquidCore/CCTPAllowance.swift b/Sources/HyperliquidCore/Funding/CCTP/CCTPAllowance.swift similarity index 100% rename from Sources/HyperliquidCore/CCTPAllowance.swift rename to Sources/HyperliquidCore/Funding/CCTP/CCTPAllowance.swift diff --git a/Sources/HyperliquidCore/CCTPDeposit.swift b/Sources/HyperliquidCore/Funding/CCTP/CCTPDeposit.swift similarity index 100% rename from Sources/HyperliquidCore/CCTPDeposit.swift rename to Sources/HyperliquidCore/Funding/CCTP/CCTPDeposit.swift diff --git a/Sources/HyperliquidCore/CCTPDepositClient.swift b/Sources/HyperliquidCore/Funding/CCTP/CCTPDepositClient.swift similarity index 100% rename from Sources/HyperliquidCore/CCTPDepositClient.swift rename to Sources/HyperliquidCore/Funding/CCTP/CCTPDepositClient.swift diff --git a/Sources/HyperliquidCore/CCTPFeeQuote.swift b/Sources/HyperliquidCore/Funding/CCTP/CCTPFeeQuote.swift similarity index 100% rename from Sources/HyperliquidCore/CCTPFeeQuote.swift rename to Sources/HyperliquidCore/Funding/CCTP/CCTPFeeQuote.swift diff --git a/Sources/HyperliquidCore/CCTPHyperCoreDestination.swift b/Sources/HyperliquidCore/Funding/CCTP/CCTPHyperCoreDestination.swift similarity index 100% rename from Sources/HyperliquidCore/CCTPHyperCoreDestination.swift rename to Sources/HyperliquidCore/Funding/CCTP/CCTPHyperCoreDestination.swift diff --git a/Sources/HyperliquidCore/CCTPNetwork.swift b/Sources/HyperliquidCore/Funding/CCTP/CCTPNetwork.swift similarity index 100% rename from Sources/HyperliquidCore/CCTPNetwork.swift rename to Sources/HyperliquidCore/Funding/CCTP/CCTPNetwork.swift diff --git a/Sources/HyperliquidCore/CCTPNetworkRegistry.swift b/Sources/HyperliquidCore/Funding/CCTP/CCTPNetworkRegistry.swift similarity index 100% rename from Sources/HyperliquidCore/CCTPNetworkRegistry.swift rename to Sources/HyperliquidCore/Funding/CCTP/CCTPNetworkRegistry.swift diff --git a/Sources/HyperliquidCore/DepositAsset.swift b/Sources/HyperliquidCore/Funding/DepositAsset.swift similarity index 100% rename from Sources/HyperliquidCore/DepositAsset.swift rename to Sources/HyperliquidCore/Funding/DepositAsset.swift diff --git a/Sources/HyperliquidCore/DepositSourceBalance.swift b/Sources/HyperliquidCore/Funding/DepositSourceBalance.swift similarity index 100% rename from Sources/HyperliquidCore/DepositSourceBalance.swift rename to Sources/HyperliquidCore/Funding/DepositSourceBalance.swift diff --git a/Sources/HyperliquidCore/FundingIntent.swift b/Sources/HyperliquidCore/Funding/FundingIntent.swift similarity index 100% rename from Sources/HyperliquidCore/FundingIntent.swift rename to Sources/HyperliquidCore/Funding/FundingIntent.swift diff --git a/Sources/HyperliquidCore/FundingReadClient.swift b/Sources/HyperliquidCore/Funding/FundingReadClient.swift similarity index 100% rename from Sources/HyperliquidCore/FundingReadClient.swift rename to Sources/HyperliquidCore/Funding/FundingReadClient.swift diff --git a/Sources/HyperliquidCore/UnitDeposit.swift b/Sources/HyperliquidCore/Funding/Unit/UnitDeposit.swift similarity index 100% rename from Sources/HyperliquidCore/UnitDeposit.swift rename to Sources/HyperliquidCore/Funding/Unit/UnitDeposit.swift diff --git a/Sources/HyperliquidCore/ZcashDepositEncoding.swift b/Sources/HyperliquidCore/Funding/Unit/ZcashDepositEncoding.swift similarity index 100% rename from Sources/HyperliquidCore/ZcashDepositEncoding.swift rename to Sources/HyperliquidCore/Funding/Unit/ZcashDepositEncoding.swift diff --git a/Sources/HyperliquidCore/HIP3Catalog.swift b/Sources/HyperliquidCore/MarketData/HIP3Catalog.swift similarity index 100% rename from Sources/HyperliquidCore/HIP3Catalog.swift rename to Sources/HyperliquidCore/MarketData/HIP3Catalog.swift diff --git a/Sources/HyperliquidCore/MarketData/L4/L4BookQueries.swift b/Sources/HyperliquidCore/MarketData/L4/L4BookQueries.swift new file mode 100644 index 0000000..ff9d07c --- /dev/null +++ b/Sources/HyperliquidCore/MarketData/L4/L4BookQueries.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum L4BookQueryError: Error, Equatable, Sendable { + case invalidLimit, invalidPrice, invalidCursor, staleCursor +} + +/// Local pagination identity, not an exchange sequence or a queue-priority position. +public struct L4OrderPageCursor: Equatable, Sendable { + public let environment: NetworkEnvironment + public let coin: String + public let side: L4OrderIdentity.Side + public let price: Decimal + public let levelRevision: UUID + public let afterOID: UInt64 +} + +/// A bounded inspection of one unchanged price level, sorted numerically by OID, never FIFO. +public struct L4OrderPage: Sendable { + public let environment: NetworkEnvironment + public let coin: String + public let side: L4OrderIdentity.Side + public let price: Decimal + public let height: UInt64 + public let time: Int64 + public let levelRevision: UUID? + public let totalOrderCount: Int + public let totalSize: Decimal + public let orders: [L4Order] + public let nextCursor: L4OrderPageCursor? +} diff --git a/Sources/HyperliquidCore/MarketData/L4/L4BookReducer.swift b/Sources/HyperliquidCore/MarketData/L4/L4BookReducer.swift new file mode 100644 index 0000000..b587028 --- /dev/null +++ b/Sources/HyperliquidCore/MarketData/L4/L4BookReducer.swift @@ -0,0 +1,260 @@ +import Foundation + +/// Maintains every resting OID, aggregated level membership, and sorted price indexes for one connection. +/// Order statuses and trades must not also decrement this authoritative book state. +public struct L4BookReducer { + public enum Status: Equatable, Sendable { case awaitingSnapshot, live, resynchronizationRequired } + private struct PriceKey: Hashable { + let side: L4OrderIdentity.Side + let price: Decimal + } + private struct Level { + let rawPrice: String + var size: Decimal = 0 + var members: Set = [] + var revision: UUID? + } + private final class StagedLevel { + var value: Level + init(_ value: Level) { self.value = value } + } + private enum Change { case present(L4Order), removed } + + public let environment: NetworkEnvironment + public let coin: String + public let maximumOrders: Int + public let maximumChanges: Int + public private(set) var status: Status = .awaitingSnapshot + public private(set) var orderCount = 0 + private var orders: [UInt64: L4Order] = [:] + private var levels: [PriceKey: Level] = [:] + private var bids: [Decimal] = [] + private var asks: [Decimal] = [] + private var height: UInt64? + private var time: Int64? + + public init(environment: NetworkEnvironment, coin: String, maximumOrders: Int = 500_000, maximumChanges: Int = 1_000_000) throws { + guard !coin.isEmpty, coin.utf8.count <= 128, coin == coin.trimmingCharacters(in: .whitespacesAndNewlines) else { + throw L4BookError.invalidPayload + } + self.environment = environment + self.coin = coin + self.maximumOrders = min(2_000_000, max(1, maximumOrders)) + self.maximumChanges = min(2_000_000, max(1, maximumChanges)) + } + + public func order(_ oid: UInt64) -> L4Order? { orders[oid] } + + /// Numeric OID order is for deterministic inspection. It is not FIFO or a queue-priority estimate. + public func ordersSortedByID(side: L4OrderIdentity.Side, price: Decimal) -> [L4Order] { + (levels[PriceKey(side: side, price: price)]?.members ?? []).sorted().compactMap { orders[$0] } + } + + /// Returns at most `limit` rows plus an opaque continuation. Only O(limit) IDs are retained while scanning. + /// Changes at another price leave the cursor valid; any touch/reset of this level invalidates it. + public func orderPage(side: L4OrderIdentity.Side, price: Decimal, after cursor: L4OrderPageCursor? = nil, + limit: Int = 100) throws -> L4OrderPage { + guard (1...500).contains(limit) else { throw L4BookQueryError.invalidLimit } + guard !price.isNaN, price > 0 else { throw L4BookQueryError.invalidPrice } + guard status == .live, let height, let time else { throw L4BookError.snapshotRequired } + let level = levels[PriceKey(side: side, price: price)] + if let cursor { + guard cursor.environment == environment, cursor.coin == coin, cursor.side == side, + cursor.price == price, cursor.afterOID > 0 else { throw L4BookQueryError.invalidCursor } + guard cursor.levelRevision == level?.revision else { throw L4BookQueryError.staleCursor } + } + let ids = Self.lowestIDs(level?.members ?? [], after: cursor?.afterOID, capacity: limit + 1) + let page = try ids.prefix(limit).map { oid in + guard let order = orders[oid] else { throw L4BookError.invalidOrder } + return order + } + let next: L4OrderPageCursor? + if ids.count > limit, let last = page.last, let revision = level?.revision { + next = L4OrderPageCursor(environment: environment, coin: coin, side: side, price: price, + levelRevision: revision, afterOID: last.oid) + } else { next = nil } + return L4OrderPage(environment: environment, coin: coin, side: side, price: price, height: height, time: time, + levelRevision: level?.revision, totalOrderCount: level?.members.count ?? 0, + totalSize: level?.size ?? 0, orders: page, nextCursor: next) + } + + /// Bounded max heap: retain the lowest capacity OIDs without copying or sorting the whole membership set. + private static func lowestIDs(_ members: Set, after: UInt64?, capacity: Int) -> [UInt64] { + var heap: [UInt64] = [] + heap.reserveCapacity(capacity) + for oid in members where after.map({ oid > $0 }) ?? true { + if heap.count < capacity { + heap.append(oid) + var index = heap.count - 1 + while index > 0 { + let parent = (index - 1) / 2 + guard heap[index] > heap[parent] else { break } + heap.swapAt(index, parent) + index = parent + } + } else if oid < heap[0] { + heap[0] = oid + var index = 0 + while index * 2 + 1 < heap.count { + let left = index * 2 + 1, right = left + 1 + let child = right < heap.count && heap[right] > heap[left] ? right : left + guard heap[child] > heap[index] else { break } + heap.swapAt(index, child) + index = child + } + } + } + return heap.sorted() + } + + public mutating func invalidate() { + clearOrders() + status = .resynchronizationRequired + // An accepted chain watermark survives faults in this connection. + } + + public mutating func resetForNewConnection() { + clearOrders() + height = nil + time = nil + status = .awaitingSnapshot + } + + public mutating func apply(_ update: L4BookUpdate) throws -> L4BookView { + do { return try applyBlock(update) } catch { + invalidate() + throw error + } + } + + public func view(maximumLevelsPerSide: Int = 20) throws -> L4BookView { + guard status == .live, let height, let time else { throw L4BookError.snapshotRequired } + let book = try projection(bids: bids, asks: asks, time: time, maximumLevels: maximumLevelsPerSide) { levels[$0] } + return L4BookView(book: book, height: height, orderCount: orderCount, + priceLevelCount: bids.count + asks.count, maximumLevelsPerSide: maximumLevelsPerSide) + } + + private mutating func applyBlock(_ update: L4BookUpdate) throws -> L4BookView { + guard update.coin == coin, update.changes.count <= maximumChanges else { throw L4BookError.invalidPayload } + if let height { + guard update.snapshot ? update.height >= height : update.height > height else { throw L4BookError.invalidWatermark } + } + if let time, update.time < time { throw L4BookError.invalidWatermark } + guard update.snapshot || status == .live else { throw L4BookError.snapshotRequired } + let reset = update.snapshot + var orderChanges: [UInt64: Change] = [:] + var levelChanges: [PriceKey: StagedLevel] = [:] + var count = reset ? 0 : orderCount + func existing(_ oid: UInt64) -> L4Order? { + if let change = orderChanges[oid] { + if case .present(let order) = change { return order } + return nil + } + return reset ? nil : orders[oid] + } + for change in update.changes { + let identity = change.identity + let key = PriceKey(side: identity.side, price: identity.price) + let previous = existing(identity.oid) + let staged = levelChanges[key] ?? StagedLevel((reset ? nil : levels[key]) ?? Level(rawPrice: identity.rawPrice)) + levelChanges[key] = staged + switch change { + case .new(let order): + guard previous == nil else { throw L4BookError.duplicateOrder } + guard count < maximumOrders else { throw L4BookError.capacityExceeded } + count += 1 + guard staged.value.members.insert(order.oid).inserted else { throw L4BookError.duplicateOrder } + staged.value.size = try L4Decimal.add(staged.value.size, order.size) + orderChanges[order.oid] = .present(order) + case .update(let order): + guard !reset else { throw L4BookError.invalidOrder } + guard let previous else { throw L4BookError.unknownOrder } + guard previous.identity.matches(identity) else { throw L4BookError.identityChanged } + staged.value.size = try L4Decimal.add(try L4Decimal.subtract(staged.value.size, previous.size), order.size) + orderChanges[order.oid] = .present(order) + case .remove: + guard !reset else { throw L4BookError.invalidOrder } + guard let previous else { throw L4BookError.unknownOrder } + guard previous.identity.matches(identity) else { throw L4BookError.identityChanged } + guard staged.value.members.remove(identity.oid) != nil else { throw L4BookError.unknownOrder } + staged.value.size = try L4Decimal.subtract(staged.value.size, previous.size) + count -= 1 + orderChanges[identity.oid] = .removed + } + guard staged.value.members.isEmpty ? staged.value.size == 0 : staged.value.size > 0 else { throw L4BookError.invalidOrder } + } + var nextBids = reset ? [] : bids + var nextAsks = reset ? [] : asks + if reset { + nextBids = levelChanges.keys.filter { $0.side == .bid }.map(\.price).sorted(by: >) + nextAsks = levelChanges.keys.filter { $0.side == .ask }.map(\.price).sorted() + } else { + for (key, staged) in levelChanges { + let hasMembers = !staged.value.members.isEmpty + guard (levels[key] != nil) != hasMembers else { continue } + if key.side == .bid { Self.updatePrices(&nextBids, price: key.price, insert: hasMembers, descending: true) } else { Self.updatePrices(&nextAsks, price: key.price, insert: hasMembers, descending: false) } + } + } + let book = try projection(bids: nextBids, asks: nextAsks, time: update.time, maximumLevels: 20) { + levelChanges[$0]?.value ?? (reset ? nil : levels[$0]) + } + // All validation and checked arithmetic finish before committing either index. + if reset { orders.removeAll(keepingCapacity: true); levels.removeAll(keepingCapacity: true) } + for (oid, change) in orderChanges { + switch change { + case .present(let order): orders[oid] = order + case .removed: orders.removeValue(forKey: oid) + } + } + // One new local revision per committed batch; untouched levels keep their inspection cursors. + let revision = UUID() + for (key, staged) in levelChanges { + if staged.value.members.isEmpty { levels.removeValue(forKey: key) } else { + staged.value.revision = revision + levels[key] = staged.value + } + } + bids = nextBids + asks = nextAsks + orderCount = count + height = update.height + time = update.time + status = .live + return L4BookView(book: book, height: update.height, orderCount: count, + priceLevelCount: bids.count + asks.count, maximumLevelsPerSide: 20) + } + + private func projection(bids: [Decimal], asks: [Decimal], time: Int64, maximumLevels: Int, + level: (PriceKey) -> Level?) throws -> OrderBook { + guard (1...1000).contains(maximumLevels) else { throw L4BookError.capacityExceeded } + if let bid = bids.first, let ask = asks.first, bid > ask { throw L4BookError.crossedBook } + func rows(_ prices: [Decimal], side: L4OrderIdentity.Side) throws -> [BookLevel] { + var total: Decimal = 0 + return try prices.prefix(maximumLevels).map { price in + guard let value = level(PriceKey(side: side, price: price)), !value.members.isEmpty, value.size > 0 else { + throw L4BookError.invalidOrder + } + total = try L4Decimal.add(total, value.size) + return BookLevel(px: value.rawPrice, sz: NSDecimalNumber(decimal: value.size).stringValue, n: value.members.count) + } + } + return try OrderBook(coin: coin, time: time, levels: [rows(bids, side: .bid), rows(asks, side: .ask)]) + } + + private mutating func clearOrders() { + orders.removeAll() + levels.removeAll() + bids.removeAll() + asks.removeAll() + orderCount = 0 + } + + private static func updatePrices(_ prices: inout [Decimal], price: Decimal, insert: Bool, descending: Bool) { + var low = 0, high = prices.count + while low < high { + let middle = (low + high) / 2 + if descending ? prices[middle] > price : prices[middle] < price { low = middle + 1 } else { high = middle } + } + if insert { prices.insert(price, at: low) } else if prices.indices.contains(low), prices[low] == price { prices.remove(at: low) } + } +} diff --git a/Sources/HyperliquidCore/MarketData/L4/L4Models.swift b/Sources/HyperliquidCore/MarketData/L4/L4Models.swift new file mode 100644 index 0000000..301ac94 --- /dev/null +++ b/Sources/HyperliquidCore/MarketData/L4/L4Models.swift @@ -0,0 +1,108 @@ +import Foundation + +public enum L4BookError: Error, Equatable, Sendable { + case snapshotRequired, invalidOrder, duplicateOrder, unknownOrder, identityChanged + case invalidWatermark, capacityExceeded, arithmeticOverflow, crossedBook, invalidPayload +} + +public struct L4OrderIdentity: Equatable, Sendable { + public enum Side: String, Codable, Sendable { case bid = "B", ask = "A" } + public let oid: UInt64 + public let user: String + public let coin: String + public let side: Side + public let rawPrice: String + public let price: Decimal + + public init(oid: UInt64, user: String, coin: String, side: Side, price: String) throws { + guard oid > 0, Address.isValid(user), !coin.isEmpty, coin.utf8.count <= 128, + coin == coin.trimmingCharacters(in: .whitespacesAndNewlines) else { throw L4BookError.invalidOrder } + self.oid = oid + self.user = user.lowercased() + self.coin = coin + self.side = side + rawPrice = price + self.price = try L4Decimal.positive(price) + } + + func matches(_ other: Self) -> Bool { + oid == other.oid && user == other.user && coin == other.coin && side == other.side && price == other.price + } +} + +public struct L4Order: Equatable, Sendable { + public let identity: L4OrderIdentity + public let rawSize: String + public let size: Decimal + public var oid: UInt64 { identity.oid } + + public init(identity: L4OrderIdentity, size: String) throws { + self.identity = identity + rawSize = size + self.size = try L4Decimal.positive(size) + } +} + +public enum L4OrderChange: Equatable, Sendable { + case new(L4Order), update(L4Order), remove(L4OrderIdentity) + + public var identity: L4OrderIdentity { + switch self { + case .new(let order), .update(let order): order.identity + case .remove(let identity): identity + } + } +} + +/// A complete provider batch, scoped to one coin. Height is a watermark, not a contiguous sequence guarantee. +public struct L4BookUpdate: Equatable, Sendable { + public let coin: String + public let height: UInt64 + public let time: Int64 + public let snapshot: Bool + public let changes: [L4OrderChange] + + public init(coin: String, height: UInt64, time: Int64, snapshot: Bool, changes: [L4OrderChange]) throws { + guard !coin.isEmpty, coin.utf8.count <= 128, coin == coin.trimmingCharacters(in: .whitespacesAndNewlines), + time >= 0, changes.allSatisfy({ $0.identity.coin == coin }) else { + throw L4BookError.invalidPayload + } + self.coin = coin + self.height = height + self.time = time + self.snapshot = snapshot + self.changes = changes + } +} + +/// Bounded aggregated projection of the complete OID map, without a claim of price-time queue ordering. +public struct L4BookView: Sendable { + public let book: OrderBook + public let height: UInt64 + public let orderCount: Int + public let priceLevelCount: Int + public let maximumLevelsPerSide: Int +} + +enum L4Decimal { + static func positive(_ raw: String) throws -> Decimal { + guard raw.utf8.count <= 128 else { throw L4BookError.invalidOrder } + let significant = raw.filter { $0 != "." }.drop(while: { $0 == "0" }).reversed().drop(while: { $0 == "0" }) + guard significant.count <= 38, let value = try? ReviewDecimal.parse(raw), value > 0 else { + throw L4BookError.invalidOrder + } + return value + } + + static func add(_ a: Decimal, _ b: Decimal) throws -> Decimal { + var a = a, b = b, result = Decimal() + guard NSDecimalAdd(&result, &a, &b, .plain) == .noError else { throw L4BookError.arithmeticOverflow } + return result + } + + static func subtract(_ a: Decimal, _ b: Decimal) throws -> Decimal { + var a = a, b = b, result = Decimal() + guard NSDecimalSubtract(&result, &a, &b, .plain) == .noError, result >= 0 else { throw L4BookError.arithmeticOverflow } + return result + } +} diff --git a/Sources/HyperliquidCore/MarketData/L4/Quicknode/Generated/quicknode_l4.grpc.swift b/Sources/HyperliquidCore/MarketData/L4/Quicknode/Generated/quicknode_l4.grpc.swift new file mode 100644 index 0000000..e1f50c1 --- /dev/null +++ b/Sources/HyperliquidCore/MarketData/L4/Quicknode/Generated/quicknode_l4.grpc.swift @@ -0,0 +1,184 @@ +/// Wire-compatible subset of Quicknode's orderbook.proto, StreamL4BookUpdates. +/// https://github.com/quiknode-labs/hypercore-grpc-examples/blob/main/proto/orderbook.proto +/// No field numbers or enum values have been changed. Other RPCs are intentionally omitted. + +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the gRPC Swift generator plugin for the protocol buffer compiler. +// Source: quicknode_l4.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/grpc/grpc-swift-2 + +import GRPCCore +import GRPCProtobuf + +// MARK: - hyperliquid.OrderBookStreaming + +/// Namespace containing generated types for the "hyperliquid.OrderBookStreaming" service. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +internal enum Hyperliquid_OrderBookStreaming: Sendable { + /// Service descriptor for the "hyperliquid.OrderBookStreaming" service. + internal static let descriptor = GRPCCore.ServiceDescriptor(fullyQualifiedService: "hyperliquid.OrderBookStreaming") + /// Namespace for method metadata. + internal enum Method: Sendable { + /// Namespace for "StreamL4BookUpdates" metadata. + internal enum StreamL4BookUpdates: Sendable { + /// Request type for "StreamL4BookUpdates". + internal typealias Input = Hyperliquid_L4BookUpdatesRequest + /// Response type for "StreamL4BookUpdates". + internal typealias Output = Hyperliquid_L4BookUpdatesUpdate + /// Descriptor for "StreamL4BookUpdates". + internal static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "hyperliquid.OrderBookStreaming"), + method: "StreamL4BookUpdates", + type: .serverStreaming + ) + } + /// Descriptors for all methods in the "hyperliquid.OrderBookStreaming" service. + internal static let descriptors: [GRPCCore.MethodDescriptor] = [ + StreamL4BookUpdates.descriptor + ] + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension GRPCCore.ServiceDescriptor { + /// Service descriptor for the "hyperliquid.OrderBookStreaming" service. + internal static let hyperliquid_OrderBookStreaming = GRPCCore.ServiceDescriptor(fullyQualifiedService: "hyperliquid.OrderBookStreaming") +} + +// MARK: hyperliquid.OrderBookStreaming (client) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Hyperliquid_OrderBookStreaming { + /// Generated client protocol for the "hyperliquid.OrderBookStreaming" service. + /// + /// You don't need to implement this protocol directly, use the generated + /// implementation, ``Client``. + internal protocol ClientProtocol: Sendable { + /// Call the "StreamL4BookUpdates" method. + /// + /// - Parameters: + /// - request: A request containing a single `Hyperliquid_L4BookUpdatesRequest` message. + /// - serializer: A serializer for `Hyperliquid_L4BookUpdatesRequest` messages. + /// - deserializer: A deserializer for `Hyperliquid_L4BookUpdatesUpdate` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response and returns its result to + /// the caller. Returning from the closure will cancel the RPC if it hasn't + /// already finished. + /// - Returns: The result of `handleResponse`. + func streamL4BookUpdates( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + } + + /// Generated client for the "hyperliquid.OrderBookStreaming" service. + /// + /// The ``Client`` provides an implementation of ``ClientProtocol`` which wraps + /// a `GRPCCore.GRPCCClient`. The underlying `GRPCClient` provides the long-lived + /// means of communication with the remote peer. + internal struct Client: ClientProtocol where Transport: GRPCCore.ClientTransport { + private let client: GRPCCore.GRPCClient + + /// Creates a new client wrapping the provided `GRPCCore.GRPCClient`. + /// + /// - Parameters: + /// - client: A `GRPCCore.GRPCClient` providing a communication channel to the service. + internal init(wrapping client: GRPCCore.GRPCClient) { + self.client = client + } + + /// Call the "StreamL4BookUpdates" method. + /// + /// - Parameters: + /// - request: A request containing a single `Hyperliquid_L4BookUpdatesRequest` message. + /// - serializer: A serializer for `Hyperliquid_L4BookUpdatesRequest` messages. + /// - deserializer: A deserializer for `Hyperliquid_L4BookUpdatesUpdate` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response and returns its result to + /// the caller. Returning from the closure will cancel the RPC if it hasn't + /// already finished. + /// - Returns: The result of `handleResponse`. + internal func streamL4BookUpdates( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + try await self.client.serverStreaming( + request: request, + descriptor: Hyperliquid_OrderBookStreaming.Method.StreamL4BookUpdates.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + } +} + +// Helpers providing default arguments to 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Hyperliquid_OrderBookStreaming.ClientProtocol { + /// Call the "StreamL4BookUpdates" method. + /// + /// - Parameters: + /// - request: A request containing a single `Hyperliquid_L4BookUpdatesRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response and returns its result to + /// the caller. Returning from the closure will cancel the RPC if it hasn't + /// already finished. + /// - Returns: The result of `handleResponse`. + internal func streamL4BookUpdates( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + try await self.streamL4BookUpdates( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } +} + +// Helpers providing sugared APIs for 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Hyperliquid_OrderBookStreaming.ClientProtocol { + /// Call the "StreamL4BookUpdates" method. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response and returns its result to + /// the caller. Returning from the closure will cancel the RPC if it hasn't + /// already finished. + /// - Returns: The result of `handleResponse`. + internal func streamL4BookUpdates( + _ message: Hyperliquid_L4BookUpdatesRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.streamL4BookUpdates( + request: request, + options: options, + onResponse: handleResponse + ) + } +} \ No newline at end of file diff --git a/Sources/HyperliquidCore/MarketData/L4/Quicknode/Generated/quicknode_l4.pb.swift b/Sources/HyperliquidCore/MarketData/L4/Quicknode/Generated/quicknode_l4.pb.swift new file mode 100644 index 0000000..46ec45e --- /dev/null +++ b/Sources/HyperliquidCore/MarketData/L4/Quicknode/Generated/quicknode_l4.pb.swift @@ -0,0 +1,264 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: quicknode_l4.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +/// Wire-compatible subset of Quicknode's orderbook.proto, StreamL4BookUpdates. +/// https://github.com/quiknode-labs/hypercore-grpc-examples/blob/main/proto/orderbook.proto +/// No field numbers or enum values have been changed. Other RPCs are intentionally omitted. + +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate nonisolated struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +nonisolated enum Hyperliquid_L4OrderDiffType: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case new // = 1 + case update // = 2 + case remove // = 3 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .new + case 2: self = .update + case 3: self = .remove + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .new: return 1 + case .update: return 2 + case .remove: return 3 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [Hyperliquid_L4OrderDiffType] = [ + .unspecified, + .new, + .update, + .remove, + ] + +} + +nonisolated struct Hyperliquid_L4BookUpdatesRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var coins: [String] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Hyperliquid_L4BookUpdatesUpdate: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var time: UInt64 = 0 + + var height: UInt64 = 0 + + var diffs: [Hyperliquid_L4OrderDiff] = [] + + var snapshot: Bool = false + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Hyperliquid_L4OrderDiff: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var diffType: Hyperliquid_L4OrderDiffType = .unspecified + + var coin: String = String() + + var oid: UInt64 = 0 + + var user: String = String() + + var side: String = String() + + var px: String = String() + + var sz: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +fileprivate nonisolated let _protobuf_package = "hyperliquid" + +nonisolated extension Hyperliquid_L4OrderDiffType: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0L4_ORDER_DIFF_TYPE_UNSPECIFIED\0\u{1}L4_ORDER_DIFF_TYPE_NEW\0\u{1}L4_ORDER_DIFF_TYPE_UPDATE\0\u{1}L4_ORDER_DIFF_TYPE_REMOVE\0") +} + +nonisolated extension Hyperliquid_L4BookUpdatesRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".L4BookUpdatesRequest" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}coins\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedStringField(value: &self.coins) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.coins.isEmpty { + try visitor.visitRepeatedStringField(value: self.coins, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Hyperliquid_L4BookUpdatesRequest, rhs: Hyperliquid_L4BookUpdatesRequest) -> Bool { + if lhs.coins != rhs.coins {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Hyperliquid_L4BookUpdatesUpdate: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".L4BookUpdatesUpdate" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}time\0\u{1}height\0\u{1}diffs\0\u{1}snapshot\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt64Field(value: &self.time) }() + case 2: try { try decoder.decodeSingularUInt64Field(value: &self.height) }() + case 3: try { try decoder.decodeRepeatedMessageField(value: &self.diffs) }() + case 4: try { try decoder.decodeSingularBoolField(value: &self.snapshot) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.time != 0 { + try visitor.visitSingularUInt64Field(value: self.time, fieldNumber: 1) + } + if self.height != 0 { + try visitor.visitSingularUInt64Field(value: self.height, fieldNumber: 2) + } + if !self.diffs.isEmpty { + try visitor.visitRepeatedMessageField(value: self.diffs, fieldNumber: 3) + } + if self.snapshot != false { + try visitor.visitSingularBoolField(value: self.snapshot, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Hyperliquid_L4BookUpdatesUpdate, rhs: Hyperliquid_L4BookUpdatesUpdate) -> Bool { + if lhs.time != rhs.time {return false} + if lhs.height != rhs.height {return false} + if lhs.diffs != rhs.diffs {return false} + if lhs.snapshot != rhs.snapshot {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Hyperliquid_L4OrderDiff: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".L4OrderDiff" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}diff_type\0\u{1}coin\0\u{1}oid\0\u{1}user\0\u{1}side\0\u{1}px\0\u{1}sz\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.diffType) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.coin) }() + case 3: try { try decoder.decodeSingularUInt64Field(value: &self.oid) }() + case 4: try { try decoder.decodeSingularStringField(value: &self.user) }() + case 5: try { try decoder.decodeSingularStringField(value: &self.side) }() + case 6: try { try decoder.decodeSingularStringField(value: &self.px) }() + case 7: try { try decoder.decodeSingularStringField(value: &self.sz) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.diffType != .unspecified { + try visitor.visitSingularEnumField(value: self.diffType, fieldNumber: 1) + } + if !self.coin.isEmpty { + try visitor.visitSingularStringField(value: self.coin, fieldNumber: 2) + } + if self.oid != 0 { + try visitor.visitSingularUInt64Field(value: self.oid, fieldNumber: 3) + } + if !self.user.isEmpty { + try visitor.visitSingularStringField(value: self.user, fieldNumber: 4) + } + if !self.side.isEmpty { + try visitor.visitSingularStringField(value: self.side, fieldNumber: 5) + } + if !self.px.isEmpty { + try visitor.visitSingularStringField(value: self.px, fieldNumber: 6) + } + if !self.sz.isEmpty { + try visitor.visitSingularStringField(value: self.sz, fieldNumber: 7) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Hyperliquid_L4OrderDiff, rhs: Hyperliquid_L4OrderDiff) -> Bool { + if lhs.diffType != rhs.diffType {return false} + if lhs.coin != rhs.coin {return false} + if lhs.oid != rhs.oid {return false} + if lhs.user != rhs.user {return false} + if lhs.side != rhs.side {return false} + if lhs.px != rhs.px {return false} + if lhs.sz != rhs.sz {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Sources/HyperliquidCore/MarketData/L4/Quicknode/QuicknodeL4Client.swift b/Sources/HyperliquidCore/MarketData/L4/Quicknode/QuicknodeL4Client.swift new file mode 100644 index 0000000..13f87ad --- /dev/null +++ b/Sources/HyperliquidCore/MarketData/L4/Quicknode/QuicknodeL4Client.swift @@ -0,0 +1,97 @@ +import Foundation +import GRPCCore +import GRPCProtobuf +import GRPCNIOTransportHTTP2Posix + +public enum QuicknodeL4StreamError: Error, Equatable, Sendable { + case invalidLimits, streamEnded, authenticationFailed, permissionDenied, resourceExhausted, transportFailure +} + +/// One read-only, server-filtered coin stream. The caller owns cancellation and reconnect policy. +/// Each callback completes before the next batch is consumed; there is no unbounded task-per-update queue. +@available(macOS 15.0, *) +public struct QuicknodeL4Client: Sendable { + public let configuration: QuicknodeL4Configuration + + public init(configuration: QuicknodeL4Configuration) { self.configuration = configuration } + + public func stream( + coin: String, credential: QuicknodeL4Credential, + maximumMessageBytes: Int = 64 * 1024 * 1024, maximumChanges: Int = 1_000_000, + onUpdate: @escaping @Sendable (L4BookUpdate) async throws -> Void + ) async throws { + guard (1...128 * 1024 * 1024).contains(maximumMessageBytes), (1...2_000_000).contains(maximumChanges) else { + throw QuicknodeL4StreamError.invalidLimits + } + // Validate the market before opening any network connection. + _ = try L4BookUpdate(coin: coin, height: 0, time: 0, snapshot: true, changes: []) + do { + try await withGRPCClient( + transport: .http2NIOPosix( + target: .dns(host: configuration.host, port: configuration.port), + transportSecurity: .tls + ) + ) { client in + try await Self.consume(client: client, coin: coin, credential: credential, + maximumMessageBytes: maximumMessageBytes, maximumChanges: maximumChanges, + onUpdate: onUpdate) + } + } catch { throw Self.sanitizedError(error) } + } + + private struct ConsumerError: Error { let underlying: any Error } + + static func sanitizedError(_ error: any Error) -> any Error { + if Task.isCancelled || error is CancellationError { return CancellationError() } + if let error = error as? ConsumerError { return error.underlying } + if error is L4BookError || error is QuicknodeL4StreamError { return error } + if let error = error as? RPCError { + // Keep provider status text, causes and metadata out of ordinary App diagnostics. + switch error.code { + case .unauthenticated: return QuicknodeL4StreamError.authenticationFailed + case .permissionDenied: return QuicknodeL4StreamError.permissionDenied + case .resourceExhausted: return QuicknodeL4StreamError.resourceExhausted + default: return QuicknodeL4StreamError.transportFailure + } + } + return QuicknodeL4StreamError.transportFailure + } + + static func consume( + client: GRPCClient, coin: String, credential: QuicknodeL4Credential, + maximumMessageBytes: Int, maximumChanges: Int, + onUpdate: @escaping @Sendable (L4BookUpdate) async throws -> Void + ) async throws { + var request = Hyperliquid_L4BookUpdatesRequest() + request.coins = [coin] + var options = CallOptions.defaults + // HTTP/2 transport 2.11.0 uses maxRequestMessageBytes for its inbound frame limit. + // Set both bounds; the deserializer independently checks the response before protobuf allocation. + options.maxRequestMessageBytes = max(1024, maximumMessageBytes) + options.maxResponseMessageBytes = maximumMessageBytes + options.waitForReady = false + let service = Hyperliquid_OrderBookStreaming.Client(wrapping: client) + let rpcRequest = ClientRequest(message: request, metadata: ["x-token": .string(credential.metadataValue)]) + try await service.streamL4BookUpdates( + request: rpcRequest, serializer: ProtobufSerializer(), + deserializer: QuicknodeL4BoundedDeserializer(maximumBytes: maximumMessageBytes), options: options + ) { response in + for try await message in response.messages { + try Task.checkCancellation() + let update = try QuicknodeL4WireMapping.decode(message, coin: coin, maximumChanges: maximumChanges) + do { try await onUpdate(update) } catch { throw ConsumerError(underlying: error) } + } + } + throw QuicknodeL4StreamError.streamEnded + } +} + +@available(macOS 15.0, *) +struct QuicknodeL4BoundedDeserializer: MessageDeserializer { + let maximumBytes: Int + + func deserialize(_ bytes: Bytes) throws -> Hyperliquid_L4BookUpdatesUpdate { + guard bytes.count <= maximumBytes else { throw RPCError(code: .resourceExhausted, message: "L4 message exceeds configured size") } + return try ProtobufDeserializer().deserialize(bytes) + } +} diff --git a/Sources/HyperliquidCore/MarketData/L4/Quicknode/QuicknodeL4Configuration.swift b/Sources/HyperliquidCore/MarketData/L4/Quicknode/QuicknodeL4Configuration.swift new file mode 100644 index 0000000..9f737c5 --- /dev/null +++ b/Sources/HyperliquidCore/MarketData/L4/Quicknode/QuicknodeL4Configuration.swift @@ -0,0 +1,54 @@ +import Foundation + +public enum QuicknodeL4ConfigurationError: Error, Equatable, Sendable { + case invalidEndpoint, environmentMismatch, invalidCredential +} + +/// Public connection coordinates only. Credentials never belong in this value or its URL. +public struct QuicknodeL4Configuration: Equatable, Sendable { + public let environment: NetworkEnvironment + public let host: String + public let port: Int + public var authority: String { "\(host):\(port)" } + + public init(environment: NetworkEnvironment, endpoint: String) throws { + guard !endpoint.isEmpty, endpoint.utf8.count <= 300, + endpoint == endpoint.trimmingCharacters(in: .whitespacesAndNewlines) else { + throw QuicknodeL4ConfigurationError.invalidEndpoint + } + let url = endpoint.contains("://") ? endpoint : "grpcs://" + endpoint + guard let parts = URLComponents(string: url), parts.scheme == "grpcs", + parts.user == nil, parts.password == nil, parts.query == nil, parts.fragment == nil, + parts.path.isEmpty, let host = parts.host?.lowercased(), !host.contains("%"), + (parts.port ?? 10000) == 10000 else { throw QuicknodeL4ConfigurationError.invalidEndpoint } + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + guard host.utf8.count <= 253, labels.allSatisfy({ label in + !label.isEmpty && label.utf8.count <= 63 && label.first != "-" && label.last != "-" && + label.utf8.allSatisfy { (97...122).contains($0) || (48...57).contains($0) || $0 == 45 } + }) else { throw QuicknodeL4ConfigurationError.invalidEndpoint } + let suffix = environment == .testnet ? ".hype-testnet.quiknode.pro" : ".hype-mainnet.quiknode.pro" + guard host.hasSuffix(suffix), host.count > suffix.count else { + throw QuicknodeL4ConfigurationError.environmentMismatch + } + self.environment = environment + self.host = host + port = 10000 + } +} + +/// A read-only market-data token supplied by the caller, typically from local Keychain storage. +/// This is deliberately neither Codable nor part of the connection configuration. +public struct QuicknodeL4Credential: Sendable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + let metadataValue: String + public var description: String { "QuicknodeL4Credential([redacted])" } + public var debugDescription: String { description } + public var customMirror: Mirror { Mirror(self, children: ["value": "[redacted]"]) } + + public init(token: String) throws { + guard !token.isEmpty, token.utf8.count <= 4096, + token.utf8.allSatisfy({ (33...126).contains($0) }) else { + throw QuicknodeL4ConfigurationError.invalidCredential + } + metadataValue = token + } +} diff --git a/Sources/HyperliquidCore/MarketData/L4/Quicknode/QuicknodeL4WireMapping.swift b/Sources/HyperliquidCore/MarketData/L4/Quicknode/QuicknodeL4WireMapping.swift new file mode 100644 index 0000000..e9c18b7 --- /dev/null +++ b/Sources/HyperliquidCore/MarketData/L4/Quicknode/QuicknodeL4WireMapping.swift @@ -0,0 +1,24 @@ +import Foundation + +enum QuicknodeL4WireMapping { + static func decode(_ message: Hyperliquid_L4BookUpdatesUpdate, coin: String, maximumChanges: Int) throws -> L4BookUpdate { + guard message.time <= UInt64(Int64.max), message.diffs.count <= maximumChanges else { + throw L4BookError.invalidPayload + } + var changes: [L4OrderChange] = [] + changes.reserveCapacity(message.diffs.count) + for diff in message.diffs { + guard let side = L4OrderIdentity.Side(rawValue: diff.side) else { throw L4BookError.invalidPayload } + let identity = try L4OrderIdentity(oid: diff.oid, user: diff.user, coin: diff.coin, side: side, price: diff.px) + let change: L4OrderChange + switch diff.diffType.rawValue { + case 1: change = .new(try L4Order(identity: identity, size: diff.sz)) + case 2: change = .update(try L4Order(identity: identity, size: diff.sz)) + case 3: change = .remove(identity) + default: throw L4BookError.invalidPayload + } + if identity.coin == coin { changes.append(change) } + } + return try L4BookUpdate(coin: coin, height: message.height, time: Int64(message.time), snapshot: message.snapshot, changes: changes) + } +} diff --git a/Sources/HyperliquidCore/MarketData/L4/QuicknodeL4BookCodec.swift b/Sources/HyperliquidCore/MarketData/L4/QuicknodeL4BookCodec.swift new file mode 100644 index 0000000..c738051 --- /dev/null +++ b/Sources/HyperliquidCore/MarketData/L4/QuicknodeL4BookCodec.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Validates the JSON representation of Quicknode StreamL4BookUpdates (for fixtures and gateways). +public enum QuicknodeL4BookCodec { + public static func decode(_ value: JSONValue, coin: String, maximumChanges: Int = 1_000_000) throws -> L4BookUpdate { + guard let height = unsigned(value["height"]), let time = unsigned(value["time"]), time <= UInt64(Int64.max), + case .array(let events) = value["diffs"], events.count <= max(0, min(2_000_000, maximumChanges)), + value["snapshot"] == .null || value["snapshot"].bool != nil else { throw L4BookError.invalidPayload } + var changes: [L4OrderChange] = [] + for event in events { + let change = try decodeChange(event) + if change.identity.coin == coin { changes.append(change) } + } + return try L4BookUpdate(coin: coin, height: height, time: Int64(time), snapshot: value["snapshot"].bool ?? false, changes: changes) + } + + private static func decodeChange(_ raw: JSONValue) throws -> L4OrderChange { + guard let oid = unsigned(raw["oid"]), let user = raw["user"].string, let coin = raw["coin"].string, + let sideText = raw["side"].string, let side = L4OrderIdentity.Side(rawValue: sideText), + let price = raw["px"].string, + raw["diff_type"] == .null || raw["diffType"] == .null || raw["diff_type"] == raw["diffType"] else { + throw L4BookError.invalidPayload + } + let identity = try L4OrderIdentity(oid: oid, user: user, coin: coin, side: side, price: price) + let kind = raw["diff_type"] == .null ? raw["diffType"] : raw["diff_type"] + switch kind { + case .string("L4_ORDER_DIFF_TYPE_NEW"), .integer(1): + guard let size = raw["sz"].string else { throw L4BookError.invalidPayload } + return .new(try L4Order(identity: identity, size: size)) + case .string("L4_ORDER_DIFF_TYPE_UPDATE"), .integer(2): + guard let size = raw["sz"].string else { throw L4BookError.invalidPayload } + return .update(try L4Order(identity: identity, size: size)) + case .string("L4_ORDER_DIFF_TYPE_REMOVE"), .integer(3): return .remove(identity) + default: throw L4BookError.invalidPayload + } + } + + private static func unsigned(_ value: JSONValue) -> UInt64? { + switch value { + case .integer(let integer): return UInt64(exactly: integer) + case .string(let text): + guard !text.isEmpty, text.utf8.allSatisfy({ (48...57).contains($0) }) else { return nil } + return UInt64(text) + default: return nil + } + } +} diff --git a/Sources/HyperliquidCore/LargeTradeMonitor.swift b/Sources/HyperliquidCore/MarketData/LargeTradeMonitor.swift similarity index 100% rename from Sources/HyperliquidCore/LargeTradeMonitor.swift rename to Sources/HyperliquidCore/MarketData/LargeTradeMonitor.swift diff --git a/Sources/HyperliquidCore/MarketContextBook.swift b/Sources/HyperliquidCore/MarketData/MarketContextBook.swift similarity index 100% rename from Sources/HyperliquidCore/MarketContextBook.swift rename to Sources/HyperliquidCore/MarketData/MarketContextBook.swift diff --git a/Sources/HyperliquidCore/TradeObservationWindow.swift b/Sources/HyperliquidCore/MarketData/TradeObservationWindow.swift similarity index 100% rename from Sources/HyperliquidCore/TradeObservationWindow.swift rename to Sources/HyperliquidCore/MarketData/TradeObservationWindow.swift diff --git a/Sources/HyperliquidCore/HyperliquidAPI.swift b/Sources/HyperliquidCore/Networking/HyperliquidAPI.swift similarity index 100% rename from Sources/HyperliquidCore/HyperliquidAPI.swift rename to Sources/HyperliquidCore/Networking/HyperliquidAPI.swift diff --git a/Sources/HyperliquidCore/NIOHTTPTransport.swift b/Sources/HyperliquidCore/Networking/NIOHTTPTransport.swift similarity index 100% rename from Sources/HyperliquidCore/NIOHTTPTransport.swift rename to Sources/HyperliquidCore/Networking/NIOHTTPTransport.swift diff --git a/Sources/HyperliquidCore/NIOWebSocketTransport.swift b/Sources/HyperliquidCore/Networking/NIOWebSocketTransport.swift similarity index 100% rename from Sources/HyperliquidCore/NIOWebSocketTransport.swift rename to Sources/HyperliquidCore/Networking/NIOWebSocketTransport.swift diff --git a/Sources/HyperliquidCore/EthereumSigning.swift b/Sources/HyperliquidCore/Signing/EthereumSigning.swift similarity index 100% rename from Sources/HyperliquidCore/EthereumSigning.swift rename to Sources/HyperliquidCore/Signing/EthereumSigning.swift diff --git a/Sources/HyperliquidCore/PasskeyVerification.swift b/Sources/HyperliquidCore/Signing/PasskeyVerification.swift similarity index 100% rename from Sources/HyperliquidCore/PasskeyVerification.swift rename to Sources/HyperliquidCore/Signing/PasskeyVerification.swift diff --git a/Sources/HyperliquidCore/SigningPayload.swift b/Sources/HyperliquidCore/Signing/SigningPayload.swift similarity index 100% rename from Sources/HyperliquidCore/SigningPayload.swift rename to Sources/HyperliquidCore/Signing/SigningPayload.swift diff --git a/Sources/HyperliquidCore/Trading/Builders/BuilderFee.swift b/Sources/HyperliquidCore/Trading/Builders/BuilderFee.swift new file mode 100644 index 0000000..8832269 --- /dev/null +++ b/Sources/HyperliquidCore/Trading/Builders/BuilderFee.swift @@ -0,0 +1,90 @@ +import Foundation + +public enum BuilderFeeError: Error, Equatable, Sendable { + case invalidAddress, invalidRate, invalidApproval, invalidContext, signatureMismatch, + unsupportedAction, feeChanged +} + +/// Fees are expressed in tenths of a basis point: 10 = 0.01%. +public struct BuilderFee: Equatable, Sendable { + public let address: String + public let tenthsOfBasisPoint: Int + public var rate: Decimal { Decimal(tenthsOfBasisPoint) / 100_000 } + public var percentage: String { + NSDecimalNumber(decimal: Decimal(tenthsOfBasisPoint) / 1000).stringValue + "%" + } + public var wire: WireValue { + .map([("b", .string(address)), ("f", .uint(UInt64(tenthsOfBasisPoint)))]) + } + + public init(address: String, tenthsOfBasisPoint: Int) throws { + guard Address.isValid(address), + address.lowercased() != "0x0000000000000000000000000000000000000000" + else { throw BuilderFeeError.invalidAddress } + guard (1...1000).contains(tenthsOfBasisPoint) else { throw BuilderFeeError.invalidRate } + self.address = address.lowercased() + self.tenthsOfBasisPoint = tenthsOfBasisPoint + } + + /// Builder recipients must retain standard account mode and at least 100 USDC in perps account value. + public static func isEligible(accountValue: String, abstraction: String) throws -> Bool { + let value = try ReviewDecimal.parse(accountValue) + return ["default", "disabled"].contains(abstraction) && value >= 100 + } + + public func applicable(to intent: OrderIntent) throws -> BuilderFee? { + if intent.market.kind == .perp { + guard tenthsOfBasisPoint <= 100 else { throw BuilderFeeError.invalidRate } + return self + } + return intent.side == .sell ? self : nil + } + + public func approvedLimit(_ response: JSONValue) throws -> Int { + guard case .integer(let value) = response, (0...1000).contains(value) else { + throw BuilderFeeError.invalidApproval + } + return Int(value) + } + + /// Append only to a documented order action, before hashing/signing. Modify/cancel actions are not extended. + public func appending(toOrderAction action: WireValue) throws -> WireValue { + guard case .map(let fields) = action, Set(fields.map(\.0)).count == fields.count, + let type = fields.first(where: { $0.0 == "type" })?.1, case .string("order") = type, + !fields.contains(where: { $0.0 == "builder" }), + let orders = fields.first(where: { $0.0 == "orders" })?.1, + case .array(let wires) = orders, !wires.isEmpty + else { throw BuilderFeeError.unsupportedAction } + var chargeable = false + for wire in wires { + guard case .map(let order) = wire, Set(order.map(\.0)).count == order.count, + let asset = order.first(where: { $0.0 == "a" })?.1, + case .uint(let assetID) = asset, + let direction = order.first(where: { $0.0 == "b" })?.1, + case .bool(let buy) = direction + else { throw BuilderFeeError.unsupportedAction } + let perp = assetID < 10_000 || assetID >= 100_000 + if perp && tenthsOfBasisPoint > 100 { throw BuilderFeeError.invalidRate } + chargeable = chargeable || perp || !buy + } + return chargeable ? .map(fields + [("builder", wire)]) : action + } +} + +extension ExchangeAction { + public static func place(_ intent: OrderIntent, cloid: String? = nil, builder: BuilderFee?) + throws + -> WireValue + { + let action = place(intent, cloid: cloid) + guard let applicable = try builder?.applicable(to: intent) else { return action } + return try applicable.appending(toOrderAction: action) + } +} + +extension ProtectionBatch { + public func action(builder: BuilderFee?) throws -> WireValue { + guard mode != .modify, let builder else { return action } + return try builder.appending(toOrderAction: action) + } +} diff --git a/Sources/HyperliquidCore/Trading/Builders/BuilderFeeApproval.swift b/Sources/HyperliquidCore/Trading/Builders/BuilderFeeApproval.swift new file mode 100644 index 0000000..15bc8bf --- /dev/null +++ b/Sources/HyperliquidCore/Trading/Builders/BuilderFeeApproval.swift @@ -0,0 +1,100 @@ +import Foundation + +/// A user-signed approval, independent of approveAgent and of the API wallet's L1 action signature. +public struct BuilderFeeApproval: Sendable { + public let owner: String + public let builder: String + public let feeLimit: Int + public let chainID: UInt64 + public let nonce: UInt64 + public let environment: NetworkEnvironment + public var maxFeeRate: String { + NSDecimalNumber(decimal: Decimal(feeLimit) / 1000).stringValue + "%" + } + + public init( + owner: String, builder: String, feeLimit: Int, chainID: UInt64, + nonce: UInt64, environment: NetworkEnvironment + ) throws { + guard Address.isValid(owner), Address.isValid(builder), + builder.lowercased() != "0x0000000000000000000000000000000000000000" + else { throw BuilderFeeError.invalidAddress } + guard (0...1000).contains(feeLimit) else { throw BuilderFeeError.invalidRate } + guard chainID > 0, chainID <= UInt64(Int64.max), nonce > 0, nonce <= UInt64(Int64.max) + else { + throw BuilderFeeError.invalidContext + } + self.owner = owner.lowercased() + self.builder = builder.lowercased() + self.feeLimit = feeLimit + self.chainID = chainID + self.nonce = nonce + self.environment = environment + } + + public var action: WireValue { + .map([ + ("type", .string("approveBuilderFee")), + ("hyperliquidChain", .string(environment == .mainnet ? "Mainnet" : "Testnet")), + ("signatureChainId", .string("0x" + String(chainID, radix: 16))), + ("maxFeeRate", .string(maxFeeRate)), ("builder", .string(builder)), + ("nonce", .uint(nonce)), + ]) + } + + public var typedData: JSONValue { + func field(_ name: String, _ type: String) -> JSONValue { + .object(["name": .string(name), "type": .string(type)]) + } + return .object([ + "domain": .object([ + "name": .string("HyperliquidSignTransaction"), "version": .string("1"), + "chainId": .integer(Int64(chainID)), + "verifyingContract": .string("0x0000000000000000000000000000000000000000"), + ]), + "types": .object([ + "EIP712Domain": .array([ + field("name", "string"), field("version", "string"), + field("chainId", "uint256"), + field("verifyingContract", "address"), + ]), + "HyperliquidTransaction:ApproveBuilderFee": .array([ + field("hyperliquidChain", "string"), field("maxFeeRate", "string"), + field("builder", "address"), field("nonce", "uint64"), + ]), + ]), + "primaryType": .string("HyperliquidTransaction:ApproveBuilderFee"), + "message": .object([ + "hyperliquidChain": .string(environment == .mainnet ? "Mainnet" : "Testnet"), + "maxFeeRate": .string(maxFeeRate), "builder": .string(builder), + "nonce": .integer(Int64(nonce)), + ]), + ]) + } + + public var digest: Data { + get throws { + let structure = Keccak256.hash( + EthereumSigning.text( + "HyperliquidTransaction:ApproveBuilderFee(string hyperliquidChain,string maxFeeRate,address builder,uint64 nonce)" + ) + + EthereumSigning.text(environment == .mainnet ? "Mainnet" : "Testnet") + + EthereumSigning.text(maxFeeRate) + (try EthereumSigning.addressWord(builder)) + + EthereumSigning.word(nonce)) + return Keccak256.hash( + Data([0x19, 0x01]) + + EthereumSigning.domain(name: "HyperliquidSignTransaction", chainID: chainID) + + structure + ) + } + } + + public func request(signature: WalletSignature) throws -> Data { + guard try EthereumSigning.recover(signature: signature, digest: digest) == owner else { + throw BuilderFeeError.signatureMismatch + } + return try WireValue.map([ + ("action", action), ("nonce", .uint(nonce)), ("signature", signature.wire), + ]).jsonData() + } +} diff --git a/Sources/HyperliquidCore/ExchangeReceipt.swift b/Sources/HyperliquidCore/Trading/Orders/ExchangeReceipt.swift similarity index 100% rename from Sources/HyperliquidCore/ExchangeReceipt.swift rename to Sources/HyperliquidCore/Trading/Orders/ExchangeReceipt.swift diff --git a/Sources/HyperliquidCore/OrderBookSlippage.swift b/Sources/HyperliquidCore/Trading/Orders/OrderBookSlippage.swift similarity index 100% rename from Sources/HyperliquidCore/OrderBookSlippage.swift rename to Sources/HyperliquidCore/Trading/Orders/OrderBookSlippage.swift diff --git a/Sources/HyperliquidCore/OrderIntent.swift b/Sources/HyperliquidCore/Trading/Orders/OrderIntent.swift similarity index 100% rename from Sources/HyperliquidCore/OrderIntent.swift rename to Sources/HyperliquidCore/Trading/Orders/OrderIntent.swift diff --git a/Sources/HyperliquidCore/OrderLifecycle.swift b/Sources/HyperliquidCore/Trading/Orders/OrderLifecycle.swift similarity index 100% rename from Sources/HyperliquidCore/OrderLifecycle.swift rename to Sources/HyperliquidCore/Trading/Orders/OrderLifecycle.swift diff --git a/Sources/HyperliquidCore/OrderModification.swift b/Sources/HyperliquidCore/Trading/Orders/OrderModification.swift similarity index 100% rename from Sources/HyperliquidCore/OrderModification.swift rename to Sources/HyperliquidCore/Trading/Orders/OrderModification.swift diff --git a/Sources/HyperliquidCore/PerpOpenInterest.swift b/Sources/HyperliquidCore/Trading/Orders/PerpOpenInterest.swift similarity index 100% rename from Sources/HyperliquidCore/PerpOpenInterest.swift rename to Sources/HyperliquidCore/Trading/Orders/PerpOpenInterest.swift diff --git a/Sources/HyperliquidCore/PerpTradingCapacity.swift b/Sources/HyperliquidCore/Trading/Orders/PerpTradingCapacity.swift similarity index 100% rename from Sources/HyperliquidCore/PerpTradingCapacity.swift rename to Sources/HyperliquidCore/Trading/Orders/PerpTradingCapacity.swift diff --git a/Sources/HyperliquidCore/IsolatedMargin.swift b/Sources/HyperliquidCore/Trading/Positions/IsolatedMargin.swift similarity index 100% rename from Sources/HyperliquidCore/IsolatedMargin.swift rename to Sources/HyperliquidCore/Trading/Positions/IsolatedMargin.swift diff --git a/Sources/HyperliquidCore/PositionIntent.swift b/Sources/HyperliquidCore/Trading/Positions/PositionIntent.swift similarity index 100% rename from Sources/HyperliquidCore/PositionIntent.swift rename to Sources/HyperliquidCore/Trading/Positions/PositionIntent.swift diff --git a/Sources/HyperliquidCore/ProtectionBatch.swift b/Sources/HyperliquidCore/Trading/Protection/ProtectionBatch.swift similarity index 100% rename from Sources/HyperliquidCore/ProtectionBatch.swift rename to Sources/HyperliquidCore/Trading/Protection/ProtectionBatch.swift diff --git a/Sources/HyperliquidCore/ProtectionReadClient.swift b/Sources/HyperliquidCore/Trading/Protection/ProtectionReadClient.swift similarity index 98% rename from Sources/HyperliquidCore/ProtectionReadClient.swift rename to Sources/HyperliquidCore/Trading/Protection/ProtectionReadClient.swift index 0ed0076..822467d 100644 --- a/Sources/HyperliquidCore/ProtectionReadClient.swift +++ b/Sources/HyperliquidCore/Trading/Protection/ProtectionReadClient.swift @@ -45,7 +45,7 @@ public struct ProtectionAccountSnapshot: Sendable { } try asset.validate(address: owner, market: market) } - public func validate(_ batch: ProtectionBatch, now: Double = ReceiptClock.now) throws + public func validate(_ batch: ProtectionBatch, now: Double = ReceiptClock.now, builder: BuilderFee? = nil) throws -> [OrderFeeEstimate] { try validateContext( owner: batch.owner, market: batch.market, environment: batch.environment, now: now) @@ -83,7 +83,7 @@ public struct ProtectionAccountSnapshot: Sendable { } } } - return try batch.legs.map { try fees.estimate(for: $0.intent) } + return try batch.legs.map { try fees.estimate(for: $0.intent, builder: batch.mode == .modify ? nil : builder) } } } diff --git a/Sources/HyperliquidCore/OrderReview.swift b/Sources/HyperliquidCore/Trading/Risk/OrderReview.swift similarity index 92% rename from Sources/HyperliquidCore/OrderReview.swift rename to Sources/HyperliquidCore/Trading/Risk/OrderReview.swift index 0b46f8a..12d0d10 100644 --- a/Sources/HyperliquidCore/OrderReview.swift +++ b/Sources/HyperliquidCore/Trading/Risk/OrderReview.swift @@ -2,7 +2,7 @@ import Foundation /// User-specific rates already include the account's tier and staking discount. /// Applies the official additional referral and stable-pair factors for the -/// current catalog: validator perps and USDC-quoted spot, without builders. +/// current catalog: validator perps and USDC-quoted spot. Optional builder fees are added afterwards. public struct UserFeeRates: Sendable, Equatable { public let perpMaker: Decimal public let perpTaker: Decimal @@ -21,7 +21,7 @@ public struct UserFeeRates: Sendable, Equatable { throw OrderValidationError.invalid("Invalid account fee rates") } } - public func estimate(for intent: OrderIntent) throws -> OrderFeeEstimate { + public func estimate(for intent: OrderIntent, builder: BuilderFee? = nil) throws -> OrderFeeEstimate { guard intent.market.kind == .perp ? intent.market.assetID < 10_000 : intent.market.quoteTokenIndex == 0 @@ -33,7 +33,7 @@ public struct UserFeeRates: Sendable, Equatable { intent.market.kind == .spot && intent.market.isStablePair ? Decimal(string: "0.2")! : 1 var maker = (intent.market.kind == .spot ? spotMaker : perpMaker) * stableScale if maker > 0 { maker *= 1 - referralDiscount } - let taker = + var taker = (intent.market.kind == .spot ? spotTaker : perpTaker) * stableScale * (1 - referralDiscount) let notional = @@ -42,6 +42,10 @@ public struct UserFeeRates: Sendable, Equatable { else { throw OrderValidationError.invalid("Order value is too large to estimate fees") } + let effectiveBuilder = try builder?.applicable(to: intent) + let builderRate = effectiveBuilder?.rate ?? 0 + maker += builderRate + taker += builderRate let makerOnly = intent.kind == .limit && intent.timeInForce == .postOnly let takerOnly = intent.kind == .market || (intent.kind == .limit && intent.timeInForce == .ioc) @@ -49,7 +53,8 @@ public struct UserFeeRates: Sendable, Equatable { let lower = makerOnly ? maker : takerOnly ? taker : min(maker, taker) let upper = makerOnly ? maker : takerOnly ? taker : max(maker, taker) return OrderFeeEstimate( - lower: notional * lower, upper: notional * upper, maximumRate: upper) + lower: notional * lower, upper: notional * upper, maximumRate: upper, + builder: effectiveBuilder, builderAmount: notional * builderRate) } } @@ -57,7 +62,17 @@ public struct OrderFeeEstimate: Sendable, Equatable { public let lower: Decimal public let upper: Decimal public let maximumRate: Decimal + public let builder: BuilderFee? + public let builderAmount: Decimal + public init(lower: Decimal, upper: Decimal, maximumRate: Decimal, builder: BuilderFee? = nil, builderAmount: Decimal = 0) { + self.lower = lower + self.upper = upper + self.maximumRate = maximumRate + self.builder = builder + self.builderAmount = builderAmount + } public func validate(current: OrderFeeEstimate) throws { + guard current.builder == builder else { throw BuilderFeeError.feeChanged } guard current.maximumRate <= maximumRate else { throw OrderValidationError.invalid( "Account fees increased. Create a new preview to review the current estimate.") diff --git a/Sources/HyperliquidCore/OrderRisk.swift b/Sources/HyperliquidCore/Trading/Risk/OrderRisk.swift similarity index 100% rename from Sources/HyperliquidCore/OrderRisk.swift rename to Sources/HyperliquidCore/Trading/Risk/OrderRisk.swift diff --git a/Sources/HyperliquidCore/PositionRiskMonitor.swift b/Sources/HyperliquidCore/Trading/Risk/PositionRiskMonitor.swift similarity index 100% rename from Sources/HyperliquidCore/PositionRiskMonitor.swift rename to Sources/HyperliquidCore/Trading/Risk/PositionRiskMonitor.swift diff --git a/Sources/HyperliquidCore/TradingSession.swift b/Sources/HyperliquidCore/Trading/Sessions/TradingSession.swift similarity index 96% rename from Sources/HyperliquidCore/TradingSession.swift rename to Sources/HyperliquidCore/Trading/Sessions/TradingSession.swift index 11dfddd..6e35b1f 100644 --- a/Sources/HyperliquidCore/TradingSession.swift +++ b/Sources/HyperliquidCore/Trading/Sessions/TradingSession.swift @@ -84,7 +84,7 @@ public enum SessionState: String, Sendable { } /// Trading key with account/network binding and a finite authority window. -/// Persistence is opt-in and only exports authenticated ciphertext protected by a passkey. +/// Persistence is opt-in and only exports authenticated ciphertext protected by a caller-owned key. /// This actor never submits requests; callers must run the native confirmation workflow first. public actor TradingSession { public private(set) var state: SessionState = .inactive @@ -103,7 +103,7 @@ public actor TradingSession { throw WalletError.invalid("End or resolve the existing session first") } guard Address.isValid(owner), chainID > 0, chainID <= UInt64(Int64.max), - (3_600_000...604_800_000).contains(duration), + (3_600_000...15_552_000_000).contains(duration), now <= UInt64(Int64.max) - duration else { throw WalletError.invalid("Invalid session account or chain") } let key = try EphemeralSigningKey() @@ -221,8 +221,9 @@ public actor TradingSession { recoveryBinding = nil } public func enableRecovery(key: SymmetricKey, binding: Data) throws -> TradingSessionRecovery { - guard state == .active, key.bitCount == 256, !binding.isEmpty else { - throw WalletError.invalid("Verify an active session before enabling recovery") + guard [.prepared, .authorizing, .unverified, .active].contains(state), + self.key != nil, key.bitCount == 256, !binding.isEmpty else { + throw WalletError.invalid("Prepare a session before enabling protected recovery") } recoveryKey = key recoveryBinding = binding @@ -234,7 +235,8 @@ public actor TradingSession { } public func encryptedRecovery() throws -> TradingSessionRecovery { guard let approval, let key, let recoveryKey, let recoveryBinding, - state == .active, Self.timestamp < approval.validUntil else { + [.prepared, .authorizing, .unverified, .active].contains(state), + Self.timestamp < approval.validUntil else { throw WalletError.invalid("Passkey recovery is unavailable") } let sealed = try key.seal( diff --git a/Sources/HyperliquidCore/TradingSessionRecovery.swift b/Sources/HyperliquidCore/Trading/Sessions/TradingSessionRecovery.swift similarity index 95% rename from Sources/HyperliquidCore/TradingSessionRecovery.swift rename to Sources/HyperliquidCore/Trading/Sessions/TradingSessionRecovery.swift index 3bb6277..85c3b9a 100644 --- a/Sources/HyperliquidCore/TradingSessionRecovery.swift +++ b/Sources/HyperliquidCore/Trading/Sessions/TradingSessionRecovery.swift @@ -19,7 +19,7 @@ public struct TradingSessionRecovery: Codable, Sendable { approval.createdAt <= now, approval.nonce == approval.createdAt, approval.validUntil > now, approval.validUntil <= UInt64(Int64.max), approval.validUntil > approval.createdAt, - approval.validUntil - approval.createdAt <= 604_800_000, + approval.validUntil - approval.createdAt <= 15_552_000_000, sealedKey.count == 68 else { throw WalletError.invalid("The remembered wallet is expired or belongs to another network") } diff --git a/Sources/HyperliquidCore/Models.swift b/Sources/HyperliquidCore/Types/Models.swift similarity index 100% rename from Sources/HyperliquidCore/Models.swift rename to Sources/HyperliquidCore/Types/Models.swift diff --git a/Sources/HyperliquidCore/ReceiptClock.swift b/Sources/HyperliquidCore/Types/ReceiptClock.swift similarity index 100% rename from Sources/HyperliquidCore/ReceiptClock.swift rename to Sources/HyperliquidCore/Types/ReceiptClock.swift diff --git a/Sources/HyperliquidCore/WalletBridge.swift b/Sources/HyperliquidCore/Wallet/WalletBridge.swift similarity index 100% rename from Sources/HyperliquidCore/WalletBridge.swift rename to Sources/HyperliquidCore/Wallet/WalletBridge.swift diff --git a/Sources/HyperliquidCore/WalletConnection.swift b/Sources/HyperliquidCore/Wallet/WalletConnection.swift similarity index 100% rename from Sources/HyperliquidCore/WalletConnection.swift rename to Sources/HyperliquidCore/Wallet/WalletConnection.swift diff --git a/Sources/HyperliquidCore/WalletOwnership.swift b/Sources/HyperliquidCore/Wallet/WalletOwnership.swift similarity index 100% rename from Sources/HyperliquidCore/WalletOwnership.swift rename to Sources/HyperliquidCore/Wallet/WalletOwnership.swift diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 635e201..9e47b6b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,7 +8,13 @@ Dependencies are fetched by Swift Package Manager and retain their own licenses | SwiftNIO | https://github.com/apple/swift-nio | Apache-2.0, plus bundled component notices | | SwiftNIO SSL | https://github.com/apple/swift-nio-ssl | Apache-2.0, plus BoringSSL notices | | AsyncHTTPClient | https://github.com/swift-server/async-http-client | Apache-2.0 | +| gRPC Swift 2 | https://github.com/grpc/grpc-swift-2 | Apache-2.0 | +| gRPC Swift NIO transport | https://github.com/grpc/grpc-swift-nio-transport | Apache-2.0 | +| gRPC Swift protobuf | https://github.com/grpc/grpc-swift-protobuf | Apache-2.0 | +| SwiftProtobuf | https://github.com/apple/swift-protobuf | Apache-2.0; bundled protobuf compiler components retain their own notices | Consult each resolved dependency's LICENSE/NOTICE files for full terms, including transitive dependencies. Exact revisions are recorded in Package.resolved. Signing test vectors include published examples from the MIT-licensed [Hyperliquid Python SDK](https://github.com/hyperliquid-dex/hyperliquid-python-sdk). The repeating test private key in SigningTests.swift is a publicly documented fixture, not an application credential. The JSON vectors contain synthetic accounts, fixed nonces and expected signatures for protocol compatibility tests. + +The L4 protobuf schema is a wire-compatible subset of the published [Quicknode HyperCore example definition](https://github.com/quiknode-labs/hypercore-grpc-examples/blob/main/proto/orderbook.proto). Generated clients are produced by the official gRPC Swift build plugin; generated files preserve the generator headers. diff --git a/Tests/HyperliquidCoreTests/AccountHistoryTests.swift b/Tests/HyperliquidCoreTests/Accounts/AccountHistoryTests.swift similarity index 98% rename from Tests/HyperliquidCoreTests/AccountHistoryTests.swift rename to Tests/HyperliquidCoreTests/Accounts/AccountHistoryTests.swift index c54b584..58bc4f1 100644 --- a/Tests/HyperliquidCoreTests/AccountHistoryTests.swift +++ b/Tests/HyperliquidCoreTests/Accounts/AccountHistoryTests.swift @@ -3,7 +3,7 @@ import Testing @testable import HyperliquidCore -private let archiveOwner = "0x5742261Ef0516AD70C4217cc573fa21485289D3B" +private let archiveOwner = "0xAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCd" private func archivedFill(_ time: Int64, tid: Int64 = 1, coin: String = "ETH", fee: String = "1") -> JSONValue { .object([ diff --git a/Tests/HyperliquidCoreTests/AccountStreamTests.swift b/Tests/HyperliquidCoreTests/Accounts/AccountStreamTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/AccountStreamTests.swift rename to Tests/HyperliquidCoreTests/Accounts/AccountStreamTests.swift diff --git a/Tests/HyperliquidCoreTests/AgentTests.swift b/Tests/HyperliquidCoreTests/Analytics/AgentTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/AgentTests.swift rename to Tests/HyperliquidCoreTests/Analytics/AgentTests.swift diff --git a/Tests/HyperliquidCoreTests/Fixtures/CoreFixturePaths.swift b/Tests/HyperliquidCoreTests/Fixtures/CoreFixturePaths.swift new file mode 100644 index 0000000..9bcf13d --- /dev/null +++ b/Tests/HyperliquidCoreTests/Fixtures/CoreFixturePaths.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Locate the SDK fixtures from any nested test module or dependency checkout. +enum CoreFixturePaths { + static func packageRoot(filePath: StaticString = #filePath) throws -> URL { + var directory = URL(fileURLWithPath: String(describing: filePath)).deletingLastPathComponent() + while directory.path != "/" { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("Package.swift").path), + FileManager.default.fileExists(atPath: directory.appendingPathComponent("Sources/HyperliquidCore").path) { + return directory + } + directory.deleteLastPathComponent() + } + throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: String(describing: filePath)]) + } +} diff --git a/Tests/HyperliquidCoreTests/CCTPAllowanceTests.swift b/Tests/HyperliquidCoreTests/Funding/CCTP/CCTPAllowanceTests.swift similarity index 97% rename from Tests/HyperliquidCoreTests/CCTPAllowanceTests.swift rename to Tests/HyperliquidCoreTests/Funding/CCTP/CCTPAllowanceTests.swift index cca0aec..ddabfb2 100644 --- a/Tests/HyperliquidCoreTests/CCTPAllowanceTests.swift +++ b/Tests/HyperliquidCoreTests/Funding/CCTP/CCTPAllowanceTests.swift @@ -4,8 +4,7 @@ import Testing @testable import HyperliquidCore @Test func cctpAllowanceTransactionsMatchIndependentABIForBothNetworksAndDestinations() throws { - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent() + let root = try CoreFixturePaths.packageRoot() let fixtureURL = root.appendingPathComponent("docs/verification/funding/cctp-vectors.json") let fixture = try JSONDecoder().decode(JSONValue.self, from: Data(contentsOf: fixtureURL)) let owner = try #require(fixture["owner"].string) diff --git a/Tests/HyperliquidCoreTests/CCTPDepositTests.swift b/Tests/HyperliquidCoreTests/Funding/CCTP/CCTPDepositTests.swift similarity index 98% rename from Tests/HyperliquidCoreTests/CCTPDepositTests.swift rename to Tests/HyperliquidCoreTests/Funding/CCTP/CCTPDepositTests.swift index 32ae85f..8ca7e5f 100644 --- a/Tests/HyperliquidCoreTests/CCTPDepositTests.swift +++ b/Tests/HyperliquidCoreTests/Funding/CCTP/CCTPDepositTests.swift @@ -4,8 +4,7 @@ import Testing @testable import HyperliquidCore @Test func cctpDepositAuthorizationHookAndABIEqualIndependentEthAccountAndEthABIVectors() throws { - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent() + let root = try CoreFixturePaths.packageRoot() let fixture = try JSONDecoder().decode( JSONValue.self, from: Data( diff --git a/Tests/HyperliquidCoreTests/CCTPNetworkTests.swift b/Tests/HyperliquidCoreTests/Funding/CCTP/CCTPNetworkTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/CCTPNetworkTests.swift rename to Tests/HyperliquidCoreTests/Funding/CCTP/CCTPNetworkTests.swift diff --git a/Tests/HyperliquidCoreTests/DepositBalanceReadTests.swift b/Tests/HyperliquidCoreTests/Funding/DepositBalanceReadTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/DepositBalanceReadTests.swift rename to Tests/HyperliquidCoreTests/Funding/DepositBalanceReadTests.swift diff --git a/Tests/HyperliquidCoreTests/DepositSourceBalanceTests.swift b/Tests/HyperliquidCoreTests/Funding/DepositSourceBalanceTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/DepositSourceBalanceTests.swift rename to Tests/HyperliquidCoreTests/Funding/DepositSourceBalanceTests.swift diff --git a/Tests/HyperliquidCoreTests/FundingIntentTests.swift b/Tests/HyperliquidCoreTests/Funding/FundingIntentTests.swift similarity index 98% rename from Tests/HyperliquidCoreTests/FundingIntentTests.swift rename to Tests/HyperliquidCoreTests/Funding/FundingIntentTests.swift index 2ce8092..ec8c6c8 100644 --- a/Tests/HyperliquidCoreTests/FundingIntentTests.swift +++ b/Tests/HyperliquidCoreTests/Funding/FundingIntentTests.swift @@ -12,8 +12,7 @@ private func fundingToken(_ spot: Bool = false) throws -> FundingToken { } @Test func fundingActionsMatchIndependentSDKDigestSignaturesAndHumanReadableData() throws { - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent() + let root = try CoreFixturePaths.packageRoot() let fixture = try JSONDecoder().decode( JSONValue.self, from: Data( diff --git a/Tests/HyperliquidCoreTests/FundingSnapshotTests.swift b/Tests/HyperliquidCoreTests/Funding/FundingSnapshotTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/FundingSnapshotTests.swift rename to Tests/HyperliquidCoreTests/Funding/FundingSnapshotTests.swift diff --git a/Tests/HyperliquidCoreTests/TransferTests.swift b/Tests/HyperliquidCoreTests/Funding/TransferTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/TransferTests.swift rename to Tests/HyperliquidCoreTests/Funding/TransferTests.swift diff --git a/Tests/HyperliquidCoreTests/UnitDepositTests.swift b/Tests/HyperliquidCoreTests/Funding/Unit/UnitDepositTests.swift similarity index 57% rename from Tests/HyperliquidCoreTests/UnitDepositTests.swift rename to Tests/HyperliquidCoreTests/Funding/Unit/UnitDepositTests.swift index 774044a..2508975 100644 --- a/Tests/HyperliquidCoreTests/UnitDepositTests.swift +++ b/Tests/HyperliquidCoreTests/Funding/Unit/UnitDepositTests.swift @@ -3,25 +3,40 @@ import Testing @testable import HyperliquidCore -private let unitOwner = "0x5742261ef0516ad70c4217cc573fa21485289d3b" +private let unitOwner = "0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" +// Frozen official guardian proofs for a synthetic owner; no network requests in these tests. private func unitResponse(_ environment: NetworkEnvironment) -> JSONValue { if environment == .mainnet { return .object([ - "status": .string("OK"), "address": .string("0xCFDbb9cf18Cd1E5b5Bf37eD873161bc9A0538055"), + "address": .string("0x7483A1F4f90fED731f17EAa75655AE10d08b1520"), "signatures": .object([ - "field-node": .string("Mhx6T0Z0QzqromXHWre9/abKF8avb6HxbJffXahEG1p//z2XQhlsFqvf5q9aV/93QFmuoeG2GXsSI/e3UtRCyQ=="), - "hl-node": .string("3lmQKiW09X/92tYMvwQDoQPUbc8WXXAP2jbAP3+iJgSBVFSEmV2tefFCP3jxfnEoUBfPpYvosZwESKtdLIkwtw=="), - "unit-node": .string("4IMewgEZq7jFPaGUfL6RgTIq8RqZLgeoa510qo8R1WeEuFbcgIWJZS4JhuqLp+jT4Tp1Q8hlHhJNzCpN/VhA5g==") - ]) + "field-node": .string( + "TmFVbTLDomZT6uYwXpzl/WiJ9rZ/JC4YyPfn10HQpdA8R+ZTYRVN8tjnIO9fQRLIV5Vj4mSerGhPRhOKSRDAfw==" + ), + "hl-node": .string( + "F7xw13GkLGX4oggW4swKpQlZLCGwhf5kXccdv8LyU+0UR4prSG+qWcyrUs4j1nq5HfVxXAdAOQEFTHniEDj2/g==" + ), + "unit-node": .string( + "1XL55hALNn9uKWdUUXL3iVMflAUYa9nLugqk+c8HujB4IS6FyhObGhvjuqK6QFiClKZ6lF4/yWL9hvocqKEozw==" + ), + ]), + "status": .string("OK"), ]) } return .object([ - "status": .string("OK"), "address": .string("0xaD47D129Dc89CaA0e6B8F88a589960d71845F817"), + "address": .string("0x701cCbF884f8d6e51804f2C714b8B6d5c6201E58"), "signatures": .object([ - "field-node": .string("k+lTYWunf+eAkhg6uPIJ3oL8hQlCBDZhgUInqzakNgqTJ0LHy8P/uyh9g3mx5LD3uHTTyJ4D4vMshspf4Hl8cw=="), - "hl-node-testnet": .string("6ZtmVSF5HzNdI+Ozbgn26vNtuJE6tiSUJgHcQZ01OgVbjJtDJzEqG1mhfu+Bm1PVqwtW/p+lPpFTO1aGpTpfpA=="), - "node-1": .string("r96MZg6TJRyUGXa7ocQ3Zuew8f/JGe/UP226qdwRxog/nGd44UM/iKisOWgcDDJ0aDlY7TtNShcM5xUu7FL4Vg==") - ]) + "field-node": .string( + "9e2W4U8VT6jDMyKJYMw26Pza4me+uhkobEnAtalp5+pzPBUT3ugEDI63MYsqJi0cafZJGu3IKsXrYxFgIDAcug==" + ), + "hl-node-testnet": .string( + "1PANpxIRwznylWRbFc0T0tSnvaadfOd4cxiwK/16pDmkhB2ui97zjPb3m0UPbtk4NHDcePYY53yvsxL2P4HGSg==" + ), + "node-1": .string( + "onVvp11r3nbhtDuJQTlzL0UjuYQ9igbYpxfaSqZPW35OLT5v7YTTW2P2H1tlP8u/YKDDsTUGmsvjwgf8YjUILA==" + ), + ]), + "status": .string("OK"), ]) } @@ -31,7 +46,8 @@ func unitDepositAddressVerifiesOfficialSignaturesAndRejectsChangedOwnerChainOrAd ) throws { let response = unitResponse(environment) let address = try UnitDepositAddress( - response: response, asset: .eth, owner: unitOwner.uppercased().replacingOccurrences(of: "0X", with: "0x"), + response: response, asset: .eth, + owner: unitOwner.uppercased().replacingOccurrences(of: "0X", with: "0x"), environment: environment) #expect(address.owner == unitOwner && address.asset == .eth) #expect(address.address == response["address"].string) @@ -41,7 +57,8 @@ func unitDepositAddressVerifiesOfficialSignaturesAndRejectsChangedOwnerChainOrAd environment: environment) } #expect(throws: (any Error).self) { - try UnitDepositAddress(response: response, asset: .sol, owner: unitOwner, environment: environment) + try UnitDepositAddress( + response: response, asset: .sol, owner: unitOwner, environment: environment) } #expect(throws: (any Error).self) { try UnitDepositAddress( @@ -51,32 +68,40 @@ func unitDepositAddressVerifiesOfficialSignaturesAndRejectsChangedOwnerChainOrAd guard case .object(var changed) = response else { return } changed["address"] = .string("0x1111111111111111111111111111111111111111") #expect(throws: (any Error).self) { - try UnitDepositAddress(response: .object(changed), asset: .eth, owner: unitOwner, environment: environment) + try UnitDepositAddress( + response: .object(changed), asset: .eth, owner: unitOwner, environment: environment) } } @Test func unitDepositAddressRequiresTwoAuthenticatedGuardiansAndNeverCountsAliasesTwice() throws { guard case .object(var response) = unitResponse(.testnet), - case .object(var signatures) = response["signatures"] else { return } + case .object(var signatures) = response["signatures"] + else { return } signatures.removeValue(forKey: "field-node") response["signatures"] = .object(signatures) - #expect(try UnitDepositAddress( - response: .object(response), asset: .eth, owner: unitOwner, environment: .testnet).owner == unitOwner) + #expect( + try UnitDepositAddress( + response: .object(response), asset: .eth, owner: unitOwner, environment: .testnet + ).owner == unitOwner) signatures.removeValue(forKey: "hl-node-testnet") signatures["unit-node"] = signatures["node-1"] signatures["unknown"] = signatures["node-1"] response["signatures"] = .object(signatures) #expect(throws: (any Error).self) { - try UnitDepositAddress(response: .object(response), asset: .eth, owner: unitOwner, environment: .testnet) + try UnitDepositAddress( + response: .object(response), asset: .eth, owner: unitOwner, environment: .testnet) } response["coinType"] = .string("bitcoin") #expect(throws: (any Error).self) { - try UnitDepositAddress(response: .object(response), asset: .eth, owner: unitOwner, environment: .testnet) + try UnitDepositAddress( + response: .object(response), asset: .eth, owner: unitOwner, environment: .testnet) } } -@Test(.enabled(if: ProcessInfo.processInfo.environment["HL_LIVE_PROBE"] == "1" - && ProcessInfo.processInfo.environment["HL_TESTNET_ACCOUNT"] != nil)) +@Test( + .enabled( + if: ProcessInfo.processInfo.environment["HL_LIVE_PROBE"] == "1" + && ProcessInfo.processInfo.environment["HL_TESTNET_ACCOUNT"] != nil)) func unitDepositPublicAddressAndEVMBalanceProbe() async throws { let owner = try #require(ProcessInfo.processInfo.environment["HL_TESTNET_ACCOUNT"]) for environment in NetworkEnvironment.allCases { diff --git a/Tests/HyperliquidCoreTests/Funding/Unit/ZcashDepositEncodingTests.swift b/Tests/HyperliquidCoreTests/Funding/Unit/ZcashDepositEncodingTests.swift new file mode 100644 index 0000000..1f5c027 --- /dev/null +++ b/Tests/HyperliquidCoreTests/Funding/Unit/ZcashDepositEncodingTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +@testable import HyperliquidCore + +@Test func zcashDepositTEXEncodingMatchesZIP320VectorAndRejectsChangedChecksumAndNetwork() throws { + let address = "tex1s2rt77ggv6q989lr49rkgzmh5slsksa9khdgte" + #expect( + try ZcashDepositEncoding.transparentAddress(fromTEX: address, environment: .mainnet) + == "t1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC") + #expect(throws: (any Error).self) { + try ZcashDepositEncoding.transparentAddress(fromTEX: address, environment: .testnet) + } + #expect(throws: (any Error).self) { + try ZcashDepositEncoding.transparentAddress( + fromTEX: String(address.dropLast()) + "q", environment: .mainnet) + } +} + +@Test func zcashDepositAddressAuthenticatesTheExactRecipientBehindTheReturnedTEXAddress() throws { + let response: JSONValue = .object([ + "address": .string("tex1m69nhjfr5vjaa3rqu6u53kkrga0mnlkndntdgn"), + "signatures": .object([ + "field-node": .string( + "NrlY68iVAKjCCsr2t4m5QPTaPYe4qYFk1ppaVUGMnI60hIiiG/onqpegkTIA7NCIoL02bLa+WsJMpQhcgFoMxA==" + ), + "hl-node": .string( + "msSGBeopiyo7c1bijWlr3qBKMc39PtZRTBt5i984u5LvRaVtvEuI0JfcM2+cbo6hyExgSCxSb4D6V/xI/t1DYg==" + ), + ]), + "status": .string("OK"), + ]) + let owner = "0xAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCd" + let verified = try UnitDepositAddress( + response: response, asset: .zec, owner: owner, environment: .mainnet) + #expect(verified.address == response["address"].string && verified.owner == owner.lowercased()) + #expect(throws: (any Error).self) { + try UnitDepositAddress( + response: response, asset: .zec, owner: "0x1111111111111111111111111111111111111111", + environment: .mainnet) + } +} diff --git a/Tests/HyperliquidCoreTests/HIP3CatalogTests.swift b/Tests/HyperliquidCoreTests/MarketData/HIP3CatalogTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/HIP3CatalogTests.swift rename to Tests/HyperliquidCoreTests/MarketData/HIP3CatalogTests.swift diff --git a/Tests/HyperliquidCoreTests/MarketData/L4/L4BookQueryTests.swift b/Tests/HyperliquidCoreTests/MarketData/L4/L4BookQueryTests.swift new file mode 100644 index 0000000..0bdfc9f --- /dev/null +++ b/Tests/HyperliquidCoreTests/MarketData/L4/L4BookQueryTests.swift @@ -0,0 +1,109 @@ +import Foundation +import Testing +@testable import HyperliquidCore + +@Suite struct L4BookQueryTests { + private func order(_ oid: UInt64, price: String = "100", size: String = "1", + side: L4OrderIdentity.Side = .bid) throws -> L4Order { + try .init(identity: .init(oid: oid, user: "0x0000000000000000000000000000000000000001", + coin: "ETH", side: side, price: price), size: size) + } + + private func block(_ height: UInt64 = 10, snapshot: Bool = false, + _ changes: [L4OrderChange] = []) throws -> L4BookUpdate { + try .init(coin: "ETH", height: height, time: Int64(height * 100), snapshot: snapshot, changes: changes) + } + + @Test func boundedPagesVisitAllOidsExactlyOnceAndRetainWalletAttribution() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + let ids = Array(1...2003).map(UInt64.init) + [UInt64.max] + let changes = try ids.reversed().map { try L4OrderChange.new(order($0)) } + _ = try reducer.apply(block(snapshot: true, changes + [.new(order(3000, price: "101", side: .ask))])) + var cursor: L4OrderPageCursor? + var observed: [UInt64] = [] + repeat { + let page = try reducer.orderPage(side: .bid, price: 100, after: cursor, limit: 37) + #expect(page.orders.count <= 37 && page.totalOrderCount == ids.count && page.totalSize == Decimal(ids.count)) + #expect(page.height == 10 && page.time == 1000 && page.environment == .testnet && page.coin == "ETH") + #expect(page.orders.allSatisfy { $0.identity.user == "0x0000000000000000000000000000000000000001" }) + observed += page.orders.map(\.oid) + cursor = page.nextCursor + } while cursor != nil + #expect(observed == ids && Set(observed).count == ids.count) + #expect(reducer.status == .live && reducer.orderCount == ids.count + 1) + } + + @Test func unchangedLevelCanContinueAcrossOtherPriceUpdates() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + _ = try reducer.apply(block(snapshot: true, [.new(order(1)), .new(order(2)), .new(order(3, price: "99"))])) + let first = try reducer.orderPage(side: .bid, price: 100, limit: 1) + let cursor = try #require(first.nextCursor) + _ = try reducer.apply(block(11, [.update(order(3, price: "99", size: "5"))])) + let next = try reducer.orderPage(side: .bid, price: 100, after: cursor, limit: 1) + #expect(next.orders.map(\.oid) == [2] && next.nextCursor == nil) + #expect(next.levelRevision == first.levelRevision && next.height == 11 && next.time == 1100) + } + + @Test(arguments: [0, 1, 2, 3]) func touchedDeletedOrReplacedLevelsRejectPriorPagination(mutation: Int) throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + let initial = try [L4OrderChange.new(order(1)), .new(order(2))] + _ = try reducer.apply(block(snapshot: true, initial)) + let cursor = try #require(reducer.orderPage(side: .bid, price: 100, limit: 1).nextCursor) + switch mutation { + case 0: _ = try reducer.apply(block(11, [.update(order(2, size: "2"))])) + case 1: _ = try reducer.apply(block(11, [.remove(order(1).identity), .remove(order(2).identity)])) + case 2: _ = try reducer.apply(block(snapshot: true, initial)) + default: + reducer.resetForNewConnection() + _ = try reducer.apply(block(1, snapshot: true, initial)) + } + #expect(throws: L4BookQueryError.staleCursor) { try reducer.orderPage(side: .bid, price: 100, after: cursor) } + #expect(reducer.status == .live) + } + + @Test func queryScopeCannotRedirectACursorEvenWhenTwoLevelsShareABatchRevision() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + _ = try reducer.apply(block(snapshot: true, [.new(order(1)), .new(order(2)), + .new(order(3, price: "101", side: .ask)), .new(order(4, price: "101", side: .ask))])) + let cursor = try #require(reducer.orderPage(side: .bid, price: 100, limit: 1).nextCursor) + #expect(throws: L4BookQueryError.invalidCursor) { try reducer.orderPage(side: .ask, price: 101, after: cursor) } + let bad = L4OrderPageCursor(environment: .mainnet, coin: cursor.coin, side: cursor.side, price: cursor.price, + levelRevision: cursor.levelRevision, afterOID: cursor.afterOID) + #expect(throws: L4BookQueryError.invalidCursor) { try reducer.orderPage(side: .bid, price: 100, after: bad) } + var other = try L4BookReducer(environment: .testnet, coin: "ETH") + _ = try other.apply(block(snapshot: true, [.new(order(1)), .new(order(2))])) + #expect(throws: L4BookQueryError.staleCursor) { try other.orderPage(side: .bid, price: 100, after: cursor) } + } + + @Test func invalidQueriesDoNotInvalidateTheBookAndEmptyLevelIsExplicit() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + #expect(throws: L4BookError.snapshotRequired) { try reducer.orderPage(side: .bid, price: 100) } + _ = try reducer.apply(block(snapshot: true, [.new(order(1))])) + for limit in [0, -1, 501, Int.max] { + #expect(throws: L4BookQueryError.invalidLimit) { try reducer.orderPage(side: .bid, price: 100, limit: limit) } + } + for price in [Decimal.zero, -1, .nan] { + #expect(throws: L4BookQueryError.invalidPrice) { try reducer.orderPage(side: .bid, price: price) } + } + let empty = try reducer.orderPage(side: .ask, price: 101) + #expect(empty.orders.isEmpty && empty.totalOrderCount == 0 && empty.totalSize == 0) + #expect(empty.nextCursor == nil && empty.levelRevision == nil && reducer.orderCount == 1 && reducer.status == .live) + reducer.invalidate() + #expect(throws: L4BookError.snapshotRequired) { try reducer.orderPage(side: .bid, price: 100) } + } + + @Test func boundedSelectionMatchesSortingForDifferentPagesAndLimits() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + var seed: UInt64 = 44 + var ids: Set = [UInt64.max] + for _ in 0..<5000 { seed = seed &* 6_364_136_223_846_793_005 &+ 1; if seed > 0 { ids.insert(seed) } } + _ = try reducer.apply(block(snapshot: true, try ids.map { try .new(order($0)) })) + for limit in [1, 7, 100, 500] { + let first = try reducer.orderPage(side: .bid, price: try #require(Decimal(string: "100.000")), limit: limit) + #expect(first.orders.map(\.oid) == Array(ids.sorted().prefix(limit))) + let cursor = try #require(first.nextCursor) + let second = try reducer.orderPage(side: .bid, price: 100, after: cursor, limit: limit) + #expect(second.orders.map(\.oid) == Array(ids.sorted().dropFirst(limit).prefix(limit))) + } + } +} diff --git a/Tests/HyperliquidCoreTests/MarketData/L4/L4BookReducerTests.swift b/Tests/HyperliquidCoreTests/MarketData/L4/L4BookReducerTests.swift new file mode 100644 index 0000000..d79c3f5 --- /dev/null +++ b/Tests/HyperliquidCoreTests/MarketData/L4/L4BookReducerTests.swift @@ -0,0 +1,206 @@ +import Foundation +import Testing +@testable import HyperliquidCore + +@Suite struct L4BookReducerTests { + private let user = "0xFeDcFeDcFeDcFeDcFeDcFeDcFeDcFeDcFeDcFeDc" + + private func order(_ oid: UInt64, _ price: String = "100", _ size: String = "1", + side: L4OrderIdentity.Side = .bid, user: String? = nil) throws -> L4Order { + try L4Order(identity: L4OrderIdentity(oid: oid, user: user ?? self.user, coin: "ETH", side: side, price: price), size: size) + } + + private func block(_ height: UInt64 = 10, snapshot: Bool = false, time: Int64 = 100, + _ changes: [L4OrderChange] = []) throws -> L4BookUpdate { + try L4BookUpdate(coin: "ETH", height: height, time: time, snapshot: snapshot, changes: changes) + } + + @Test func retainsCompleteOrderMapBeyondVisibleProjection() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + let bids = try (1...40).map { try order(UInt64($0), String(100 - $0), "0.1") } + let ask = try order(.max, "101", "0.2", side: .ask) + let view = try reducer.apply(block(snapshot: true, bids.map(L4OrderChange.new) + [.new(ask)])) + #expect(view.orderCount == 41 && view.priceLevelCount == 41) + #expect(view.book.bids.count == 20 && view.maximumLevelsPerSide == 20) + #expect(reducer.order(.max) == ask && reducer.order(40) == bids.last) + #expect(try reducer.view(maximumLevelsPerSide: 100).book.bids.count == 40) + #expect(reducer.environment == .testnet) + } + + @Test func updatesAreAbsoluteAndDecimalAggregationIsExact() throws { + var reducer = try L4BookReducer(environment: .mainnet, coin: "ETH") + let a = try order(1, "100.0", "0.1"), b = try order(2, "100.00", "0.2") + let initial = try reducer.apply(block(snapshot: true, [.new(a), .new(b)])) + #expect(initial.book.bids.first?.sz == "0.3" && initial.book.bids.first?.n == 2) + let resized = try order(1, "100", "0.4") + let changed = try reducer.apply(block(11, [.update(resized)])) + #expect(changed.book.bids.first?.sz == "0.6" && changed.book.bids.first?.n == 2) + let removed = try reducer.apply(block(12, [.remove(b.identity)])) + #expect(removed.book.bids.first?.sz == "0.4" && removed.orderCount == 1) + let empty = try reducer.apply(block(13, [.remove(resized.identity)])) + #expect(empty.book.bids.isEmpty && empty.priceLevelCount == 0 && reducer.order(1) == nil) + #expect(initial.book.bids.first?.sz == "0.3") + } + + @Test func replacementSnapshotsAtSameHeightDiscardAllPriorState() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + _ = try reducer.apply(block(snapshot: true, [.new(order(1))])) + let replacement = try reducer.apply(block(snapshot: true, [.new(order(2, "101"))])) + #expect(reducer.order(1) == nil && reducer.order(2) != nil && replacement.orderCount == 1) + let empty = try reducer.apply(block(snapshot: true)) + #expect(empty.orderCount == 0 && empty.book.levels == [[], []] && reducer.status == .live) + } + + @Test func rejectedBatchInvalidatesAllIndexesAndNeedsFreshSnapshot() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + let old = try reducer.apply(block(snapshot: true, [.new(order(1))])) + #expect(throws: L4BookError.unknownOrder) { + try reducer.apply(block(11, [.new(order(2)), .remove(order(9).identity)])) + } + #expect(reducer.status == .resynchronizationRequired && reducer.orderCount == 0) + #expect(reducer.order(1) == nil && reducer.order(2) == nil) + #expect(throws: L4BookError.snapshotRequired) { try reducer.view() } + #expect(throws: L4BookError.snapshotRequired) { try reducer.apply(block(12)) } + #expect(old.orderCount == 1 && old.book.bids.first?.sz == "1") + #expect(throws: L4BookError.invalidWatermark) { try reducer.apply(block(9, snapshot: true)) } + #expect(try reducer.apply(block(10, snapshot: true)).orderCount == 0) + } + + @Test func duplicateNewUnknownUpdateAndSnapshotMutationsAreRejected() throws { + for kind in 0...3 { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + let a = try order(1) + _ = try reducer.apply(block(snapshot: true, [.new(a)])) + let update: L4BookUpdate + let error: L4BookError + switch kind { + case 0: update = try block(11, [.new(a)]); error = .duplicateOrder + case 1: update = try block(11, [.update(order(2))]); error = .unknownOrder + case 2: update = try block(11, snapshot: true, [.update(a)]); error = .invalidOrder + default: update = try block(11, snapshot: true, [.remove(a.identity)]); error = .invalidOrder + } + #expect(throws: error) { try reducer.apply(update) } + #expect(reducer.status == .resynchronizationRequired && reducer.orderCount == 0) + } + } + + @Test func identityCannotSilentlyChangeOnUpdateOrRemove() throws { + for kind in 0...3 { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + _ = try reducer.apply(block(snapshot: true, [.new(order(1))])) + let changed: L4Order + switch kind { + case 0: changed = try order(1, "99") + case 1: changed = try order(1, side: .ask) + default: changed = try order(1, user: "0x0000000000000000000000000000000000000001") + } + let change = kind == 3 ? L4OrderChange.remove(changed.identity) : .update(changed) + #expect(throws: L4BookError.identityChanged) { try reducer.apply(block(11, [change])) } + } + } + + @Test func filteredHeightGapsAreAllowedButReplayAndClockRollbackAreNot() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + _ = try reducer.apply(block(snapshot: true)) + #expect(try reducer.apply(block(100)).height == 100) + #expect(throws: L4BookError.invalidWatermark) { try reducer.apply(block(100)) } + #expect(throws: L4BookError.invalidWatermark) { try reducer.apply(block(99, snapshot: true)) } + #expect(throws: L4BookError.invalidWatermark) { try reducer.apply(block(101, snapshot: true, time: 99)) } + reducer.resetForNewConnection() + #expect(reducer.status == .awaitingSnapshot) + #expect(throws: L4BookError.snapshotRequired) { try reducer.apply(block(1, time: 1)) } + #expect(try reducer.apply(block(1, snapshot: true, time: 1)).height == 1) + } + + @Test func crossingAndCapacityLimitsCannotPublishPartialBooks() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH", maximumOrders: 1) + #expect(throws: L4BookError.capacityExceeded) { + try reducer.apply(block(snapshot: true, [.new(order(1)), .new(order(2))])) + } + #expect(reducer.orderCount == 0) + var crossing = try L4BookReducer(environment: .testnet, coin: "ETH") + #expect(throws: L4BookError.crossedBook) { + try crossing.apply(block(snapshot: true, [.new(order(1, "101")), .new(order(2, "100", side: .ask))])) + } + #expect(crossing.orderCount == 0) + var bounded = try L4BookReducer(environment: .testnet, coin: "ETH", maximumChanges: 1) + #expect(throws: L4BookError.invalidPayload) { + try bounded.apply(block(snapshot: true, [.new(order(1)), .new(order(2))])) + } + _ = try bounded.apply(block(snapshot: true)) + #expect(throws: L4BookError.capacityExceeded) { try bounded.view(maximumLevelsPerSide: 0) } + #expect(throws: L4BookError.capacityExceeded) { try bounded.view(maximumLevelsPerSide: 1001) } + } + + @Test func decimalsRejectRoundingAndCheckedAdditionRejectsLossOfPrecision() throws { + for raw in ["0", "-1", "NaN", "1e3", " 1", "1.", "0.123456789012345678901234567890123456789"] { + #expect(throws: L4BookError.invalidOrder) { try order(1, "100", raw) } + } + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + let huge = "1" + String(repeating: "0", count: 100) + #expect(throws: L4BookError.arithmeticOverflow) { + try reducer.apply(block(snapshot: true, [.new(order(1, "100", huge)), .new(order(2, "100", "0.1"))])) + } + #expect(reducer.orderCount == 0) + #expect(throws: L4BookError.invalidPayload) { try L4BookUpdate(coin: " ETH", height: 1, time: 0, snapshot: true, changes: []) } + } + + @Test func multipleMutationsInOneBatchMaintainLevelMembership() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + _ = try reducer.apply(block(snapshot: true)) + let a = try order(1), b = try order(1, "99", "2") + let view = try reducer.apply(block(11, [.new(a), .update(order(1, "100", "3")), .remove(a.identity), .new(b)])) + #expect(view.orderCount == 1 && view.priceLevelCount == 1) + #expect(view.book.bids.first?.px == "99" && view.book.bids.first?.sz == "2") + #expect(reducer.ordersSortedByID(side: .bid, price: 100).isEmpty) + #expect(reducer.ordersSortedByID(side: .bid, price: 99) == [b]) + } + + @Test func sharedPriceSnapshotAndDeterministicInspectionRetainEveryOID() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + let orders = try (1...10_000).reversed().map { try order(UInt64($0), "100", "0.001") } + let view = try reducer.apply(block(snapshot: true, orders.map(L4OrderChange.new))) + #expect(view.book.bids.first?.n == 10_000 && view.book.bids.first?.sz == "10") + #expect(reducer.ordersSortedByID(side: .bid, price: 100).map(\.oid) == Array(1...10_000).map(UInt64.init)) + } + + @Test func seededMutationStreamMatchesIndependentReferenceAggregation() throws { + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + _ = try reducer.apply(block(snapshot: true)) + var reference: [UInt64: L4Order] = [:] + var seed: UInt64 = 42 + func random(_ bound: UInt64) -> UInt64 { + seed = seed &* 6_364_136_223_846_793_005 &+ 1 + return (seed >> 32) % bound + } + for height in UInt64(11)...1010 { + let oid = random(80) + 1 + let change: L4OrderChange + if let previous = reference[oid] { + if random(3) == 0 { change = .remove(previous.identity); reference[oid] = nil } + else { + let resized = try L4Order(identity: previous.identity, size: String(random(20) + 1)) + change = .update(resized); reference[oid] = resized + } + } else { + let side: L4OrderIdentity.Side = random(2) == 0 ? .bid : .ask + let price = side == .bid ? 100 - random(30) : 101 + random(30) + let added = try order(oid, String(price), String(random(20) + 1), side: side) + change = .new(added); reference[oid] = added + } + _ = try reducer.apply(block(height, [change])) + let view = try reducer.view(maximumLevelsPerSide: 100) + #expect(view.orderCount == reference.count) + for (index, side) in [L4OrderIdentity.Side.bid, .ask].enumerated() { + let grouped = Dictionary(grouping: reference.values.filter { $0.identity.side == side }, by: { $0.identity.price }) + let prices = grouped.keys.sorted { side == .bid ? $0 > $1 : $0 < $1 } + #expect(view.book.levels[index].count == prices.count) + for (row, price) in zip(view.book.levels[index], prices) { + let orders = grouped[price] ?? [] + #expect(Decimal(string: row.px) == price && row.n == orders.count) + #expect(Decimal(string: row.sz) == orders.reduce(Decimal.zero) { $0 + $1.size }) + } + } + } + } +} diff --git a/Tests/HyperliquidCoreTests/MarketData/L4/QuicknodeL4BookCodecTests.swift b/Tests/HyperliquidCoreTests/MarketData/L4/QuicknodeL4BookCodecTests.swift new file mode 100644 index 0000000..3d60200 --- /dev/null +++ b/Tests/HyperliquidCoreTests/MarketData/L4/QuicknodeL4BookCodecTests.swift @@ -0,0 +1,66 @@ +import Foundation +import Testing +@testable import HyperliquidCore + +@Suite struct QuicknodeL4BookCodecTests { + private func diff(_ kind: JSONValue = .string("L4_ORDER_DIFF_TYPE_NEW"), coin: String = "ETH", oid: JSONValue = .string("18446744073709551615")) -> JSONValue { + .object(["diff_type": kind, "coin": .string(coin), "oid": oid, + "user": .string("0xFeDcFeDcFeDcFeDcFeDcFeDcFeDcFeDcFeDcFeDc"), + "side": .string("B"), "px": .string("100"), "sz": .string("0.1")]) + } + + private func payload(_ diffs: [JSONValue], height: JSONValue = .string("18446744073709551615"), snapshot: JSONValue = .bool(true)) -> JSONValue { + .object(["time": .string("100"), "height": height, "snapshot": snapshot, "diffs": .array(diffs)]) + } + + @Test func acceptsExactUInt64AndBothEnumRepresentations() throws { + for kind in [JSONValue.string("L4_ORDER_DIFF_TYPE_NEW"), .integer(1)] { + let update = try QuicknodeL4BookCodec.decode(payload([diff(kind)]), coin: "ETH") + #expect(update.height == .max && update.snapshot) + #expect(update.changes.first?.identity.oid == .max) + } + guard case .object(var fields) = diff(.integer(2)) else { return } + fields["diffType"] = fields.removeValue(forKey: "diff_type") + let update = try QuicknodeL4BookCodec.decode(payload([.object(fields)]), coin: "ETH") + guard case .update(let order) = try #require(update.changes.first) else { + Issue.record("Expected an absolute size update"); return + } + #expect(order.size == Decimal(string: "0.1")) + } + + @Test func removesNeedNoSizeAndFilteredEmptyBatchesKeepWatermarks() throws { + guard case .object(var fields) = diff(.integer(3)) else { return } + fields.removeValue(forKey: "sz") + let removed = try QuicknodeL4BookCodec.decode(payload([.object(fields)], snapshot: .bool(false)), coin: "ETH") + guard case .remove(let identity) = try #require(removed.changes.first) else { + Issue.record("Expected removal without a fabricated size"); return + } + #expect(identity.oid == .max && !removed.snapshot) + let filtered = try QuicknodeL4BookCodec.decode(payload([diff(coin: "BTC")]), coin: "ETH") + #expect(filtered.changes.isEmpty && filtered.height == .max && filtered.snapshot) + #expect(try QuicknodeL4BookCodec.decode(payload([], snapshot: .null), coin: "ETH").snapshot == false) + } + + @Test func rejectsLossyNumbersConflictingAliasesAndUnboundedBatches() throws { + for oid in [JSONValue.number(9_007_199_254_740_992), .string("18446744073709551616"), .string("+1"), .integer(-1)] { + #expect(throws: L4BookError.invalidPayload) { try QuicknodeL4BookCodec.decode(payload([diff(oid: oid)]), coin: "ETH") } + } + guard case .object(var fields) = diff() else { return } + fields["diffType"] = .integer(3) + #expect(throws: L4BookError.invalidPayload) { try QuicknodeL4BookCodec.decode(payload([.object(fields)]), coin: "ETH") } + #expect(throws: L4BookError.invalidPayload) { try QuicknodeL4BookCodec.decode(payload([diff()]), coin: "ETH", maximumChanges: 0) } + #expect(throws: L4BookError.invalidPayload) { try QuicknodeL4BookCodec.decode(payload([], snapshot: .string("true")), coin: "ETH") } + #expect(throws: L4BookError.invalidPayload) { try QuicknodeL4BookCodec.decode(payload([], height: .number(1)), coin: "ETH") } + } + + @Test func validatesForeignEventsBeforeFilteringAndRejectsInvalidIdentities() throws { + #expect(throws: L4BookError.invalidPayload) { + try QuicknodeL4BookCodec.decode(payload([diff(.integer(9), coin: "BTC")]), coin: "ETH") + } + for field in ["side", "user", "px", "coin"] { + guard case .object(var fields) = diff() else { continue } + fields[field] = .string(field == "coin" ? "" : "?") + #expect(throws: (any Error).self) { try QuicknodeL4BookCodec.decode(payload([.object(fields)]), coin: "ETH") } + } + } +} diff --git a/Tests/HyperliquidCoreTests/MarketData/L4/QuicknodeL4ClientTests.swift b/Tests/HyperliquidCoreTests/MarketData/L4/QuicknodeL4ClientTests.swift new file mode 100644 index 0000000..4a4bccf --- /dev/null +++ b/Tests/HyperliquidCoreTests/MarketData/L4/QuicknodeL4ClientTests.swift @@ -0,0 +1,193 @@ +import Foundation +import Testing +import GRPCCore +import GRPCInProcessTransport +import GRPCProtobuf +import GRPCNIOTransportHTTP2Posix +import SwiftProtobuf +@testable import HyperliquidCore + +@available(macOS 15.0, *) +private struct QuicknodeL4ClientHarness { + private struct StopConsumer: Error, Equatable {} + private actor Collector { + var updates: [L4BookUpdate] = [] + func append(_ value: L4BookUpdate) { updates.append(value) } + } + + private func message(snapshot: Bool = true) -> Hyperliquid_L4BookUpdatesUpdate { + var diff = Hyperliquid_L4OrderDiff() + diff.diffType = .init(rawValue: 1)! + diff.coin = "ETH" + diff.oid = .max + diff.user = "0xfedcfedcfedcfedcfedcfedcfedcfedcfedcfedc" + diff.side = "B" + diff.px = "100" + diff.sz = "0.1" + var message = Hyperliquid_L4BookUpdatesUpdate() + message.time = 100 + message.height = 100 + message.snapshot = snapshot + message.diffs = [diff] + return message + } + + private struct Service: RegistrableRPCService { + let messages: [Hyperliquid_L4BookUpdatesUpdate] + var failure: RPCError? + + func registerMethods(with router: inout RPCRouter) { + router.registerHandler( + forMethod: MethodDescriptor(fullyQualifiedService: "hyperliquid.OrderBookStreaming", method: "StreamL4BookUpdates"), + deserializer: ProtobufDeserializer(), + serializer: ProtobufSerializer() + ) { request, _ in + let request = try await ServerRequest(stream: request) + #expect(request.message.coins == ["ETH"]) + #expect(Array(request.metadata[stringValues: "x-token"]) == ["fixture-token"]) + return StreamingServerResponse { writer in + for message in messages { try await writer.write(message) } + if let failure { throw failure } + return [:] + } + } + } + } + + private func run(service: Service, onUpdate: @escaping @Sendable (L4BookUpdate) async throws -> Void) async throws { + let transport = InProcessTransport() + try await withGRPCServer(transport: transport.server, services: [service]) { _ in + try await withGRPCClient(transport: transport.client) { client in + do { + try await QuicknodeL4Client.consume(client: client, coin: "ETH", credential: .init(token: "fixture-token"), + maximumMessageBytes: 1024, maximumChanges: 10, onUpdate: onUpdate) + } catch { throw QuicknodeL4Client.sanitizedError(error) } + } + } + } + + func typedRPCDeliversInitialAndReplacementSnapshotThenDetectsStreamEnd() async throws { + let collector = Collector() + var replacement = message() + replacement.diffs = [] + do { + try await run(service: Service(messages: [message(), replacement])) { await collector.append($0) } + Issue.record("A closed stream must request reconnection") + } catch let error as QuicknodeL4StreamError { #expect(error == .streamEnded) } + let updates = await collector.updates + #expect(updates.count == 2 && updates.allSatisfy(\.snapshot)) + #expect(updates.first?.changes.first?.identity.oid == .max && updates.last?.changes.isEmpty == true) + var reducer = try L4BookReducer(environment: .testnet, coin: "ETH") + for update in updates { _ = try reducer.apply(update) } + #expect(reducer.orderCount == 0) + } + + func statusAfterValidMessagesIsNotMistakenForSuccessfulCompletionOrLoggedVerbatim() async throws { + let collector = Collector() + let failure = RPCError(code: .resourceExhausted, message: "provider fixture-token detail", metadata: ["x-token": "fixture-token"]) + do { + try await run(service: Service(messages: [message()], failure: failure)) { await collector.append($0) } + Issue.record("Expected trailing gRPC status failure") + } catch let error as QuicknodeL4StreamError { + #expect(error == .resourceExhausted && !String(reflecting: error).contains("fixture-token")) + } + #expect(await collector.updates.count == 1) + #expect(QuicknodeL4Client.sanitizedError(RPCError(code: .unauthenticated, message: "fixture-token")) as? QuicknodeL4StreamError == .authenticationFailed) + #expect(QuicknodeL4Client.sanitizedError(RPCError(code: .permissionDenied, message: "fixture-token")) as? QuicknodeL4StreamError == .permissionDenied) + } + + func consumerFailureAndCancellationStopTheRPCWithoutBeingReclassified() async throws { + do { + try await run(service: Service(messages: [message()])) { _ in throw StopConsumer() } + Issue.record("Expected consumer stop") + } catch { #expect(error is StopConsumer) } + do { + try await run(service: Service(messages: [message()])) { _ in throw CancellationError() } + Issue.record("Expected cancellation") + } catch { #expect(error is CancellationError) } + } + + func http2LoopbackEnforcesResponseSizeBeforeCallback() async throws { + var large = message() + large.diffs = (0..<32).map { index in + var diff = large.diffs[0] + diff.oid = .max - UInt64(index) + return diff + } + #expect(try large.serializedData().count > 1024) + for limit in [4096, 1] { + let collector = Collector() + try await withGRPCServer( + transport: .http2NIOPosix(address: .ipv4(host: "127.0.0.1", port: 0), transportSecurity: .plaintext), + services: [Service(messages: [large])] + ) { server in + let address = try #require(try await server.listeningAddress?.ipv4) + do { + try await withGRPCClient( + transport: .http2NIOPosix(target: .ipv4(address: address.host, port: address.port), transportSecurity: .plaintext) + ) { client in + do { + try await QuicknodeL4Client.consume(client: client, coin: "ETH", credential: .init(token: "fixture-token"), + maximumMessageBytes: limit, maximumChanges: 100) { await collector.append($0) } + } catch { throw QuicknodeL4Client.sanitizedError(error) } + } + Issue.record("A closed or oversized stream must report its state") + } catch let error as QuicknodeL4StreamError { + if limit == 1 { + // Transport 2.11.0 reports oversized frame decoding as an internal status. + #expect(error == .resourceExhausted || error == .transportFailure) + } else { #expect(error == .streamEnded) } + } + } + #expect(await collector.updates.count == (limit == 1 ? 0 : 1)) + } + do { + _ = try QuicknodeL4BoundedDeserializer(maximumBytes: 1).deserialize([UInt8(255), 255]) + Issue.record("Size must be checked before attempting to decode malformed protobuf") + } catch let error as RPCError { #expect(error.code == .resourceExhausted) } + } + + func wireMappingUsesExactIntegersAndValidatesAllDiffsBeforeFiltering() throws { + let original = message() + let bytes = try original.serializedData() + let decoded = try Hyperliquid_L4BookUpdatesUpdate(serializedBytes: bytes) + #expect(try QuicknodeL4WireMapping.decode(decoded, coin: "ETH", maximumChanges: 10).changes.first?.identity.oid == .max) + #expect(throws: L4BookError.invalidPayload) { try QuicknodeL4WireMapping.decode(decoded, coin: "ETH", maximumChanges: 0) } + var invalid = original + invalid.time = .max + #expect(throws: L4BookError.invalidPayload) { try QuicknodeL4WireMapping.decode(invalid, coin: "ETH", maximumChanges: 10) } + invalid = original + invalid.diffs[0].diffType = .init(rawValue: 99)! + invalid.diffs[0].coin = "BTC" + #expect(throws: L4BookError.invalidPayload) { try QuicknodeL4WireMapping.decode(invalid, coin: "ETH", maximumChanges: 10) } + var remove = original + remove.diffs[0].diffType = .init(rawValue: 3)! + remove.diffs[0].sz = "" + guard case .remove = try QuicknodeL4WireMapping.decode(remove, coin: "ETH", maximumChanges: 10).changes.first else { + Issue.record("Removal must not require size"); return + } + } +} + +@Suite struct QuicknodeL4ClientTests { + @Test(.enabled(if: ProcessInfo.processInfo.isOperatingSystemAtLeast(.init(majorVersion: 15, minorVersion: 0, patchVersion: 0)), "gRPC L4 requires macOS 15")) + func typedRPCDeliversInitialAndReplacementSnapshotThenDetectsStreamEnd() async throws { + if #available(macOS 15.0, *) { try await QuicknodeL4ClientHarness().typedRPCDeliversInitialAndReplacementSnapshotThenDetectsStreamEnd() } + } + @Test(.enabled(if: ProcessInfo.processInfo.isOperatingSystemAtLeast(.init(majorVersion: 15, minorVersion: 0, patchVersion: 0)), "gRPC L4 requires macOS 15")) + func statusAfterValidMessagesIsNotMistakenForSuccessfulCompletionOrLoggedVerbatim() async throws { + if #available(macOS 15.0, *) { try await QuicknodeL4ClientHarness().statusAfterValidMessagesIsNotMistakenForSuccessfulCompletionOrLoggedVerbatim() } + } + @Test(.enabled(if: ProcessInfo.processInfo.isOperatingSystemAtLeast(.init(majorVersion: 15, minorVersion: 0, patchVersion: 0)), "gRPC L4 requires macOS 15")) + func consumerFailureAndCancellationStopTheRPCWithoutBeingReclassified() async throws { + if #available(macOS 15.0, *) { try await QuicknodeL4ClientHarness().consumerFailureAndCancellationStopTheRPCWithoutBeingReclassified() } + } + @Test(.enabled(if: ProcessInfo.processInfo.isOperatingSystemAtLeast(.init(majorVersion: 15, minorVersion: 0, patchVersion: 0)), "gRPC L4 requires macOS 15")) + func http2LoopbackEnforcesResponseSizeBeforeCallback() async throws { + if #available(macOS 15.0, *) { try await QuicknodeL4ClientHarness().http2LoopbackEnforcesResponseSizeBeforeCallback() } + } + @Test(.enabled(if: ProcessInfo.processInfo.isOperatingSystemAtLeast(.init(majorVersion: 15, minorVersion: 0, patchVersion: 0)), "gRPC L4 requires macOS 15")) + func wireMappingUsesExactIntegersAndValidatesAllDiffsBeforeFiltering() throws { + if #available(macOS 15.0, *) { try QuicknodeL4ClientHarness().wireMappingUsesExactIntegersAndValidatesAllDiffsBeforeFiltering() } + } +} diff --git a/Tests/HyperliquidCoreTests/MarketData/L4/QuicknodeL4ConfigurationTests.swift b/Tests/HyperliquidCoreTests/MarketData/L4/QuicknodeL4ConfigurationTests.swift new file mode 100644 index 0000000..de8a630 --- /dev/null +++ b/Tests/HyperliquidCoreTests/MarketData/L4/QuicknodeL4ConfigurationTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +@testable import HyperliquidCore + +@Suite struct QuicknodeL4ConfigurationTests { + private let endpoint = "silent-red-dream.hype-testnet.quiknode.pro:10000" + + @Test func publicEndpointKeepsNetworkIdentityAndCanonicalAuthority() throws { + let config = try QuicknodeL4Configuration(environment: .testnet, endpoint: endpoint) + #expect(config.environment == .testnet && config.port == 10000 && config.authority == endpoint) + #expect(try QuicknodeL4Configuration(environment: .testnet, endpoint: "grpcs://" + endpoint) == config) + #expect(try QuicknodeL4Configuration(environment: .testnet, endpoint: "silent-red-dream.hype-testnet.quiknode.pro") == config) + #expect(throws: QuicknodeL4ConfigurationError.environmentMismatch) { + try QuicknodeL4Configuration(environment: .mainnet, endpoint: endpoint) + } + } + + @Test func rejectsCredentialURLsUnexpectedPortsAndHostConfusion() throws { + for invalid in ["https://" + endpoint + "/token", "grpcs://user:password@" + endpoint, + endpoint + "/token", endpoint + "?x-token=secret", endpoint + "#secret", + "silent-red-dream.hype-testnet.quiknode.pro:443", " " + endpoint, + "x..hype-testnet.quiknode.pro:10000", "-x.hype-testnet.quiknode.pro:10000", + "x.hype-testnet.quiknode.pro.evil.example:10000", "localhost:10000", ""] { + #expect(throws: (any Error).self) { try QuicknodeL4Configuration(environment: .testnet, endpoint: invalid) } + } + } + + @Test func credentialsRejectMetadataInjectionAndDoNotPrintSecrets() throws { + let token = "example-test-token" + let credential = try QuicknodeL4Credential(token: token) + #expect(credential.metadataValue == token) + #expect(!String(describing: credential).contains(token)) + #expect(!String(reflecting: credential).contains(token)) + #expect(Mirror(reflecting: credential).children.allSatisfy { String(describing: $0.value) == "[redacted]" }) + for invalid in ["", "token\r\nx-token:other", "token other", "é", String(repeating: "a", count: 4097)] { + #expect(throws: QuicknodeL4ConfigurationError.invalidCredential) { try QuicknodeL4Credential(token: invalid) } + } + } +} diff --git a/Tests/HyperliquidCoreTests/LargeTradeMonitorTests.swift b/Tests/HyperliquidCoreTests/MarketData/LargeTradeMonitorTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/LargeTradeMonitorTests.swift rename to Tests/HyperliquidCoreTests/MarketData/LargeTradeMonitorTests.swift diff --git a/Tests/HyperliquidCoreTests/MarketContextTests.swift b/Tests/HyperliquidCoreTests/MarketData/MarketContextTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/MarketContextTests.swift rename to Tests/HyperliquidCoreTests/MarketData/MarketContextTests.swift diff --git a/Tests/HyperliquidCoreTests/LiveAPIProbeTests.swift b/Tests/HyperliquidCoreTests/Networking/LiveAPIProbeTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/LiveAPIProbeTests.swift rename to Tests/HyperliquidCoreTests/Networking/LiveAPIProbeTests.swift diff --git a/Tests/HyperliquidCoreTests/NIOTransportTests.swift b/Tests/HyperliquidCoreTests/Networking/NIOTransportTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/NIOTransportTests.swift rename to Tests/HyperliquidCoreTests/Networking/NIOTransportTests.swift diff --git a/Tests/HyperliquidCoreTests/PasskeyVerificationTests.swift b/Tests/HyperliquidCoreTests/Signing/PasskeyVerificationTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/PasskeyVerificationTests.swift rename to Tests/HyperliquidCoreTests/Signing/PasskeyVerificationTests.swift diff --git a/Tests/HyperliquidCoreTests/SigningTests.swift b/Tests/HyperliquidCoreTests/Signing/SigningTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/SigningTests.swift rename to Tests/HyperliquidCoreTests/Signing/SigningTests.swift diff --git a/Tests/HyperliquidCoreTests/Trading/Builders/BuilderFeeTests.swift b/Tests/HyperliquidCoreTests/Trading/Builders/BuilderFeeTests.swift new file mode 100644 index 0000000..f21eb5e --- /dev/null +++ b/Tests/HyperliquidCoreTests/Trading/Builders/BuilderFeeTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing + +@testable import HyperliquidCore + +private let builderAddress = "0x2222222222222222222222222222222222222222" +private let builderOwner = "0x14791697260e4c9a71f18484c9f997b308e59325" + +private func builderIntent(spot: Bool = false, side: OrderSide = .buy, kind: OrderKind = .limit) + throws -> OrderIntent +{ + let market = + spot + ? Market( + coin: "@1", symbol: "TOKEN", assetID: 10_001, sizeDecimals: 4, maxLeverage: 1, + kind: .spot, + baseTokenIndex: 1, quoteTokenIndex: 0) + : Market( + coin: "ETH", symbol: "ETH", assetID: 1, sizeDecimals: 4, maxLeverage: 25, kind: .perp) + return try OrderPlanner.preview( + market: market, side: side, kind: kind, size: "100", isUSD: false, + price: "100", trigger: nil, mark: 100, leverage: 1, reduceOnly: false) +} + +private func builderFees() throws -> UserFeeRates { + try .init( + response: .object([ + "userAddRate": .string("0.00015"), "userCrossRate": .string("0.00045"), + "userSpotAddRate": .string("0.0004"), "userSpotCrossRate": .string("0.0007"), + "activeReferralDiscount": .string("0.1"), + ])) +} + +@Suite struct BuilderFeeTests { + @Test func recipientsRequireStandardModeAndSufficientPerpsValue() throws { + for mode in ["default", "disabled"] { + #expect(try BuilderFee.isEligible(accountValue: "100", abstraction: mode)) + #expect(try !BuilderFee.isEligible(accountValue: "99.99", abstraction: mode)) + } + for mode in ["unifiedAccount", "portfolioMargin", "dexAbstraction", "future", ""] { + #expect(try !BuilderFee.isEligible(accountValue: "1000", abstraction: mode)) + } + #expect(throws: OrderValidationError.self) { + try BuilderFee.isEligible(accountValue: "NaN", abstraction: "default") + } + } + + @Test func ratesLimitsAndApprovalReadsAreExact() throws { + let fee = try BuilderFee(address: builderAddress, tenthsOfBasisPoint: 10) + #expect(fee.rate == Decimal(string: "0.0001") && fee.percentage == "0.01%") + #expect(fee.address == builderAddress.lowercased()) + #expect(try fee.approvedLimit(.integer(10)) == 10) + #expect(throws: BuilderFeeError.invalidApproval) { try fee.approvedLimit(.string("10")) } + #expect(throws: BuilderFeeError.invalidApproval) { try fee.approvedLimit(.integer(-1)) } + #expect(throws: BuilderFeeError.invalidRate) { + try BuilderFee(address: builderAddress, tenthsOfBasisPoint: 0) + } + #expect(throws: BuilderFeeError.invalidAddress) { + try BuilderFee( + address: "0x" + String(repeating: "0", count: 40), tenthsOfBasisPoint: 10) + } + let maximum = try BuilderFee(address: builderAddress, tenthsOfBasisPoint: 1000) + #expect(throws: BuilderFeeError.invalidRate) { try maximum.applicable(to: builderIntent()) } + #expect(try maximum.applicable(to: builderIntent(spot: true, side: .sell)) == maximum) + } + + @Test(arguments: [NetworkEnvironment.mainnet, .testnet]) + func approvalMatchesIndependentPythonVectors(_ environment: NetworkEnvironment) throws { + let approval = try BuilderFeeApproval( + owner: builderOwner, builder: builderAddress, feeLimit: 10, + chainID: 42161, nonce: 1_700_000_000_000, environment: environment) + let digest = + environment == .mainnet + ? "73cbce803f075cfafa27e1ad8b39a3b8daee8455df4cb237e01d443b74ed9212" + : "be0bb003a27b1b3ba46e9fc2179ae3ec1108686a08f7aec75ae77b6fdd7a5914" + let signature = + environment == .mainnet + ? "4d02e1a2b04908fe90556e4d18d889e5243c3b86e50aa91b90c0efca3ec7649c2c388321ebc991612c588d78dbf3fc91732a286c7efb0ee8d6ea542d4ab5127a1b" + : "4772ccdf0bfdaf6a1b4f16ae1815ace65815f2edaafbd2c1f7bbe8bbc8d648476b83b5d3b1ee597de64ac1b2649dd2350c313f07c2694d3ff2a3b1f1a138a4a71b" + #expect(try approval.digest.hex == "0x" + digest) + let request = try JSONDecoder().decode( + JSONValue.self, from: approval.request(signature: .init(hex: "0x" + signature))) + #expect(request["action"]["type"].string == "approveBuilderFee") + #expect(request["action"]["nonce"] == request["nonce"]) + #expect(request["action"]["maxFeeRate"].string == "0.01%") + #expect(request["expiresAfter"] == .null && request["vaultAddress"] == .null) + #expect( + approval.typedData["primaryType"].string == "HyperliquidTransaction:ApproveBuilderFee") + } + + @Test func approvalRejectsAnAgentSignatureAndSupportsZeroFeeRevocation() throws { + let approval = try BuilderFeeApproval( + owner: builderOwner, builder: builderAddress, feeLimit: 10, + chainID: 42161, nonce: 1_700_000_000_000, environment: .testnet) + let agent = try EphemeralSigningKey() + #expect(throws: BuilderFeeError.signatureMismatch) { + try approval.request(signature: agent.sign(digest: approval.digest)) + } + let revoke = try BuilderFeeApproval( + owner: builderOwner, builder: builderAddress, feeLimit: 0, + chainID: 42161, nonce: 1_700_000_000_001, environment: .testnet) + #expect(revoke.maxFeeRate == "0%") + #expect(try revoke.digest != approval.digest) + #expect(throws: BuilderFeeError.invalidContext) { + try BuilderFeeApproval( + owner: builderOwner, builder: builderAddress, feeLimit: 10, chainID: UInt64.max, + nonce: 1, + environment: .mainnet) + } + } + + @Test func builderIsCoveredByTheOrderHashAndCannotExtendUndocumentedActions() throws { + let fee = try BuilderFee(address: builderAddress, tenthsOfBasisPoint: 10) + let intent = try builderIntent() + let original = ExchangeAction.place(intent) + let action = try ExchangeAction.place(intent, builder: fee) + let json = try JSONDecoder().decode(JSONValue.self, from: action.jsonData()) + #expect( + json["builder"]["b"].string == builderAddress.lowercased() + && json["builder"]["f"].int64 == 10 + ) + #expect( + try SigningPayload.actionHash(action: original, nonce: 1) + != SigningPayload.actionHash(action: action, nonce: 1)) + #expect(throws: BuilderFeeError.unsupportedAction) { + try fee.appending(toOrderAction: ExchangeAction.cancel(assetID: 1, oid: 1)) + } + #expect(throws: BuilderFeeError.unsupportedAction) { + try fee.appending(toOrderAction: ExchangeAction.modify(oid: 1, intent: intent)) + } + #expect(throws: BuilderFeeError.unsupportedAction) { + try fee.appending(toOrderAction: action) + } + } + + @Test func spotBuyAndSellHaveDifferentBuilderFeeApplicability() throws { + let fee = try BuilderFee(address: builderAddress, tenthsOfBasisPoint: 10) + let buy = try builderIntent(spot: true) + let sell = try builderIntent(spot: true, side: .sell) + let buyAction = try ExchangeAction.place(buy, builder: fee) + let sellAction = try ExchangeAction.place(sell, builder: fee) + #expect( + try JSONDecoder().decode(JSONValue.self, from: buyAction.jsonData())["builder"] == .null + ) + #expect( + try JSONDecoder().decode(JSONValue.self, from: sellAction.jsonData())["builder"]["f"] + .int64 + == 10) + #expect(try builderFees().estimate(for: buy, builder: fee).builderAmount == 0) + #expect(try builderFees().estimate(for: sell, builder: fee).builderAmount == 1) + } + + @Test func builderFeeIsNotReferralDiscountedAndCannotChangeAfterReview() throws { + let fee = try BuilderFee(address: builderAddress, tenthsOfBasisPoint: 10) + let intent = try builderIntent(kind: .market) + let rates = try builderFees() + let base = try rates.estimate(for: intent) + let charged = try rates.estimate(for: intent, builder: fee) + #expect(charged.builderAmount == 1) + #expect(charged.lower == base.lower + 1 && charged.upper == base.upper + 1) + #expect(charged.maximumRate == base.maximumRate + Decimal(string: "0.0001")!) + #expect(throws: BuilderFeeError.feeChanged) { try base.validate(current: charged) } + let other = try BuilderFee( + address: "0x1111111111111111111111111111111111111111", tenthsOfBasisPoint: 10) + #expect(throws: BuilderFeeError.feeChanged) { + try charged.validate(current: rates.estimate(for: intent, builder: other)) + } + } +} diff --git a/Tests/HyperliquidCoreTests/OrderBookSlippageTests.swift b/Tests/HyperliquidCoreTests/Trading/Orders/OrderBookSlippageTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/OrderBookSlippageTests.swift rename to Tests/HyperliquidCoreTests/Trading/Orders/OrderBookSlippageTests.swift diff --git a/Tests/HyperliquidCoreTests/OrderLifecycleTests.swift b/Tests/HyperliquidCoreTests/Trading/Orders/OrderLifecycleTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/OrderLifecycleTests.swift rename to Tests/HyperliquidCoreTests/Trading/Orders/OrderLifecycleTests.swift diff --git a/Tests/HyperliquidCoreTests/OrderModificationTests.swift b/Tests/HyperliquidCoreTests/Trading/Orders/OrderModificationTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/OrderModificationTests.swift rename to Tests/HyperliquidCoreTests/Trading/Orders/OrderModificationTests.swift diff --git a/Tests/HyperliquidCoreTests/PerpOpenInterestTests.swift b/Tests/HyperliquidCoreTests/Trading/Orders/PerpOpenInterestTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/PerpOpenInterestTests.swift rename to Tests/HyperliquidCoreTests/Trading/Orders/PerpOpenInterestTests.swift diff --git a/Tests/HyperliquidCoreTests/PerpTradingCapacityTests.swift b/Tests/HyperliquidCoreTests/Trading/Orders/PerpTradingCapacityTests.swift similarity index 99% rename from Tests/HyperliquidCoreTests/PerpTradingCapacityTests.swift rename to Tests/HyperliquidCoreTests/Trading/Orders/PerpTradingCapacityTests.swift index 705e235..ace16dd 100644 --- a/Tests/HyperliquidCoreTests/PerpTradingCapacityTests.swift +++ b/Tests/HyperliquidCoreTests/Trading/Orders/PerpTradingCapacityTests.swift @@ -3,7 +3,7 @@ import Testing @testable import HyperliquidCore -private let capacityAddress = "0x5742261Ef0516AD70C4217cc573fa21485289D3B" +private let capacityAddress = "0xAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCd" private let capacityMarket = Market( coin: "ETH", symbol: "ETH", assetID: 1, sizeDecimals: 4, maxLeverage: 20, kind: .perp) private func capacityAsset( diff --git a/Tests/HyperliquidCoreTests/IsolatedMarginTests.swift b/Tests/HyperliquidCoreTests/Trading/Positions/IsolatedMarginTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/IsolatedMarginTests.swift rename to Tests/HyperliquidCoreTests/Trading/Positions/IsolatedMarginTests.swift diff --git a/Tests/HyperliquidCoreTests/PositionIntentTests.swift b/Tests/HyperliquidCoreTests/Trading/Positions/PositionIntentTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/PositionIntentTests.swift rename to Tests/HyperliquidCoreTests/Trading/Positions/PositionIntentTests.swift diff --git a/Tests/HyperliquidCoreTests/ProtectionBatchTests.swift b/Tests/HyperliquidCoreTests/Trading/Protection/ProtectionBatchTests.swift similarity index 91% rename from Tests/HyperliquidCoreTests/ProtectionBatchTests.swift rename to Tests/HyperliquidCoreTests/Trading/Protection/ProtectionBatchTests.swift index f24c932..8f2be7c 100644 --- a/Tests/HyperliquidCoreTests/ProtectionBatchTests.swift +++ b/Tests/HyperliquidCoreTests/Trading/Protection/ProtectionBatchTests.swift @@ -222,8 +222,7 @@ private func pairSnapshot( } @Test func protectionBatchSigningMatchesFrozenOfficialSDKForPairBracketAndModify() throws { - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent() + let root = try CoreFixturePaths.packageRoot() let fixture = try JSONDecoder().decode( JSONValue.self, from: Data( @@ -300,3 +299,22 @@ private func pairSnapshot( } } } + +@Test func protectionBatchBuilderFeesMatchPlacementsAndLeaveModificationWireUnchanged() throws { + let builder = try BuilderFee(address: pairOwner, tenthsOfBasisPoint: 10) + let batch = try pairBatch() + let fees = try pairSnapshot().validate(batch, builder: builder) + #expect(fees.allSatisfy { $0.builder == builder && $0.builderAmount > 0 }) + let placed = try batch.action(builder: builder) + let json = try JSONDecoder().decode(JSONValue.self, from: placed.jsonData()) + #expect(json["builder"]["f"].int64 == 10) + #expect(json["grouping"].string == "positionTpsl" && json["orders"].array.count == 2) + #expect(try SigningPayload.actionHash(action: placed, nonce: 1) != SigningPayload.actionHash(action: batch.action, nonce: 1)) + let originals = [pairRow(kind: "Take Profit Market", oid: 70), pairRow(kind: "Stop Market", oid: 71)] + let modified = try ProtectionBatch.modification( + owner: pairOwner, environment: .testnet, market: pairMarket, position: pairPosition(), originals: originals, + mark: 2000, takeProfit: .init(trigger: "2200", execution: .market, limit: "2200"), + stopLoss: .init(trigger: "1800", execution: .market, limit: "1800")) + #expect(try modified.action(builder: builder).messagePack() == modified.action.messagePack()) + #expect(try pairSnapshot(orders: originals).validate(modified, builder: builder).allSatisfy { $0.builder == nil }) +} diff --git a/Tests/HyperliquidCoreTests/WholePositionProtectionTests.swift b/Tests/HyperliquidCoreTests/Trading/Protection/WholePositionProtectionTests.swift similarity index 98% rename from Tests/HyperliquidCoreTests/WholePositionProtectionTests.swift rename to Tests/HyperliquidCoreTests/Trading/Protection/WholePositionProtectionTests.swift index ba92cde..88d93a0 100644 --- a/Tests/HyperliquidCoreTests/WholePositionProtectionTests.swift +++ b/Tests/HyperliquidCoreTests/Trading/Protection/WholePositionProtectionTests.swift @@ -32,8 +32,7 @@ private func wholeIntent( } @Test func wholePositionProtectionMatchesIndependentSDKBothSidesTypesModesAndNetworks() throws { - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent() + let root = try CoreFixturePaths.packageRoot() let fixture = try JSONDecoder().decode( JSONValue.self, from: Data( @@ -225,8 +224,7 @@ private func wholeReplacement(_ original: OpenOrder, mode: TriggerExecution? = n } @Test func wholePositionProtectionModificationMatchesSDKAndNeverChangesItsWireSizing() throws { - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent() + let root = try CoreFixturePaths.packageRoot() let fixture = try JSONDecoder().decode( JSONValue.self, from: Data( diff --git a/Tests/HyperliquidCoreTests/OrderReviewTests.swift b/Tests/HyperliquidCoreTests/Trading/Risk/OrderReviewTests.swift similarity index 99% rename from Tests/HyperliquidCoreTests/OrderReviewTests.swift rename to Tests/HyperliquidCoreTests/Trading/Risk/OrderReviewTests.swift index e07e5d5..87e343b 100644 --- a/Tests/HyperliquidCoreTests/OrderReviewTests.swift +++ b/Tests/HyperliquidCoreTests/Trading/Risk/OrderReviewTests.swift @@ -139,7 +139,7 @@ private func reviewFees(maker: String = "0.0004", referral: String = "0.1") thro } @Test func activeAssetDataBindsAccountAndMarketBeforeLeverageUse() throws { - let address = "0x5742261Ef0516AD70C4217cc573fa21485289D3B" + let address = "0xAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCd" let raw = try reviewJSON( """ {"user":"\(address.lowercased())","coin":"ETH","leverage":{"type":"cross","value":20},"maxTradeSzs":["0.0","0.0"],"availableToTrade":["0.0","0.0"],"markPx":"2659.4"} diff --git a/Tests/HyperliquidCoreTests/OrderRiskTests.swift b/Tests/HyperliquidCoreTests/Trading/Risk/OrderRiskTests.swift similarity index 99% rename from Tests/HyperliquidCoreTests/OrderRiskTests.swift rename to Tests/HyperliquidCoreTests/Trading/Risk/OrderRiskTests.swift index de0a6ae..e5df762 100644 --- a/Tests/HyperliquidCoreTests/OrderRiskTests.swift +++ b/Tests/HyperliquidCoreTests/Trading/Risk/OrderRiskTests.swift @@ -3,7 +3,7 @@ import Testing @testable import HyperliquidCore -private let riskAddress = "0x5742261Ef0516AD70C4217cc573fa21485289D3B" +private let riskAddress = "0xAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCd" private let riskMarket = Market( coin: "ETH", symbol: "ETH", assetID: 1, sizeDecimals: 4, maxLeverage: 20, kind: .perp) private func riskJSON(_ raw: String) throws -> JSONValue { diff --git a/Tests/HyperliquidCoreTests/PositionRiskMonitorTests.swift b/Tests/HyperliquidCoreTests/Trading/Risk/PositionRiskMonitorTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/PositionRiskMonitorTests.swift rename to Tests/HyperliquidCoreTests/Trading/Risk/PositionRiskMonitorTests.swift diff --git a/Tests/HyperliquidCoreTests/Trading/Sessions/ManagedSessionTests.swift b/Tests/HyperliquidCoreTests/Trading/Sessions/ManagedSessionTests.swift new file mode 100644 index 0000000..cac4f61 --- /dev/null +++ b/Tests/HyperliquidCoreTests/Trading/Sessions/ManagedSessionTests.swift @@ -0,0 +1,65 @@ +import CryptoKit +import Foundation +import Testing + +@testable import HyperliquidCore + +private let managedOwner = "0x1111111111111111111111111111111111111111" + +@Test func managedSessionSupportsAllDurationsAndRejectsBeyondProtocolMaximum() async throws { + for duration: UInt64 in [ + 3_600_000, 86_400_000, 604_800_000, 2_592_000_000, 7_776_000_000, 15_552_000_000, + ] { + let session = TradingSession() + let approval = try await session.prepare( + owner: managedOwner, chainID: 42161, environment: .mainnet, + duration: duration) + #expect(approval.validUntil - approval.createdAt == duration) + #expect(approval.agentName == "HyperliquidX valid_until \(approval.validUntil)") + let key = SymmetricKey(size: .bits256) + let binding = Data("managed-session-test".utf8) + let recovery = try await session.enableRecovery(key: key, binding: binding) + try recovery.validate(environment: .mainnet) + await session.end() + let restored = try await session.restore( + recovery, key: key, binding: binding, environment: .mainnet) + #expect(restored.agentAddress == approval.agentAddress) + #expect(await session.state == .unverified) + await #expect(throws: WalletError.self) { + try await session.signedRequest( + action: ExchangeAction.leverage(assetID: 1, value: 2, cross: true), + owner: managedOwner, environment: .mainnet) + } + } + for duration: UInt64 in [0, 3_599_999, 15_552_000_001, UInt64.max] { + let session = TradingSession() + await #expect(throws: WalletError.self) { + try await session.prepare( + owner: managedOwner, chainID: 42161, environment: .mainnet, duration: duration) + } + } +} + +@Test func pendingAuthorizationRecoveryStillRequiresExchangeRegistration() async throws { + let session = TradingSession() + let approval = try await session.prepare( + owner: managedOwner, chainID: 42161, environment: .mainnet) + _ = try await session.beginApproval(id: approval.id, owner: managedOwner, chainID: 42161) + let key = SymmetricKey(size: .bits256) + let binding = Data("pending-approval".utf8) + let recovery = try await session.enableRecovery(key: key, binding: binding) + await session.end() + _ = try await session.restore(recovery, key: key, binding: binding, environment: .mainnet) + await #expect(throws: WalletError.self) { + try await session.verifyRegistration(id: approval.id, agents: .array([])) + } + try await session.verifyRegistration( + id: approval.id, + agents: .array([ + .object([ + "name": .string(SessionApproval.name), "address": .string(approval.agentAddress), + "validUntil": .integer(Int64(approval.validUntil)), + ]) + ])) + #expect(await session.state == .active) +} diff --git a/Tests/HyperliquidCoreTests/SessionRecoveryTests.swift b/Tests/HyperliquidCoreTests/Trading/Sessions/SessionRecoveryTests.swift similarity index 98% rename from Tests/HyperliquidCoreTests/SessionRecoveryTests.swift rename to Tests/HyperliquidCoreTests/Trading/Sessions/SessionRecoveryTests.swift index 8ff43d5..bf241e0 100644 --- a/Tests/HyperliquidCoreTests/SessionRecoveryTests.swift +++ b/Tests/HyperliquidCoreTests/Trading/Sessions/SessionRecoveryTests.swift @@ -4,7 +4,7 @@ import Testing @testable import HyperliquidCore -private let recoveryOwner = "0x12403356d523ff2e7c2e762a17b7306ad0fbd839" +private let recoveryOwner = "0xfedcfedcfedcfedcfedcfedcfedcfedcfedcfedc" private let recoveryAction = ExchangeAction.leverage(assetID: 1, value: 2, cross: true) private func recoveryAgents(_ value: SessionApproval) -> JSONValue { .array([.object(["name": .string(SessionApproval.name), "address": .string(value.agentAddress), diff --git a/Tests/HyperliquidCoreTests/SessionTests.swift b/Tests/HyperliquidCoreTests/Trading/Sessions/SessionTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/SessionTests.swift rename to Tests/HyperliquidCoreTests/Trading/Sessions/SessionTests.swift diff --git a/Tests/HyperliquidCoreTests/CoreTests.swift b/Tests/HyperliquidCoreTests/Types/CoreTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/CoreTests.swift rename to Tests/HyperliquidCoreTests/Types/CoreTests.swift diff --git a/Tests/HyperliquidCoreTests/BridgeTests.swift b/Tests/HyperliquidCoreTests/Wallet/BridgeTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/BridgeTests.swift rename to Tests/HyperliquidCoreTests/Wallet/BridgeTests.swift diff --git a/Tests/HyperliquidCoreTests/FundingWalletBridgeTests.swift b/Tests/HyperliquidCoreTests/Wallet/FundingWalletBridgeTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/FundingWalletBridgeTests.swift rename to Tests/HyperliquidCoreTests/Wallet/FundingWalletBridgeTests.swift diff --git a/Tests/HyperliquidCoreTests/WalletConnectionTests.swift b/Tests/HyperliquidCoreTests/Wallet/WalletConnectionTests.swift similarity index 94% rename from Tests/HyperliquidCoreTests/WalletConnectionTests.swift rename to Tests/HyperliquidCoreTests/Wallet/WalletConnectionTests.swift index ba6bc64..76aa3cc 100644 --- a/Tests/HyperliquidCoreTests/WalletConnectionTests.swift +++ b/Tests/HyperliquidCoreTests/Wallet/WalletConnectionTests.swift @@ -4,7 +4,7 @@ import Testing @testable import HyperliquidCore @Test func walletConnectionRejectsAccountChainAndReconnectChanges() throws { - let address = "0x5742261Ef0516AD70C4217cc573fa21485289D3B" + let address = "0xAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCdAbCd" let first = try WalletConnection(address: address, chainID: 42161) let equivalent = try WalletConnection( address: address.lowercased(), chainID: 42161, id: first.id) diff --git a/Tests/HyperliquidCoreTests/WalletOwnershipTests.swift b/Tests/HyperliquidCoreTests/Wallet/WalletOwnershipTests.swift similarity index 100% rename from Tests/HyperliquidCoreTests/WalletOwnershipTests.swift rename to Tests/HyperliquidCoreTests/Wallet/WalletOwnershipTests.swift diff --git a/Tests/HyperliquidCoreTests/ZcashDepositEncodingTests.swift b/Tests/HyperliquidCoreTests/ZcashDepositEncodingTests.swift deleted file mode 100644 index 21d2a8e..0000000 --- a/Tests/HyperliquidCoreTests/ZcashDepositEncodingTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation -import Testing - -@testable import HyperliquidCore - -@Test func zcashDepositTEXEncodingMatchesZIP320VectorAndRejectsChangedChecksumAndNetwork() throws { - let address = "tex1s2rt77ggv6q989lr49rkgzmh5slsksa9khdgte" - #expect(try ZcashDepositEncoding.transparentAddress(fromTEX: address, environment: .mainnet) - == "t1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC") - #expect(throws: (any Error).self) { - try ZcashDepositEncoding.transparentAddress(fromTEX: address, environment: .testnet) - } - #expect(throws: (any Error).self) { - try ZcashDepositEncoding.transparentAddress(fromTEX: String(address.dropLast()) + "q", environment: .mainnet) - } -} - -@Test func zcashDepositAddressAuthenticatesTheExactRecipientBehindTheReturnedTEXAddress() throws { - let response: JSONValue = .object([ - "status": .string("OK"), "address": .string("tex133pe24m5utaqrfjhln0xd3resg74z2rhenesz0"), - "signatures": .object([ - "field-node": .string("tNY/nGrmLSf2JasSZAak3vQ1Wb0tebiTCxqcrziGiGR9mLlvDqscNDlMqNs+I7NZbFg259aWvaFb+NVe9XtAqA=="), - "hl-node": .string("o+T8aWlLCoJYVoCnSPzRH3mWUXxCqeGpS6Pu1uPGKXX3EcX1q1FCLZugdDO/qKo7OpB0lZgHJXqjIZWfIWrCbQ==") - ]) - ]) - let owner = "0x5742261Ef0516AD70C4217cc573fa21485289D3B" - let verified = try UnitDepositAddress(response: response, asset: .zec, owner: owner, environment: .mainnet) - #expect(verified.address == response["address"].string && verified.owner == owner.lowercased()) - #expect(throws: (any Error).self) { - try UnitDepositAddress(response: response, asset: .zec, owner: "0x1111111111111111111111111111111111111111", - environment: .mainnet) - } -} diff --git a/Tools/L4ProtocolCodegen/Package.resolved b/Tools/L4ProtocolCodegen/Package.resolved new file mode 100644 index 0000000..1ffff70 --- /dev/null +++ b/Tools/L4ProtocolCodegen/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "94e70e2a99a16f030b105590d947eee9bd3e860bc2c58682f6e6959acedafbf6", + "pins" : [ + { + "identity" : "grpc-swift-2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-2.git", + "state" : { + "revision" : "e31eb485c4575e0dbed3d1925e24084f755ac6e8", + "version" : "2.4.4" + } + }, + { + "identity" : "grpc-swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-protobuf.git", + "state" : { + "revision" : "83f5cdb2f8b4c7b57ed9419c6f4f6380b683500e", + "version" : "2.5.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "f96b909e1a9e469620bc5037c1e24e00153f376f", + "version" : "1.7.5" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "3d537cb81aef272a03cb3be815439e9bf0eb3bd3", + "version" : "1.39.0" + } + } + ], + "version" : 3 +} diff --git a/Tools/L4ProtocolCodegen/Package.swift b/Tools/L4ProtocolCodegen/Package.swift new file mode 100644 index 0000000..07241f8 --- /dev/null +++ b/Tools/L4ProtocolCodegen/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "L4ProtocolCodegen", + platforms: [.macOS(.v15)], + dependencies: [ + .package(url: "https://github.com/grpc/grpc-swift-2.git", exact: "2.4.4"), + .package(url: "https://github.com/grpc/grpc-swift-protobuf.git", exact: "2.5.0"), + .package(url: "https://github.com/apple/swift-protobuf.git", exact: "1.39.0") + ], + targets: [ + .target( + name: "L4Protocol", + dependencies: [ + .product(name: "GRPCCore", package: "grpc-swift-2"), + .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), + .product(name: "SwiftProtobuf", package: "swift-protobuf") + ], + path: "Protocol", + plugins: [.plugin(name: "GRPCProtobufGenerator", package: "grpc-swift-protobuf")] + ) + ] +) diff --git a/Tools/L4ProtocolCodegen/Protocol/ProtocolModule.swift b/Tools/L4ProtocolCodegen/Protocol/ProtocolModule.swift new file mode 100644 index 0000000..779b4cd --- /dev/null +++ b/Tools/L4ProtocolCodegen/Protocol/ProtocolModule.swift @@ -0,0 +1,3 @@ +// SwiftPM needs a Swift source to discover this development-only generation target. +// Runtime SDK consumers compile the committed output instead. +import GRPCProtobuf diff --git a/Tools/L4ProtocolCodegen/Protocol/grpc-swift-proto-generator-config.json b/Tools/L4ProtocolCodegen/Protocol/grpc-swift-proto-generator-config.json new file mode 100644 index 0000000..a721748 --- /dev/null +++ b/Tools/L4ProtocolCodegen/Protocol/grpc-swift-proto-generator-config.json @@ -0,0 +1,4 @@ +{ + "generate": { "clients": true, "servers": false, "messages": true }, + "generatedSource": { "accessLevel": "Internal" } +} diff --git a/Tools/L4ProtocolCodegen/Protocol/quicknode_l4.proto b/Tools/L4ProtocolCodegen/Protocol/quicknode_l4.proto new file mode 100644 index 0000000..e27fda4 --- /dev/null +++ b/Tools/L4ProtocolCodegen/Protocol/quicknode_l4.proto @@ -0,0 +1,37 @@ +// Wire-compatible subset of Quicknode's orderbook.proto, StreamL4BookUpdates. +// https://github.com/quiknode-labs/hypercore-grpc-examples/blob/main/proto/orderbook.proto +// No field numbers or enum values have been changed. Other RPCs are intentionally omitted. +syntax = "proto3"; +package hyperliquid; + +service OrderBookStreaming { + rpc StreamL4BookUpdates (L4BookUpdatesRequest) returns (stream L4BookUpdatesUpdate); +} + +message L4BookUpdatesRequest { + repeated string coins = 1; +} + +enum L4OrderDiffType { + L4_ORDER_DIFF_TYPE_UNSPECIFIED = 0; + L4_ORDER_DIFF_TYPE_NEW = 1; + L4_ORDER_DIFF_TYPE_UPDATE = 2; + L4_ORDER_DIFF_TYPE_REMOVE = 3; +} + +message L4BookUpdatesUpdate { + uint64 time = 1; + uint64 height = 2; + repeated L4OrderDiff diffs = 3; + bool snapshot = 4; +} + +message L4OrderDiff { + L4OrderDiffType diff_type = 1; + string coin = 2; + uint64 oid = 3; + string user = 4; + string side = 5; + string px = 6; + string sz = 7; +} diff --git a/docs/L4_BOOK.md b/docs/L4_BOOK.md new file mode 100644 index 0000000..0a9fc0d --- /dev/null +++ b/docs/L4_BOOK.md @@ -0,0 +1,75 @@ +# Hyperliquid L4 book + +`MarketData/L4` contains Hyperliquid-specific order state and Quicknode's read-only wire adapter. Application configuration persistence, credential storage, quality state, recording and reconnect policy belong to the consuming application. + +## Coverage + +`L4BookReducer` retains an exact OID map and per-price membership/quantity indexes. It projects a bounded number of aggregated price levels for display while retaining orders beyond that projection. Sizes and price keys use checked `Decimal` arithmetic. Updates carry absolute remaining size. The book changes only through the authoritative order-diff stream; trades and order-status observations must not subtract quantity again. + +Each order includes OID, pseudonymous wallet address, coin, side, price and current size. A wallet address identifies an on-chain participant; it does not establish a real-world person or institution. `ordersSortedByID` supports deterministic inspection, with no FIFO or queue-priority claim. + +The selected RPC is `hyperliquid.OrderBookStreaming/StreamL4BookUpdates`: typed NEW/UPDATE/REMOVE state changes. `StreamL4Book` offers fuller order and lifecycle detail and is a separate integration; this implementation does not subscribe to it. Trigger and TP/SL orders have a separate provider stream and are outside resting-book depth. + +Every `snapshot: true` batch replaces the complete state, including a mid-stream snapshot at the current height. Non-snapshot heights must increase; jumps are allowed because filtered streams need not emit every chain block. Increasing height alone cannot prove that a provider delivered all changes. A fault clears the reducer's actionable state and requires a fresh snapshot. The accepted height/time watermark survives faults within a connection. Only `resetForNewConnection()` clears that watermark. The application may keep an earlier immutable view visible with stale provenance. + +## Client + +The reducer and configuration models support macOS 14. The official gRPC Swift 2 transport requires macOS 15. Public APIs that open gRPC connections are availability-gated; the package deployment target remains macOS 14. + +```swift +let configuration = try QuicknodeL4Configuration( + environment: .testnet, + endpoint: "your-endpoint.hype-testnet.quiknode.pro:10000" +) +// Obtain this read-only credential through the application's secret store. +let credential = try QuicknodeL4Credential(token: tokenFromSecretStore) +if #available(macOS 15.0, *) { + try await QuicknodeL4Client(configuration: configuration).stream( + coin: "ETH", credential: credential + ) { update in + try await engine.apply(update) + } +} +``` + +`engine` above is an application-owned actor containing `L4BookReducer`. The callback is awaited before consuming the next batch. The caller cancels the enclosing task to close the connection and restarts with a fresh reducer baseline. A normally closed subscription throws `streamEnded`; provider errors are reduced to typed categories without provider text or metadata. Errors thrown by the consumer callback propagate to the caller. This client does not automatically retry or share subscriptions. + +The client subscribes to one coin on the server, uses TLS with standard certificate validation, and sends `x-token` metadata. Hostnames are validated against the configured Quicknode network; URLs containing credentials, paths, queries or fragments are rejected. Credentials are not Codable and their normal descriptions/mirrors redact the value. The SDK does not save tokens, invoke Keychain, or access trading credentials. + +Default limits are 64 MiB per protobuf response, 1,000,000 changes per batch and 500,000 retained orders per reducer. Explicit upper bounds are 128 MiB, 2,000,000 changes and 2,000,000 orders. Limits are independent: receiving a response successfully does not bypass reducer capacity. A larger market exceeding the configured limits requires reconfiguration and a new baseline. Source timestamps, authenticated coverage, HIP-3 support, ordering and long-running recovery still require provider-specific live validation. + +## Reproducible protocol code + +The [protocol subset](../Tools/L4ProtocolCodegen/Protocol/quicknode_l4.proto) keeps Quicknode's published package, RPC name, fields and enum values. Generated internal message/client types are committed under `MarketData/L4/Quicknode/Generated`, with the official generator headers intact. SDK consumers compile these files directly. The official build plugin runs only in the separate development package `Tools/L4ProtocolCodegen`; consuming the SDK does not execute this plugin or require Xcode to trust an additional code generator. + +To update the protocol or verify reproducibility: + +```sh +bash scripts/generate-l4-protocol.sh +bash scripts/generate-l4-protocol.sh --check +``` + +The script builds the development package, copies both generated Swift files without editing them, and checks byte equality in `--check` mode. It requires a macOS 15+ development host for the generator. Runtime and development dependencies have separate pinned manifests and lockfiles. Generated files are ordinary SDK sources; dependency checkout files are never edited. + +```sh +swift test --no-parallel +``` + +The suite covers reducer/reference equivalence, whole-state resets, checked arithmetic, malformed batches, capacity, network/credential validation, typed in-process RPC delivery, trailing error status, consumer cancellation and an actual HTTP/2 loopback message-size check. These are local contract tests, not evidence that an authenticated provider endpoint is delivering complete production state. + +## Bounded order inspection + +`L4BookReducer.orderPage(side:price:after:limit:)` reads one price level without exporting the entire OID map. The result contains exact aggregate size/count, current block/time, validated orders with wallet attribution, and an opaque next cursor. Limits are 1–500 rows (default 100). Numeric OID sorting is deterministic inspection, not queue/FIFO order. + +The selector scans existing level membership and retains at most `limit + 1` IDs in a bounded heap, then sorts only those IDs. It uses O(limit) auxiliary memory; CPU remains proportional to the level membership and is not part of a claimed streaming latency SLA. Callers should request pages on demand rather than every source update. + +A cursor binds environment, coin, side, normalized price, and a local level revision. Every committed batch assigns a fresh revision to touched levels; unrelated levels keep theirs. Replacement snapshots and new connections cannot reuse old level cursors. Changing or deleting the queried level raises `L4BookQueryError.staleCursor`, so clients must refresh the first page instead of merging inconsistent pages. This local revision is not an exchange sequence. Queries never mutate or invalidate the book; invalid limits/prices/scope, absent baselines and stale cursors are explicit errors. An absent level in a live book is an explicit empty page. + +## Primary contracts + +- [Quicknode typed L4 updates](https://www.quicknode.com/docs/hyperliquid/datasets/l4-book-updates) +- [Quicknode protobuf definitions](https://github.com/quiknode-labs/hypercore-grpc-examples/blob/main/proto/orderbook.proto) +- [Quicknode gRPC connection and authentication](https://www.quicknode.com/docs/hyperliquid/grpc-api) +- [gRPC Swift 2](https://github.com/grpc/grpc-swift-2) +- [gRPC HTTP/2 transport](https://github.com/grpc/grpc-swift-nio-transport) +- [gRPC Swift protobuf generation](https://github.com/grpc/grpc-swift-protobuf) diff --git a/docs/MODULES.md b/docs/MODULES.md new file mode 100644 index 0000000..3d98b73 --- /dev/null +++ b/docs/MODULES.md @@ -0,0 +1,40 @@ +# HyperliquidCore modules + +HyperliquidCore contains Hyperliquid protocol, API and domain behavior. Its public product remains a single Swift module: `import HyperliquidCore`. These directories organize ownership; they do not add products or change public APIs. + +Application screens, workspace/window state, rendering, user preferences, Keychain policy, generic recording/replay orchestration and institutional operator workflows belong to the consuming application. Hyperliquid-specific data formats, signing, account rules and market/order state belong here. Supporting CCTP and Unit code describes routes into Hyperliquid; this package is not a general multi-exchange SDK. + +## Implementation and tests + +| Directory | Responsibility | Representative files | +| --- | --- | --- | +| `Types` | Network identity, wire JSON and shared Hyperliquid models; receipt clock | `Models`, `ReceiptClock` | +| `Networking` | Hyperliquid REST/WS API and bounded NIO transports | `HyperliquidAPI`, `NIOHTTPTransport`, `NIOWebSocketTransport` | +| `MarketData` | Market catalogs, market context, Hyperliquid trades and observation windows | `HIP3Catalog`, `MarketContextBook`, `LargeTradeMonitor`, `TradeObservationWindow` | +| `MarketData/L4` | Hyperliquid per-OID state, checked price indexes and Quicknode typed L4 protocol/client | `L4BookReducer`, `L4Models`, `QuicknodeL4Client` | +| `Accounts` | Hyperliquid account subscriptions, state and history | `AccountStream`, `AccountHistory` | +| `Signing` | Exchange signing, wire serialization and credential proof verification | `EthereumSigning`, `SigningPayload`, `PasskeyVerification` | +| `Trading/Sessions` | Hyperliquid API-wallet authorization and protected-session recovery contracts | `TradingSession`, `TradingSessionRecovery` | +| `Trading/Orders` | Intents, modification, lifecycle, receipts, slippage and venue capacity | `OrderIntent`, `OrderModification`, `OrderLifecycle`, `ExchangeReceipt`, `PerpTradingCapacity`, `PerpOpenInterest` | +| `Trading/Risk` | Hyperliquid order/account checks, fees, margin and position risk observations | `OrderReview`, `OrderRisk`, `PositionRiskMonitor` | +| `Trading/Positions` | Position-scoped intents and isolated-margin actions | `PositionIntent`, `IsolatedMargin` | +| `Trading/Protection` | Parent/TP/SL actions and their read contracts | `ProtectionBatch`, `ProtectionReadClient` | +| `Wallet` | Wallet identity, ownership proofs and local bridge protocol | `WalletConnection`, `WalletOwnership`, `WalletBridge` | +| `Funding` | Hyperliquid transfers and deposit route/account contracts | `FundingIntent`, `BalanceTransfer`, `FundingReadClient`, `DepositAsset`, `BridgeDeposit`, `DepositSourceBalance` | +| `Funding/CCTP` | Circle-based Hyperliquid deposit route, allowance, fees and destination rules | `CCTPDeposit`, `CCTPDepositClient`, `CCTPAllowance`, `CCTPNetworkRegistry` | +| `Funding/Unit` | Unit deposit requests and address encoding used by those routes | `UnitDeposit`, `ZcashDepositEncoding` | +| `Analytics` | Hyperliquid account/market analytical queries and read contracts | `AgentQuery`, `AgentAnalytics`, `AgentReadClient` | + +Implementation files are under [Sources/HyperliquidCore](../Sources/HyperliquidCore). Tests mirror these directories under [Tests/HyperliquidCoreTests](../Tests/HyperliquidCoreTests). Tests spanning multiple areas stay with the workflow they primarily verify. + +## Verification fixtures + +Published signing/funding JSON vectors remain in [docs/verification](verification). Nested tests locate the package root through [CoreFixturePaths](../Tests/HyperliquidCoreTests/Fixtures/CoreFixturePaths.swift), so moving a test does not change its fixture selection. The helper also works from an editable package or dependency checkout, without relying on the process working directory or `.git` metadata. + +The original directory reorganization preserved all 51 existing implementation files byte-for-byte. Only six fixture path expressions in five test files change, plus the new shared test helper. That reorganization changed no package dependency, target, product, signing payload, request body or domain calculation. Later L4 commits add protocol-specific implementation and dependencies described separately below. + +```sh +swift test --no-parallel +``` + +Network probes remain opt-in as described in the [README](../README.md). The L4 implementation follows `MarketData/L4` ownership. See [L4 coverage, macOS availability and protocol generation](L4_BOOK.md). Committed protocol output is under `MarketData/L4/Quicknode/Generated`; the official generator runs in the separate development package `Tools/L4ProtocolCodegen`. App presentation, configuration persistence, Keychain and reconnect orchestration remain outside this package. diff --git a/docs/verification/PUBLIC_CREDENTIAL_REVIEW.md b/docs/verification/PUBLIC_CREDENTIAL_REVIEW.md new file mode 100644 index 0000000..0f7a7ca --- /dev/null +++ b/docs/verification/PUBLIC_CREDENTIAL_REVIEW.md @@ -0,0 +1,46 @@ +# Public repository credential review — 2026-11-01 + +Reviewed SDK revision: `99bc413b4230cbc6e0e9fe81b92ceacfccf44221`. + +The GitHub repository was already **public** when checked. No real private key, mnemonic, API credential, credential-bearing provider URL or private-key container was identified by the checks below. This is a bounded credential/disclosure review, not a formal security audit or a guarantee that every possible secret format is detectable. + +## Scope and evidence + +- Gitleaks **8.30.1**, official macOS arm64 release verified against its published SHA-256 checksum, scanned a clean archive of tracked HEAD content and Git history across all reachable refs. All scanner reports/logs were redacted; repository content was not uploaded to an external scanning service. +- The Git history inventory contained **20 reachable commits and 169 unique text blobs**. A supplemental scan read every reachable blob for private-key containers, credentials embedded in URLs, Quicknode URL tokens, mnemonic literals and named wallet-key literals. +- Neither of the two earlier account-specific public wallet addresses occurs in current SDK sources, tests or documentation. +- SDK regression: **240 tests in 6 suites passed** after fixture cleanup, 0.648 s (build 6.43 s). No user wallet signing or fund transfer was performed. +- GitHub's secret-scanning alerts API returned **Secret scanning is disabled on this repository**. This was not counted as an independent clean scan. + +The official scanner binary SHA-256 was `b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5`. + +## Reviewed scanner findings + +Gitleaks reported eight findings in current files and the same eight original additions in history, all under `generic-api-key`: + +| Content | Count | Classification | +| --- | --- | --- | +| Expected public signing-wallet address in SigningTests | 1 | Address derived from the published synthetic signing key | +| USDC contract addresses asserted in CCTP tests | 3 | Public 20-byte contract addresses | +| TokenMessenger addresses in CCTP JSON vectors | 4 | Public 20-byte contract addresses | + +All eight matched literals are `0x` followed by 40 hexadecimal digits. None is a private key or API credential. They remain in the tests because they verify public protocol data. + +The supplemental scan found five named fixed wallet-key literals. Each exactly matched the existing public synthetic signing fixture described in [THIRD_PARTY_NOTICES](../../THIRD_PARTY_NOTICES.md). It found no other named wallet-key literal, mnemonic literal, credential-bearing URL or private-key container. Other hashes, signatures, public guardian keys and transaction calldata in protocol fixtures are expected public cryptographic test data. + +## Configuration and historical disclosure + +Builder address and product fee policy belong in the consuming App. SDK Builder APIs take these values as arguments and contain no HyperliquidX fee destination default. New Builder signing vectors use a synthetic destination. + +Older account/L4/session fixtures now use synthetic accounts. Unit fixture responses were regenerated for a synthetic owner through the public Unit API and frozen with genuine guardian signatures. The production guardian keys, two-party quorum, signing payload checks and network binding were not weakened. + +Previously published commits still contain the earlier public wallet addresses and associated historical fixtures. Those are public account identifiers, with possible account-linkage implications, rather than signing credentials. This review did **not** rewrite published Git history, delete branches or force push. Current-source cleanup does not erase earlier public copies. + +Ignored/untracked files, local Keychain contents, local wallets, build caches, unreachable Git objects and GitHub-hosted artifacts were outside the tracked-content review. No real wallet Keychain entry or private key was read. + +## Public-source conclusion + +The checked content provides no credential-leak blocker to keeping this SDK public or merging the reviewed changes. The package has an MIT license and [third-party notices](../../THIRD_PARTY_NOTICES.md). Enable GitHub secret scanning and push protection when available to add ongoing detection; these settings were left unchanged during this review. + +A separately authorized history-disclosure cleanup would be required if removing the earlier public account identifiers from published history is a release requirement. + diff --git a/scripts/generate-l4-protocol.sh b/scripts/generate-l4-protocol.sh new file mode 100755 index 0000000..69c15cd --- /dev/null +++ b/scripts/generate-l4-protocol.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -euo pipefail +task_sdk_root="$(cd "$(dirname "$0")/.." && pwd)" +task_codegen_dir="$task_sdk_root/Tools/L4ProtocolCodegen" +if [[ $# -gt 1 || ( $# -eq 1 && "$1" != "--check" ) ]]; then + echo "Usage: bash scripts/generate-l4-protocol.sh [--check]" >&2 + exit 2 +fi +swift build --package-path "$task_codegen_dir" +python3 - "$task_sdk_root" "${1:-}" <<'PY' +from pathlib import Path +import sys + +root = Path(sys.argv[1]) +outputs = root / 'Tools/L4ProtocolCodegen/.build/plugins/outputs' +destination = root / 'Sources/HyperliquidCore/MarketData/L4/Quicknode/Generated' +destination.mkdir(parents=True, exist_ok=True) +outdated = [] +for name in ('quicknode_l4.pb.swift', 'quicknode_l4.grpc.swift'): + candidates = list(outputs.rglob(name)) + if len(candidates) != 1: + raise SystemExit(f'Expected one generated {name}, found {len(candidates)}') + content = candidates[0].read_bytes() + target = destination / name + if sys.argv[2] == '--check': + if not target.exists() or target.read_bytes() != content: + outdated.append(name) + else: + target.write_bytes(content) +if outdated: + raise SystemExit('Generated L4 protocol sources are outdated: ' + ', '.join(outdated)) +print('L4 protocol sources verified.' if sys.argv[2] == '--check' else 'L4 protocol sources generated.') +PY