Skip to content
Merged
20 changes: 20 additions & 0 deletions visionOS/MempoolVisionOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
B1C2D3E4F5A67890123456789012346A /* MempoolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A67890123456789012346B /* MempoolView.swift */; };
B1C2D3E4F5A67890123456789012346C /* TransactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A67890123456789012346D /* TransactionView.swift */; };
B1C2D3E4F5A67890123456789012346E /* UTXOView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A67890123456789012346F /* UTXOView.swift */; };
B1C2D3E4F5A67890123456789012348A /* MempoolStrata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A678901234567890123485 /* MempoolStrata.swift */; };
B1C2D3E4F5A67890123456789012348B /* RecommendedFees.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A678901234567890123486 /* RecommendedFees.swift */; };
B1C2D3E4F5A67890123456789012348C /* FeePanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A678901234567890123487 /* FeePanelView.swift */; };
B1C2D3E4F5A67890123456789012348D /* SearchPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A678901234567890123488 /* SearchPanelView.swift */; };
B1C2D3E4F5A67890123456789012348E /* TransactionImmersiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A678901234567890123489 /* TransactionImmersiveView.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand All @@ -41,6 +46,11 @@
B1C2D3E4F5A67890123456789012346D /* TransactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionView.swift; sourceTree = "<group>"; };
B1C2D3E4F5A67890123456789012346F /* UTXOView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTXOView.swift; sourceTree = "<group>"; };
B1C2D3E4F5A678901234567890123470 /* MempoolVisionOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MempoolVisionOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
B1C2D3E4F5A678901234567890123485 /* MempoolStrata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MempoolStrata.swift; sourceTree = "<group>"; };
B1C2D3E4F5A678901234567890123486 /* RecommendedFees.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendedFees.swift; sourceTree = "<group>"; };
B1C2D3E4F5A678901234567890123487 /* FeePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeePanelView.swift; sourceTree = "<group>"; };
B1C2D3E4F5A678901234567890123488 /* SearchPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchPanelView.swift; sourceTree = "<group>"; };
B1C2D3E4F5A678901234567890123489 /* TransactionImmersiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionImmersiveView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -76,6 +86,8 @@
B1C2D3E4F5A678901234567890123463 /* Block.swift */,
B1C2D3E4F5A678901234567890123465 /* Transaction.swift */,
B1C2D3E4F5A678901234567890123467 /* UTXO.swift */,
B1C2D3E4F5A678901234567890123485 /* MempoolStrata.swift */,
B1C2D3E4F5A678901234567890123486 /* RecommendedFees.swift */,
);
path = Models;
sourceTree = "<group>";
Expand All @@ -89,6 +101,9 @@
B1C2D3E4F5A67890123456789012346B /* MempoolView.swift */,
B1C2D3E4F5A67890123456789012346D /* TransactionView.swift */,
B1C2D3E4F5A67890123456789012346F /* UTXOView.swift */,
B1C2D3E4F5A678901234567890123487 /* FeePanelView.swift */,
B1C2D3E4F5A678901234567890123488 /* SearchPanelView.swift */,
B1C2D3E4F5A678901234567890123489 /* TransactionImmersiveView.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -223,6 +238,11 @@
B1C2D3E4F5A67890123456789012346E /* UTXOView.swift in Sources */,
B1C2D3E4F5A678901234567890123456 /* MempoolVisionOSApp.swift in Sources */,
00175B112E51571A003D45DF /* Blockchain3DView.swift in Sources */,
B1C2D3E4F5A67890123456789012348A /* MempoolStrata.swift in Sources */,
B1C2D3E4F5A67890123456789012348B /* RecommendedFees.swift in Sources */,
B1C2D3E4F5A67890123456789012348C /* FeePanelView.swift in Sources */,
B1C2D3E4F5A67890123456789012348D /* SearchPanelView.swift in Sources */,
B1C2D3E4F5A67890123456789012348E /* TransactionImmersiveView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
21 changes: 21 additions & 0 deletions visionOS/MempoolVisionOS/Models/MempoolStrata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

struct MempoolStrata: Identifiable, Codable {
let id = UUID()
let feeRange: ClosedRange<Double>
let transactionCount: Int
let totalSize: Int
let averageFee: Double
let color: StrataColor

enum StrataColor: String, Codable, CaseIterable {
case red = "high"
case orange = "medium"
case yellow = "low"
case green = "minimal"
}

var visualHeight: Float {
return Float(transactionCount) / 1000.0 * Float(averageFee) / 50.0
}
}
22 changes: 22 additions & 0 deletions visionOS/MempoolVisionOS/Models/RecommendedFees.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

struct RecommendedFees: Codable {
let fastestFee: Int
let halfHourFee: Int
let hourFee: Int
let economyFee: Int
let minimumFee: Int
}

struct SearchResult: Identifiable, Codable {
let id = UUID()
let type: SearchResultType
let title: String
let subtitle: String

enum SearchResultType: String, Codable {
case transaction
case address
case block
}
}
183 changes: 182 additions & 1 deletion visionOS/MempoolVisionOS/Services/MempoolService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import Combine

class MempoolService: ObservableObject {
private let baseURL = "https://mempool.space/api/v1"
private let wsURL = "wss://mempool.space/api/v1/ws"
private var webSocketTask: URLSessionWebSocketTask?

@Published var blocks: [Block] = []
@Published var mempoolTransactions: [Transaction] = []
@Published var mempoolStrata: [MempoolStrata] = []
@Published var recommendedFees: RecommendedFees?
@Published var isLoading = false
@Published var error: String?
@Published var isConnectedToWebSocket = false

func fetchBlocks() async {
await MainActor.run { isLoading = true }
Expand Down Expand Up @@ -95,4 +100,180 @@ class MempoolService: ObservableObject {
}
}
}
}

func connectWebSocket() {
guard let url = URL(string: wsURL) else { return }

webSocketTask = URLSession.shared.webSocketTask(with: url)
webSocketTask?.resume()

sendWebSocketMessage(["action": "want", "data": ["mempool-blocks", "stats", "blocks"]])

receiveWebSocketMessage()

DispatchQueue.main.async {
self.isConnectedToWebSocket = true
}
}

private func sendWebSocketMessage(_ message: [String: Any]) {
guard let data = try? JSONSerialization.data(withJSONObject: message),
let string = String(data: data, encoding: .utf8) else { return }

let message = URLSessionWebSocketTask.Message.string(string)
webSocketTask?.send(message) { error in
if let error = error {
print("❌ WebSocket send error: \(error)")
}
}
}

private func receiveWebSocketMessage() {
webSocketTask?.receive { [weak self] result in
switch result {
case .success(let message):
self?.handleWebSocketMessage(message)
self?.receiveWebSocketMessage()
case .failure(let error):
print("❌ WebSocket receive error: \(error)")
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self?.connectWebSocket()
}
}
}
}

private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) {
switch message {
case .string(let text):
guard let data = text.data(using: .utf8) else { return }

do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
DispatchQueue.main.async {
self.processWebSocketData(json)
}
}
} catch {
print("❌ Error parsing WebSocket message: \(error)")
}
case .data(let data):
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
DispatchQueue.main.async {
self.processWebSocketData(json)
}
}
} catch {
print("❌ Error parsing WebSocket data: \(error)")
}
@unknown default:
break
}
}

private func processWebSocketData(_ json: [String: Any]) {
if let feesData = json["fees"] as? [String: Any] {
if let fastest = feesData["fastestFee"] as? Int,
let halfHour = feesData["halfHourFee"] as? Int,
let hour = feesData["hourFee"] as? Int,
let economy = feesData["economyFee"] as? Int,
let minimum = feesData["minimumFee"] as? Int {

self.recommendedFees = RecommendedFees(
fastestFee: fastest,
halfHourFee: halfHour,
hourFee: hour,
economyFee: economy,
minimumFee: minimum
)
}
}

if let mempoolBlocksData = json["mempool-blocks"] as? [[String: Any]] {
processMempoolBlocks(mempoolBlocksData)
}
}

private func processMempoolBlocks(_ mempoolBlocks: [[String: Any]]) {
var strata: [MempoolStrata] = []

for (index, blockData) in mempoolBlocks.enumerated() {
if let feeRange = blockData["feeRange"] as? [Double],
let nTx = blockData["nTx"] as? Int,
let totalSize = blockData["totalSize"] as? Int,
let medianFee = blockData["medianFee"] as? Double,
feeRange.count >= 2 {

let color: MempoolStrata.StrataColor
if medianFee > 100 {
color = .red
} else if medianFee > 50 {
color = .orange
} else if medianFee > 20 {
color = .yellow
} else {
color = .green
}

let stratum = MempoolStrata(
feeRange: feeRange[0]...feeRange[1],
transactionCount: nTx,
totalSize: totalSize,
averageFee: medianFee,
color: color
)

strata.append(stratum)
}
}

self.mempoolStrata = strata
}

func searchTransactionOrAddress(_ query: String) async -> [SearchResult] {
guard !query.isEmpty else { return [] }

var results: [SearchResult] = []

if query.count == 64 && query.allSatisfy({ $0.isHexDigit }) {
do {
let url = URL(string: "\(baseURL)/tx/\(query)")!
let (_, response) = try await URLSession.shared.data(from: url)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
results.append(SearchResult(
type: .transaction,
title: "Transaction",
subtitle: "\(query.prefix(16))..."
))
}
} catch {
print("Transaction search failed: \(error)")
}
}

if query.count >= 26 && query.count <= 62 {
do {
let url = URL(string: "\(baseURL)/address/\(query)")!
let (_, response) = try await URLSession.shared.data(from: url)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
results.append(SearchResult(
type: .address,
title: "Address",
subtitle: "\(query.prefix(20))..."
))
}
} catch {
print("Address search failed: \(error)")
}
}

return results
}

func disconnectWebSocket() {
webSocketTask?.cancel()
webSocketTask = nil
isConnectedToWebSocket = false
}
}
37 changes: 36 additions & 1 deletion visionOS/MempoolVisionOS/ViewModels/BlockchainViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import SwiftUI
import RealityKit
import Combine

@MainActor
class BlockchainViewModel: ObservableObject {
Expand All @@ -12,8 +13,13 @@ class BlockchainViewModel: ObservableObject {
@Published var cameraPosition: SIMD3<Float> = SIMD3<Float>(0, 0, 5)
@Published var feeDistribution: [Double] = []
@Published var errorMessage: String?
@Published var mempoolStrata: [MempoolStrata] = []
@Published var recommendedFees: RecommendedFees?
@Published var isConnectedToWebSocket = false
@Published var searchResults: [SearchResult] = []

private let mempoolService = MempoolService()
private var cancellables = Set<AnyCancellable>()

enum ViewType {
case blockchain
Expand All @@ -34,6 +40,23 @@ class BlockchainViewModel: ObservableObject {
var mempoolTransactions: [Transaction] {
mempoolService.mempoolTransactions
}

init() {
mempoolService.$mempoolStrata
.receive(on: DispatchQueue.main)
.assign(to: \.mempoolStrata, on: self)
.store(in: &cancellables)

mempoolService.$recommendedFees
.receive(on: DispatchQueue.main)
.assign(to: \.recommendedFees, on: self)
.store(in: &cancellables)

mempoolService.$isConnectedToWebSocket
.receive(on: DispatchQueue.main)
.assign(to: \.isConnectedToWebSocket, on: self)
.store(in: &cancellables)
}

func loadData() async {
isLoading = true
Expand Down Expand Up @@ -77,4 +100,16 @@ class BlockchainViewModel: ObservableObject {
func clearError() {
errorMessage = nil
}
}

func connectToRealTimeData() {
mempoolService.connectWebSocket()
}

func searchTransactionOrAddress(_ query: String) async -> [SearchResult] {
let results = await mempoolService.searchTransactionOrAddress(query)
await MainActor.run {
self.searchResults = results
}
return results
}
}
Loading
Loading