diff --git a/DASHSYNC_MIGRATION.md b/DASHSYNC_MIGRATION.md index 2ebce6cfa..de85aca2a 100644 --- a/DASHSYNC_MIGRATION.md +++ b/DASHSYNC_MIGRATION.md @@ -100,7 +100,7 @@ Status meanings: | 12 | Network switch | Done; teardown tail | Remove the temporary DashSync wallet-registry mirror with C6-E. | | 13 | Backup seed phrase | Done | Reads the active SDK wallet mnemonic from host-owned storage. | | 14 | Wipe wallet | Done; teardown tail | Remove the DashSync registry/Core Data wipe arm after all consumers are gone. | -| 15 | Provider-key derivation | Done for retained scope | Owner/Voting are SDK-native; Operator/HPMN and legacy “used at” UI were intentionally dropped because the required public-key/list lookup surfaces are unavailable. | +| 15 | Provider-key derivation | Done | All four families are SDK-native: Owner/Voting via the key-wallet path surface, Operator (BLS) and Evonode Operator (Ed25519) via `ManagedPlatformWallet.providerKeyAtIndex` (`platform_wallet_provider_key_at_index` grew per-index BLS/EdDSA public-key export, removing the earlier blocker). Overview counts and per-keypair “used at” restored from the Rust masternode aggregation (`PlatformWalletManager.masternodes(for:)`). | | 16 | DashPay identity creation | Done; invitation tail | Standard SDK-funded identity/name registration is migrated, with three funding paths (Core asset-lock, Platform Payment, shielded Type-20 — the privacy-preserving default, gated by `ShieldedIdentityFundingReadiness`). Invitation-funded create/accept remains part of the invitation decision. | | 17 | DashPay identity/profile read-write | Done; compatibility tail | Retire remaining `DSBlockchainIdentity`-typed profile properties/categories and route every old view directly through `DWCurrentUserIdentityInfo` / `DWProfileUpdateBridge`. | | 18 | DashPay contacts and pay-to-contact | Done | PR #787 rebuilt contacts on SwiftDashSDK/SwiftData/SwiftUI and removed the old contacts subsystem. Invitations were explicitly out of scope. | diff --git a/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/DerivationPathKeys/DerivationPathKeysView.swift b/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/DerivationPathKeys/DerivationPathKeysView.swift index d4a5449cf..1b5f4ee7e 100644 --- a/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/DerivationPathKeys/DerivationPathKeysView.swift +++ b/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/DerivationPathKeys/DerivationPathKeysView.swift @@ -45,6 +45,10 @@ class DerivationPathKeysViewModel: ObservableObject { func item(for info: DerivationPathInfo, at index: Int) -> DerivationPathKeysItem { model.itemForInfo(info, atIndex: index) } + + func usageInfo(at index: Int) -> String { + model.usageInfoForKey(at: index) + } } // MARK: - DerivationPathKeysContentView @@ -75,12 +79,18 @@ struct DerivationPathKeysContentView: View { // Keypair sections ForEach(0.. String { + guard let use = usage.use(for: key, at: index) else { + return NSLocalizedString("Not yet used", comment: "") + } + guard let service = use.serviceAddress, !service.isEmpty else { + return NSLocalizedString("Used", comment: "") + } + if use.revoked { + return NSLocalizedString("Previously used at: ", comment: "") + service + } + return NSLocalizedString("Used at: ", comment: "") + service + } + func itemForInfo(_ info: DerivationPathInfo, atIndex index: Int) -> DerivationPathKeysItem { let unavailable = NSLocalizedString("Not available", comment: "") - let value: String + let index = UInt32(index) + let value: String? switch info { case .address: - value = deriver?.address(at: UInt32(index)) ?? unavailable + value = ecdsaDeriver?.address(at: index) case .privateKey: - value = deriver?.privateKeyHex(at: UInt32(index)) ?? unavailable + switch key { + case .owner, .voting: + value = ecdsaDeriver?.privateKeyHex(at: index) + case .operator, .evonodeOperator: + value = providerDeriver?.key(at: index)?.privateKeyHex + } case .wifPrivateKey: - value = deriver?.wif(at: UInt32(index)) ?? unavailable + value = ecdsaDeriver?.wif(at: index) + case .publicKey: + value = providerDeriver?.key(at: index)?.publicKeyHex + case .publicKeyLegacy: + value = providerDeriver?.key(at: index)?.legacyPublicKeyHex + case .platformNodeId: + value = providerDeriver?.key(at: index)?.nodeIdHex + case .tenderdashNodeKey: + value = providerDeriver?.tenderdashNodeKeyBase64(at: index) } - return DerivationPathKeysItem(info: info, value: value) + return DerivationPathKeysItem(info: info, value: value ?? unavailable) } } @@ -128,10 +182,10 @@ extension DerivationPathKeysModel { /// voting `m/9'/'/3'/1'`, owner `m/9'/'/3'/2'` (ECDSA, fully /// hardened account path, soft key index; coin = 5' mainnet / 1' testnet). /// -/// Only Owner/Voting are supported — Operator (BLS) and HPMN/Platform (EdDSA) -/// were removed because the FFI doesn't export their per-index public keys. +/// Internal (not private): `MasternodeKeyUsage` reuses `address(at:)` for +/// its owner/voting address join. @MainActor -private final class MasternodeProviderKeyDeriver { +final class MasternodeProviderKeyDeriver { private let key: MNKey private let masterPath: String private let accountType: AccountType @@ -154,6 +208,10 @@ private final class MasternodeProviderKeyDeriver { case .owner: path = "m/9'/\(coinType)/3'/2'" type = .providerOwnerKeys + case .operator, .evonodeOperator: + // BLS / Ed25519 families derive through `ProviderKeyDeriver` + // (the platform-wallet FFI), not the key-wallet path surface. + return nil } guard let (manager, wallet, walletId) = SwiftDashSDKHost.shared.derivationWallet() else { @@ -193,6 +251,8 @@ private final class MasternodeProviderKeyDeriver { account = collection.getProviderVotingKeysAccount() case .owner: account = collection.getProviderOwnerKeysAccount() + case .operator, .evonodeOperator: + account = nil } guard let pool = account?.getAddressPool(type: .single) ?? account?.getExternalAddressPool(), @@ -202,3 +262,65 @@ private final class MasternodeProviderKeyDeriver { return info.address } } + +// MARK: - ProviderKeyDeriver + +/// Derives masternode Operator (BLS) and Evonode Operator (Ed25519 +/// platform-node) keys through the platform-wallet FFI +/// (`ManagedPlatformWallet.providerKeyAtIndex`). All derivation and +/// serialization (modern + legacy BLS encodings, the platform node id) +/// happens on the Rust side; results are memoized per index because the +/// Ed25519 family pulls the wallet seed through the mnemonic resolver on +/// every call. +@MainActor +private final class ProviderKeyDeriver { + private let kind: ManagedPlatformWallet.ProviderKeyKind + private let wallet: ManagedPlatformWallet + private var cache: [UInt32: ManagedPlatformWallet.ProviderDerivedKey] = [:] + + init?(key: MNKey) { + switch key { + case .operator: + kind = .operatorBLS + case .evonodeOperator: + kind = .platformNodeEdDSA + case .owner, .voting: + return nil + } + guard let wallet = SwiftDashSDKHost.shared.wallet else { + return nil + } + self.wallet = wallet + } + + func key(at index: UInt32) -> ManagedPlatformWallet.ProviderDerivedKey? { + if let cached = cache[index] { + return cached + } + // The screen is auth-gated and shows private-key rows for every + // family, so derive with the private scalar included up front. + guard let derived = try? wallet.providerKeyAtIndex( + kind: kind, + index: index, + includePrivate: true + ) else { + return nil + } + cache[index] = derived + return derived + } + + /// The Ed25519 platform-node key in dashmate's "Enter Ed25519 node key" + /// format: base64 of the 64-byte `priv(32) ‖ pub(32)` concatenation. + /// Pure re-encoding of the two hex strings the FFI returned — no crypto. + func tenderdashNodeKeyBase64(at index: UInt32) -> String? { + guard kind == .platformNodeEdDSA, + let derived = key(at: index), + let privateHex = derived.privateKeyHex, + let priv = Data(hex: privateHex), priv.count == 32, + let pub = Data(hex: derived.publicKeyHex), pub.count == 32 else { + return nil + } + return (priv + pub).base64EncodedString() + } +} diff --git a/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/KeysOverviewView.swift b/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/KeysOverviewView.swift index 966e1d728..707da462e 100644 --- a/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/KeysOverviewView.swift +++ b/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/KeysOverviewView.swift @@ -43,9 +43,9 @@ struct KeysOverviewContentView: View { // Keys list container VStack(spacing: kMenuVGap) { - ForEach(viewModel.items, id: \.self) { item in + ForEach(viewModel.items) { item in KeyItemButton(item: item) { - handleItemTap(item) + handleItemTap(item.key) } } } @@ -79,18 +79,29 @@ struct KeysOverviewContentView: View { // MARK: - KeyItemButton struct KeyItemButton: View { - let item: MNKey + let item: KeysOverviewViewModel.Item let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 0) { - Text(item.title) - .font(.subheadline) - .foregroundColor(Color(uiColor: .dw_label())) + VStack(alignment: .leading, spacing: 2) { + Text(item.key.title) + .font(.subheadline) + .foregroundColor(Color(uiColor: .dw_label())) + + Text(String.localizedStringWithFormat(NSLocalizedString("%d key(s)", comment: "#bc-ignore!"), item.keyCount)) + .font(.caption) + .foregroundColor(Color(uiColor: .dw_secondaryText())) + } Spacer() + Text(String.localizedStringWithFormat(NSLocalizedString("%ld used", comment: "#bc-ignore!"), item.usedCount)) + .font(.footnote) + .foregroundColor(Color(uiColor: .dw_secondaryText())) + .padding(.trailing, 8) + Image("list-chevron-right") .resizable() .aspectRatio(contentMode: .fit) @@ -109,14 +120,28 @@ struct KeyItemButton: View { // MARK: - KeysOverviewViewModel +@MainActor class KeysOverviewViewModel: ObservableObject { - @Published var items: [MNKey] = MNKey.allCases -} + struct Item: Identifiable { + let key: MNKey + /// Keys to show: everything up to the first unused index, minimum 1 + /// (DashSync's `max(firstUnusedIndex, 1)` semantics). + let keyCount: Int + let usedCount: Int + + var id: MNKey { key } + } -// MARK: - MNKey Identifiable + @Published var items: [Item] = [] -extension MNKey: Identifiable { - var id: Self { self } + init() { + let usage = MasternodeKeyUsage.resolve() + items = MNKey.allCases.map { key in + Item(key: key, + keyCount: max(usage.firstUnusedIndex(for: key), 1), + usedCount: usage.usedCount(for: key)) + } + } } // MARK: - Preview diff --git a/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/Model/WalletKeysOverviewModel.swift b/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/Model/WalletKeysOverviewModel.swift index f06e62c32..e02748e88 100644 --- a/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/Model/WalletKeysOverviewModel.swift +++ b/DashWallet/Sources/UI/Menu/Tools/Masternode Keys/Overview/Model/WalletKeysOverviewModel.swift @@ -16,16 +16,18 @@ // import Foundation +import SwiftDashSDK // MARK: - MNKey -// Only Owner and Voting keys are supported. Operator (BLS) and HPMN/Platform -// (EdDSA) keys were removed: SwiftDashSDK can derive their private keys but the -// FFI does not export per-index BLS/EdDSA *public* keys, which the masternode -// setup requires, so those families can't be shown DashSync-free. +/// The four masternode key families a ProRegTx references: Owner and Voting +/// (ECDSA, on-chain P2PKH addresses), Operator (BLS), and Evonode Operator +/// (Ed25519 platform-node key, formerly "HPMN Operator"). enum MNKey: CaseIterable { case owner case voting + case `operator` + case evonodeOperator } extension MNKey { @@ -35,6 +37,115 @@ extension MNKey { return NSLocalizedString("Owner keys", comment: "") case .voting: return NSLocalizedString("Voting keys", comment: "") + case .operator: + return NSLocalizedString("Operator keys", comment: "") + case .evonodeOperator: + return NSLocalizedString("Evonode Operator keys", comment: "") } } } + +// MARK: - MasternodeKeyUsage + +/// Which key indexes of each masternode key family this wallet's masternodes +/// use, resolved from the Rust masternode aggregation +/// (`PlatformWalletManager.masternodes(for:)`). +/// +/// Operator (BLS) and Evonode Operator (Ed25519) ownership is resolved by +/// Rust itself (derive-and-compare; `operatorInWallet`/`operatorKeyIndex`, +/// `platformInWallet`/`platformKeyIndex`). Owner and Voting keys have +/// on-chain P2PKH addresses, so they're joined in Swift by matching the +/// aggregation's Rust-encoded base58 addresses against the wallet's derived +/// address at each index. +@MainActor +struct MasternodeKeyUsage { + struct KeyUse { + /// The masternode's `ip:port`, when known. + let serviceAddress: String? + /// True when that registration was since revoked. + let revoked: Bool + } + + /// Owner/Voting address-join scan window. Matches the SDK's + /// pre-derivation count (`PLATFORM_NODE_KEY_PREDERIVE_COUNT`). + private static let scanWindow: UInt32 = 20 + + private let used: [MNKey: [UInt32: KeyUse]] + + private init(used: [MNKey: [UInt32: KeyUse]]) { + self.used = used + } + + func use(for key: MNKey, at index: Int) -> KeyUse? { + guard index >= 0 else { return nil } + return used[key]?[UInt32(index)] + } + + func usedCount(for key: MNKey) -> Int { + used[key]?.count ?? 0 + } + + /// The index after the highest used one (0 when the family is unused) — + /// DashSync's `firstUnusedIndex` semantics, which the key screens use as + /// the initially visible keypair. + func firstUnusedIndex(for key: MNKey) -> Int { + guard let maxUsed = used[key]?.keys.max() else { return 0 } + return Int(maxUsed) + 1 + } + + static func resolve() -> MasternodeKeyUsage { + guard let manager = SwiftDashSDKHost.shared.manager, + let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { + return MasternodeKeyUsage(used: [:]) + } + let masternodes = manager.masternodes(for: walletId) + guard !masternodes.isEmpty else { + return MasternodeKeyUsage(used: [:]) + } + + var used: [MNKey: [UInt32: KeyUse]] = [:] + // A revoked registration never overrides a live one at the same index. + func record(_ key: MNKey, _ index: UInt32, _ use: KeyUse) { + if let current = used[key]?[index], !current.revoked, use.revoked { + return + } + used[key, default: [:]][index] = use + } + + // Operator / Evonode Operator: Rust already resolved the in-wallet + // key index for these no-address key types. + for mn in masternodes { + let use = KeyUse(serviceAddress: mn.serviceAddress, revoked: mn.revoked) + if mn.operatorInWallet { + record(.operator, mn.operatorKeyIndex, use) + } + if mn.platformOwnershipChecked, mn.platformInWallet { + record(.evonodeOperator, mn.platformKeyIndex, use) + } + } + + // Owner / Voting: join the aggregation's base58 addresses against + // the wallet's derived address at each index in the scan window. + for key in [MNKey.owner, .voting] { + var useByAddress: [String: KeyUse] = [:] + for mn in masternodes { + let address = (key == .owner) ? mn.ownerAddress : mn.votingAddress + guard let address, !address.isEmpty else { continue } + let use = KeyUse(serviceAddress: mn.serviceAddress, revoked: mn.revoked) + if let current = useByAddress[address], !current.revoked, use.revoked { + continue + } + useByAddress[address] = use + } + guard !useByAddress.isEmpty, + let deriver = MasternodeProviderKeyDeriver(key: key) else { continue } + for index in 0..