Compare commits
1 Commits
main
...
add-verifi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98183cd21c |
@ -14,6 +14,7 @@ final class SetupWizardViewModel {
|
||||
case descriptorImport
|
||||
case walletName
|
||||
case review
|
||||
case verify
|
||||
}
|
||||
|
||||
enum CreationMode {
|
||||
@ -46,6 +47,10 @@ final class SetupWizardViewModel {
|
||||
var externalDescriptor: String = ""
|
||||
var internalDescriptor: String = ""
|
||||
|
||||
// Verification step
|
||||
var firstReceiveAddress: String = ""
|
||||
var addressDerivationError: String?
|
||||
|
||||
// Electrum server
|
||||
var electrumHost: String = ""
|
||||
var electrumPort: String = ""
|
||||
@ -91,7 +96,7 @@ final class SetupWizardViewModel {
|
||||
|
||||
/// Progress
|
||||
var stepCount: Int {
|
||||
creationMode == .createNew ? 5 : 3
|
||||
creationMode == .createNew ? 6 : 3
|
||||
}
|
||||
|
||||
var currentStepIndex: Int {
|
||||
@ -103,6 +108,7 @@ final class SetupWizardViewModel {
|
||||
case .descriptorImport: 2
|
||||
case .walletName: creationMode == .createNew ? 4 : 3
|
||||
case .review: stepCount - 1
|
||||
case .verify: stepCount - 1
|
||||
}
|
||||
}
|
||||
|
||||
@ -217,6 +223,38 @@ final class SetupWizardViewModel {
|
||||
internalDescriptor = "wsh(sortedmulti(\(requiredSignatures),\(internalKeys)))"
|
||||
}
|
||||
|
||||
var combinedDescriptor: String {
|
||||
let cosignerData = (0 ..< totalCosigners).map {
|
||||
(xpub: cosignerXpubs[$0], fingerprint: cosignerFingerprints[$0], derivationPath: cosignerDerivationPaths[$0])
|
||||
}
|
||||
return BitcoinService.buildCombinedDescriptor(
|
||||
requiredSignatures: requiredSignatures,
|
||||
cosigners: cosignerData,
|
||||
network: network
|
||||
)
|
||||
}
|
||||
|
||||
func deriveFirstAddress() {
|
||||
let bdkNetwork = BitcoinService.shared.bdkNetwork(from: network)
|
||||
do {
|
||||
let extDesc = try Descriptor(descriptor: externalDescriptor, network: bdkNetwork)
|
||||
let chgDesc = try Descriptor(descriptor: internalDescriptor, network: bdkNetwork)
|
||||
let persister = try Persister.newInMemory()
|
||||
let tempWallet = try Wallet(
|
||||
descriptor: extDesc,
|
||||
changeDescriptor: chgDesc,
|
||||
network: bdkNetwork,
|
||||
persister: persister
|
||||
)
|
||||
let info = tempWallet.peekAddress(keychain: .external, index: 0)
|
||||
firstReceiveAddress = info.address.description
|
||||
addressDerivationError = nil
|
||||
} catch {
|
||||
addressDerivationError = "Failed to derive address: \(error.localizedDescription)"
|
||||
firstReceiveAddress = ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseImportedDescriptor() -> Bool {
|
||||
// If the input is a JSON object (e.g. Specter Desktop export), extract the descriptor field
|
||||
if let descriptor = URService.extractDescriptorFromJSON(importedDescriptorText) {
|
||||
@ -392,8 +430,11 @@ final class SetupWizardViewModel {
|
||||
}
|
||||
currentStep = .walletName
|
||||
case .walletName:
|
||||
currentStep = .review
|
||||
deriveFirstAddress()
|
||||
currentStep = .verify
|
||||
case .review:
|
||||
break // unused in current flow
|
||||
case .verify:
|
||||
break // handled by saveWallet
|
||||
}
|
||||
}
|
||||
@ -411,6 +452,7 @@ final class SetupWizardViewModel {
|
||||
}
|
||||
// Import flow: back button is hidden, wallet already created
|
||||
case .review: currentStep = .walletName
|
||||
case .verify: currentStep = .walletName
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -39,7 +39,9 @@ struct SetupWizardView: View {
|
||||
onSave: viewModel.creationMode == .importDescriptor ? saveAndFinish : nil
|
||||
)
|
||||
case .review:
|
||||
WalletReviewView(viewModel: viewModel, onComplete: saveAndFinish)
|
||||
EmptyView() // unused in current flow
|
||||
case .verify:
|
||||
WalletVerifyView(viewModel: viewModel, onComplete: saveAndFinish)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
300
hellbender/Views/Setup/WalletVerifyView.swift
Normal file
300
hellbender/Views/Setup/WalletVerifyView.swift
Normal file
@ -0,0 +1,300 @@
|
||||
import SwiftUI
|
||||
import URKit
|
||||
|
||||
struct WalletVerifyView: View {
|
||||
@Bindable var viewModel: SetupWizardViewModel
|
||||
let onComplete: () -> Void
|
||||
@State private var showDescriptorQR = false
|
||||
@State private var showDescriptorPDF = false
|
||||
@State private var copiedDescriptor = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Text("Verify Wallet")
|
||||
.font(.hbDisplay(28))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
|
||||
// MARK: - Wallet Summary
|
||||
|
||||
VStack(spacing: 16) {
|
||||
ReviewRow(label: "Name", value: viewModel.walletName.isEmpty ? "My Wallet" : viewModel.walletName)
|
||||
ReviewRow(label: "Type", value: "\(viewModel.requiredSignatures)-of-\(viewModel.totalCosigners) Multisig")
|
||||
ReviewRow(label: "Network", value: viewModel.network.displayName)
|
||||
ReviewRow(label: "Script", value: "P2WSH (Native Segwit)")
|
||||
}
|
||||
.hbCard()
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// MARK: - Cosigners
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Cosigners")
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
|
||||
ForEach(0 ..< viewModel.totalCosigners, id: \.self) { index in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(viewModel.cosignerLabels[index])
|
||||
.font(.hbBody(15))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("FP:")
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
Text(viewModel.cosignerFingerprints[index])
|
||||
.font(.hbMono(12))
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
}
|
||||
|
||||
Text(viewModel.cosignerXpubs[index])
|
||||
.font(.hbMono(10))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.hbSurfaceElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.hbCard()
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// MARK: - Back Up Your Descriptor
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
.font(.system(size: 20))
|
||||
Text("Back Up Your Descriptor")
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
}
|
||||
|
||||
Text("The output descriptor is your **only** recovery path. If you lose Hellbender (phone dies, app deleted, data corrupted), the descriptor is the only thing needed to rebuild the wallet in any compatible coordinator (Sparrow, Nunchuk, etc.). Without it, you'd need to re-gather all cosigner xpubs and reconstruct the exact same configuration — which may not be possible.")
|
||||
.font(.hbBody(13))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
BulletRow(text: "Print to PDF")
|
||||
BulletRow(text: "Import into Sparrow Wallet on another computer")
|
||||
BulletRow(text: "Save to an encrypted drive")
|
||||
}
|
||||
|
||||
Button(action: { showDescriptorPDF = true }) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.richtext")
|
||||
Text("PDF/Print Output Descriptor")
|
||||
.font(.hbBody(15))
|
||||
}
|
||||
.foregroundStyle(Color.purple)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.purple.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
Button(action: { showDescriptorQR = true }) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
Text("Show Descriptor QR")
|
||||
.font(.hbBody(15))
|
||||
}
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.hbBitcoinOrange.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = viewModel.combinedDescriptor
|
||||
copiedDescriptor = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
copiedDescriptor = false
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: copiedDescriptor ? "checkmark" : "doc.on.doc")
|
||||
Text(copiedDescriptor ? "Copied!" : "Copy Descriptor")
|
||||
.font(.hbBody(15))
|
||||
}
|
||||
.foregroundStyle(Color.hbSteelBlue)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.hbSteelBlue.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.hbCard()
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// MARK: - Verify Receive Address
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("Verify Receive Address", systemImage: "checkmark.shield")
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
|
||||
Text("Verifying your first receive address confirms that Hellbender built the correct output descriptor and will generate the same addresses as your cosigner devices. If the addresses don't match, funds sent to this wallet could be unspendable.")
|
||||
.font(.hbBody(13))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
|
||||
if let error = viewModel.addressDerivationError {
|
||||
Text(error)
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbError)
|
||||
|
||||
Button(action: { viewModel.deriveFirstAddress() }) {
|
||||
Text("Retry")
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
}
|
||||
} else if !viewModel.firstReceiveAddress.isEmpty {
|
||||
QRCodeView(content: viewModel.firstReceiveAddress)
|
||||
.frame(width: 200, height: 200)
|
||||
.padding(12)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Text(viewModel.firstReceiveAddress)
|
||||
.font(.hbMono(12))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.textSelection(.enabled)
|
||||
|
||||
Text("Index 0")
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
Text("Load the output descriptor into your hardware signer (SeedSigner, Krux, etc.) and verify this address matches the first receive address shown on the device.")
|
||||
.font(.hbBody(13))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
.hbCard()
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
Button(action: onComplete) {
|
||||
Text("Create Wallet")
|
||||
.hbPrimaryButton()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Button(action: { viewModel.goBack() }) {
|
||||
Text("Back")
|
||||
.font(.hbBody(16))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
.sheet(isPresented: $showDescriptorQR) {
|
||||
DescriptorQRSheet(descriptor: viewModel.combinedDescriptor)
|
||||
}
|
||||
.sheet(isPresented: $showDescriptorPDF) {
|
||||
DescriptorPDFView(
|
||||
walletName: viewModel.walletName.isEmpty ? "My Wallet" : viewModel.walletName,
|
||||
descriptor: viewModel.externalDescriptor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
|
||||
private struct ReviewRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.hbBody(15))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DescriptorQRSheet: View {
|
||||
let descriptor: String
|
||||
let descriptorUR: UR?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(descriptor: String) {
|
||||
self.descriptor = descriptor
|
||||
descriptorUR = try? URService.encodeCryptoOutput(descriptor: descriptor)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.hbBackground.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
if let ur = descriptorUR {
|
||||
URDisplaySheet(ur: ur)
|
||||
.padding(5)
|
||||
.background(Color.white)
|
||||
.shadow(color: Color.hbBitcoinOrange.opacity(0.2), radius: 20)
|
||||
} else {
|
||||
Text("Failed to encode descriptor")
|
||||
.font(.hbBody())
|
||||
.foregroundStyle(Color.hbError)
|
||||
}
|
||||
|
||||
Text("Scan to import this wallet descriptor")
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = descriptor
|
||||
}) {
|
||||
Label("Copy Descriptor", systemImage: "doc.on.doc")
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbSteelBlue)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.navigationTitle("Wallet Descriptor")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BulletRow: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\u{2022}")
|
||||
.font(.hbBody(13))
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
Text(text)
|
||||
.font(.hbBody(13))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user