Compare commits

..

1 Commits

5 changed files with 101 additions and 130 deletions

View File

@ -342,7 +342,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 25;
MARKETING_VERSION = 0.2.0;
};
name = Debug;
@ -351,7 +351,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
buildSettings = {
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 25;
MARKETING_VERSION = 0.2.0;
};
name = Release;

View File

@ -0,0 +1,87 @@
{
"originHash" : "11f3c5d73e6615e055e5b9f3671e6180f277a34f298c3f7c6935dcc8dd281089",
"pins" : [
{
"identity" : "bbqr-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/bitcoinppl/bbqr-swift",
"state" : {
"revision" : "83b828077ecc4f5d2cf8889da5543a61b4a60a3c",
"version" : "0.3.1"
}
},
{
"identity" : "bcswiftdcbor",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/BCSwiftDCBOR",
"state" : {
"revision" : "21efa67ada2f22a6c277e1961f1059bb376e9b1a",
"version" : "2.0.2"
}
},
{
"identity" : "bcswiftfloat16",
"kind" : "remoteSourceControl",
"location" : "https://github.com/blockchaincommons/BCSwiftFloat16",
"state" : {
"revision" : "a27f3935a7b1db715713eda67369b02feade2ded",
"version" : "2.0.0"
}
},
{
"identity" : "bcswifttags",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/BCSwiftTags",
"state" : {
"revision" : "ced8d92c7cc53375cdf9806c59251fe0161f02ec",
"version" : "0.2.3"
}
},
{
"identity" : "bdk-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/newtonick/bdk-swift",
"state" : {
"revision" : "4660bc83ea6088906edb090652d261e8ed4c09e3",
"version" : "2.3.1-ssl-patch"
}
},
{
"identity" : "swift-numberkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/objecthub/swift-numberkit.git",
"state" : {
"revision" : "33af3f9011e45dcd8ee696492d30dbcd5a8a67f3",
"version" : "2.6.0"
}
},
{
"identity" : "swiftsortedcollections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/wolfmcnally/SwiftSortedCollections",
"state" : {
"revision" : "dd6c8e0eaef987e55a35c056d185144a7c71fc19",
"version" : "0.1.0"
}
},
{
"identity" : "urkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/URKit",
"state" : {
"revision" : "c0a447560768e2552cf85a586dea8cfc26162891",
"version" : "15.1.0"
}
},
{
"identity" : "urui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BlockchainCommons/URUI",
"state" : {
"revision" : "c1b0ac2d0ba77741f00f439d311e7c85ee26a70a",
"version" : "12.0.0"
}
}
],
"version" : 3
}

View File

