259 lines
9.1 KiB
Swift
259 lines
9.1 KiB
Swift
|
|
import Foundation
|
|||
|
|
import Combine
|
|||
|
|
import AppKit
|
|||
|
|
|
|||
|
|
@MainActor
|
|||
|
|
class PresenceEngine: ObservableObject {
|
|||
|
|
static let shared = PresenceEngine()
|
|||
|
|
|
|||
|
|
@Published var isTracking = false
|
|||
|
|
@Published var isPresent = false
|
|||
|
|
@Published var sessionSeconds: Int = 0
|
|||
|
|
@Published var pendingSeconds: Int = 0
|
|||
|
|
@Published var serverBalance: Int = 0
|
|||
|
|
@Published var isOnline = false
|
|||
|
|
@Published var networkNodes: [(name: String, location: String, online: Bool)] = []
|
|||
|
|
@Published var networkHealth: String = "0%"
|
|||
|
|
@Published var networkOnline: Int = 0
|
|||
|
|
@Published var networkTotal: Int = 3
|
|||
|
|
@Published var protocolVersion: String = "2.0.0"
|
|||
|
|
@Published var protocolMode: String = "MAINNET"
|
|||
|
|
@Published var protocolCrypto: String = "ML-DSA-65 (FIPS 204)"
|
|||
|
|
@Published var ledgerVerified: Bool = false
|
|||
|
|
@Published var ledgerBalance: Int = 0
|
|||
|
|
@Published var walletSynced: Bool = false
|
|||
|
|
@Published var t2BlockNumber: Int = 0
|
|||
|
|
@Published var t2SecondsElapsed: Int = 0
|
|||
|
|
@Published var t2TrackingSeconds: Int = 0
|
|||
|
|
@Published var showSymbolInMenuBar: Bool = true
|
|||
|
|
@Published var showBalanceInMenuBar: Bool = false
|
|||
|
|
@Published var genesisDate: Date = {
|
|||
|
|
var c = DateComponents()
|
|||
|
|
c.year = 2026; c.month = 1; c.day = 9; c.hour = 0; c.minute = 0
|
|||
|
|
return Calendar.current.date(from: c) ?? Date()
|
|||
|
|
}()
|
|||
|
|
|
|||
|
|
var displayBalance: Int { serverBalance + pendingSeconds }
|
|||
|
|
|
|||
|
|
/// Callback fired AFTER pendingSeconds is updated in tick() — for menu bar sync
|
|||
|
|
var onTick: (() -> Void)?
|
|||
|
|
|
|||
|
|
// ── GENESIS ECONOMICS ──
|
|||
|
|
// Montana Genesis: 09.01.2026 — network launch
|
|||
|
|
// Price Genesis: 12.03.2021 — BIPL (Bill Payment) price anchor
|
|||
|
|
//
|
|||
|
|
// Model: 1 second of human presence = 1 Ɉ
|
|||
|
|
// BIPL price: 1 second = $0.1605 USD
|
|||
|
|
// RUB rate at price genesis (12.03.2021): ~75 ₽/$
|
|||
|
|
// 1 Ɉ = $0.1605 = ₽12.04
|
|||
|
|
//
|
|||
|
|
// Source: https://x.com/tojesatoshi/status/2012823709858275473
|
|||
|
|
//
|
|||
|
|
static let genesisPriceUSD: Double = 0.1605 // $0.1605 per Ɉ (BIPL)
|
|||
|
|
static let genesisPriceRUB: Double = 12.04 // ₽12.04 per Ɉ ($0.1605 × 75 ₽/$)
|
|||
|
|
static let genesisSettlementDate = "12.03.2021"
|
|||
|
|
|
|||
|
|
var balanceUSD: Double { Double(displayBalance) * Self.genesisPriceUSD }
|
|||
|
|
var balanceRUB: Double { Double(displayBalance) * Self.genesisPriceRUB }
|
|||
|
|
|
|||
|
|
var epochDays: Int {
|
|||
|
|
let diff = Calendar.current.dateComponents([.day], from: genesisDate, to: Date())
|
|||
|
|
return max(diff.day ?? 0, 0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var epochNumber: Int { epochDays / 7 + 1 }
|
|||
|
|
|
|||
|
|
let t2Duration = 600
|
|||
|
|
|
|||
|
|
var t2SecondsRemaining: Int { max(t2Duration - t2SecondsElapsed, 0) }
|
|||
|
|
var t2Progress: Double { min(Double(t2SecondsElapsed) / Double(t2Duration), 1.0) }
|
|||
|
|
var t2PendingCoins: Int { t2TrackingSeconds }
|
|||
|
|
|
|||
|
|
/// Available balance = everything confirmed BEFORE current T2 window
|
|||
|
|
var availableBalance: Int { max(displayBalance - t2PendingCoins, 0) }
|
|||
|
|
|
|||
|
|
private var tickTimer: Timer?
|
|||
|
|
private var reportTimer: Timer?
|
|||
|
|
private var syncTimer: Timer?
|
|||
|
|
private var tickCount = 0
|
|||
|
|
private let pendingKey = "montana_presence_pending"
|
|||
|
|
private let balanceKey = "montana_presence_balance"
|
|||
|
|
let api = MontanaAPIClient()
|
|||
|
|
|
|||
|
|
private init() {
|
|||
|
|
pendingSeconds = UserDefaults.standard.integer(forKey: pendingKey)
|
|||
|
|
serverBalance = UserDefaults.standard.integer(forKey: balanceKey)
|
|||
|
|
showSymbolInMenuBar = UserDefaults.standard.object(forKey: "menubar_symbol") as? Bool ?? true
|
|||
|
|
showBalanceInMenuBar = UserDefaults.standard.object(forKey: "menubar_balance") as? Bool ?? false
|
|||
|
|
updateT2()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func toggleMenuBarSymbol() {
|
|||
|
|
showSymbolInMenuBar.toggle()
|
|||
|
|
UserDefaults.standard.set(showSymbolInMenuBar, forKey: "menubar_symbol")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func toggleMenuBarBalance() {
|
|||
|
|
showBalanceInMenuBar.toggle()
|
|||
|
|
UserDefaults.standard.set(showBalanceInMenuBar, forKey: "menubar_balance")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var address: String? {
|
|||
|
|
get { UserDefaults.standard.string(forKey: "montana_address") }
|
|||
|
|
set { UserDefaults.standard.set(newValue, forKey: "montana_address") }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var walletNumber: Int {
|
|||
|
|
UserDefaults.standard.integer(forKey: "wallet_number")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var displayAddress: String {
|
|||
|
|
let num = walletNumber
|
|||
|
|
if num > 0 { return "\u{0248}-\(num)" }
|
|||
|
|
if let addr = address { return String(addr.prefix(8)) + "..." + String(addr.suffix(4)) }
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func autoStart() {
|
|||
|
|
guard let addr = address, !addr.isEmpty, !isTracking else { return }
|
|||
|
|
if walletNumber == 0 {
|
|||
|
|
Task {
|
|||
|
|
do {
|
|||
|
|
let wallet = try await api.registerAgentWallet(address: addr)
|
|||
|
|
UserDefaults.standard.set(wallet.number, forKey: "wallet_number")
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
startTracking()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func startTracking() {
|
|||
|
|
guard !isTracking else { return }
|
|||
|
|
isTracking = true
|
|||
|
|
isPresent = true
|
|||
|
|
sessionSeconds = 0
|
|||
|
|
t2TrackingSeconds = 0
|
|||
|
|
tickTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|||
|
|
Task { @MainActor in self?.tick() }
|
|||
|
|
}
|
|||
|
|
reportTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
|
|||
|
|
Task { @MainActor in await self?.reportToServer() }
|
|||
|
|
}
|
|||
|
|
syncTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
|
|||
|
|
Task { @MainActor in await self?.syncBalance() }
|
|||
|
|
}
|
|||
|
|
Task { await syncBalance() }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func stopTracking() {
|
|||
|
|
isTracking = false
|
|||
|
|
isPresent = false
|
|||
|
|
tickTimer?.invalidate()
|
|||
|
|
tickTimer = nil
|
|||
|
|
reportTimer?.invalidate()
|
|||
|
|
reportTimer = nil
|
|||
|
|
syncTimer?.invalidate()
|
|||
|
|
syncTimer = nil
|
|||
|
|
Task { await reportToServer() }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Full reset — called on logout. Zeroes everything.
|
|||
|
|
func fullReset() {
|
|||
|
|
stopTracking()
|
|||
|
|
serverBalance = 0
|
|||
|
|
pendingSeconds = 0
|
|||
|
|
sessionSeconds = 0
|
|||
|
|
tickCount = 0
|
|||
|
|
isOnline = false
|
|||
|
|
walletSynced = false
|
|||
|
|
ledgerVerified = false
|
|||
|
|
ledgerBalance = 0
|
|||
|
|
t2TrackingSeconds = 0
|
|||
|
|
address = nil
|
|||
|
|
UserDefaults.standard.set(0, forKey: pendingKey)
|
|||
|
|
UserDefaults.standard.set(0, forKey: balanceKey)
|
|||
|
|
UserDefaults.standard.removeObject(forKey: "wallet_number")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 1 second = 1 Ɉ. Always.
|
|||
|
|
private func tick() {
|
|||
|
|
guard isTracking else { return }
|
|||
|
|
sessionSeconds += 1
|
|||
|
|
pendingSeconds += 1
|
|||
|
|
tickCount += 1
|
|||
|
|
updateT2()
|
|||
|
|
t2TrackingSeconds += 1
|
|||
|
|
if tickCount % 10 == 0 {
|
|||
|
|
UserDefaults.standard.set(pendingSeconds, forKey: pendingKey)
|
|||
|
|
}
|
|||
|
|
onTick?()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func updateT2() {
|
|||
|
|
let secs = max(Int(Date().timeIntervalSince(genesisDate)), 0)
|
|||
|
|
let newBlock = secs / t2Duration
|
|||
|
|
if newBlock != t2BlockNumber {
|
|||
|
|
t2TrackingSeconds = 0
|
|||
|
|
}
|
|||
|
|
t2BlockNumber = newBlock
|
|||
|
|
t2SecondsElapsed = secs % t2Duration
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func reportToServer() async {
|
|||
|
|
guard let addr = address, !addr.isEmpty, pendingSeconds > 0 else { return }
|
|||
|
|
let delta = pendingSeconds
|
|||
|
|
do {
|
|||
|
|
let newBalance = try await api.reportPresence(address: addr, seconds: delta)
|
|||
|
|
let expectedMin = serverBalance + delta
|
|||
|
|
serverBalance = max(newBalance, expectedMin)
|
|||
|
|
pendingSeconds -= delta
|
|||
|
|
if pendingSeconds < 0 { pendingSeconds = 0 }
|
|||
|
|
isOnline = true
|
|||
|
|
walletSynced = pendingSeconds == 0
|
|||
|
|
UserDefaults.standard.set(pendingSeconds, forKey: pendingKey)
|
|||
|
|
UserDefaults.standard.set(serverBalance, forKey: balanceKey)
|
|||
|
|
} catch {
|
|||
|
|
serverBalance += delta
|
|||
|
|
pendingSeconds -= delta
|
|||
|
|
if pendingSeconds < 0 { pendingSeconds = 0 }
|
|||
|
|
isOnline = false
|
|||
|
|
UserDefaults.standard.set(pendingSeconds, forKey: pendingKey)
|
|||
|
|
UserDefaults.standard.set(serverBalance, forKey: balanceKey)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func syncBalance() async {
|
|||
|
|
guard let addr = address, !addr.isEmpty else { return }
|
|||
|
|
|
|||
|
|
if pendingSeconds == 0 {
|
|||
|
|
do {
|
|||
|
|
let balance = try await api.fetchBalance(address: addr)
|
|||
|
|
serverBalance = max(balance, serverBalance)
|
|||
|
|
isOnline = true
|
|||
|
|
walletSynced = true
|
|||
|
|
UserDefaults.standard.set(serverBalance, forKey: balanceKey)
|
|||
|
|
} catch {
|
|||
|
|
isOnline = false
|
|||
|
|
walletSynced = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
do {
|
|||
|
|
let (net, proto) = try await api.fetchStatus()
|
|||
|
|
networkNodes = net.nodes
|
|||
|
|
networkOnline = net.onlineCount
|
|||
|
|
networkTotal = net.totalNodes
|
|||
|
|
networkHealth = net.health
|
|||
|
|
protocolVersion = proto.version
|
|||
|
|
protocolMode = proto.mode
|
|||
|
|
protocolCrypto = proto.crypto
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
do {
|
|||
|
|
let verify = try await api.fetchLedgerVerify(address: addr)
|
|||
|
|
ledgerVerified = verify.verified
|
|||
|
|
ledgerBalance = verify.ledgerBalance
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
}
|