From a0c6978fb821315c97bc7f8f3f846a4f6a919bec Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 6 Jul 2026 14:45:47 +0700 Subject: [PATCH] fix(swift-sdk): decode DIP-0018 platform addresses in address queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Get Address Info query form (and the rest of the Swift address query/display layer) could not query bech32m platform addresses. Two stacked client-side bugs: 1. Wrong HRP. Detection and bech32m routing keyed off `dashevo`/`tdashevo`, but the canonical DIP-0018 HRP (rs-dpp, and the wallet's own persistence handler) is `dash`/`tdash`. Real `tdash1...` addresses failed every prefix check and fell through to hex → "Invalid hex string for address"; the format chip read "Unknown format". 2. Wrong type byte. Once routing was fixed, the FFI rejected the bytes with `UnexpectedVariant { allowed: 0..1, found: 176 }`. Platform addresses have two 21-byte encodings: the bech32m payload uses the user-facing type byte (0xb0 P2PKH / 0x80 P2SH) while the FFI (bincode `PlatformAddress`) uses the storage variant byte (0x00 / 0x01). The Swift codec treated the bytes opaquely and never translated, breaking both query decode and result display. Centralize the fix in `Bech32m`: correct HRP constants, a `looksLikePlatformAddress` detector, and two-way `storageBytes(fromBech32mPayload:)` / `bech32mPayload(fromStorageBytes:)` mapping. Wire through `getInfo`/`getInfos`, all `toBech32m` encoders, `DataTransformers`, `Validation`, and the query form. The hex test field feeds the FFI directly, so its vector uses the storage form (`00...`). Verified on the simulator against a fresh testnet wallet: `tdash1kzdl4c3...` → Valid bech32m / 21 bytes → balance 295,643,280 credits, nonce 2, Found, and the result round-trips back to the canonical `tdash1...` form. Co-Authored-By: Claude Opus 4.8 --- .../SwiftDashSDK/Address/Addresses.swift | 29 +++---- .../Address/PlatformAddressInfo.swift | 84 ++++++++++++++++--- .../SwiftDashSDK/Utils/DataTransformers.swift | 22 ++--- .../SwiftDashSDK/Utils/Validation.swift | 4 +- .../ViewModels/GetAddressInfoViewModel.swift | 6 +- .../GetAddressesInfosViewModel.swift | 6 +- .../Views/AddressQueriesView.swift | 17 ++-- 7 files changed, 118 insertions(+), 50 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift index c804d24fec4..304c05c7f3d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift @@ -71,29 +71,30 @@ public class Addresses: @unchecked Sendable { /// Fetch information about a single Platform address using bech32m string /// - /// - Parameter bech32mAddress: Bech32m-encoded address (e.g., "tdashevo1qqyfsqyzcn5hzu7echru54njypdq0v4d7gv8pkdf") + /// - Parameter bech32mAddress: Bech32m-encoded address (e.g., "tdash1kzdl4c3apkekqevkqrzctgagv2v2ng5hysegt5x4") /// - Returns: PlatformAddressInfo containing nonce and balance, or nil if address not found /// - Throws: SDKError if the query fails or bech32m is invalid public func getInfo(bech32mAddress: String) throws -> PlatformAddressInfo? { guard let decoded = Bech32m.decode(bech32mAddress) else { throw SDKError.invalidParameter("Invalid bech32m address") } - guard decoded.data.count == 21 else { - throw SDKError.invalidParameter("Invalid Platform address: expected 21 bytes, got \(decoded.data.count)") + guard let storageBytes = Bech32m.storageBytes(fromBech32mPayload: decoded.data) else { + throw SDKError.invalidParameter( + "Invalid Platform address: expected 21 bytes with a P2PKH (0xb0) or P2SH (0x80) type byte, got \(decoded.data.count) bytes") } - return try getInfo(addressBytes: decoded.data) + return try getInfo(addressBytes: storageBytes) } /// Fetch information about a single Platform address (auto-detects format) /// - /// - Parameter address: Address string - can be hex (42 chars) or bech32m (tdashevo1.../dashevo1...) + /// - Parameter address: Address string - can be hex (42 chars) or bech32m (tdash1.../dash1...) /// - Returns: PlatformAddressInfo containing nonce and balance, or nil if address not found /// - Throws: SDKError if the query fails or address format is invalid public func getInfo(address: String) throws -> PlatformAddressInfo? { let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) - // Check if it's a bech32m address (starts with dashevo1 or tdashevo1) - if trimmed.lowercased().hasPrefix("dashevo1") || trimmed.lowercased().hasPrefix("tdashevo1") { + // Check if it's a bech32m address (starts with the dash/tdash HRP) + if Bech32m.looksLikePlatformAddress(trimmed) { return try getInfo(bech32mAddress: trimmed) } @@ -223,10 +224,10 @@ public class Addresses: @unchecked Sendable { guard let decoded = Bech32m.decode(bech32m) else { throw SDKError.invalidParameter("Invalid bech32m address at index \(index)") } - guard decoded.data.count == 21 else { - throw SDKError.invalidParameter("Invalid Platform address at index \(index): expected 21 bytes") + guard let storageBytes = Bech32m.storageBytes(fromBech32mPayload: decoded.data) else { + throw SDKError.invalidParameter("Invalid Platform address at index \(index): expected 21 bytes with a P2PKH/P2SH type byte") } - return decoded.data + return storageBytes } return try getInfos(addressesBytesList: addressesBytesList) } @@ -241,14 +242,14 @@ public class Addresses: @unchecked Sendable { let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) // Check if it's a bech32m address - if trimmed.lowercased().hasPrefix("dashevo1") || trimmed.lowercased().hasPrefix("tdashevo1") { + if Bech32m.looksLikePlatformAddress(trimmed) { guard let decoded = Bech32m.decode(trimmed) else { throw SDKError.invalidParameter("Invalid bech32m address at index \(index)") } - guard decoded.data.count == 21 else { - throw SDKError.invalidParameter("Invalid Platform address at index \(index): expected 21 bytes") + guard let storageBytes = Bech32m.storageBytes(fromBech32mPayload: decoded.data) else { + throw SDKError.invalidParameter("Invalid Platform address at index \(index): expected 21 bytes with a P2PKH/P2SH type byte") } - return decoded.data + return storageBytes } // Otherwise try as hex diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Address/PlatformAddressInfo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Address/PlatformAddressInfo.swift index 408df3ba9e1..82bb1e1fc8f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Address/PlatformAddressInfo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Address/PlatformAddressInfo.swift @@ -44,9 +44,9 @@ public struct PlatformAddressInfo: Sendable, Equatable, Codable { /// Convert address bytes to bech32m string (requires network parameter) /// Format: [type_byte][20_byte_hash] public func toBech32m(network: Network) -> String? { - guard addressBytes.count == 21 else { return nil } - let hrp = network == .mainnet ? "dashevo" : "tdashevo" - return Bech32m.encode(hrp: hrp, data: addressBytes) + guard let payload = Bech32m.bech32mPayload(fromStorageBytes: addressBytes) else { return nil } + let hrp = Bech32m.platformHrp(mainnet: network == .mainnet) + return Bech32m.encode(hrp: hrp, data: payload) } } @@ -180,9 +180,9 @@ public struct AddressBalanceChange: Sendable, Equatable { /// Convert address bytes to bech32m string public func toBech32m(network: Network) -> String? { - guard addressBytes.count == 21 else { return nil } - let hrp = network == .mainnet ? "dashevo" : "tdashevo" - return Bech32m.encode(hrp: hrp, data: addressBytes) + guard let payload = Bech32m.bech32mPayload(fromStorageBytes: addressBytes) else { return nil } + let hrp = Bech32m.platformHrp(mainnet: network == .mainnet) + return Bech32m.encode(hrp: hrp, data: payload) } } @@ -264,9 +264,9 @@ public struct CompactedAddressChange: Sendable, Equatable { /// Convert address bytes to bech32m string public func toBech32m(network: Network) -> String? { - guard addressBytes.count == 21 else { return nil } - let hrp = network == .mainnet ? "dashevo" : "tdashevo" - return Bech32m.encode(hrp: hrp, data: addressBytes) + guard let payload = Bech32m.bech32mPayload(fromStorageBytes: addressBytes) else { return nil } + let hrp = Bech32m.platformHrp(mainnet: network == .mainnet) + return Bech32m.encode(hrp: hrp, data: payload) } } @@ -317,6 +317,68 @@ public enum Bech32m { return map }() + /// DIP-0018 human-readable part for mainnet Platform addresses. + public static let platformHrpMainnet = "dash" + /// DIP-0018 human-readable part for testnet/devnet/regtest Platform addresses. + public static let platformHrpTestnet = "tdash" + + /// HRP for the given network per DIP-0018 (mainnet = "dash", all others = "tdash"). + public static func platformHrp(mainnet: Bool) -> String { + mainnet ? platformHrpMainnet : platformHrpTestnet + } + + /// Whether a string looks like a bech32m Platform address (starts with the + /// mainnet or testnet HRP + separator). Detection only — `decode` still + /// validates the checksum, HRP, and 21-byte length. A 42-char hex address + /// can never match, since neither HRP prefix is valid hex. + public static func looksLikePlatformAddress(_ address: String) -> Bool { + let lower = address.lowercased() + return lower.hasPrefix(platformHrpMainnet + "1") + || lower.hasPrefix(platformHrpTestnet + "1") + } + + // Platform addresses have two distinct 21-byte serializations that differ + // only in the leading type byte (per DIP-0018, mirrored in rs-dpp + // `PlatformAddress`): + // • bech32m payload — user-facing: 0xb0 = P2PKH, 0x80 = P2SH + // • storage / FFI — bincode `PlatformAddress`: 0x00 = P2PKH, 0x01 = P2SH + // The FFI (`PlatformAddress::from_bytes`) consumes and (`address_fetch_info`) + // returns the storage form; only bech32m strings carry the user-facing byte. + static let p2pkhBech32mTypeByte: UInt8 = 0xb0 + static let p2shBech32mTypeByte: UInt8 = 0x80 + static let p2pkhStorageTypeByte: UInt8 = 0x00 + static let p2shStorageTypeByte: UInt8 = 0x01 + + /// Convert a decoded bech32m payload (`[0xb0|0x80] || 20-byte hash`) to the + /// storage/FFI form (`[0x00|0x01] || hash`) that the FFI expects. Returns + /// nil for a bad length or an unknown type byte. + public static func storageBytes(fromBech32mPayload data: Data) -> Data? { + let bytes = [UInt8](data) + guard bytes.count == 21 else { return nil } + let mapped: UInt8 + switch bytes[0] { + case p2pkhBech32mTypeByte: mapped = p2pkhStorageTypeByte + case p2shBech32mTypeByte: mapped = p2shStorageTypeByte + default: return nil + } + return Data([mapped] + bytes[1...]) + } + + /// Inverse of `storageBytes(fromBech32mPayload:)`: convert the storage/FFI + /// form (`[0x00|0x01] || hash`) to a bech32m payload (`[0xb0|0x80] || hash`) + /// for encoding. Returns nil for a bad length or an unknown type byte. + public static func bech32mPayload(fromStorageBytes data: Data) -> Data? { + let bytes = [UInt8](data) + guard bytes.count == 21 else { return nil } + let mapped: UInt8 + switch bytes[0] { + case p2pkhStorageTypeByte: mapped = p2pkhBech32mTypeByte + case p2shStorageTypeByte: mapped = p2shBech32mTypeByte + default: return nil + } + return Data([mapped] + bytes[1...]) + } + /// Decode result containing HRP and data public struct DecodeResult { public let hrp: String @@ -373,8 +435,8 @@ public enum Bech32m { guard let result = decode(address) else { return false } - // Valid Platform addresses have dashevo or tdashevo HRP and 21 bytes of data - let validHrp = result.hrp == "dashevo" || result.hrp == "tdashevo" + // Valid Platform addresses have the DIP-0018 dash/tdash HRP and 21 bytes of data + let validHrp = result.hrp == platformHrpMainnet || result.hrp == platformHrpTestnet let validLength = result.data.count == 21 return validHrp && validLength } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Utils/DataTransformers.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Utils/DataTransformers.swift index 63661f4a7e4..68dae4c28a6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Utils/DataTransformers.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Utils/DataTransformers.swift @@ -41,29 +41,29 @@ public enum AddressTransformer { return nil } - /// Parses a bech32m Platform address to its raw address bytes. - /// - Parameter address: Bech32m address string (dashevo1... or tdashevo1...). - /// - Returns: Address bytes (21 bytes) if valid, nil otherwise. + /// Parses a bech32m Platform address to its raw storage/FFI address bytes. + /// - Parameter address: Bech32m address string (dash1... or tdash1...). + /// - Returns: Storage-form address bytes (21 bytes, type byte 0x00/0x01) if + /// valid, nil otherwise. public static func parseBech32mAddress(_ address: String) -> Data? { let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) guard let result = Bech32m.decode(trimmed), - (result.hrp == "dashevo" || result.hrp == "tdashevo"), - result.data.count == 21 else { + (result.hrp == Bech32m.platformHrpMainnet || result.hrp == Bech32m.platformHrpTestnet) else { return nil } - return result.data + return Bech32m.storageBytes(fromBech32mPayload: result.data) } /// Formats address bytes for display, optionally as bech32m. /// - Parameters: /// - data: Address bytes (21 bytes for Platform address). /// - asBech32m: If true, encodes as bech32m; otherwise returns hex. - /// - isTestnet: If asBech32m is true, determines testnet (tdashevo1) vs mainnet (dashevo1). + /// - isTestnet: If asBech32m is true, determines testnet (tdash1) vs mainnet (dash1). /// - Returns: Formatted address string. public static func formatAddress(_ data: Data, asBech32m: Bool = false, isTestnet: Bool = true) -> String { - if asBech32m, data.count == 21 { - let hrp = isTestnet ? "tdashevo" : "dashevo" - return Bech32m.encode(hrp: hrp, data: data) ?? dataToHex(data) + if asBech32m, let payload = Bech32m.bech32mPayload(fromStorageBytes: data) { + let hrp = Bech32m.platformHrp(mainnet: !isTestnet) + return Bech32m.encode(hrp: hrp, data: payload) ?? dataToHex(data) } return dataToHex(data) } @@ -75,7 +75,7 @@ public enum AddressTransformer { let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) // Check if it's a bech32m address - if trimmed.lowercased().hasPrefix("dashevo1") || trimmed.lowercased().hasPrefix("tdashevo1") { + if Bech32m.looksLikePlatformAddress(trimmed) { return parseBech32mAddress(trimmed) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Utils/Validation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Utils/Validation.swift index c084120061f..5de4a37ae89 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Utils/Validation.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Utils/Validation.swift @@ -59,7 +59,7 @@ public enum AddressValidator { !id.trimmingCharacters(in: .whitespaces).isEmpty } - /// Validates a bech32m Platform address (dashevo1... or tdashevo1...). + /// Validates a bech32m Platform address (dash1... or tdash1...). /// - Returns: `true` if string is a valid bech32m address with correct HRP and 21 bytes of data. public static func validateBech32mAddress(_ address: String) -> Bool { Bech32m.isValidPlatformAddress(address) @@ -69,7 +69,7 @@ public enum AddressValidator { /// - Returns: `true` if string is valid in either format. public static func validateAddress(_ address: String) -> Bool { let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.lowercased().hasPrefix("dashevo1") || trimmed.lowercased().hasPrefix("tdashevo1") { + if Bech32m.looksLikePlatformAddress(trimmed) { return validateBech32mAddress(trimmed) } return validateHexAddress(trimmed) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/GetAddressInfoViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/GetAddressInfoViewModel.swift index 96ad6304af0..2eeedca1523 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/GetAddressInfoViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/GetAddressInfoViewModel.swift @@ -9,14 +9,16 @@ final class GetAddressInfoViewModel: BaseViewModel { @Published var addressInput = "" @Published var result: PlatformAddressInfo? - static let testBech32m = "tdashevo1qqyfsqyzcn5hzu7echru54njypdq0v4d7gv8pkdf" + static let testBech32m = "tdash1kzdl4c3apkekqevkqrzctgagv2v2ng5hysegt5x4" + // Hex field feeds the FFI directly, which expects the storage-form type byte + // (0x00 = P2PKH), not the user-facing bech32m byte (0xb0). static let testAddressHex = "00" + "1234567890abcdef1234567890abcdef12345678" var isFormValid: Bool { !addressInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var detectedFormat: (icon: String, color: Color, description: String) { let trimmed = addressInput.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if trimmed.hasPrefix("dashevo1") || trimmed.hasPrefix("tdashevo1") { + if Bech32m.looksLikePlatformAddress(trimmed) { if Bech32m.isValidPlatformAddress(trimmed) { return ("checkmark.circle.fill", .green, "Valid bech32m address") } else { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/GetAddressesInfosViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/GetAddressesInfosViewModel.swift index 6fff541aec7..aaa6df96440 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/GetAddressesInfosViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/GetAddressesInfosViewModel.swift @@ -10,10 +10,12 @@ final class GetAddressesInfosViewModel: BaseViewModel { @Published var result: PlatformAddressInfosResult? static let testBech32mAddresses = """ - tdashevo1qqyfsqyzcn5hzu7echru54njypdq0v4d7gv8pkdf - tdashevo1qq0rs5w7e3xv6ls3f7s4hz82e44p29e38fqlmhs + tdash1kzdl4c3apkekqevkqrzctgagv2v2ng5hysegt5x4 + tdash1kz4ummcjx3t83y9tehh3ydzk0zg2hn00zgc6jtwv """ + // Hex field feeds the FFI directly, which expects the storage-form type byte + // (0x00 = P2PKH), not the user-facing bech32m byte (0xb0). static let testHexAddresses = """ 001234567890abcdef1234567890abcdef12345678 00abcdef1234567890abcdef1234567890abcdef12 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressQueriesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressQueriesView.swift index 5f1b1409c6f..74f03cec97c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressQueriesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressQueriesView.swift @@ -99,7 +99,7 @@ struct GetAddressInfoView: View { .fontWeight(.bold) Text( - "Query the balance and nonce for a Platform address. Supports both bech32m (tdashevo1.../dashevo1...) and hex formats." + "Query the balance and nonce for a Platform address. Supports both bech32m (tdash1.../dash1...) and hex formats." ) .font(.body) .foregroundColor(.secondary) @@ -147,9 +147,7 @@ struct GetAddressInfoView: View { } // Debug info for bech32m - if viewModel.addressInput.lowercased().hasPrefix("dashevo1") - || viewModel.addressInput.lowercased().hasPrefix("tdashevo1") - { + if Bech32m.looksLikePlatformAddress(viewModel.addressInput) { let debug = Bech32m.debugDecode(viewModel.addressInput) if let hex = debug.hex, let count = debug.byteCount { Text("Decoded: \(count) bytes") @@ -216,9 +214,9 @@ struct GetAddressInfoView: View { } else if let info = viewModel.result { VStack(alignment: .leading, spacing: 8) { let network: Network = - viewModel.addressInput.lowercased().hasPrefix("tdashevo") - ? .testnet - : .mainnet + viewModel.addressInput.lowercased().hasPrefix(Bech32m.platformHrpMainnet + "1") + ? .mainnet + : .testnet let bech32mAddress = info.toBech32m(network: network) ?? info.addressHex ResultRow(label: "Address", value: bech32mAddress) @@ -354,7 +352,10 @@ struct GetAddressesInfosView: View { .background(Color.red.opacity(0.1)) .cornerRadius(8) } else if let result = viewModel.result { - let isTestnet = viewModel.addressesText.lowercased().contains("tdashevo") + // Testnet HRP (tdash) contains the mainnet HRP (dash) as a + // substring, so check for the testnet prefix first. + let lowered = viewModel.addressesText.lowercased() + let isTestnet = lowered.contains(Bech32m.platformHrpTestnet + "1") let network: Network = isTestnet ? .testnet : .mainnet VStack(alignment: .leading, spacing: 12) {