@ -1333,18 +1333,12 @@ final class BitcoinService {
) -> String {
let chain = isChange ? "1" : "0"
let coinType = network.coinType
let isTestnet = network != .mainnet
let normalized = cosigners.map { cosigner -> (xpub: String, fingerprint: String, derivationPath: String) in
let raw = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let xpub = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
return (xpub: xpub, fingerprint: cosigner.fingerprint, derivationPath: cosigner.derivationPath)
}
let sorted = normalized.sorted { $0.xpub < $1.xpub }
let sorted = cosigners.sorted { $0.xpub < $1.xpub }
let keys = sorted.map { cosigner in
"[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(cosigner.xpub)/\(chain)/*"
let xpub = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(xpub)/\(chain)/*"
}.joined(separator: ",")
return "wsh(sortedmulti(\(requiredSignatures),\(keys)))"
@ -1357,20 +1351,11 @@ final class BitcoinService {
network: BitcoinNetwork
) -> String {
let coinType = network.coinType
let isTestnet = network != .mainnet
// Normalize each cosigner xpub to standard xpub/tpub format (BDK descriptor
// parser does not accept SLIP132-tagged Vpub/Zpub/Ypub/Upub keys).
let normalized = cosigners.map { cosigner -> (xpub: String, fingerprint: String, derivationPath: String) in
let raw = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let xpub = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
return (xpub: xpub, fingerprint: cosigner.fingerprint, derivationPath: cosigner.derivationPath)
}
let sorted = normalized.sorted { $0.xpub < $1.xpub }
let sorted = cosigners.sorted { $0.xpub < $1.xpub }
let keys = sorted.map { cosigner in
"[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(cosigner.xpub)/<0;1>/*"
let xpub = cosigner.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "[\(cosigner.fingerprint)/48'/\(coinType)'/0'/2']\(xpub)/<0;1>/*"
}.joined(separator: ",")
let raw = "wsh(sortedmulti(\(requiredSignatures),\(keys)))"

View File

@ -193,17 +193,13 @@ final class SetupWizardViewModel {
func buildDescriptors() {
guard allCosignersComplete else { return }
// Build key origin strings normalize to standard xpub/tpub format before
// sorting so BIP67 ordering matches what's emitted in the descriptor.
let isTestnet = network != .mainnet
// Build key origin strings and sort by xpub (BIP67 lexicographic sort)
var keyEntries: [(origin: String, xpub: String, fingerprint: String, path: String, label: String, index: Int)] = []
for i in 0 ..< totalCosigners {
let raw = cosignerXpubs[i].trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let normalized = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
keyEntries.append((
origin: "[\(cosignerFingerprints[i])/48'/\(network.coinType)'/0'/2']",
xpub: normalized,
xpub: cosignerXpubs[i],
fingerprint: cosignerFingerprints[i],
path: cosignerDerivationPaths[i],
label: cosignerLabels[i],
@ -215,10 +211,12 @@ final class SetupWizardViewModel {
keyEntries.sort { $0.xpub < $1.xpub }
let externalKeys = keyEntries.map {
"\($0.origin)\($0.xpub)/0/*"
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/0/*"
}.joined(separator: ",")
let internalKeys = keyEntries.map {
"\($0.origin)\($0.xpub)/1/*"
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/1/*"
}.joined(separator: ",")
externalDescriptor = "wsh(sortedmulti(\(requiredSignatures),\(externalKeys)))"

View File

@ -211,105 +211,6 @@ struct DescriptorTests {
#expect(desc.contains("[73c5da0a/48'/1'/0'/2']"), "Should contain cosigner fingerprint/path")
}
// MARK: - SLIP132 Vpub/Zpub normalization
/// Cosigners as they might be entered by the user first one is in SLIP132
/// `Vpub` format (BIP-84 wsh testnet), the other two are standard `tpub`.
/// BDK's descriptor parser only accepts `xpub`/`tpub`, so the descriptor
/// builder must normalize the `Vpub` to `tpub` before assembly.
private static let mixedFormatCosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: "Vpub5kv6Y3xqGFyhZQyCz8LzaSwVzAJLJTvHcUewWAhrLRRRjZeYs53qrfspVEBKZw6rvwGy8Z1ef7e7Vzsu3BLF6MkjFXWnLpmftKQT1Eub5Cf",
fingerprint: "d03ce438", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDE2JvCZ3g8tEX3yegvXFn9cpzUyA2EEg6EwS7sAHcPER9yA6nFKdGPyLzsswYWa3SvEbKFmUiyFe9QQrpVpKwxojCud4ThNEv8R3j411Lcs",
fingerprint: "f9755e5b", derivationPath: "m/48'/1'/0'/2'"),
(xpub: "tpubDFEegnzQJr8LdYmGh1dGy3vqVgWtZ5w6q2cw4fbXhp15A29hvpf4NtAeFNvmmDRFTzeu1CveXs6dK2iPVADn2fSXWAQhHZhtLRGeHLmiBi5",
fingerprint: "acc95047", derivationPath: "m/48'/1'/0'/2'"),
]
/// The expected `tpub` form of the `Vpub` from `mixedFormatCosigners[0]`.
private static let convertedTpub =
"tpubDE4AYPPuhwTk7ENvANSMNU84wRecxjikg4e1WFHE4a6fxsNogCqnA7zzxyDoXp93JeyWNViXEKnkqaysaCrZRnTZDLYXnmbt7zrGxWYc3Mx"
@Test func combinedDescriptorNormalizesVpubToTpub() {
let desc = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4
)
// No SLIP132-tagged keys should remain in the assembled descriptor.
#expect(!desc.contains("Vpub"), "Descriptor should not contain SLIP132 Vpub keys after normalization")
#expect(!desc.contains("Zpub"), "Descriptor should not contain SLIP132 Zpub keys after normalization")
// The converted tpub from the original Vpub must be present, paired with
// the cosigner's original fingerprint.
#expect(desc.contains(Self.convertedTpub), "Vpub should normalize to expected tpub: \(Self.convertedTpub)")
#expect(desc.contains("[d03ce438/48'/1'/0'/2']\(Self.convertedTpub)"), "Converted tpub should retain the original fingerprint/origin")
}
@Test func singleChainDescriptorNormalizesVpubToTpub() {
let external = BitcoinService.buildDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4,
isChange: false
)
#expect(!external.contains("Vpub"), "External descriptor should not contain Vpub")
#expect(external.contains(Self.convertedTpub), "External descriptor should contain the converted tpub")
}
@Test func descriptorBuiltFromMixedFormatsMatchesAllTpubVersion() {
// Building the descriptor from the Vpub-mixed list should produce the same
// result as building it from the equivalent all-tpub list proving the
// SLIP132 input is fully normalized away.
let allTpubCosigners: [(xpub: String, fingerprint: String, derivationPath: String)] = [
(xpub: Self.convertedTpub, fingerprint: "d03ce438", derivationPath: "m/48'/1'/0'/2'"),
Self.mixedFormatCosigners[1],
Self.mixedFormatCosigners[2],
]
let fromMixed = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4
)
let fromAllTpub = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: allTpubCosigners,
network: .testnet4
)
#expect(fromMixed == fromAllTpub, "Descriptor built from Vpub+tpub mix should equal all-tpub descriptor")
}
@Test func descriptorSortsByNormalizedXpubForBIP67() {
// The user-supplied example: cosigners entered in [Vpub, tpub, tpub] order
// with fingerprints [d03ce438, f9755e5b, acc95047]. After normalization,
// BIP67 lexicographic sort by tpub puts them in this fingerprint order:
// 1. f9755e5b (tpubDE2JvCZ3g8tEX...)
// 2. d03ce438 (tpubDE4AYPPuhwTk7... converted from Vpub)
// 3. acc95047 (tpubDFEegnzQJr8L...)
let desc = BitcoinService.buildCombinedDescriptor(
requiredSignatures: 2,
cosigners: Self.mixedFormatCosigners,
network: .testnet4
)
let fp1 = desc.range(of: "[f9755e5b/48'/1'/0'/2']")
let fp2 = desc.range(of: "[d03ce438/48'/1'/0'/2']")
let fp3 = desc.range(of: "[acc95047/48'/1'/0'/2']")
#expect(fp1 != nil, "Descriptor should contain f9755e5b key origin")
#expect(fp2 != nil, "Descriptor should contain d03ce438 key origin")
#expect(fp3 != nil, "Descriptor should contain acc95047 key origin")
if let fp1, let fp2, let fp3 {
#expect(fp1.lowerBound < fp2.lowerBound, "f9755e5b (tpubDE2J...) should sort before d03ce438 (tpubDE4A...)")
#expect(fp2.lowerBound < fp3.lowerBound, "d03ce438 (tpubDE4A...) should sort before acc95047 (tpubDFEe...)")
}
}
/// Real descriptor decoded from a known-good crypto-output UR (from URServiceTests)
private func realURDescriptor() -> String? {
let urString = "UR:CRYPTO-OUTPUT/TAADMETAADMSOEADADAOLFTAADDLOSAOWKAXHDCLAOPDFNLNESAXHSJOFTVWFWHPTDUYPYHSROVLSWVDSRVWKBNNECZTHYMOURGSFDVDVAAAHDCXGMDKHPWMZTLRSOBSMWIOBWFWRPTODKNSEYAMTAHKRKQDISJTGWNSTSSFQDKPZSVTAHTAADEHOEADAEAOADAMTAADDYOTADLOCSDYYKADYKAEYKAOYKAOCYDYOTJEGMAXAAAYCYOYJNLKZMASJZGUIHIHIEGUINIOJTIHJPCXEYTAADDLOSAOWKAXHDCLAXIYMYFYWEMKASIOVSFYFDFDVASWONMTSKURSSTDMHVWSKLEAMKOVSGSDSCNSGNDOEAAHDCXBAMHFTFLGSDTBGBGFGGUREENGLFYTSHSCEJNKPHGGLFDFMTEWLENBDBBOXDYEMWTAHTAADEHOEADAEAOADAMTAADDYOTADLOCSDYYKADYKAEYKAOYKAOCYKNBWOSPAAXAAAYCYGRFPNSJOASJZGUIHIHIEGUINIOJTIHJPCXEHDLSWWZMD"